Escribir un motor de aventura de texto multijugador en Node.js: crear el cliente de terminal (parte 3)
Publicado: 2022-03-10Primero te mostré cómo definir un proyecto como este y te di los conceptos básicos de la arquitectura, así como la mecánica detrás del motor del juego. Luego, le mostré la implementación básica del motor: una API REST básica que le permite atravesar un mundo definido por JSON.
Hoy, les mostraré cómo crear un cliente de texto de la vieja escuela para nuestra API usando nada más que Node.js.
Otras partes de esta serie
- Parte 1: La Introducción
- Parte 2: Diseño del servidor de Game Engine
- Parte 4: Agregar chat a nuestro juego
Revisión del diseño original
Cuando propuse por primera vez un esquema básico para la interfaz de usuario, propuse cuatro secciones en la pantalla:
Aunque en teoría parece correcto, me perdí el hecho de que cambiar entre el envío de comandos del juego y mensajes de texto sería una molestia, así que en lugar de que nuestros jugadores cambien manualmente, haremos que nuestro analizador de comandos se asegure de que sea capaz de discernir si Estamos tratando de comunicarnos con el juego o con nuestros amigos.
Entonces, en lugar de tener cuatro secciones en nuestra pantalla, ahora tendremos tres:
Esa es una captura de pantalla real del cliente final del juego. Puede ver la pantalla del juego a la izquierda y el chat a la derecha, con un único cuadro de entrada común en la parte inferior. El módulo que estamos usando nos permite personalizar colores y algunos efectos básicos. Podrás clonar este código de Github y hacer lo que quieras con la apariencia.
Sin embargo, una advertencia: aunque la captura de pantalla anterior muestra el chat funcionando como parte de la aplicación, mantendremos este artículo enfocado en configurar el proyecto y definir un marco donde podamos crear una aplicación dinámica basada en IU de texto. Nos centraremos en agregar soporte de chat en el próximo y último capítulo de esta serie.
Las herramientas que necesitaremos
Aunque existen muchas bibliotecas que nos permiten crear herramientas CLI con Node.js, agregar una interfaz de usuario basada en texto es una bestia completamente diferente para domar. En particular, solo pude encontrar una biblioteca (muy completa, fíjate) que me permitiera hacer exactamente lo que quería: bendita.
Esta biblioteca es muy poderosa y proporciona muchas funciones que no usaremos para este proyecto (como proyectar sombras, arrastrar y soltar y otras). Básicamente, vuelve a implementar toda la biblioteca ncurses (una biblioteca C que permite a los desarrolladores crear interfaces de usuario basadas en texto) que no tiene enlaces de Node.js, y lo hace directamente en JavaScript; así que, si tuviéramos que hacerlo, podríamos muy bien comprobar su código interno (algo que no recomendaría a menos que fuera absolutamente necesario).
Aunque la documentación de Blessed es bastante extensa, consiste principalmente en detalles individuales sobre cada método provisto (en lugar de tener tutoriales que explican cómo usar esos métodos juntos) y carece de ejemplos en todas partes, por lo que puede ser difícil profundizar en él. si tiene que entender cómo funciona un método en particular. Dicho esto, una vez que lo entiendes, todo funciona de la misma manera, lo cual es una gran ventaja ya que no todas las bibliotecas o incluso los idiomas (te estoy mirando, PHP) tienen una sintaxis consistente.
Pero aparte de la documentación; la gran ventaja de esta biblioteca es que funciona según las opciones de JSON. Por ejemplo, si quisiera dibujar un cuadro en la esquina superior derecha de la pantalla, haría algo como esto:
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' } } });
Como puede imaginar, allí también se definen otros aspectos de la caja (como su tamaño), que puede ser perfectamente dinámico según el tamaño de la terminal, el tipo de borde y los colores, incluso para eventos de desplazamiento. Si ha realizado desarrollo front-end en algún momento, encontrará mucha superposición entre los dos.
El punto que estoy tratando de hacer aquí es que todo lo relacionado con la representación del cuadro se configura a través del objeto JSON que se pasa al método del box
. Eso, para mí, es perfecto porque puedo extraer fácilmente ese contenido en un archivo de configuración y crear una lógica comercial capaz de leerlo y decidir qué elementos dibujar en la pantalla. Lo que es más importante, nos ayudará a tener una idea de cómo se verán una vez que hayan sido dibujados.
Esta será la base para todo el aspecto de la interfaz de usuario de este módulo ( ¡más sobre eso en un segundo! ).
Arquitectura del módulo
La arquitectura principal de este módulo se basa completamente en los widgets de la interfaz de usuario que mostraremos. Un grupo de estos widgets se considera una pantalla y todas estas pantallas se definen en un solo archivo JSON (que puede encontrar dentro de la carpeta /config
).
Este archivo tiene más de 250 líneas, por lo que mostrarlo aquí no tiene sentido. Puede ver el archivo completo en línea, pero un pequeño fragmento se ve así:
"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", //... } } }
El elemento "pantallas" contendrá la lista de pantallas dentro de la aplicación. Cada pantalla contiene una lista de widgets (que cubriré en un momento) y cada widget tiene su definición específica de bendiciones y archivos de controlador relacionados (cuando corresponda).
Puede ver cómo cada elemento "params" (dentro de un widget en particular) representa el conjunto real de parámetros esperados por los métodos que vimos anteriormente. El resto de las claves definidas allí ayudan a proporcionar contexto sobre qué tipo de widgets representar y su comportamiento.
Algunos puntos de interés:
Controladores de pantalla
Cada elemento de la pantalla tiene una propiedad de archivo que hace referencia al código asociado a esa pantalla. Este código no es más que un objeto que debe tener un método init
(la lógica de inicialización para esa pantalla en particular tiene lugar dentro de él). En particular, el motor principal de la interfaz de usuario llamará a ese método init
de cada pantalla, que a su vez debería ser responsable de inicializar cualquier lógica que pueda necesitar (es decir, configurar los eventos de los cuadros de entrada).
El siguiente es el código de la pantalla principal, donde la aplicación solicita al jugador que seleccione una opción para iniciar un juego nuevo o unirse a uno existente:
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 } }
Como puede ver, el método init
llama al método setupInput
que básicamente configura la devolución de llamada correcta para manejar la entrada del usuario. Esa devolución de llamada contiene la lógica para decidir qué hacer en función de la entrada del usuario (ya sea 1 o 2).
Controladores de widgets
Algunos de los widgets (generalmente widgets de entrada) tienen una propiedad handlerPath
, que hace referencia al archivo que contiene la lógica detrás de ese componente en particular. Esto no es lo mismo que el controlador de pantalla anterior. A estos no les importan mucho los componentes de la interfaz de usuario. En cambio, manejan la lógica de unión entre la interfaz de usuario y cualquier biblioteca que estemos usando para interactuar con servicios externos (como la API del motor del juego).
Tipos de widgets
Otra adición menor a la definición JSON de los widgets son sus tipos. En lugar de ir con los nombres que Beato definió para ellos, estoy creando otros nuevos para darme más margen de maniobra en lo que respecta a su comportamiento. Después de todo, es posible que un widget de ventana no siempre "solo muestre información", o que un cuadro de entrada no siempre funcione de la misma manera.
Este fue principalmente un movimiento preventivo, solo para asegurarme de tener esa habilidad si alguna vez la necesito en el futuro, pero como están a punto de ver, no estoy usando tantos tipos diferentes de componentes de todos modos.
Múltiples Pantallas
Aunque la pantalla principal es la que te mostré en la captura de pantalla anterior, el juego requiere algunas otras pantallas para solicitar cosas como tu nombre de jugador o si estás creando una nueva sesión de juego o incluso uniéndote a una existente. La forma en que manejé eso fue, nuevamente, a través de la definición de todas estas pantallas en el mismo archivo JSON. Y para pasar de una pantalla a la siguiente, usamos la lógica dentro de los archivos del controlador de pantalla.
Podemos hacer esto simplemente usando la siguiente línea de código:
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
Le mostraré más detalles sobre la propiedad de la interfaz de usuario en un segundo, pero solo estoy usando ese método loadScreen
para volver a representar la pantalla y seleccionar los componentes correctos del archivo JSON usando la cadena pasada como parámetro. Muy sencillo.
Ejemplos de código
Ahora es el momento de revisar la carne y las papas de este artículo: las muestras de código. Solo voy a resaltar lo que creo que son las pequeñas gemas en su interior, pero siempre puedes echar un vistazo al código fuente completo directamente en el repositorio en cualquier momento.
Uso de archivos de configuración para generar automáticamente la interfaz de usuario
Ya he cubierto parte de esto, pero creo que vale la pena explorar los detalles detrás de este generador. La esencia detrás de esto (archivo index.js dentro de la carpeta /ui
) es que es un envoltorio alrededor del objeto Bendito. Y el método más interesante dentro de él es el método loadScreen
.
Este método toma la configuración (a través del módulo de configuración) para una pantalla específica y revisa su contenido, tratando de generar los widgets correctos según el tipo de cada 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() },
Como puede ver, el código es un poco largo, pero la lógica detrás de él es simple:
- Carga la configuración para la pantalla específica actual;
- Limpia cualquier widget existente previamente;
- Repasa cada widget y lo instancia;
- Si se pasó una alerta adicional como un mensaje flash (que es básicamente un concepto que robé de Web Dev en el que configura un mensaje para que se muestre en la pantalla hasta la próxima actualización);
- Renderizar la pantalla real;
- Y finalmente, requiere el controlador de pantalla y ejecuta su método "init".
¡Eso es todo! Puede consultar el resto de los métodos: en su mayoría están relacionados con widgets individuales y cómo representarlos.
Comunicación entre la interfaz de usuario y la lógica empresarial
Aunque a gran escala, la interfaz de usuario, el back-end y el servidor de chat tienen una comunicación algo basada en capas; la interfaz en sí necesita al menos una arquitectura interna de dos capas en la que los elementos puros de la interfaz de usuario interactúen con un conjunto de funciones que representen la lógica central dentro de este proyecto en particular.
El siguiente diagrama muestra la arquitectura interna del cliente de texto que estamos construyendo:
Déjame explicarlo un poco más. Como mencioné anteriormente, loadScreenMethod
creará presentaciones de interfaz de usuario de los widgets (estos son objetos bendecidos). Pero están contenidos como parte del objeto de lógica de pantalla, que es donde configuramos los eventos básicos (como onSubmit
para los cuadros de entrada).
Permítanme darles un ejemplo práctico. Esta es la primera pantalla que ve al iniciar el cliente de interfaz de usuario:
Hay tres secciones en esta pantalla:
- solicitud de nombre de usuario,
- Opciones de menú / información,
- Pantalla de entrada para las opciones del menú.
Básicamente, lo que queremos hacer es solicitar el nombre de usuario y luego pedirles que elijan una de las dos opciones (ya sea iniciando un juego nuevo o uniéndose a uno existente).
El código que se encarga de eso es el siguiente:
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 } }
Sé que es mucho código, pero concéntrate en el método init
. Lo último que hace es llamar al método setInput
que se encarga de agregar los eventos correctos a los cuadros de entrada correctos.
Por lo tanto, con estas líneas:
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()
Estamos accediendo a los objetos Benditos y obteniendo sus referencias, para que luego podamos configurar los eventos de submit
. Entonces, después de enviar el nombre de usuario, cambiamos el enfoque al segundo cuadro de entrada (literalmente con input.focus()
).
Dependiendo de la opción que elijamos del menú, llamaremos a cualquiera de los métodos:
-
createNewGame
: crea un nuevo juego al interactuar con su controlador asociado; -
moveToIDRequest
: muestra la siguiente pantalla encargada de solicitar el ID del juego para unirse.
Comunicación con el motor del juego
Por último, pero no menos importante (y siguiendo el ejemplo anterior), si presiona 2, notará que el método createNewGame
usa los métodos del controlador createNewGame
y luego joinGame
(uniéndose al juego justo después de crearlo).
Ambos métodos están destinados a simplificar la interacción con la API de Game Engine. Aquí está el código para el controlador de esta pantalla:
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) }) } }
Ahí ves dos formas diferentes de manejar este comportamiento. El primer método en realidad usa la clase apiClient
, que nuevamente envuelve las interacciones con GameEngine en otra capa de abstracción.
Sin embargo, el segundo método realiza la acción directamente mediante el envío de una solicitud POST a la URL correcta con la carga útil correcta. Después no se hace nada lujoso; solo enviamos el cuerpo de la respuesta a la lógica de la interfaz de usuario.
Nota : si está interesado en la versión completa del código fuente de este cliente, puede consultarlo aquí.
Ultimas palabras
Esto es todo para el cliente basado en texto para nuestra aventura de texto. Cubrí:
- Cómo estructurar una aplicación cliente;
- Cómo usé Blessed como tecnología central para crear la capa de presentación;
- Cómo estructurar la interacción con los servicios de back-end de un cliente complejo;
- Y con suerte, con el repositorio completo disponible.
Y aunque es posible que la interfaz de usuario no se vea exactamente como la versión original, cumple su propósito. Con suerte, este artículo te dio una idea de cómo diseñar un proyecto de este tipo y te inclinaste a probarlo por ti mismo en el futuro. Blessed es definitivamente una herramienta muy poderosa, pero tendrás que tener paciencia con ella mientras aprendes a usarla y cómo navegar a través de sus documentos.
En la siguiente y última parte, cubriré cómo agregué el servidor de chat tanto en el back-end como para este cliente de texto.
¡Nos vemos en la próxima!
Otras partes de esta serie
- Parte 1: La Introducción
- Parte 2: Diseño del servidor de Game Engine
- Parte 4: Agregar chat a nuestro juego