Scrierea unui motor de aventură text multiplayer în Node.js: proiectarea serverului motorului de joc (partea 2)
Publicat: 2022-03-10După o analiză atentă și implementarea efectivă a modulului, unele dintre definițiile pe care le-am făcut în timpul fazei de proiectare au trebuit să fie schimbate. Aceasta ar trebui să fie o scenă familiară pentru oricine a lucrat vreodată cu un client dornic care visează la un produs ideal, dar trebuie să fie reținut de echipa de dezvoltare.
Odată ce caracteristicile au fost implementate și testate, echipa ta va începe să observe că unele caracteristici ar putea diferi de planul original și este în regulă. Pur și simplu notificați, ajustați și continuați. Așa că, fără alte prelungiri, permiteți-mi mai întâi să explic ce s-a schimbat față de planul inițial.
Alte părți ale acestei serii
- Partea 1: Introducere
- Partea 3: Crearea clientului terminal
- Partea 4: Adăugarea de chat în jocul nostru
Mecanica de luptă
Aceasta este probabil cea mai mare schimbare față de planul inițial. Știu că am spus că voi merge cu o implementare D&D-esque în care fiecare PC și NPC implicat va primi o valoare de inițiativă și după aceea, vom desfășura o luptă pe rând. A fost o idee bună, dar implementarea acesteia pe un serviciu bazat pe REST este puțin complicată, deoarece nu puteți iniția comunicarea din partea serverului și nici nu puteți menține starea între apeluri.
Deci, în schimb, voi profita de mecanica simplificată a REST și voi folosi asta pentru a simplifica mecanica noastră de luptă. Versiunea implementată va fi bazată pe jucători în loc de grup și va permite jucătorilor să atace NPC-uri (Personajele non-player). Dacă atacul lor reușește, NPC-urile vor fi ucise sau vor ataca înapoi fie rănind, fie ucigând jucătorul.
Dacă un atac reușește sau eșuează, va fi determinat de tipul de armă folosită și de slăbiciunile pe care le-ar putea avea un NPC. Deci, practic, dacă monstrul pe care încerci să-l ucizi este slab împotriva armei tale, acesta moare. În caz contrar, nu va fi afectat și, cel mai probabil, va fi foarte supărat.
Declanșatoare
Dacă ați acordat o atenție deosebită definiției jocului JSON din articolul meu anterior, este posibil să fi observat definiția declanșatorului găsită pe elementele scenei. Un anume a implicat actualizarea stării jocului ( statusUpdate
). În timpul implementării, mi-am dat seama că funcționează ca comutator oferă libertate limitată. Vedeți, în modul în care a fost implementat (din punct de vedere idiomatic), ați putut să setați un statut, dar dezactivarea lui nu a fost o opțiune. Deci, în schimb, am înlocuit acest efect de declanșare cu două noi: addStatus
și removeStatus
. Acestea vă vor permite să definiți exact când pot avea loc aceste efecte - dacă este deloc. Simt că acest lucru este mult mai ușor de înțeles și de raționat.
Aceasta înseamnă că declanșatoarele arată acum astfel:
"triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]
Când ridicăm articolul, stabilim o stare, iar când îl aruncăm, îl eliminăm. În acest fel, a avea mai mulți indicatori de stare la nivel de joc este complet posibil și ușor de gestionat.
Implementarea
Cu aceste actualizări în afara drumului, putem începe să acoperim implementarea efectivă. Din punct de vedere arhitectural, nimic nu s-a schimbat; încă construim o API REST care va conține logica motorului de joc principal.
Stiva tehnologică
Pentru acest proiect anume, modulele pe care le voi folosi sunt următoarele:
Modul | Descriere |
---|---|
Express.js | Evident, voi folosi Express pentru a fi baza pentru întregul motor. |
Winston | Tot ceea ce privește înregistrarea se va ocupa de Winston. |
Config | Fiecare variabilă constantă și dependentă de mediu va fi gestionată de modulul config.js, ceea ce simplifică foarte mult sarcina de a le accesa. |
Mangustă | Acesta va fi ORM-ul nostru. Voi modela toate resursele folosind modelele Mongoose și le voi folosi pentru a interacționa direct cu baza de date. |
uuid | Va trebui să generăm niște ID-uri unice - acest modul ne va ajuta cu această sarcină. |
În ceea ce privește alte tehnologii utilizate în afară de Node.js, avem MongoDB și Redis . Îmi place să folosesc Mongo din cauza lipsei schemei necesare. Acest simplu fapt îmi permite să mă gândesc la codul meu și la formatele de date, fără a fi nevoit să-mi fac griji cu privire la actualizarea structurii tabelelor mele, migrarea schemelor sau tipurile de date aflate în conflict.
În ceea ce privește Redis, tind să-l folosesc ca sistem de suport cât de mult pot în proiectele mele și acest caz nu este diferit. Voi folosi Redis pentru tot ceea ce poate fi considerat informații volatile, cum ar fi numerele membrilor de partid, solicitările de comandă și alte tipuri de date care sunt suficient de mici și destul de volatile pentru a nu merita stocare permanentă.
De asemenea, voi folosi funcția de expirare a cheii Redis pentru a gestiona automat unele aspecte ale fluxului (mai multe despre asta în curând).
Definiția API
Înainte de a trece la interacțiunea client-server și la definițiile fluxului de date, vreau să trec peste punctele finale definite pentru acest API. Nu sunt atât de multe, mai ales trebuie să respectăm principalele caracteristici descrise în partea 1:
Caracteristică | Descriere |
---|---|
Alăturați-vă unui joc | Un jucător se va putea alătura unui joc specificând ID-ul jocului. |
Creați un joc nou | Un jucător poate crea și o nouă instanță de joc. Motorul ar trebui să returneze un ID, astfel încât alții să îl poată folosi pentru a se alătura. |
Scena de întoarcere | Această funcție ar trebui să returneze scena curentă în care se află petrecerea. Practic, va returna descrierea, cu toate informațiile asociate (acțiuni posibile, obiecte din ea etc.). |
Interacționează cu scena | Acesta va fi unul dintre cele mai complexe, deoarece va lua o comandă de la client și va efectua acea acțiune - lucruri precum mutarea, împingerea, luarea, privirea, citirea, pentru a numi doar câteva. |
Verificați inventarul | Deși aceasta este o modalitate de a interacționa cu jocul, nu are legătură directă cu scena. Deci, verificarea inventarului pentru fiecare jucător va fi considerată o acțiune diferită. |
Înregistrați aplicația client | Acțiunile de mai sus necesită un client valid pentru a le executa. Acest punct final va verifica aplicația client și va returna un ID de client care va fi utilizat în scopuri de autentificare la solicitările ulterioare. |
Lista de mai sus se traduce în următoarea listă de puncte finale:
Verb | Punct final | Descriere |
---|---|---|
POST | /clients | Aplicațiile client vor necesita să obțină o cheie ID client folosind acest punct final. |
POST | /games | Noi instanțe de joc sunt create folosind acest punct final de către aplicațiile client. |
POST | /games/:id | Odată ce jocul este creat, acest punct final va permite membrilor grupului să se alăture acestuia și să înceapă să joace. |
OBȚINE | /games/:id/:playername | Acest punct final va returna starea curentă a jocului pentru un anumit jucător. |
POST | /games/:id/:playername/commands | În cele din urmă, cu acest punct final, aplicația client va putea trimite comenzi (cu alte cuvinte, acest punct final va fi folosit pentru a juca). |
Permiteți-mi să intru puțin mai în detaliu despre unele dintre conceptele pe care le-am descris în lista anterioară.
Aplicații client
Aplicațiile client vor trebui să se înregistreze în sistem pentru a începe să-l folosească. Toate punctele finale (cu excepția primului din listă) sunt securizate și vor necesita o cheie de aplicație validă pentru a fi trimisă împreună cu cererea. Pentru a obține cheia respectivă, aplicațiile client trebuie să solicite pur și simplu una. Odată furnizate, acestea vor dura atât timp cât sunt utilizate sau vor expira după o lună de neutilizare. Acest comportament este controlat prin stocarea cheii în Redis și setarea unui TTL de o lună.
Instanță de joc
Crearea unui joc nou înseamnă, practic, crearea unei noi instanțe a unui anumit joc. Această nouă instanță va conține o copie a tuturor scenelor și a conținutului acestora. Orice modificare adusă jocului va afecta doar petrecerea. În acest fel, multe grupuri pot juca același joc în mod individual.
Starea jocului jucătorului
Acesta este similar cu cel precedent, dar unic pentru fiecare jucător. În timp ce instanța de joc deține starea jocului pentru întreaga petrecere, starea jocului jucătorului deține starea curentă pentru un anumit jucător. În principal, acesta deține inventarul, poziția, scena curentă și HP (puncte de sănătate).
Comenzile jucătorului
Odată ce totul este configurat și aplicația client s-a înregistrat și s-a alăturat unui joc, poate începe să trimită comenzi. Comenzile implementate în această versiune a motorului includ: move
, look
, pickup
și attack
.
- Comanda de
move
vă va permite să traversați harta. Veți putea specifica direcția spre care doriți să vă deplasați, iar motorul vă va anunța rezultatul. Dacă aruncați o privire rapidă la partea 1, puteți vedea abordarea pe care am luat-o pentru a gestiona hărțile. (Pe scurt, harta este reprezentată ca un grafic, unde fiecare nod reprezintă o cameră sau o scenă și este conectat doar la alte noduri care reprezintă camere adiacente.)
Distanța dintre noduri este prezentă și în reprezentare și cuplată cu viteza standard pe care o are un jucător; să mergi dintr-o cameră în alta s-ar putea să nu fie la fel de simplu ca să-ți spui comanda, dar va trebui, de asemenea, să traversezi distanța. În practică, aceasta înseamnă că trecerea dintr-o cameră în alta ar putea necesita mai multe comenzi de mutare). Celălalt aspect interesant al acestei comenzi vine din faptul că acest motor este menit să susțină partide multiplayer, iar grupul nu poate fi împărțit (cel puțin nu în acest moment).
Prin urmare, soluția pentru aceasta este similară cu un sistem de vot: fiecare membru de partid va trimite o cerere de comandă de mutare oricând dorește. Odată ce mai mult de jumătate dintre ei au făcut acest lucru, se va folosi direcția cea mai solicitată. -
look
este destul de diferită de mutare. Acesta permite jucătorului să specifice o direcție, un articol sau NPC pe care dorește să-l inspecteze. Logica cheie din spatele acestei comenzi este luată în considerare atunci când vă gândiți la descrierile dependente de stare.
De exemplu, să presupunem că intri într-o cameră nouă, dar este complet întuneric (nu vezi nimic) și mergi înainte ignorând-o. Câteva camere mai târziu, ridici o torță aprinsă de pe un perete. Așa că acum poți să te întorci și să reinspectezi acea cameră întunecată. Din moment ce ați luat torța, acum puteți vedea în interiorul ei și puteți interacționa cu oricare dintre obiectele și NPC-urile pe care le găsiți acolo.
Acest lucru se realizează prin menținerea unui set de atribute de stare la nivel de joc și specific jucătorului și permițând creatorului jocului să specifice mai multe descrieri pentru elementele noastre dependente de stare în fișierul JSON. Fiecare descriere este apoi echipată cu un text implicit și un set de altele condiționate, în funcție de starea curentă. Acestea din urmă sunt opționale; singura care este obligatorie este valoarea implicită.
În plus, această comandă are o versiune scurtă pentru alook at room: look around
; asta pentru că jucătorii vor încerca să inspecteze o cameră foarte des, așa că furnizarea unei comenzi scurte (sau alias) care este mai ușor de tastat are foarte mult sens. - Comanda de
pickup
joacă un rol foarte important pentru joc. Această comandă are grijă să adauge articole în inventarul jucătorilor sau în mâinile acestora (dacă sunt liberi). Pentru a înțelege unde este menit să fie stocat fiecare articol, definiția lor are o proprietate „destinație” care specifică dacă este destinat inventarului sau mâinilor jucătorului. Orice lucru care este preluat cu succes din scenă este apoi eliminat din ea, actualizându-se versiunea jocului a instanței de joc. - Comanda de
use
vă va permite să afectați mediul folosind articole din inventarul dvs. De exemplu, ridicarea unei chei dintr-o cameră vă va permite să o utilizați pentru a deschide o ușă încuiată într-o altă cameră. - Există o comandă specială, una care nu este legată de joc, ci în schimb o comandă de ajutor menită să obțină anumite informații, cum ar fi ID-ul jocului curent sau numele jucătorului. Această comandă se numește get , iar jucătorii o pot folosi pentru a interoga motorul jocului. De exemplu: obțineți gameid .
- În cele din urmă, ultima comandă implementată pentru această versiune a motorului este comanda de
attack
. Am acoperit-o deja pe aceasta; practic, va trebui să specificați ținta și arma cu care o atacați. În acest fel, sistemul va putea verifica punctele slabe ale țintei și va determina rezultatul atacului dumneavoastră.
Interacțiunea client-motor
Pentru a înțelege cum să utilizați punctele finale enumerate mai sus, permiteți-mi să vă arăt cum poate interacționa orice potențial client cu noul nostru API.
Etapa | Descriere |
---|---|
Înregistrați clientul | În primul rând, aplicația client trebuie să solicite o cheie API pentru a putea accesa toate celelalte puncte finale. Pentru a obține cheia respectivă, trebuie să se înregistreze pe platforma noastră. Singurul parametru de furnizat este numele aplicației, atâta tot. |
Creați un joc | După ce se obține cheia API, primul lucru de făcut (presupunând că aceasta este o interacțiune nouă) este să creați o instanță de joc nou-nouță. Gândiți-vă la asta astfel: fișierul JSON pe care l-am creat în ultima mea postare conține definiția jocului, dar trebuie să creăm o instanță a acestuia doar pentru dvs. și grupul dvs. (gândiți-vă la clase și obiecte, aceeași afacere). Puteți face cu acea instanță orice doriți și nu va afecta alte părți. |
Alăturați-vă jocului | După crearea jocului, veți primi un ID de joc înapoi de la motor. Apoi puteți utiliza acel ID de joc pentru a vă alătura instanței folosind numele dvs. de utilizator unic. Dacă nu vă alăturați jocului, nu puteți juca, deoarece participarea la joc va crea, de asemenea, o instanță de stare a jocului numai pentru dvs. Aici vor fi salvate inventarul, poziția și statisticile de bază în legătură cu jocul pe care îl jucați. Este posibil să jucați mai multe jocuri în același timp și, în fiecare, aveți stări independente. |
Trimite comenzi | Cu alte cuvinte: joacă jocul. Pasul final este să începeți să trimiteți comenzi. Cantitatea de comenzi disponibile a fost deja acoperită și poate fi extinsă cu ușurință (mai multe despre asta într-un pic). De fiecare dată când trimiteți o comandă, jocul va returna noua stare de joc pentru ca clientul dvs. să vă actualizeze vizualizarea în consecință. |
Să ne murdărim mâinile
Am analizat cât am putut de mult design, în speranța că aceste informații vă vor ajuta să înțelegeți următoarea parte, așa că haideți să intrăm în elementele motorului de joc.
Notă : nu vă voi arăta codul complet în acest articol, deoarece este destul de mare și nu este tot interesant. În schimb, voi arăta părțile mai relevante și voi trimite la depozitul complet în cazul în care doriți mai multe detalii.
Fișierul principal
În primul rând: acesta este un proiect Express și codul standard a fost generat folosind propriul generator Express, așa că fișierul app.js ar trebui să vă fie familiar. Vreau doar să trec peste două ajustări pe care îmi place să le fac la acel cod pentru a-mi simplifica munca.
Mai întâi, adaug următorul fragment pentru a automatiza includerea noilor fișiere de rută:
const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })
Este destul de simplu într-adevăr, dar elimină nevoia de a solicita manual fiecare fișier de rută pe care îl creați în viitor. Apropo, require-dir
este un modul simplu care are grijă să solicite automat fiecare fișier din interiorul unui folder. Asta e.
Cealaltă schimbare pe care îmi place să o fac este să-mi modific puțin gestionarea erorilor. Chiar ar trebui să încep să folosesc ceva mai robust, dar pentru nevoile la îndemână, simt că acest lucru duce la bun sfârșit:
// error handler app.use(function(err, req, res, next) { // render the error page if(typeof err === "string") { err = { status: 500, message: err } } res.status(err.status || 500); let errorObj = { error: true, msg: err.message, errCode: err.status || 500 } if(err.trace) { errorObj.trace = err.trace } res.json(errorObj); });
Codul de mai sus are grijă de diferitele tipuri de mesaje de eroare cu care ar trebui să ne confruntăm - fie obiecte complete, obiecte de eroare reale aruncate de Javascript sau mesaje de eroare simple fără alt context. Acest cod va prelua totul și îl va formata într-un format standard.
Manipularea comenzilor
Acesta este încă unul dintre acele aspecte ale motorului care trebuia să fie ușor de extins. Într-un proiect ca acesta, este absolut logic să presupunem că vor apărea noi comenzi în viitor. Dacă doriți să evitați ceva, atunci probabil că ar fi să evitați să faceți modificări la codul de bază atunci când încercați să adăugați ceva nou trei sau patru luni în viitor.
Nicio cantitate de comentarii de cod nu va face sarcina de a modifica codul la care nu te-ai atins (sau nici măcar nu te-ai gândit) în câteva luni, așa că prioritatea este de a evita cât mai multe modificări posibil. Din fericire pentru noi, există câteva modele pe care le putem implementa pentru a rezolva acest lucru. În special, am folosit un amestec de modele Command și Factory.
Practic, am încapsulat comportamentul fiecărei comenzi într-o singură clasă care moștenește de la o clasă BaseCommand
care conține codul generic pentru toate comenzile. În același timp, am adăugat un modul CommandParser
care preia șirul trimis de client și returnează comanda efectivă de executat.
Analizatorul este foarte simplu, deoarece toate comenzile implementate au acum comanda reală cu privire la primul lor cuvânt (adică „mută-te spre nord”, „ ridică cuțitul” și așa mai departe) este o chestiune simplă de a împărți șirul și a obține prima parte:
const requireDir = require("require-dir") const validCommands = requireDir('./commands') class CommandParser { constructor(command) { this.command = command } normalizeAction(strAct) { strAct = strAct.toLowerCase().split(" ")[0] return strAct } verifyCommand() { if(!this.command) return false if(!this.command.action) return false if(!this.command.context) return false let action = this.normalizeAction(this.command.action) if(validCommands[action]) { return validCommands[action] } return false } parse() { let validCommand = this.verifyCommand() if(validCommand) { let cmdObj = new validCommand(this.command) return cmdObj } else { return false } } }
Notă : Folosesc încă o dată modulul require-dir
pentru a simplifica includerea oricăror clase de comandă existente și noi. Pur și simplu îl adaug în folder și întregul sistem îl poate ridica și îl poate folosi.
Acestea fiind spuse, există multe moduri în care acest lucru poate fi îmbunătățit; de exemplu, posibilitatea de a adăuga suport pentru sinonime pentru comenzile noastre ar fi o caracteristică grozavă (deci a spune „mută-te spre nord”, „mergi spre nord” sau chiar „mergi spre nord” ar însemna același lucru). Acesta este ceva ce am putea centraliza în această clasă și să afecteze toate comenzile în același timp.
Nu voi intra în detalii despre niciuna dintre comenzi pentru că, din nou, este prea mult cod pentru a fi afișat aici, dar puteți vedea în următorul cod de rută cum am reușit să generalizez acea manipulare a comenzilor existente (și viitoare):
/** Interaction with a particular scene */ router.post('/:id/:playername/:scene', function(req, res, next) { let command = req.body command.context = { gameId: req.params.id, playername: req.params.playername, } let parser = new CommandParser(command) let commandObj = parser.parse() //return the command instance if(!commandObj) return next({ //error handling status: 400, errorCode: config.get("errorCodes.invalidCommand"), message: "Unknown command" }) commandObj.run((err, result) => { //execute the command if(err) return next(err) res.json(result) }) })
Toate comenzile necesită doar metoda de run
- orice altceva este suplimentar și este destinat uzului intern.
Vă încurajez să consultați întregul cod sursă (chiar să îl descărcați și să vă jucați cu el dacă doriți!). În următoarea parte a acestei serii, vă voi arăta implementarea reală a clientului și interacțiunea acestui API.
Gânduri de închidere
Poate că nu am acoperit foarte mult din codul meu aici, dar sper totuși că articolul a fost util pentru a vă arăta cum abordez proiectele - chiar și după faza inițială de proiectare. Simt că mulți oameni încearcă să înceapă să codifice ca prim răspuns la o idee nouă și care uneori poate sfârși prin a descuraja un dezvoltator, deoarece nu există un plan real stabilit și nici un obiectiv de atins - în afară de a avea produsul final pregătit ( și aceasta este o piatră de hotar prea mare pentru a fi abordată din ziua 1). Deci, din nou, speranța mea cu aceste articole este să împărtășesc un mod diferit de a lucra singur (sau ca parte a unui grup mic) la proiecte mari.
Sper că ți-a plăcut lectura! Vă rugăm să nu ezitați să lăsați un comentariu mai jos cu orice tip de sugestii sau recomandări, mi-ar plăcea să citesc ce părere aveți și dacă sunteți nerăbdător să începeți să testați API-ul cu propriul cod de client.
Ne vedem la următorul!
Alte părți ale acestei serii
- Partea 1: Introducere
- Partea 3: Crearea clientului terminal
- Partea 4: Adăugarea de chat în jocul nostru