كتابة محرك مغامرة نصية متعددة اللاعبين في Node.js: إنشاء عميل المحطة الطرفية (الجزء 3)

نشرت: 2022-03-10
ملخص سريع ↬ سيركز هذا الجزء الثالث من السلسلة على إضافة عميل يستند إلى النص لمحرك اللعبة الذي تم إنشاؤه في الجزء 2. يشرح فرناندو دوجليو التصميم الأساسي للهندسة المعمارية واختيار الأداة وأبرز الكود من خلال توضيح كيفية إنشاء نص- قائم على واجهة المستخدم بمساعدة Node.js.

لقد أوضحت لك أولاً كيفية تحديد مشروع مثل هذا ، وقدمت لك أساسيات الهندسة بالإضافة إلى الآليات الكامنة وراء محرك اللعبة. بعد ذلك ، أوضحت لك التنفيذ الأساسي للمحرك - واجهة برمجة تطبيقات REST الأساسية التي تتيح لك اجتياز عالم محدد بواسطة JSON.

اليوم ، سأوضح لك كيفية إنشاء عميل نصي من المدرسة القديمة لواجهة برمجة التطبيقات الخاصة بنا باستخدام أي شيء آخر غير Node.js.

أجزاء أخرى من هذه السلسلة

  • الجزء الأول: المقدمة
  • الجزء 2: تصميم خادم محرك اللعبة
  • الجزء 4: إضافة الدردشة إلى لعبتنا

مراجعة التصميم الأصلي

عندما اقترحت لأول مرة إطارًا سلكيًا أساسيًا لواجهة المستخدم ، اقترحت أربعة أقسام على الشاشة:

(معاينة كبيرة)

على الرغم من أن هذا يبدو صحيحًا من الناحية النظرية ، فقد فاتني حقيقة أن التبديل بين إرسال أوامر اللعبة والرسائل النصية سيكون أمرًا مؤلمًا ، لذا فبدلاً من تبديل لاعبينا يدويًا ، سيكون لدينا محلل الأوامر الخاص بنا للتأكد من قدرته على تمييز ما إذا كنا تحاول التواصل مع اللعبة أو مع أصدقائنا.

لذلك ، بدلاً من وجود أربعة أقسام في شاشتنا ، سيكون لدينا الآن ثلاثة:

(معاينة كبيرة)

هذه لقطة شاشة فعلية لعميل اللعبة النهائي. يمكنك رؤية شاشة اللعبة على اليسار ، والدردشة على اليمين ، مع وجود مربع إدخال مشترك واحد في الأسفل. تسمح لنا الوحدة التي نستخدمها بتخصيص الألوان وبعض التأثيرات الأساسية. ستتمكن من استنساخ هذا الرمز من Github والقيام بما تريد بالشكل والمظهر.

رغم ذلك ، هناك تحذير واحد: على الرغم من أن لقطة الشاشة أعلاه تُظهر عمل الدردشة كجزء من التطبيق ، فإننا سنبقي هذه المقالة مركزة على إعداد المشروع وتحديد إطار عمل يمكننا من خلاله إنشاء تطبيق ديناميكي يستند إلى واجهة مستخدم نصية. سنركز على إضافة دعم الدردشة في الفصل التالي والأخير من هذه السلسلة.

المزيد بعد القفز! أكمل القراءة أدناه ↓

الأدوات التي سنحتاجها

على الرغم من وجود العديد من المكتبات التي تتيح لنا إنشاء أدوات CLI باستخدام Node.js ، فإن إضافة واجهة مستخدم نصية تعد وحشًا مختلفًا تمامًا يجب ترويضه. على وجه الخصوص ، كنت قادرًا على العثور على مكتبة واحدة فقط (كاملة جدًا ، تهتم بك) تتيح لي أن أفعل ما أريده بالضبط: طوبى.

هذه المكتبة قوية جدًا وتوفر الكثير من الميزات التي لن نستخدمها لهذا المشروع (مثل إلقاء الظلال والسحب والإفلات وغيرها). يقوم بشكل أساسي بإعادة تنفيذ مكتبة ncurses بالكامل (مكتبة C تسمح للمطورين بإنشاء واجهات مستخدم قائمة على النصوص) والتي لا تحتوي على روابط Node.js ، وهي تفعل ذلك مباشرة في JavaScript ؛ لذلك ، إذا اضطررنا إلى ذلك ، فيمكننا التحقق من الكود الداخلي (وهو أمر لا أوصي به إلا إذا كان عليك ذلك تمامًا).

على الرغم من أن وثائق Blessed واسعة للغاية ، إلا أنها تتكون أساسًا من تفاصيل فردية حول كل طريقة يتم تقديمها (على عكس وجود دروس تشرح كيفية استخدام هذه الطرق معًا) وهي تفتقر إلى الأمثلة في كل مكان ، لذلك قد يكون من الصعب البحث فيها. إذا كان عليك فهم كيفية عمل طريقة معينة. مع ما يقال ، بمجرد أن تفهمها لشخص ما ، كل شيء يعمل بنفس الطريقة ، وهي ميزة كبيرة حيث لا تحتوي كل مكتبة أو حتى لغة (أنا أنظر إليك ، PHP) على بنية متسقة.

لكن التوثيق جانبا. الإضافة الكبيرة لهذه المكتبة هي أنها تعمل على أساس خيارات JSON. على سبيل المثال ، إذا أردت رسم مربع في الزاوية اليمنى العليا من الشاشة ، يمكنك القيام بشيء مثل هذا:

 var box = blessed.box({ top: '0', right: '0', width: '50%', height: '50%', content: 'Hello {bold}world{/bold}!', tags: true, border: { type: 'line' }, style: { fg: 'white', bg: 'magenta', border: { fg: '#f0f0f0' }, hover: { bg: 'green' } } });

كما يمكنك أن تتخيل ، يتم تحديد جوانب أخرى من الصندوق هناك أيضًا (مثل حجمها) ، والتي يمكن أن تكون ديناميكية تمامًا بناءً على حجم الجهاز ونوع الحدود والألوان - حتى بالنسبة لأحداث التمرير. إذا كنت قد أجريت تطويرًا للواجهة الأمامية في وقت ما ، فستجد الكثير من التداخل بين الاثنين.

النقطة التي أحاول توضيحها هنا هي أن كل شيء يتعلق بتمثيل الصندوق يتم تكوينه من خلال كائن JSON الذي تم تمريره إلى طريقة box . هذا ، بالنسبة لي ، مثالي لأنه يمكنني بسهولة استخراج هذا المحتوى في ملف تكوين ، وإنشاء منطق عمل قادر على قراءته وتحديد العناصر التي يجب رسمها على الشاشة. الأهم من ذلك ، أنه سيساعدنا في الحصول على لمحة عن الشكل الذي سيبدو عليه بمجرد رسمه.

سيكون هذا هو الأساس لجانب واجهة المستخدم بالكامل لهذه الوحدة ( المزيد عن ذلك في ثانية! ).

هندسة الوحدة النمطية

تعتمد البنية الأساسية لهذه الوحدة كليًا على أدوات واجهة المستخدم التي سنعرضها. تعتبر مجموعة من هذه الأدوات بمثابة شاشة ، ويتم تحديد كل هذه الشاشات في ملف JSON واحد (يمكنك العثور عليه داخل مجلد /config ).

يحتوي هذا الملف على أكثر من 250 سطرًا ، لذا فإن عرضه هنا لا معنى له. يمكنك إلقاء نظرة على الملف الكامل عبر الإنترنت ، ولكن يبدو المقتطف الصغير منه كالتالي:

 "screens": { "main-options": { "file": "./main-options.js", "elements": { "username-request": { "type": "input-prompt", "params": { "position": { "top": "0%", "left": "0%", "width": "100%", "height": "25%" }, "content": "Input your username: ", "inputOnFocus": true, "border": { "type": "line" }, "style": { "fg": "white", "bg": "blue", "border": { "fg": "#f0f0f0" }, "hover": { "bg": "green" } } } }, "options": { "type": "window", "params": { "position": { "top": "25%", "left": "0%", "width": "100%", "height": "50%" }, "content": "Please select an option: \n1. Join an existing game.\n2. Create a new game", "border": { "type": "line" }, "style": { //... } } }, "input": { "type": "input", "handlerPath": "../lib/main-options-handler", //... } } }

سيحتوي عنصر "الشاشات" على قائمة الشاشات داخل التطبيق. تحتوي كل شاشة على قائمة من عناصر واجهة المستخدم (التي سأغطيها قليلاً) وكل عنصر واجهة مستخدم له تعريف خاص به وملفات معالج ذات صلة (عند الاقتضاء).

يمكنك أن ترى كيف أن كل عنصر "معلمات" (داخل عنصر واجهة مستخدم معين) يمثل المجموعة الفعلية من المعلمات المتوقعة من خلال الطرق التي رأيناها سابقًا. تساعد بقية المفاتيح المحددة هناك في توفير سياق حول نوع عناصر واجهة المستخدم التي سيتم عرضها وسلوكها.

بعض النقاط المهمة:

معالجات الشاشة

يحتوي كل عنصر شاشة على خاصية ملف تشير إلى الكود المرتبط بهذه الشاشة. هذا الرمز ليس سوى كائن يجب أن يكون له طريقة init (منطق التهيئة لتلك الشاشة المعينة يحدث داخلها). على وجه الخصوص ، سوف يستدعي محرك واجهة المستخدم الرئيسي طريقة init لكل شاشة ، والتي بدورها يجب أن تكون مسؤولة عن تهيئة أي منطق قد تحتاجه (أي إعداد أحداث مربعات الإدخال).

فيما يلي رمز الشاشة الرئيسية ، حيث يطلب التطبيق من اللاعب تحديد خيار إما لبدء لعبة جديدة تمامًا أو الانضمام إلى لعبة موجودة:

 const logger = require("../utils/logger") module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }

كما ترى ، تستدعي طريقة init طريقة setupInput التي تقوم أساسًا بتكوين رد الاتصال الصحيح للتعامل مع إدخال المستخدم. يحتفظ رد الاتصال هذا بالمنطق لتحديد ما يجب فعله بناءً على مدخلات المستخدم (إما 1 أو 2).

معالجات القطعة

تحتوي بعض عناصر واجهة المستخدم (عادةً عناصر واجهة تعامل الإدخال) على خاصية handlerPath ، والتي تشير إلى الملف الذي يحتوي على المنطق وراء هذا المكون المحدد. هذا ليس هو نفسه معالج الشاشة السابق. هؤلاء لا يهتمون بمكونات واجهة المستخدم كثيرًا. بدلاً من ذلك ، فهم يتعاملون مع منطق الغراء بين واجهة المستخدم وأي مكتبة نستخدمها للتفاعل مع الخدمات الخارجية (مثل واجهة برمجة تطبيقات محرك اللعبة).

أنواع القطعة

إضافة ثانوية أخرى إلى تعريف JSON لعناصر واجهة المستخدم هي أنواعها. بدلاً من استخدام الأسماء المباركة المحددة لهم ، أقوم بإنشاء أسماء جديدة لمنحني مساحة أكبر للمناورة عندما يتعلق الأمر بسلوكهم. بعد كل شيء ، قد لا تقوم أداة النافذة دائمًا "بعرض المعلومات فقط" ، أو قد لا يعمل مربع الإدخال دائمًا بنفس الطريقة.

كانت هذه في الغالب خطوة استباقية ، فقط للتأكد من أن لدي هذه القدرة إذا احتجت إليها في المستقبل ، ولكن كما أنت على وشك أن ترى ، فأنا لا أستخدم العديد من الأنواع المختلفة من المكونات على أي حال.

شاشات متعددة

على الرغم من أن الشاشة الرئيسية هي الشاشة التي أظهرتها لك في لقطة الشاشة أعلاه ، إلا أن اللعبة تتطلب بعض الشاشات الأخرى من أجل طلب أشياء مثل اسم اللاعب الخاص بك أو ما إذا كنت تنشئ جلسة لعبة جديدة تمامًا أو حتى الانضمام إلى واحدة موجودة. الطريقة التي تعاملت بها مع ذلك كانت ، مرة أخرى ، من خلال تعريف كل هذه الشاشات في نفس ملف JSON. وللانتقال من شاشة إلى أخرى ، نستخدم المنطق داخل ملفات معالج الشاشة.

يمكننا القيام بذلك ببساطة عن طريق استخدام سطر التعليمات البرمجية التالي:

 this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })

سأعرض لك مزيدًا من التفاصيل حول خاصية واجهة المستخدم في غضون ثانية ، لكنني أستخدم فقط طريقة loadScreen لإعادة عرض الشاشة واختيار المكونات الصحيحة من ملف JSON باستخدام السلسلة التي تم تمريرها كمعامل. واضحة جدا.

عينات التعليمات البرمجية

حان الوقت الآن للتحقق من اللحوم والبطاطس في هذه المقالة: عينات الكود. سأقوم فقط بتسليط الضوء على ما أعتقد أنه الجواهر الصغيرة الموجودة بداخله ، ولكن يمكنك دائمًا إلقاء نظرة على كود المصدر الكامل مباشرة في المستودع في أي وقت.

استخدام ملفات التكوين لإنشاء واجهة المستخدم تلقائيًا

لقد غطيت بالفعل جزءًا من هذا ، لكنني أعتقد أن الأمر يستحق استكشاف التفاصيل وراء هذا المولد. جوهره (ملف index.js داخل مجلد /ui ) هو أنه عبارة عن غلاف حول الكائن المبارك. والطريقة الأكثر إثارة للاهتمام داخلها هي طريقة loadScreen .

تلتقط هذه الطريقة التكوين (من خلال وحدة التكوين) لشاشة واحدة محددة وتنتقل عبر محتواها ، في محاولة لإنشاء الأدوات المناسبة بناءً على نوع كل عنصر.

 loadScreen: function(sname, extras, done) { if(typeof extras == "function") { done = extras } let screen = config.get('screens.' + sname) let screenElems = {} if(this.screenElements.length > 0) { //remove previous screen this.screenElements.map( e => e.detach()) this.screen.realloc() } Object.keys(screen.elements).forEach( eName => { let elemObj = null let element = screen.elements[eName] if(element.type == 'window') { elemObj = this.setUpWindow(element) } if(element.type == 'input') { elemObj = this.setUpInputBox(element) } if(element.type == 'input-prompt') { elemObj = this.setUpInputBox(element) } screenElems[eName] = { meta: element, obj: elemObj } }) if(typeof extras === 'object' && extras.flashmessage) { this.setUpAlert(extras.flashmessage) } this.renderScreen() let logicPath = require(screen.file) logicPath.init(screenElems, this) done() },

كما ترى ، الشفرة طويلة بعض الشيء ، لكن المنطق الكامن وراءها بسيط:

  1. يقوم بتحميل التكوين للشاشة المحددة الحالية ؛
  2. ينظف أي عناصر واجهة مستخدم موجودة مسبقًا ؛
  3. يمر فوق كل عنصر واجهة مستخدم ويقوم بإنشاء مثيل لها ؛
  4. إذا تم تمرير تنبيه إضافي كرسالة فلاش (وهو في الأساس مفهوم سرقته من Web Dev حيث تقوم بإعداد رسالة لتظهر على الشاشة حتى التحديث التالي) ؛
  5. تقديم الشاشة الفعلية ؛
  6. وأخيرًا ، اطلب معالج الشاشة ونفذ طريقة "init".

هذا هو! يمكنك التحقق من باقي الطرق - ترتبط في الغالب بعناصر واجهة مستخدم فردية وكيفية عرضها.

التواصل بين واجهة المستخدم ومنطق الأعمال

على الرغم من النطاق الكبير ، فإن واجهة المستخدم والخلفية وخادم الدردشة جميعها تتمتع باتصالات قائمة على طبقات إلى حد ما ؛ تحتاج الواجهة الأمامية نفسها على الأقل إلى بنية داخلية من طبقتين تتفاعل فيها عناصر واجهة المستخدم النقية مع مجموعة من الوظائف التي تمثل المنطق الأساسي داخل هذا المشروع المعين.

يوضح الرسم البياني التالي البنية الداخلية للعميل النصي الذي نقوم ببنائه:

(معاينة كبيرة)

اسمحوا لي أن أشرح ذلك قليلا. كما ذكرت أعلاه ، فإن loadScreenMethod ستنشئ عروض واجهة المستخدم التقديمية للأدوات (هذه كائنات مباركة). ولكن يتم تضمينها كجزء من كائن منطق الشاشة حيث نقوم بإعداد الأحداث الأساسية (مثل onSubmit لمربعات الإدخال).

اسمح لي أن أعطيك مثالاً عمليًا. هذه هي الشاشة الأولى التي تراها عند بدء تشغيل عميل واجهة المستخدم:

(معاينة كبيرة)

هناك ثلاثة أقسام على هذه الشاشة:

  1. طلب اسم المستخدم ،
  2. خيارات / معلومات القائمة ،
  3. شاشة الإدخال لخيارات القائمة.

في الأساس ، ما نريد القيام به هو طلب اسم المستخدم ثم نطلب منهم اختيار أحد الخيارين (إما بدء لعبة جديدة تمامًا أو الانضمام إلى لعبة حالية).

الكود الذي يعتني بذلك هو ما يلي:

 module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }

أعلم أن هذا عدد كبير من التعليمات البرمجية ، لكن ركز فقط على طريقة init . آخر شيء يفعله هو استدعاء طريقة setInput التي تهتم بإضافة الأحداث الصحيحة إلى مربعات الإدخال الصحيحة.

لذلك ، بهذه السطور:

 let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim()

نحن نصل إلى الكائنات المباركة ونحصل على مراجعها ، حتى نتمكن لاحقًا من إعداد أحداث submit . لذلك بعد إرسال اسم المستخدم ، نحول التركيز إلى مربع الإدخال الثاني (حرفيًا باستخدام input.focus() ).

اعتمادًا على الخيار الذي نختاره من القائمة ، فإننا نستدعي أيًا من الطريقتين:

  • createNewGame : إنشاء لعبة جديدة من خلال التفاعل مع المعالج المرتبط بها ؛
  • moveToIDRequest : يعرض الشاشة التالية المسؤولة عن طلب معرف اللعبة للانضمام.

التواصل مع محرك اللعبة

أخيرًا وليس آخرًا (واتباع المثال أعلاه) ، إذا قمت بالضغط على 2 ، ستلاحظ أن طريقة createNewGame تستخدم أساليب المعالج createNewGame ثم joinGame إلى اللعبة (الانضمام إلى اللعبة مباشرة بعد إنشائها).

تهدف كلتا الطريقتين إلى تبسيط التفاعل مع واجهة برمجة تطبيقات Game Engine. هذا هو رمز معالج هذه الشاشة:

 const request = require("request"), config = require("config"), apiClient = require("./apiClient") let API = config.get("api") module.exports = { joinGame: function(apikey, gameId, cb) { apiClient.joinGame(apikey, gameId, cb) }, createNewGame: function(apikey, cb) { request.post(API.url + API.endpoints.games + "?apikey=" + apikey, { //creating game body: { cartridgeid: config.get("app.game.cartdrigename") }, json: true }, (err, resp, body) => { cb(null, body) }) } }

هناك ترى طريقتين مختلفتين للتعامل مع هذا السلوك. تستخدم الطريقة الأولى في الواقع فئة apiClient ، والتي تقوم مرة أخرى بلف التفاعلات مع GameEngine إلى طبقة أخرى من التجريد.

الطريقة الثانية على الرغم من تنفيذ الإجراء مباشرة عن طريق إرسال طلب POST إلى عنوان URL الصحيح مع الحمولة الصحيحة. لا شيء يتوهم بعد ذلك ؛ نحن فقط نرسل نص الاستجابة إلى منطق واجهة المستخدم.

ملاحظة : إذا كنت مهتمًا بالإصدار الكامل من الكود المصدري لهذا العميل ، فيمكنك التحقق منه هنا.

الكلمات الأخيرة

هذا هو للعميل القائم على النص لمغامرتنا النصية. غطيت:

  • كيفية هيكلة تطبيق العميل ؛
  • كيف استخدمت Blessed كتقنية أساسية لإنشاء طبقة العرض التقديمي ؛
  • كيفية هيكلة التفاعل مع الخدمات الخلفية من عميل معقد ؛
  • ونأمل أن يكون المستودع الكامل متاحًا.

وعلى الرغم من أن واجهة المستخدم قد لا تبدو تمامًا مثل النسخة الأصلية ، إلا أنها تفي بالغرض منها. نأمل أن يكون هذا المقال قد أعطاك فكرة عن كيفية تصميم مثل هذا المسعى وكنت تميل إلى تجربته بنفسك في المستقبل. تعد Blessed بالتأكيد أداة قوية للغاية ، ولكن عليك أن تتحلى بالصبر أثناء تعلم كيفية استخدامها وكيفية التنقل عبر مستنداتهم.

في الجزء التالي والأخير ، سأغطي كيفية إضافة خادم الدردشة في كل من النهاية الخلفية وكذلك لعميل النص هذا.

نراكم في المرحلة التالية!

أجزاء أخرى من هذه السلسلة

  • الجزء الأول: المقدمة
  • الجزء 2: تصميم خادم محرك اللعبة
  • الجزء 4: إضافة الدردشة إلى لعبتنا