Schreiben einer Multiplayer-Text-Adventure-Engine in Node.js: Erstellen des Terminal-Clients (Teil 3)
Veröffentlicht: 2022-03-10Ich habe Ihnen zuerst gezeigt, wie man ein Projekt wie dieses definiert, und Ihnen die Grundlagen der Architektur sowie die Mechanik hinter der Spiel-Engine gegeben. Dann habe ich Ihnen die grundlegende Implementierung der Engine gezeigt – eine grundlegende REST-API, mit der Sie eine JSON-definierte Welt durchqueren können.
Heute zeige ich Ihnen, wie Sie einen Textclient der alten Schule für unsere API erstellen, indem Sie nichts anderes als Node.js verwenden.
Weitere Teile dieser Serie
- Teil 1: Die Einführung
- Teil 2: Design des Game-Engine-Servers
- Teil 4: Hinzufügen von Chat zu unserem Spiel
Überprüfung des ursprünglichen Designs
Als ich zum ersten Mal ein grundlegendes Wireframe für die Benutzeroberfläche vorschlug, schlug ich vier Abschnitte auf dem Bildschirm vor:
Obwohl das theoretisch richtig aussieht, habe ich die Tatsache übersehen, dass das Umschalten zwischen dem Senden von Spielbefehlen und Textnachrichten mühsam wäre. Anstatt unsere Spieler manuell umschalten zu lassen, lassen wir unseren Befehlsparser sicherstellen, dass er erkennen kann, ob wir versuchen, mit dem Spiel oder unseren Freunden zu kommunizieren.
Anstatt vier Abschnitte auf unserem Bildschirm zu haben, haben wir jetzt drei:
Das ist ein tatsächlicher Screenshot des endgültigen Spielclients. Sie können den Spielbildschirm auf der linken Seite und den Chat auf der rechten Seite sehen, mit einem einzigen gemeinsamen Eingabefeld unten. Das von uns verwendete Modul ermöglicht es uns, Farben und einige grundlegende Effekte anzupassen. Sie können diesen Code von Github klonen und mit dem Aussehen und Verhalten machen, was Sie wollen.
Eine Einschränkung jedoch: Obwohl der obige Screenshot zeigt, dass der Chat als Teil der Anwendung funktioniert, konzentrieren wir uns in diesem Artikel auf die Einrichtung des Projekts und die Definition eines Frameworks, in dem wir eine dynamische Text-UI-basierte Anwendung erstellen können. Wir werden uns darauf konzentrieren, im nächsten und letzten Kapitel dieser Serie Chat-Unterstützung hinzuzufügen.
Die Werkzeuge, die wir brauchen
Obwohl es viele Bibliotheken gibt, mit denen wir CLI-Tools mit Node.js erstellen können, ist das Hinzufügen einer textbasierten Benutzeroberfläche eine ganz andere Herausforderung. Insbesondere konnte ich nur eine (wohlgemerkt sehr vollständige) Bibliothek finden, mit der ich genau das tun konnte, was ich wollte: Gesegnet.
Diese Bibliothek ist sehr leistungsfähig und bietet viele Funktionen, die wir für dieses Projekt nicht verwenden werden (z. B. Schattenwurf, Drag & Drop und andere). Es implementiert im Grunde die gesamte ncurses-Bibliothek (eine C-Bibliothek, die es Entwicklern ermöglicht, textbasierte UIs zu erstellen), die keine Node.js-Bindungen hat, neu, und zwar direkt in JavaScript. Wenn es also sein müsste, könnten wir sehr gut seinen internen Code überprüfen (etwas, das ich nicht empfehlen würde, es sei denn, Sie müssten es unbedingt tun).
Obwohl die Dokumentation für Blessed ziemlich umfangreich ist, besteht sie hauptsächlich aus einzelnen Details zu jeder bereitgestellten Methode (im Gegensatz zu Tutorials, die erklären, wie diese Methoden tatsächlich zusammen verwendet werden) und es fehlen überall Beispiele, so dass es schwierig sein könnte, sich darin zu vertiefen wenn Sie verstehen müssen, wie eine bestimmte Methode funktioniert. Abgesehen davon funktioniert alles auf die gleiche Weise, sobald Sie es einmal verstanden haben, was ein großes Plus ist, da nicht jede Bibliothek oder sogar Sprache (ich sehe Sie, PHP) eine konsistente Syntax hat.
Aber Dokumentation beiseite; Das große Plus dieser Bibliothek ist, dass sie auf der Grundlage von JSON-Optionen funktioniert. Wenn Sie beispielsweise ein Kästchen in der oberen rechten Ecke des Bildschirms zeichnen möchten, würden Sie Folgendes tun:
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' } } });
Wie Sie sich vorstellen können, werden dort auch andere Aspekte der Box definiert (z. B. ihre Größe), die basierend auf der Größe des Terminals, der Art des Rahmens und der Farben perfekt dynamisch sein können – sogar für Hover-Ereignisse. Wenn Sie irgendwann Front-End-Entwicklung gemacht haben, werden Sie viele Überschneidungen zwischen den beiden feststellen.
Der Punkt, den ich hier zu machen versuche, ist, dass alles, was die Darstellung der Box betrifft, über das JSON-Objekt konfiguriert wird, das an die box
Methode übergeben wird. Das ist für mich perfekt, weil ich diesen Inhalt einfach in eine Konfigurationsdatei extrahieren und eine Geschäftslogik erstellen kann, die in der Lage ist, ihn zu lesen und zu entscheiden, welche Elemente auf dem Bildschirm gezeichnet werden sollen. Am wichtigsten ist, dass es uns hilft, einen Eindruck davon zu bekommen, wie sie aussehen werden, sobald sie gezeichnet wurden.
Dies wird die Basis für den gesamten UI-Aspekt dieses Moduls sein ( mehr dazu gleich! ).
Architektur des Moduls
Die Hauptarchitektur dieses Moduls basiert vollständig auf den UI-Widgets, die wir zeigen werden. Eine Gruppe dieser Widgets wird als Bildschirm betrachtet, und alle diese Bildschirme werden in einer einzigen JSON-Datei definiert (die Sie im Ordner /config
finden).
Diese Datei hat über 250 Zeilen, daher macht es keinen Sinn, sie hier zu zeigen. Sie können sich die vollständige Datei online ansehen, aber ein kleiner Ausschnitt davon sieht so aus:
"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", //... } } }
Das Element „Bildschirme“ enthält die Liste der Bildschirme innerhalb der Anwendung. Jeder Bildschirm enthält eine Liste von Widgets (die ich gleich behandeln werde) und jedes Widget hat seine Blesses-spezifische Definition und zugehörige Handler-Dateien (falls zutreffend).
Sie können sehen, wie jedes „params“-Element (innerhalb eines bestimmten Widgets) den tatsächlichen Satz von Parametern darstellt, der von den Methoden, die wir zuvor gesehen haben, erwartet wird. Der Rest der dort definierten Schlüssel hilft dabei, den Kontext darüber bereitzustellen, welche Art von Widgets gerendert werden sollen und wie sie sich verhalten.
Ein paar interessante Punkte:
Screen-Handler
Jedes Bildschirmelement hat eine Dateieigenschaft, die auf den diesem Bildschirm zugeordneten Code verweist. Dieser Code ist nichts anderes als ein Objekt, das eine init
-Methode haben muss (die Initialisierungslogik für diesen bestimmten Bildschirm findet darin statt). Insbesondere die Haupt-UI-Engine ruft diese init
-Methode jedes Bildschirms auf, die wiederum für die Initialisierung der erforderlichen Logik verantwortlich sein sollte (dh die Ereignisse der Eingabefelder einrichten).
Das Folgende ist der Code für den Hauptbildschirm, auf dem die Anwendung den Spieler auffordert, eine Option auszuwählen, um entweder ein brandneues Spiel zu starten oder einem bestehenden beizutreten:
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 } }
Wie Sie sehen können, ruft die init
-Methode die setupInput
Methode auf, die im Grunde den richtigen Callback für die Verarbeitung von Benutzereingaben konfiguriert. Dieser Rückruf enthält die Logik, um basierend auf der Eingabe des Benutzers (entweder 1 oder 2) zu entscheiden, was zu tun ist.
Widget-Handler
Einige der Widgets (normalerweise Eingabe-Widgets) haben eine handlerPath
Eigenschaft, die auf die Datei verweist, die die Logik hinter dieser bestimmten Komponente enthält. Dies ist nicht dasselbe wie der vorherige Bildschirmhandler. Diese kümmern sich nicht so sehr um die UI-Komponenten. Stattdessen handhaben sie die Verknüpfungslogik zwischen der Benutzeroberfläche und der Bibliothek, die wir verwenden, um mit externen Diensten zu interagieren (z. B. der API der Spiel-Engine).
Widget-Typen
Eine weitere kleine Ergänzung zur JSON-Definition der Widgets sind ihre Typen. Anstatt bei den Namen zu bleiben, die Blessed für sie definiert hat, erstelle ich neue, um mir mehr Spielraum zu geben, wenn es um ihr Verhalten geht. Schließlich zeigt ein Fenster-Widget nicht immer „nur Informationen“ an oder ein Eingabefeld funktioniert nicht immer gleich.
Dies war hauptsächlich ein vorbeugender Schritt, nur um sicherzustellen, dass ich diese Fähigkeit habe, falls ich sie in Zukunft jemals brauchen sollte, aber wie Sie gleich sehen werden, verwende ich sowieso nicht so viele verschiedene Arten von Komponenten.
Mehrere Bildschirme
Obwohl der Hauptbildschirm derjenige ist, den ich Ihnen im obigen Screenshot gezeigt habe, benötigt das Spiel einige andere Bildschirme, um Dinge wie Ihren Spielernamen abzufragen oder ob Sie eine brandneue Spielsitzung erstellen oder sogar einer bestehenden beitreten. Die Art und Weise, wie ich damit umgegangen bin, war wiederum die Definition all dieser Bildschirme in derselben JSON-Datei. Und um von einem Bildschirm zum nächsten zu wechseln, verwenden wir die Logik in den Bildschirm-Handler-Dateien.
Wir können dies einfach tun, indem wir die folgende Codezeile verwenden:
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
Ich werde Ihnen gleich weitere Details zur UI-Eigenschaft zeigen, aber ich verwende nur diese loadScreen
-Methode, um den Bildschirm neu zu rendern und die richtigen Komponenten aus der JSON-Datei auszuwählen, indem ich die als Parameter übergebene Zeichenfolge verwende. Sehr einfach.
Codebeispiele
Es ist jetzt an der Zeit, sich das Fleisch und die Kartoffeln dieses Artikels anzusehen: die Codebeispiele. Ich werde nur hervorheben, was meiner Meinung nach die kleinen Juwelen darin sind, aber Sie können sich jederzeit den vollständigen Quellcode direkt im Repository ansehen.
Verwenden von Konfigurationsdateien zum automatischen Generieren der Benutzeroberfläche
Ich habe einen Teil davon bereits behandelt, aber ich denke, es lohnt sich, die Details hinter diesem Generator zu untersuchen. Das Wesentliche dahinter (Datei index.js im Ordner /ui
) ist, dass es sich um einen Wrapper um das Blessed-Objekt handelt. Und die interessanteste Methode darin ist die loadScreen
Methode.
Diese Methode greift auf die Konfiguration (über das Konfigurationsmodul) für einen bestimmten Bildschirm zu und geht dessen Inhalt durch, wobei versucht wird, die richtigen Widgets basierend auf dem Typ jedes Elements zu generieren.
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() },
Wie Sie sehen können, ist der Code etwas langatmig, aber die Logik dahinter ist einfach:
- Es lädt die Konfiguration für den aktuellen spezifischen Bildschirm;
- Bereinigt alle zuvor vorhandenen Widgets;
- Geht jedes Widget durch und instanziiert es;
- Wenn eine zusätzliche Warnung als Flash-Nachricht übergeben wurde (was im Grunde ein Konzept ist, das ich von Web Dev gestohlen habe, bei dem Sie eine Nachricht einrichten, die bis zur nächsten Aktualisierung auf dem Bildschirm angezeigt wird);
- Rendern Sie den tatsächlichen Bildschirm;
- Und schließlich fordern Sie den Screen-Handler an und führen Sie seine „init“-Methode aus.
Das ist es! Sie können sich die restlichen Methoden ansehen – sie beziehen sich hauptsächlich auf einzelne Widgets und wie sie gerendert werden.
Kommunikation zwischen UI und Geschäftslogik
Obwohl im großen Stil, haben die Benutzeroberfläche, das Back-End und der Chat-Server alle eine etwas mehrschichtige Kommunikation; Das Frontend selbst benötigt mindestens eine zweischichtige interne Architektur, in der die reinen UI-Elemente mit einer Reihe von Funktionen interagieren, die die Kernlogik innerhalb dieses speziellen Projekts darstellen.
Das folgende Diagramm zeigt die interne Architektur für den Textclient, den wir erstellen:
Lassen Sie es mich etwas näher erläutern. Wie ich oben erwähnt habe, erstellt die loadScreenMethod
UI-Präsentationen der Widgets (dies sind Blessed-Objekte). Sie sind jedoch Teil des Bildschirmlogikobjekts, in dem wir die grundlegenden Ereignisse einrichten (z. B. onSubmit
für Eingabefelder).
Lassen Sie mich Ihnen ein praktisches Beispiel geben. Hier ist der erste Bildschirm, den Sie sehen, wenn Sie den UI-Client starten:
Auf diesem Bildschirm gibt es drei Abschnitte:
- Anfrage nach Benutzername,
- Menüpunkte / Informationen,
- Eingabemaske für die Menüoptionen.
Im Grunde wollen wir den Benutzernamen anfordern und ihn dann bitten, eine der beiden Optionen auszuwählen (entweder ein brandneues Spiel starten oder einem bestehenden beitreten).
Der Code, der sich darum kümmert, ist der folgende:
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 } }
Ich weiß, das ist eine Menge Code, aber konzentrieren Sie sich einfach auf die init
-Methode. Als letztes wird die setInput
Methode aufgerufen, die sich darum kümmert, die richtigen Ereignisse zu den richtigen Eingabefeldern hinzuzufügen.
Daher mit diesen Zeilen:
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()
Wir greifen auf die Blessed-Objekte zu und erhalten ihre Referenzen, damit wir später die submit
-Ereignisse einrichten können. Nachdem wir also den Benutzernamen übermittelt haben, schalten wir den Fokus auf das zweite Eingabefeld (buchstäblich mit input.focus()
).
Je nachdem, welche Option wir aus dem Menü auswählen, rufen wir eine der Methoden auf:
-
createNewGame
: erstellt ein neues Spiel durch Interaktion mit seinem zugeordneten Handler; -
moveToIDRequest
: Rendert den nächsten Bildschirm, der für die Anforderung der Spiel-ID zum Beitritt zuständig ist.
Kommunikation mit der Spiel-Engine
Last but not least (und nach dem obigen Beispiel): Wenn Sie 2 drücken, werden Sie feststellen, dass die Methode createNewGame
die Methoden createNewGame
und joinGame des joinGame
verwendet (dem Spiel direkt nach der Erstellung beitreten).
Beide Methoden sollen die Interaktion mit der API der Game Engine vereinfachen. Hier ist der Code für den Handler dieses Bildschirms:
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) }) } }
Dort sehen Sie zwei verschiedene Möglichkeiten, mit diesem Verhalten umzugehen. Die erste Methode verwendet tatsächlich die apiClient
-Klasse, die wiederum die Interaktionen mit der GameEngine in eine weitere Abstraktionsebene verpackt.
Die zweite Methode führt die Aktion jedoch direkt durch, indem eine POST-Anfrage an die richtige URL mit der richtigen Nutzlast gesendet wird. Danach wird nichts Besonderes getan; Wir senden nur den Text der Antwort zurück an die UI-Logik.
Hinweis : Wenn Sie an der vollständigen Version des Quellcodes für diesen Client interessiert sind, können Sie ihn hier einsehen.
Letzte Worte
Das ist es für den textbasierten Client für unser Textadventure. Ich habe abgedeckt:
- Wie man eine Client-Anwendung strukturiert;
- Wie ich Blessed als Kerntechnologie zum Erstellen der Präsentationsebene verwendet habe;
- Wie man die Interaktion mit den Back-End-Diensten eines komplexen Clients strukturiert;
- Und hoffentlich mit dem vollständigen verfügbaren Repository.
Und obwohl die Benutzeroberfläche möglicherweise nicht genau so aussieht wie die Originalversion, erfüllt sie ihren Zweck. Hoffentlich hat Ihnen dieser Artikel eine Vorstellung davon gegeben, wie man ein solches Unterfangen gestaltet, und Sie waren geneigt, es in Zukunft selbst zu versuchen. Blessed ist definitiv ein sehr mächtiges Tool, aber Sie müssen Geduld damit haben, während Sie lernen, wie man es benutzt und wie man durch ihre Dokumente navigiert.
Im nächsten und letzten Teil werde ich behandeln, wie ich den Chat-Server sowohl im Back-End als auch für diesen Text-Client hinzugefügt habe.
Wir sehen uns beim nächsten!
Weitere Teile dieser Serie
- Teil 1: Die Einführung
- Teil 2: Design des Game-Engine-Servers
- Teil 4: Hinzufügen von Chat zu unserem Spiel