Scrittura di un motore di avventura testuale multiplayer in Node.js: creazione del client terminale (parte 3)
Pubblicato: 2022-03-10Per prima cosa ti ho mostrato come definire un progetto come questo e ti ho fornito le basi dell'architettura e le meccaniche dietro il motore di gioco. Quindi, ti ho mostrato l'implementazione di base del motore: un'API REST di base che ti consente di attraversare un mondo definito da JSON.
Oggi ti mostrerò come creare un client di testo della vecchia scuola per la nostra API utilizzando nient'altro che Node.js.
Altre parti di questa serie
- Parte 1: L'introduzione
- Parte 2: Progettazione del server del motore di gioco
- Parte 4: Aggiunta di chat al nostro gioco
Revisione del design originale
Quando ho proposto per la prima volta un wireframe di base per l'interfaccia utente, ho proposto quattro sezioni sullo schermo:
Anche se in teoria sembra giusto, mi è sfuggito il fatto che passare dall'invio di comandi di gioco a messaggi di testo sarebbe stato un problema, quindi invece di far cambiare manualmente i nostri giocatori, faremo in modo che il nostro parser di comandi sia in grado di discernere se noi Stiamo cercando di comunicare con il gioco o con i nostri amici.
Quindi, invece di avere quattro sezioni nel nostro schermo, ora ne avremo tre:
Questo è uno screenshot reale del client di gioco finale. Puoi vedere la schermata di gioco a sinistra e la chat a destra, con un'unica casella di input comune in basso. Il modulo che stiamo utilizzando ci consente di personalizzare i colori e alcuni effetti di base. Sarai in grado di clonare questo codice da Github e fare quello che vuoi con l'aspetto grafico.
Un avvertimento però: sebbene lo screenshot qui sopra mostri la chat che funziona come parte dell'applicazione, manterremo questo articolo concentrato sull'impostazione del progetto e sulla definizione di un framework in cui possiamo creare un'applicazione dinamica basata sull'interfaccia utente di testo. Ci concentreremo sull'aggiunta del supporto chat nel capitolo successivo e finale di questa serie.
Gli strumenti di cui avremo bisogno
Sebbene ci siano molte librerie là fuori che ci consentono di creare strumenti CLI con Node.js, l'aggiunta di un'interfaccia utente basata su testo è una bestia completamente diversa da domare. In particolare, sono riuscito a trovare solo una libreria (molto completa, intendiamoci) che mi avrebbe permesso di fare esattamente quello che volevo: Benedetto.
Questa libreria è molto potente e fornisce molte funzionalità che non useremo per questo progetto (come la proiezione di ombre, il trascinamento della selezione e altri). Fondamentalmente reimplementa l'intera libreria ncurses (una libreria C che consente agli sviluppatori di creare interfacce utente basate su testo) che non ha collegamenti Node.js e lo fa direttamente in JavaScript; quindi, se dovessimo, potremmo benissimo controllare il suo codice interno (qualcosa che non consiglierei a meno che tu non sia assolutamente necessario).
Sebbene la documentazione per Blessed sia piuttosto ampia, consiste principalmente in dettagli individuali su ciascun metodo fornito (invece di avere tutorial che spiegano come utilizzare effettivamente questi metodi insieme) e manca di esempi ovunque, quindi potrebbe essere difficile scavare al suo interno se devi capire come funziona un particolare metodo. Detto questo, una volta capito, tutto funziona allo stesso modo, il che è un grande vantaggio poiché non tutte le librerie o persino i linguaggi (ti sto guardando, PHP) hanno una sintassi coerente.
Ma documentazione a parte; il grande vantaggio di questa libreria è che funziona in base alle opzioni JSON. Ad esempio, se volessi disegnare una casella nell'angolo in alto a destra dello schermo, faresti qualcosa del genere:
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' } } });
Come puoi immaginare, lì sono definiti anche altri aspetti della scatola (come le sue dimensioni), che possono essere perfettamente dinamici in base alle dimensioni del terminale, al tipo di bordo e ai colori, anche per gli eventi al passaggio del mouse. Se a un certo punto hai fatto lo sviluppo del front-end, troverai molte sovrapposizioni tra i due.
Il punto che sto cercando di chiarire qui è che tutto ciò che riguarda la rappresentazione della scatola è configurato tramite l'oggetto JSON passato al metodo box
. Questo, per me, è perfetto perché posso facilmente estrarre quel contenuto in un file di configurazione e creare una logica di business in grado di leggerlo e decidere quali elementi disegnare sullo schermo. Soprattutto, ci aiuterà a dare un'occhiata a come appariranno una volta disegnati.
Questa sarà la base per l'intero aspetto dell'interfaccia utente di questo modulo (ne parleremo più in un secondo! ).
Architettura del modulo
L'architettura principale di questo modulo si basa interamente sui widget dell'interfaccia utente che mostreremo. Un gruppo di questi widget è considerato una schermata e tutte queste schermate sono definite in un unico file JSON (che puoi trovare all'interno della cartella /config
).
Questo file ha oltre 250 righe, quindi mostrarlo qui non ha senso. Puoi guardare il file completo online, ma un piccolo frammento di esso assomiglia a questo:
"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", //... } } }
L'elemento "schermate" conterrà l'elenco delle schermate all'interno dell'applicazione. Ogni schermata contiene un elenco di widget (che tratterò tra un po') e ogni widget ha la sua definizione specifica per benedizioni e i relativi file del gestore (se applicabile).
Puoi vedere come ogni elemento "params" (all'interno di un particolare widget) rappresenti l'effettivo insieme di parametri previsti dai metodi che abbiamo visto in precedenza. Il resto delle chiavi definite lì aiutano a fornire un contesto sul tipo di widget da visualizzare e sul loro comportamento.
Alcuni punti di interesse:
Gestori dello schermo
Ogni elemento dello schermo ha una proprietà file che fa riferimento al codice associato a quello schermo. Questo codice non è altro che un oggetto che deve avere un metodo init
(la logica di inizializzazione per quella particolare schermata avviene al suo interno). In particolare, il motore dell'interfaccia utente principale, chiamerà quel metodo init
di ogni schermata, che a sua volta dovrebbe essere responsabile dell'inizializzazione di qualsiasi logica possa essere necessaria (ad esempio, impostare gli eventi delle caselle di input).
Quello che segue è il codice per la schermata principale, in cui l'applicazione richiede al giocatore di selezionare un'opzione per avviare una nuova partita o per unirsi a una esistente:
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 } }
Come puoi vedere, il metodo init
chiama il metodo setupInput
che sostanzialmente configura il callback corretto per gestire l'input dell'utente. Tale callback mantiene la logica per decidere cosa fare in base all'input dell'utente (1 o 2).
Gestori di widget
Alcuni dei widget (di solito i widget di input) hanno una proprietà handlerPath
, che fa riferimento al file contenente la logica dietro quel particolare componente. Questo non è lo stesso del gestore dello schermo precedente. A questi non interessano molto i componenti dell'interfaccia utente. Gestiscono invece la logica collante tra l'interfaccia utente e qualsiasi libreria che stiamo usando per interagire con servizi esterni (come l'API del motore di gioco).
Tipi di widget
Un'altra aggiunta minore alla definizione JSON dei widget sono i loro tipi. Invece di usare i nomi Benedict definiti per loro, ne sto creando di nuovi per darmi più spazio di manovra quando si tratta del loro comportamento. Dopotutto, un widget finestra potrebbe non "visualizzare solo informazioni" o una casella di input potrebbe non funzionare sempre allo stesso modo.
Questa è stata principalmente una mossa preventiva, solo per assicurarmi di avere quell'abilità se mai ne avrò bisogno in futuro, ma come stai per vedere, comunque non sto usando molti tipi diversi di componenti.
Schermi multipli
Sebbene la schermata principale sia quella che ti ho mostrato nello screenshot qui sopra, il gioco richiede alcune altre schermate per richiedere cose come il nome del tuo giocatore o se stai creando una sessione di gioco nuova di zecca o anche se ti unisci a una esistente. Il modo in cui l'ho gestito è stato, ancora una volta, attraverso la definizione di tutte queste schermate nello stesso file JSON. E per passare da una schermata alla successiva, utilizziamo la logica all'interno dei file del gestore dello schermo.
Possiamo farlo semplicemente usando la seguente riga di codice:
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
Ti mostrerò maggiori dettagli sulla proprietà dell'interfaccia utente in un secondo, ma sto solo usando quel metodo loadScreen
per rieseguire il rendering dello schermo e selezionando i componenti giusti dal file JSON usando la stringa passata come parametro. Molto semplice.
Esempi di codice
È giunto il momento di dare un'occhiata alla carne e alle patate di questo articolo: i campioni di codice. Evidenzierò solo quelle che penso siano le piccole gemme al suo interno, ma puoi sempre dare un'occhiata al codice sorgente completo direttamente nel repository in qualsiasi momento.
Utilizzo dei file di configurazione per generare automaticamente l'interfaccia utente
Ho già trattato parte di questo, ma penso che valga la pena esplorare i dettagli dietro questo generatore. Il succo dietro di esso (file index.js all'interno della cartella /ui
) è che è un wrapper attorno all'oggetto Benedetto. E il metodo più interessante al suo interno è il metodo loadScreen
.
Questo metodo acquisisce la configurazione (attraverso il modulo di configurazione) per una schermata specifica e ne esamina il contenuto, cercando di generare i widget giusti in base al tipo di ciascun elemento.
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() },
Come puoi vedere, il codice è un po' lungo, ma la logica dietro è semplice:
- Carica la configurazione per la schermata specifica corrente;
- Pulisce tutti i widget esistenti in precedenza;
- Esamina ogni widget e ne crea un'istanza;
- Se un avviso extra è stato passato come messaggio flash (che è fondamentalmente un concetto che ho rubato da Web Dev in cui si imposta un messaggio da mostrare sullo schermo fino al prossimo aggiornamento);
- Rendering dello schermo reale;
- E infine, richiedi il gestore dello schermo ed esegui il suo metodo "init".
Questo è tutto! Puoi controllare il resto dei metodi: sono principalmente correlati ai singoli widget e a come renderli.
Comunicazione tra interfaccia utente e logica aziendale
Sebbene su larga scala, l'interfaccia utente, il back-end e il server di chat hanno tutti una comunicazione basata su livelli in qualche modo; il front end stesso necessita di almeno un'architettura interna a due livelli in cui gli elementi puri dell'interfaccia utente interagiscono con un insieme di funzioni che rappresentano la logica centrale all'interno di questo particolare progetto.
Il diagramma seguente mostra l'architettura interna per il client di testo che stiamo costruendo:
Lascia che lo spieghi un po' meglio. Come accennato in precedenza, loadScreenMethod
creerà presentazioni dell'interfaccia utente dei widget (questi sono oggetti Blessed). Ma sono contenuti come parte dell'oggetto logico dello schermo che è dove impostiamo gli eventi di base (come onSubmit
per le caselle di input).
Permettetemi di farvi un esempio pratico. Ecco la prima schermata che vedi all'avvio del client dell'interfaccia utente:
Ci sono tre sezioni in questa schermata:
- Richiesta nome utente,
- Opzioni / informazioni del menu,
- Schermata di immissione per le opzioni del menu.
Fondamentalmente, quello che vogliamo fare è richiedere il nome utente e poi chiedere loro di scegliere una delle due opzioni (o avviare un gioco nuovo di zecca o unirsi a uno esistente).
Il codice che se ne occupa è il seguente:
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 } }
So che c'è molto codice, ma concentrati solo sul metodo init
. L'ultima cosa che fa è chiamare il metodo setInput
che si occupa di aggiungere gli eventi giusti alle caselle di input giuste.
Pertanto, con queste righe:
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()
Stiamo accedendo agli oggetti Benedetto e ottenendo i loro riferimenti, in modo da poter impostare in seguito gli eventi di submit
. Quindi, dopo aver inviato il nome utente, stiamo spostando lo stato attivo sulla seconda casella di input (letteralmente con input.focus()
).
A seconda dell'opzione che scegliamo dal menu, chiamiamo uno dei metodi:
-
createNewGame
: crea un nuovo gioco interagendo con il relativo gestore; -
moveToIDRequest
: rende la schermata successiva incaricata di richiedere l'ID del gioco per partecipare.
Comunicazione con il motore di gioco
Ultimo ma certamente non meno importante (e seguendo l'esempio sopra), se premi 2, noterai che il metodo createNewGame
utilizza i metodi del gestore createNewGame
e quindi joinGame
(entrando nel gioco subito dopo averlo creato).
Entrambi questi metodi hanno lo scopo di semplificare l'interazione con l'API del Game Engine. Ecco il codice per il gestore di questa schermata:
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) }) } }
Lì vedi due modi diversi per gestire questo comportamento. Il primo metodo utilizza effettivamente la classe apiClient
, che, ancora una volta, avvolge le interazioni con GameEngine in un altro livello di astrazione.
Il secondo metodo però esegue l'azione direttamente inviando una richiesta POST all'URL corretto con il giusto payload. Dopo non si fa nulla di fantasia; stiamo solo rimandando il corpo della risposta alla logica dell'interfaccia utente.
Nota : se sei interessato alla versione completa del codice sorgente per questo client, puoi verificarlo qui.
Parole finali
Questo è tutto per il client testuale per la nostra avventura testuale. Ho coperto:
- Come strutturare un'applicazione client;
- Come ho usato Blessed come tecnologia di base per creare il livello di presentazione;
- Come strutturare l'interazione con i servizi di back-end da un client complesso;
- E, si spera, con il repository completo disponibile.
E mentre l'interfaccia utente potrebbe non sembrare esattamente come la versione originale, soddisfa il suo scopo. Si spera che questo articolo ti abbia dato un'idea di come architettare un tale sforzo e tu fossi propenso a provarlo tu stesso in futuro. Benedetto è sicuramente uno strumento molto potente, ma dovrai avere pazienza con esso mentre impari come usarlo e come navigare attraverso i loro documenti.
Nella parte successiva e finale, tratterò come ho aggiunto il server di chat sia sul back-end che per questo client di testo.
Ci vediamo al prossimo!
Altre parti di questa serie
- Parte 1: L'introduzione
- Parte 2: Progettazione del server del motore di gioco
- Parte 4: Aggiunta di chat al nostro gioco