Scrierea unui motor de aventură text multiplayer în Node.js: crearea clientului terminal (partea 3)

Publicat: 2022-03-10
Rezumat rapid ↬ Această a treia parte a seriei se va concentra pe adăugarea unui client bazat pe text pentru motorul de joc care a fost creat în partea 2. Fernando Doglio explică designul arhitecturii de bază, selecția instrumentelor și evidențierea codului, arătându-vă cum să creați un text- interfață de utilizare bazată cu ajutorul Node.js.

Mai întâi v-am arătat cum să definiți un proiect ca acesta și v-am oferit elementele de bază ale arhitecturii, precum și mecanica din spatele motorului de joc. Apoi, v-am arătat implementarea de bază a motorului - un API REST de bază care vă permite să traversați o lume definită de JSON.

Astăzi, vă voi arăta cum să creați un client text vechi pentru API-ul nostru, folosind nimic altceva decât Node.js.

Alte părți ale acestei serii

  • Partea 1: Introducere
  • Partea 2: Proiectarea serverului motorului de joc
  • Partea 4: Adăugarea de chat în jocul nostru

Revizuirea designului original

Când am propus prima dată un cadru de bază pentru interfața de utilizare, am propus patru secțiuni pe ecran:

(Previzualizare mare)

Deși, teoretic, pare corect, am ratat faptul că comutarea între trimiterea comenzilor de joc și a mesajelor text ar fi o durere, așa că, în loc să-i facem pe jucători să schimbe manual, vom avea ca analizatorul nostru de comenzi să se asigure că este capabil să discerne dacă Încercăm să comunicăm cu jocul sau cu prietenii noștri.

Deci, în loc să avem patru secțiuni pe ecran, acum vom avea trei:

(Previzualizare mare)

Aceasta este o captură de ecran reală a clientului final al jocului. Puteți vedea ecranul jocului în stânga și chatul în dreapta, cu o singură casetă de introducere comună în partea de jos. Modulul pe care îl folosim ne permite să personalizăm culorile și unele efecte de bază. Veți putea clona acest cod din Github și veți face ceea ce doriți cu aspectul și senzația.

Totuși, un avertisment: deși captura de ecran de mai sus arată că chatul funcționează ca parte a aplicației, vom menține acest articol concentrat pe configurarea proiectului și definirea unui cadru în care putem crea o aplicație dinamică bazată pe text-UI. Ne vom concentra pe adăugarea asistenței prin chat în următorul și ultimul capitol al acestei serii.

Mai multe după săritură! Continuați să citiți mai jos ↓

Instrumentele de care vom avea nevoie

Deși există multe biblioteci care ne permit să creăm instrumente CLI cu Node.js, adăugarea unei interfețe de utilizare bazată pe text este o fiară complet diferită de îmblânzit. În special, am reușit să găsesc o singură bibliotecă (foarte completă, ține cont) care să mă lase să fac exact ceea ce îmi doream: Binecuvântat.

Această bibliotecă este foarte puternică și oferă o mulțime de funcții pe care nu le vom folosi pentru acest proiect (cum ar fi proiectarea de umbre, drag&drop și altele). Practic, reimplementează întreaga bibliotecă ncurses (o bibliotecă C care permite dezvoltatorilor să creeze interfețe de utilizare bazate pe text) care nu are legături Node.js și o face direct în JavaScript; așa că, dacă ar trebui, am putea foarte bine să-i verificăm codul intern (ceva pe care nu l-aș recomanda decât dacă ar trebui neapărat).

Deși documentația pentru Blessed este destul de extinsă, ea constă în principal din detalii individuale despre fiecare metodă furnizată (spre deosebire de tutoriale care explică cum să folosiți efectiv acele metode împreună) și lipsesc exemple peste tot, așa că ar putea fi dificil să o cercetați. dacă trebuie să înțelegeți cum funcționează o anumită metodă. Acestea fiind spuse, odată ce înțelegi, totul funcționează la fel, ceea ce este un mare plus, deoarece nu orice bibliotecă sau chiar limba (mă uit la tine, PHP) are o sintaxă consistentă.

Dar documentația deoparte; marele plus pentru această bibliotecă este că funcționează pe baza opțiunilor JSON. De exemplu, dacă doriți să desenați o casetă în colțul din dreapta sus al ecranului, ați face ceva de genul acesta:

 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' } } });

După cum vă puteți imagina, acolo sunt definite și alte aspecte ale casetei (cum ar fi dimensiunea), care pot fi perfect dinamice în funcție de dimensiunea terminalului, tipul de chenar și culorile - chiar și pentru evenimentele de tip hover. Dacă ați făcut dezvoltare front-end la un moment dat, veți găsi o mulțime de suprapunere între cele două.

Ideea pe care încerc să îl subliniez aici este că totul în ceea ce privește reprezentarea casetei este configurat prin intermediul obiectului JSON transmis metodei box . Pentru mine, acest lucru este perfect pentru că pot extrage cu ușurință acel conținut într-un fișier de configurare și pot crea o logică de afaceri capabilă să o citească și să decidă ce elemente să deseneze pe ecran. Cel mai important, ne va ajuta să vedem cum vor arăta odată ce vor fi desenate.

Aceasta va fi baza pentru întregul aspect al UI al acestui modul ( mai multe despre asta într-o secundă! ).

Arhitectura Modulului

Arhitectura principală a acestui modul se bazează în întregime pe widget-urile UI pe care le vom afișa. Un grup de aceste widget-uri este considerat un ecran, iar toate aceste ecrane sunt definite într-un singur fișier JSON (pe care îl puteți găsi în folderul /config ).

Acest fișier are peste 250 de linii, așa că nu are sens să-l arăți aici. Puteți să vă uitați la fișierul complet online, dar un mic fragment din acesta arată astfel:

 "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", //... } } }

Elementul „ecrane” va conține lista de ecrane din interiorul aplicației. Fiecare ecran conține o listă de widget-uri (pe care le voi acoperi puțin) și fiecare widget are definiția sa specifică binecuvântărilor și fișierele de gestionare aferente (dacă este cazul).

Puteți vedea cum fiecare element „params” (în interiorul unui anumit widget) reprezintă setul real de parametri așteptați de metodele pe care le-am văzut mai devreme. Restul cheilor definite acolo ajută la furnizarea contextului despre tipul de widget-uri care trebuie redate și despre comportamentul acestora.

Câteva puncte de interes:

Manipulatori de ecran

Fiecare element de ecran are o proprietate de fișier care face referire la codul asociat acelui ecran. Acest cod nu este altceva decât un obiect care trebuie să aibă o metodă init (logica de inițializare pentru acel ecran particular are loc în interiorul acestuia). În special, motorul UI principal, va apela acea metodă de init a fiecărui ecran, care, la rândul său, ar trebui să fie responsabil pentru inițializarea oricărei logici de care ar putea avea nevoie (adică configurarea evenimentelor casetelor de intrare).

Următorul este codul pentru ecranul principal, unde aplicația solicită jucătorului să selecteze o opțiune fie pentru a începe un joc nou-nouț, fie pentru a se alătura unuia existent:

 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 } }

După cum puteți vedea, metoda init apelează metoda setupInput care, practic, configurează callback-ul corect pentru a gestiona intrarea utilizatorului. Apelul înapoi păstrează logica pentru a decide ce să facă pe baza intrării utilizatorului (fie 1, fie 2).

Manipulatori de widget-uri

Unele dintre widget-uri (de obicei widget-uri de intrare) au o proprietate handlerPath , care face referire la fișierul care conține logica din spatele acelei componente specifice. Acesta nu este același cu gestionarea ecranului anterior. Acestora nu le pasă atât de mult de componentele UI. În schimb, ei se ocupă de logica lipită dintre UI și orice bibliotecă pe care o folosim pentru a interacționa cu servicii externe (cum ar fi API-ul motorului de joc).

Tipuri de widgeturi

O altă adăugare minoră la definiția JSON a widget-urilor este tipul acestora. În loc să merg cu numele Blessed definite pentru ei, creez altele noi pentru a-mi oferi mai mult spațiu de mișcare când vine vorba de comportamentul lor. La urma urmei, este posibil ca un widget de fereastră să nu „afișeze doar informații”, sau o casetă de introducere ar putea să nu funcționeze întotdeauna la fel.

Aceasta a fost în mare parte o mișcare preventivă, doar pentru a mă asigura că am această abilitate dacă voi avea nevoie vreodată de ea în viitor, dar, după cum veți vedea, oricum nu folosesc atât de multe tipuri diferite de componente.

Ecrane multiple

Deși ecranul principal este cel pe care ți l-am arătat în captura de ecran de mai sus, jocul necesită alte câteva ecrane pentru a solicita lucruri precum numele jucătorului tău sau dacă creezi o sesiune de joc nou-nouță sau chiar te alături uneia existente. Modul în care am gestionat asta a fost, din nou, prin definirea tuturor acestor ecrane în același fișier JSON. Și pentru a trece de la un ecran la următorul, folosim logica din interiorul fișierelor de gestionare a ecranului.

Putem face acest lucru pur și simplu utilizând următoarea linie de cod:

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

Vă voi arăta mai multe detalii despre proprietatea UI într-o secundă, dar folosesc doar acea metodă loadScreen pentru a reda ecranul și pentru a alege componentele potrivite din fișierul JSON folosind șirul transmis ca parametru. Foarte simplu.

Exemple de cod

Acum este timpul să verifici carnea și cartofii din acest articol: mostrele de cod. Voi evidenția doar ceea ce cred că sunt micile pietre prețioase din interiorul său, dar puteți oricând să aruncați o privire la codul sursă complet direct în depozit.

Utilizarea fișierelor de configurare pentru a genera automat interfața de utilizare

Am acoperit deja o parte din asta, dar cred că merită să explorez detaliile din spatele acestui generator. Esența din spatele acestuia (fișierul index.js în interiorul folderului /ui ) este că este un înveliș în jurul obiectului Blessed. Și cea mai interesantă metodă din interiorul acesteia este metoda loadScreen .

Această metodă preia configurația (prin modulul de configurare) pentru un anumit ecran și parcurge conținutul acestuia, încercând să genereze widget-urile potrivite în funcție de tipul fiecărui element.

 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() },

După cum puteți vedea, codul este puțin lung, dar logica din spatele lui este simplă:

  1. Încarcă configurația pentru ecranul specific curent;
  2. Curăță orice widget-uri existente anterior;
  3. Trece peste fiecare widget și îl instanțiază;
  4. Dacă a fost transmisă o alertă suplimentară ca mesaj flash (care este practic un concept pe care l-am furat de la Web Dev în care configurați un mesaj care să fie afișat pe ecran până la următoarea reîmprospătare);
  5. Redați ecranul real;
  6. Și, în cele din urmă, solicitați gestionarea ecranului și executați metoda „init”.

Asta e! Puteți verifica restul metodelor - acestea sunt în mare parte legate de widget-uri individuale și de modul de redare a acestora.

Comunicarea între UI și logica de afaceri

Deși la scară mare, interfața de utilizare, back-end-ul și serverul de chat au toate o comunicare oarecum bazată pe straturi; front-end-ul în sine are nevoie de cel puțin o arhitectură internă cu două straturi în care elementele pure UI interacționează cu un set de funcții care reprezintă logica de bază în interiorul acestui proiect particular.

Următoarea diagramă arată arhitectura internă pentru clientul de text pe care îl construim:

(Previzualizare mare)

Lasă-mă să explic un pic mai departe. După cum am menționat mai sus, loadScreenMethod va crea prezentări UI ale widget-urilor (acestea sunt obiecte Blessed). Dar ele sunt conținute ca parte a obiectului logic al ecranului, care este locul în care setăm evenimentele de bază (cum ar fi onSubmit pentru casetele de intrare).

Permiteți-mi să vă dau un exemplu practic. Iată primul ecran pe care îl vedeți când porniți clientul UI:

(Previzualizare mare)

Există trei secțiuni pe acest ecran:

  1. Solicitare nume de utilizator,
  2. Opțiuni/informații de meniu,
  3. Ecran de introducere a opțiunilor de meniu.

Practic, ceea ce vrem să facem este să cerem numele de utilizator și apoi să le cerem să aleagă una dintre cele două opțiuni (fie pornirea unui joc nou-nouț, fie alăturarea unuia existent).

Codul care se ocupă de asta este următorul:

 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 } }

Știu că este mult cod, dar concentrează-te doar pe metoda init . Ultimul lucru pe care îl face este să apeleze metoda setInput care se ocupă de adăugarea evenimentelor potrivite în casetele de intrare potrivite.

Prin urmare, cu aceste rânduri:

 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()

Accesăm obiectele Binecuvântate și obținem referințele lor, astfel încât să putem configura ulterior evenimentele de submit . Deci, după ce trimitem numele de utilizator, trecem la a doua casetă de introducere (literalmente cu input.focus() ).

În funcție de opțiunea pe care o alegem din meniu, apelăm una dintre metodele:

  • createNewGame : creează un joc nou interacționând cu handlerul asociat;
  • moveToIDRequest : redă următorul ecran responsabil de solicitarea ID-ului jocului pentru a se alătura.

Comunicarea cu motorul de joc

Nu în ultimul rând (și urmând exemplul de mai sus), dacă apăsați pe 2, veți observa că metoda createNewGame folosește metodele handler-ului createNewGame și apoi joinGame (alăturarea jocului imediat după ce l-ați creat).

Ambele metode sunt menite să simplifice interacțiunea cu API-ul Game Engine. Iată codul pentru handlerul acestui ecran:

 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) }) } }

Acolo vedeți două moduri diferite de a gestiona acest comportament. Prima metodă folosește de fapt clasa apiClient , care, din nou, înglobează interacțiunile cu GameEngine într-un alt strat de abstractizare.

A doua metodă, totuși, efectuează acțiunea direct, trimițând o solicitare POST la adresa URL corectă cu sarcina utilă potrivită. Nu se face nimic elegant după aceea; doar trimitem corpul răspunsului înapoi la logica UI.

Notă : Dacă sunteți interesat de versiunea completă a codului sursă pentru acest client, îl puteți verifica aici.

Cuvinte finale

Acesta este pentru clientul bazat pe text pentru aventura noastră text. am acoperit:

  • Cum se structurează o aplicație client;
  • Cum am folosit Blessed ca tehnologie de bază pentru crearea stratului de prezentare;
  • Cum să structurați interacțiunea cu serviciile back-end de la un client complex;
  • Și sperăm, cu depozitul complet disponibil.

Și, deși interfața de utilizare ar putea să nu arate exact ca versiunea originală, își îndeplinește scopul. Sperăm că acest articol ți-a dat o idee despre cum să arhitecți un astfel de demers și ai fost înclinat să-l încerci singur pe viitor. Blessed este cu siguranță un instrument foarte puternic, dar va trebui să ai răbdare cu el în timp ce înveți cum să-l folosești și cum să navighezi prin documentele lor.

În următoarea și ultima parte, voi vorbi despre cum am adăugat serverul de chat atât pe back-end, cât și pentru acest client text.

Ne vedem la următorul!

Alte părți ale acestei serii

  • Partea 1: Introducere
  • Partea 2: Proiectarea serverului motorului de joc
  • Partea 4: Adăugarea de chat în jocul nostru