Scrivere un motore di avventura testuale multigiocatore in Node.js: progettazione del server del motore di gioco (parte 2)
Pubblicato: 2022-03-10Dopo un'attenta considerazione e l'effettiva implementazione del modulo, alcune delle definizioni che ho fatto in fase di progettazione hanno dovuto essere modificate. Questa dovrebbe essere una scena familiare per chiunque abbia mai lavorato con un cliente desideroso che sogna un prodotto ideale ma ha bisogno di essere limitato dal team di sviluppo.
Una volta che le funzionalità sono state implementate e testate, il tuo team inizierà a notare che alcune caratteristiche potrebbero differire dal piano originale e va bene. Basta notificare, regolare e andare avanti. Quindi, senza ulteriori indugi, consentitemi di spiegare prima cosa è cambiato rispetto al piano originale.
Altre parti di questa serie
- Parte 1: L'introduzione
- Parte 3: Creazione del client terminale
- Parte 4: Aggiunta di chat al nostro gioco
Meccaniche di battaglia
Questo è probabilmente il più grande cambiamento rispetto al piano originale. So di aver detto che avrei optato per un'implementazione in stile D&D in cui ogni PC e NPC coinvolto avrebbe ottenuto un valore di iniziativa e, successivamente, avremmo condotto un combattimento a turni. È stata una buona idea, ma implementarla su un servizio basato su REST è un po' complicata poiché non è possibile avviare la comunicazione dal lato server, né mantenere lo stato tra le chiamate.
Quindi, invece, sfrutterò la meccanica semplificata di REST e la userò per semplificare le nostre meccaniche di battaglia. La versione implementata sarà basata sul giocatore anziché sul gruppo e consentirà ai giocatori di attaccare gli NPC (personaggi non giocanti). Se il loro attacco ha successo, gli NPC verranno uccisi oppure attaccheranno danneggiando o uccidendo il giocatore.
Il successo o il fallimento di un attacco dipenderà dal tipo di arma utilizzata e dai punti deboli che potrebbe avere un NPC. Quindi, in pratica, se il mostro che stai cercando di uccidere è debole contro la tua arma, muore. Altrimenti, sarà inalterato e, molto probabilmente, molto arrabbiato.
Trigger
Se hai prestato molta attenzione alla definizione del gioco JSON del mio precedente articolo, potresti aver notato la definizione del trigger trovata sugli elementi della scena. Uno in particolare prevedeva l'aggiornamento dello stato del gioco ( statusUpdate
). Durante l'implementazione, mi sono reso conto che il fatto che funzionasse come un interruttore forniva una libertà limitata. Vedi, nel modo in cui è stato implementato (da un punto di vista idiomatico), sei stato in grado di impostare uno stato ma disimpostarlo non era un'opzione. Quindi, invece, ho sostituito questo effetto trigger con due nuovi: addStatus
e removeStatus
. Questi ti permetteranno di definire esattamente quando questi effetti possono aver luogo, se non del tutto. Sento che questo è molto più facile da capire e ragionare.
Ciò significa che i trigger ora hanno questo aspetto:
"triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]
Quando raccogliamo l'elemento, impostiamo uno stato e, quando lo rilasciamo, lo rimuoviamo. In questo modo, avere più indicatori di stato a livello di gioco è completamente possibile e facile da gestire.
L'implemento
Con questi aggiornamenti fuori mano, possiamo iniziare a coprire l'effettiva implementazione. Dal punto di vista architettonico non è cambiato nulla; stiamo ancora costruendo un'API REST che conterrà la logica del motore di gioco principale.
La pila tecnologica
Per questo particolare progetto, i moduli che utilizzerò sono i seguenti:
Modulo | Descrizione |
---|---|
Express.js | Ovviamente, userò Express come base per l'intero motore. |
Winston | Tutto ciò che riguarda la registrazione sarà gestito da Winston. |
Config | Ogni variabile costante e dipendente dall'ambiente sarà gestita dal modulo config.js, che semplifica notevolmente il compito di accedervi. |
Mangusta | Questo sarà il nostro ORM. Modellerò tutte le risorse usando Mongoose Models e lo userò per interagire direttamente con il database. |
uido | Avremo bisogno di generare alcuni ID univoci: questo modulo ci aiuterà in questo compito. |
Per quanto riguarda altre tecnologie utilizzate oltre a Node.js, abbiamo MongoDB e Redis . Mi piace usare Mongo a causa della mancanza di schema richiesto. Questo semplice fatto mi consente di pensare al mio codice e ai formati dei dati, senza dovermi preoccupare di aggiornare la struttura delle mie tabelle, migrazioni di schemi o tipi di dati in conflitto.
Per quanto riguarda Redis, tendo a usarlo come sistema di supporto il più possibile nei miei progetti e questo caso non è diverso. Userò Redis per tutto ciò che può essere considerato un'informazione volatile, come i numeri dei membri del partito, le richieste di comando e altri tipi di dati che sono sufficientemente piccoli e sufficientemente volatili da non meritare l'archiviazione permanente.
Utilizzerò anche la funzione di scadenza della chiave di Redis per gestire automaticamente alcuni aspetti del flusso (ne parleremo a breve).
Definizione API
Prima di passare all'interazione client-server e alle definizioni del flusso di dati, voglio esaminare gli endpoint definiti per questa API. Non sono molti, per lo più dobbiamo rispettare le caratteristiche principali descritte nella Parte 1:
Caratteristica | Descrizione |
---|---|
Partecipa a una partita | Un giocatore potrà partecipare a una partita specificando l'ID della partita. |
Crea un nuovo gioco | Un giocatore può anche creare una nuova istanza di gioco. Il motore dovrebbe restituire un ID, in modo che altri possano usarlo per partecipare. |
Scena di ritorno | Questa funzione dovrebbe restituire la scena corrente in cui si trova la festa. Fondamentalmente, restituirà la descrizione, con tutte le informazioni associate (possibili azioni, oggetti in essa contenuti, ecc.). |
Interagisci con la scena | Questo sarà uno dei più complessi, perché richiederà un comando dal client ed eseguirà quell'azione: cose come spostare, spingere, prendere, guardare, leggere, solo per citarne alcuni. |
Controlla l'inventario | Sebbene questo sia un modo per interagire con il gioco, non è direttamente correlato alla scena. Quindi, il controllo dell'inventario per ogni giocatore sarà considerato un'azione diversa. |
Registra l'applicazione client | Le azioni di cui sopra richiedono un client valido per eseguirle. Questo endpoint verificherà l'applicazione client e restituirà un ID client che verrà utilizzato per scopi di autenticazione nelle richieste successive. |
L'elenco sopra si traduce nel seguente elenco di endpoint:
Verbo | Punto finale | Descrizione |
---|---|---|
INVIARE | /clients | Le applicazioni client richiederanno di ottenere una chiave ID client utilizzando questo endpoint. |
INVIARE | /games | Le nuove istanze di gioco vengono create utilizzando questo endpoint dalle applicazioni client. |
INVIARE | /games/:id | Una volta creato il gioco, questo endpoint consentirà ai membri del gruppo di unirsi a esso e iniziare a giocare. |
OTTENERE | /games/:id/:playername | Questo endpoint restituirà lo stato di gioco corrente per un particolare giocatore. |
INVIARE | /games/:id/:playername/commands | Infine, con questo endpoint, l'applicazione client sarà in grado di inviare comandi (in altre parole, questo endpoint verrà utilizzato per riprodurre). |
Vorrei entrare un po' più in dettaglio su alcuni dei concetti che ho descritto nell'elenco precedente.
App client
Le applicazioni client dovranno registrarsi nel sistema per iniziare a utilizzarlo. Tutti gli endpoint (tranne il primo dell'elenco) sono protetti e richiedono l'invio di una chiave dell'applicazione valida con la richiesta. Per ottenere quella chiave, le app client devono semplicemente richiederne una. Una volta forniti, dureranno per tutto il tempo in cui vengono utilizzati o scadranno dopo un mese di inutilizzo. Questo comportamento è controllato memorizzando la chiave in Redis e impostando un TTL di un mese su di essa.
Istanza di gioco
Creare un nuovo gioco significa sostanzialmente creare una nuova istanza di un gioco particolare. Questa nuova istanza conterrà una copia di tutte le scene e del loro contenuto. Qualsiasi modifica apportata al gioco influirà solo sul gruppo. In questo modo, molti gruppi possono giocare lo stesso gioco a modo loro.
Stato del gioco del giocatore
Questo è simile al precedente, ma unico per ogni giocatore. Mentre l'istanza di gioco mantiene lo stato di gioco per l'intero gruppo, lo stato di gioco del giocatore mantiene lo stato corrente per un particolare giocatore. Principalmente, contiene inventario, posizione, scena attuale e HP (punti salute).
Comandi del giocatore
Una volta che tutto è impostato e l'applicazione client si è registrata e si è unita a un gioco, può iniziare a inviare comandi. I comandi implementati in questa versione del motore includono: move
, look
, pickup
-up e attack
.
- Il comando di
move
ti consentirà di attraversare la mappa. Sarai in grado di specificare la direzione verso cui vuoi muoverti e il motore ti farà sapere il risultato. Se dai una rapida occhiata alla Parte 1, puoi vedere l'approccio che ho adottato per gestire le mappe. (In breve, la mappa è rappresentata come un grafico, in cui ogni nodo rappresenta una stanza o una scena ed è collegato solo ad altri nodi che rappresentano stanze adiacenti.)
La distanza tra i nodi è presente anche nella rappresentazione e accoppiata alla velocità standard di un giocatore; andare da una stanza all'altra potrebbe non essere semplice come dare il tuo comando, ma dovrai anche attraversare la distanza. In pratica questo significa che il passaggio da una stanza all'altra potrebbe richiedere più comandi di spostamento). L'altro aspetto interessante di questo comando deriva dal fatto che questo motore è pensato per supportare i party multiplayer e il party non può essere diviso (almeno non in questo momento).
Pertanto, la soluzione per questo è simile a un sistema di voto: ogni membro del partito invierà una richiesta di comando di spostamento ogni volta che lo desidera. Una volta che più della metà di loro lo avrà fatto, verrà utilizzata la direzione più richiesta. -
look
è molto diverso dal movimento. Consente al giocatore di specificare una direzione, un oggetto o un NPC che desidera ispezionare. La logica chiave alla base di questo comando viene presa in considerazione quando si pensa alle descrizioni dipendenti dallo stato.
Ad esempio, supponiamo che entri in una nuova stanza, ma è completamente buia (non vedi nulla) e vai avanti ignorandola. Poche stanze dopo, prendi una torcia accesa da un muro. Quindi ora puoi tornare indietro e ispezionare nuovamente quella stanza buia. Dato che hai raccolto la torcia, ora puoi vedere al suo interno ed essere in grado di interagire con qualsiasi oggetto e NPC che trovi lì dentro.
Ciò si ottiene mantenendo un insieme di attributi di stato a livello di gioco e specifico per il giocatore e consentendo al creatore del gioco di specificare diverse descrizioni per i nostri elementi dipendenti dallo stato nel file JSON. Ogni descrizione è poi dotata di un testo predefinito e di una serie di condizionali, a seconda dello stato attuale. Questi ultimi sono facoltativi; l'unico che è obbligatorio è il valore predefinito.
Inoltre, questo comando ha una versione abbreviata perlook at room: look around
; questo perché i giocatori cercheranno di ispezionare una stanza molto spesso, quindi fornire un comando a mano corta (o alias) che è più facile da digitare ha molto senso. - Il comando di
pickup
gioca un ruolo molto importante per il gameplay. Questo comando si occupa di aggiungere oggetti all'inventario dei giocatori o alle loro mani (se sono libere). Per capire dove deve essere conservato ogni oggetto, la loro definizione ha una proprietà "destinazione" che specifica se è destinato all'inventario o alle mani del giocatore. Tutto ciò che viene raccolto con successo dalla scena viene quindi rimosso da essa, aggiornando la versione del gioco dell'istanza di gioco. - Il comando
use
ti consentirà di influenzare l'ambiente utilizzando gli articoli nel tuo inventario. Ad esempio, raccogliere una chiave in una stanza ti consentirà di usarla per aprire una porta chiusa a chiave in un'altra stanza. - C'è un comando speciale, uno che non è correlato al gameplay, ma invece un comando di supporto inteso a ottenere informazioni particolari, come l'ID di gioco corrente o il nome del giocatore. Questo comando si chiama get e i giocatori possono usarlo per interrogare il motore di gioco. Ad esempio: ottieni gameid .
- Infine, l'ultimo comando implementato per questa versione del motore è il comando
attack
. Ho già coperto questo; in pratica, dovrai specificare il tuo obiettivo e l'arma con cui lo stai attaccando. In questo modo il sistema sarà in grado di verificare i punti deboli del bersaglio e determinare l'output del tuo attacco.
Interazione client-motore
Per capire come utilizzare gli endpoint sopra elencati, lascia che ti mostri come qualsiasi aspirante cliente può interagire con la nostra nuova API.
Fare un passo | Descrizione |
---|---|
Registrati cliente | Per prima cosa, l'applicazione client deve richiedere una chiave API per poter accedere a tutti gli altri endpoint. Per ottenere quella chiave, è necessario registrarsi sulla nostra piattaforma. L'unico parametro da fornire è il nome dell'app, tutto qui. |
Crea un gioco | Dopo aver ottenuto la chiave API, la prima cosa da fare (supponendo che si tratti di una nuova interazione) è creare una nuova istanza di gioco. Pensaci in questo modo: il file JSON che ho creato nel mio ultimo post contiene la definizione del gioco, ma dobbiamo crearne un'istanza solo per te e il tuo gruppo (pensa a classi e oggetti, stesso affare). Puoi fare con quell'istanza quello che vuoi e non influirà su altre parti. |
Unisciti al gioco | Dopo aver creato il gioco, riceverai un ID gioco dal motore. Puoi quindi utilizzare quell'ID di gioco per unirti all'istanza utilizzando il tuo nome utente univoco. Se non ti unisci al gioco, non puoi giocare, perché unirti al gioco creerà anche un'istanza dello stato del gioco solo per te. Questo sarà il punto in cui verranno salvati il tuo inventario, la tua posizione e le tue statistiche di base in relazione al gioco a cui stai giocando. Potresti potenzialmente giocare a più giochi contemporaneamente e in ognuno di essi avere stati indipendenti. |
Invia comandi | In altre parole: gioca. Il passaggio finale è iniziare a inviare comandi. La quantità di comandi disponibili era già coperta e può essere facilmente estesa (ne parleremo tra poco). Ogni volta che invii un comando, il gioco restituirà il nuovo stato di gioco affinché il tuo client aggiorni la visualizzazione di conseguenza. |
Sporchiamoci le mani
Ho esaminato quanto più design possibile, nella speranza che queste informazioni ti aiutino a capire la parte seguente, quindi entriamo nei dettagli del motore di gioco.
Nota : in questo articolo non ti mostrerò il codice completo poiché è abbastanza grande e non tutto è interessante. Mostrerò invece le parti più rilevanti e mi collegherò al repository completo nel caso in cui desideri maggiori dettagli.
Il file principale
Per prima cosa: questo è un progetto Express e il suo codice boilerplate è stato generato utilizzando il generatore di Express, quindi il file app.js dovrebbe esserti familiare. Voglio solo esaminare due modifiche che mi piace fare su quel codice per semplificare il mio lavoro.
Innanzitutto, aggiungo il seguente snippet per automatizzare l'inclusione di nuovi file di percorso:
const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })
È davvero abbastanza semplice, ma elimina la necessità di richiedere manualmente ogni file di percorso che creerai in futuro. A proposito, require-dir
è un semplice modulo che si occupa di richiedere automaticamente ogni file all'interno di una cartella. Questo è tutto.
L'altra modifica che mi piace fare è modificare un po' il mio gestore degli errori. Dovrei davvero iniziare a usare qualcosa di più robusto, ma per le esigenze a portata di mano, sento che questo fa il lavoro:
// 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); });
Il codice sopra si prende cura dei diversi tipi di messaggi di errore con cui potremmo avere a che fare: oggetti completi, oggetti di errore effettivi generati da Javascript o semplici messaggi di errore senza alcun altro contesto. Questo codice prenderà tutto e lo formatterà in un formato standard.
Comandi di gestione
Questo è un altro di quegli aspetti del motore che doveva essere facile da estendere. In un progetto come questo, ha assolutamente senso presumere che nuovi comandi verranno visualizzati in futuro. Se c'è qualcosa che vuoi evitare, probabilmente sarebbe evitare di apportare modifiche al codice di base quando si tenta di aggiungere qualcosa di nuovo tre o quattro mesi in futuro.
Nessuna quantità di commenti sul codice renderà facile il compito di modificare il codice che non hai toccato (o nemmeno pensato) per diversi mesi, quindi la priorità è evitare il maggior numero possibile di modifiche. Fortunatamente per noi, ci sono alcuni schemi che possiamo implementare per risolvere questo problema. In particolare, ho utilizzato una combinazione dei pattern Command e Factory.
Fondamentalmente ho incapsulato il comportamento di ogni comando all'interno di una singola classe che eredita da una classe BaseCommand
che contiene il codice generico a tutti i comandi. Allo stesso tempo, ho aggiunto un modulo CommandParser
che cattura la stringa inviata dal client e restituisce il comando effettivo da eseguire.
Il parser è molto semplice poiché tutti i comandi implementati ora hanno il comando effettivo per quanto riguarda la loro prima parola (cioè "sposta a nord", "prendi il coltello" e così via) è una semplice questione di dividere la stringa e ottenere la 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 } } }
Nota : sto usando ancora una volta il modulo require-dir
per semplificare l'inclusione di qualsiasi classe di comando esistente e nuova. Lo aggiungo semplicemente alla cartella e l'intero sistema è in grado di prelevarlo e usarlo.
Detto questo, ci sono molti modi in cui questo può essere migliorato; ad esempio, essere in grado di aggiungere il supporto dei sinonimi per i nostri comandi sarebbe un'ottima funzionalità (quindi dire "sposta a nord", "vai a nord" o anche "cammina a nord" significherebbe lo stesso). Questo è qualcosa che potremmo centralizzare in questa classe e influenzare tutti i comandi contemporaneamente.
Non entrerò nei dettagli su nessuno dei comandi perché, ancora una volta, è troppo codice da mostrare qui, ma puoi vedere nel seguente codice di percorso come sono riuscito a generalizzare quella gestione dei comandi esistenti (e futuri):
/** 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) }) })
Tutti i comandi richiedono solo il metodo run
, qualsiasi altra cosa è extra e pensata per uso interno.
Ti incoraggio ad andare a rivedere l'intero codice sorgente (anche a scaricarlo e giocarci se vuoi!). Nella parte successiva di questa serie, ti mostrerò l'effettiva implementazione e interazione del client di questa API.
Pensieri di chiusura
Potrei non aver trattato molto del mio codice qui, ma spero comunque che l'articolo sia stato utile per mostrarti come affronto i progetti, anche dopo la fase di progettazione iniziale. Sento che molte persone cercano di iniziare a programmare come prima risposta a una nuova idea e questo a volte può finire per scoraggiare uno sviluppatore poiché non esiste un piano reale né obiettivi da raggiungere, a parte avere il prodotto finale pronto ( e questa è una pietra miliare troppo grande da affrontare dal giorno 1). Quindi, ancora una volta, la mia speranza con questi articoli è di condividere un modo diverso di lavorare da solo (o come parte di un piccolo gruppo) su grandi progetti.
Spero che la lettura vi sia piaciuta! Sentiti libero di lasciare un commento qui sotto con qualsiasi tipo di suggerimento o consiglio, mi piacerebbe leggere cosa ne pensi e se sei ansioso di iniziare a testare l'API con il tuo codice lato client.
Ci vediamo al prossimo!
Altre parti di questa serie
- Parte 1: L'introduzione
- Parte 3: Creazione del client terminale
- Parte 4: Aggiunta di chat al nostro gioco