Escribiendo un motor de aventura de texto multijugador en Node.js: Diseño del servidor del motor de juego (Parte 2)

Publicado: 2022-03-10
Resumen rápido ↬ Bienvenidos a la segunda parte de esta serie. En la primera parte, cubrimos la arquitectura de una plataforma basada en Node.js y una aplicación de cliente que permitirá a las personas definir y jugar sus propias aventuras de texto en grupo. Esta vez, cubriremos la creación de uno de los módulos que Fernando definió la última vez (el motor del juego) y también nos centraremos en el proceso de diseño para arrojar algo de luz sobre lo que debe suceder antes de comenzar a codificar su propios proyectos de afición.

Después de una cuidadosa consideración y la implementación real del módulo, algunas de las definiciones que hice durante la fase de diseño tuvieron que cambiarse. Esta debería ser una escena familiar para cualquier persona que haya trabajado alguna vez con un cliente entusiasta que sueña con un producto ideal pero que necesita que el equipo de desarrollo lo controle.

Una vez que se hayan implementado y probado las características, su equipo comenzará a notar que algunas características pueden diferir del plan original, y eso está bien. Simplemente notifique, ajuste y continúe. Entonces, sin más preámbulos, permítanme explicar primero qué ha cambiado con respecto al plan original.

Otras partes de esta serie

  • Parte 1: La Introducción
  • Parte 3: Creando el Cliente Terminal
  • Parte 4: Agregar chat a nuestro juego

Mecánica de batalla

Este es probablemente el mayor cambio con respecto al plan original. Sé que dije que iba a optar por una implementación al estilo D&D en la que cada PC y NPC involucrado obtendría un valor de iniciativa y después de eso, ejecutaríamos un combate por turnos. Fue una buena idea, pero implementarlo en un servicio basado en REST es un poco complicado ya que no puede iniciar la comunicación desde el lado del servidor ni mantener el estado entre llamadas.

Entonces, en cambio, aprovecharé la mecánica simplificada de REST y la usaré para simplificar nuestra mecánica de batalla. La versión implementada estará basada en el jugador en lugar de en el grupo, y permitirá a los jugadores atacar a los NPC (personajes que no son jugadores). Si su ataque tiene éxito, los NPC serán asesinados o atacarán de vuelta dañando o matando al jugador.

El éxito o fracaso de un ataque estará determinado por el tipo de arma utilizada y las debilidades que pueda tener un NPC. Básicamente, si el monstruo que intentas matar es débil contra tu arma, muere. De lo contrario, no se verá afectado y, muy probablemente, muy enojado.

disparadores

Si prestó mucha atención a la definición del juego JSON de mi artículo anterior, es posible que haya notado la definición del activador que se encuentra en los elementos de la escena. Uno en particular implicaba actualizar el estado del juego ( statusUpdate ). Durante la implementación, me di cuenta de que tenerlo funcionando como un conmutador proporcionaba una libertad limitada. Verá, en la forma en que se implementó (desde un punto de vista idiomático), pudo establecer un estado pero anularlo no era una opción. Entonces, en cambio, reemplacé este efecto desencadenante con dos nuevos: addStatus y removeStatus . Estos le permitirán definir exactamente cuándo pueden tener lugar estos efectos, si es que lo hacen. Siento que esto es mucho más fácil de entender y razonar.

Esto significa que los disparadores ahora se ven así:

 "triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]

Al recoger el elemento, estamos configurando un estado, y al soltarlo, lo estamos eliminando. De esta manera, tener múltiples indicadores de estado a nivel de juego es completamente posible y fácil de administrar.

¡Más después del salto! Continúe leyendo a continuación ↓

La implementación

Con esas actualizaciones fuera del camino, podemos comenzar a cubrir la implementación real. Desde un punto de vista arquitectónico, nada cambió; todavía estamos construyendo una API REST que contendrá la lógica del motor principal del juego.

La pila de tecnología

Para este proyecto en particular, los módulos que voy a usar son los siguientes:

Módulo Descripción
Express.js Obviamente, usaré Express como base para todo el motor.
Winston Winston se encargará de todo lo relacionado con el registro.
Configuración Todas las variables constantes y dependientes del entorno serán manejadas por el módulo config.js, lo que simplifica enormemente la tarea de acceder a ellas.
Mangosta Este será nuestro ORM. Modelaré todos los recursos usando Mongoose Models y los usaré para interactuar directamente con la base de datos.
uuid Tendremos que generar algunas identificaciones únicas; este módulo nos ayudará con esa tarea.

En cuanto a otras tecnologías utilizadas además de Node.js, tenemos MongoDB y Redis . Me gusta usar Mongo debido a la falta de esquema requerido. Ese simple hecho me permite pensar en mi código y los formatos de datos, sin tener que preocuparme por actualizar la estructura de mis tablas, migraciones de esquemas o tipos de datos en conflicto.

En cuanto a Redis, tiendo a usarlo como sistema de soporte tanto como puedo en mis proyectos y este caso no es diferente. Usaré Redis para todo lo que pueda considerarse información volátil, como números de miembros del grupo, solicitudes de comando y otros tipos de datos que son lo suficientemente pequeños y volátiles como para no merecer un almacenamiento permanente.

También voy a usar la función de caducidad de clave de Redis para administrar automáticamente algunos aspectos del flujo (más sobre esto en breve).

Definición de API

Antes de pasar a la interacción cliente-servidor y las definiciones de flujo de datos, quiero repasar los puntos finales definidos para esta API. No son muchos, en su mayoría debemos cumplir con las características principales descritas en la Parte 1:

Rasgo Descripción
Únete a un juego Un jugador podrá unirse a un juego especificando la ID del juego.
Crear un nuevo juego Un jugador también puede crear una nueva instancia de juego. El motor debe devolver una ID, para que otros puedan usarla para unirse.
escena de regreso Esta función debería devolver la escena actual donde se encuentra la fiesta. Básicamente, devolverá la descripción, con toda la información asociada (posibles acciones, objetos en ella, etc.).
Interactuar con la escena Este va a ser uno de los más complejos, porque tomará un comando del cliente y realizará esa acción, como mover, empujar, tomar, mirar, leer, por nombrar solo algunos.
Verificar inventario Aunque esta es una forma de interactuar con el juego, no se relaciona directamente con la escena. Por lo tanto, revisar el inventario de cada jugador se considerará una acción diferente.
Registrar aplicación de cliente Las acciones anteriores requieren un cliente válido para ejecutarlas. Este punto final verificará la aplicación del cliente y devolverá una ID de cliente que se usará con fines de autenticación en solicitudes posteriores.

La lista anterior se traduce en la siguiente lista de puntos finales:

Verbo punto final Descripción
CORREO /clients Las aplicaciones cliente requerirán obtener una clave de ID de cliente utilizando este punto final.
CORREO /games Las aplicaciones cliente crean nuevas instancias de juego utilizando este punto final.
CORREO /games/:id Una vez que se crea el juego, este punto final permitirá que los miembros del grupo se unan y comiencen a jugar.
OBTENER /games/:id/:playername Este punto final devolverá el estado actual del juego para un jugador en particular.
CORREO /games/:id/:playername/commands Finalmente, con este punto final, la aplicación cliente podrá enviar comandos (en otras palabras, este punto final se utilizará para jugar).

Permítanme entrar un poco más en detalle sobre algunos de los conceptos que describí en la lista anterior.

Aplicaciones de cliente

Las aplicaciones cliente deberán registrarse en el sistema para comenzar a usarlo. Todos los puntos finales (excepto el primero de la lista) están protegidos y requerirán que se envíe una clave de aplicación válida con la solicitud. Para obtener esa clave, las aplicaciones cliente simplemente deben solicitar una. Una vez proporcionados, durarán el tiempo que se utilicen o caducarán al cabo de un mes de no utilizarse. Este comportamiento se controla almacenando la clave en Redis y estableciéndole un TTL de un mes.

Instancia de juego

Crear un nuevo juego básicamente significa crear una nueva instancia de un juego en particular. Esta nueva instancia contendrá una copia de todas las escenas y su contenido. Cualquier modificación realizada en el juego solo afectará al grupo. De esta manera, muchos grupos pueden jugar el mismo juego de forma individual.

Estado del juego del jugador

Este es similar al anterior, pero único para cada jugador. Mientras que la instancia del juego mantiene el estado del juego para todo el grupo, el estado del juego del jugador mantiene el estado actual de un jugador en particular. Principalmente, contiene inventario, posición, escena actual y HP (puntos de salud).

Comandos del jugador

Una vez que todo está configurado y la aplicación cliente se ha registrado y se ha unido a un juego, puede comenzar a enviar comandos. Los comandos implementados en esta versión del motor incluyen: move , look , pickup y attack .

  • El comando de move le permitirá atravesar el mapa. Podrás especificar la dirección hacia la que quieres moverte y el motor te hará saber el resultado. Si echa un vistazo rápido a la Parte 1, puede ver el enfoque que tomé para manejar los mapas. (En resumen, el mapa se representa como un gráfico, donde cada nodo representa una habitación o escena y solo está conectado a otros nodos que representan habitaciones adyacentes).

    La distancia entre nodos también está presente en la representación y aunada a la velocidad estándar que tiene un jugador; ir de una habitación a otra puede no ser tan simple como decir tu comando, pero también tendrás que recorrer la distancia. En la práctica, esto significa que pasar de una habitación a otra puede requerir varios comandos de movimiento). El otro aspecto interesante de este comando proviene del hecho de que este motor está diseñado para soportar grupos multijugador y el grupo no se puede dividir (al menos no en este momento).

    Por lo tanto, la solución para esto es similar a un sistema de votación: cada miembro del partido enviará una solicitud de comando de movimiento cuando lo desee. Una vez que más de la mitad lo hayan hecho, se utilizará la dirección más solicitada.
  • look es bastante diferente de moverse. Le permite al jugador especificar una dirección, un elemento o NPC que desea inspeccionar. La lógica clave detrás de este comando se tiene en cuenta cuando se piensa en las descripciones que dependen del estado.

    Por ejemplo, supongamos que ingresa a una nueva habitación, pero está completamente oscura (no ve nada) y avanza mientras la ignora. Unas pocas habitaciones más adelante, recoges una antorcha encendida de una pared. Así que ahora puedes regresar y volver a inspeccionar esa habitación oscura. Como recogió la antorcha, ahora puede ver su interior y puede interactuar con cualquiera de los elementos y NPC que encuentre allí.

    Esto se logra manteniendo un conjunto de atributos de estado para todo el juego y específico del jugador y permitiendo que el creador del juego especifique varias descripciones para nuestros elementos dependientes del estado en el archivo JSON. Luego, cada descripción está equipada con un texto predeterminado y un conjunto de condicionales, según el estado actual. Estos últimos son opcionales; el único que es obligatorio es el valor por defecto.

    Además, este comando tiene una versión abreviada de look at room: look around ; eso se debe a que los jugadores intentarán inspeccionar una habitación con mucha frecuencia, por lo que proporcionar un comando abreviado (o alias) que sea más fácil de escribir tiene mucho sentido.
  • El comando de pickup juega un papel muy importante para el juego. Este comando se encarga de agregar elementos al inventario de los jugadores o a sus manos (si están libres). Para comprender dónde debe almacenarse cada elemento, su definición tiene una propiedad de "destino" que especifica si está destinado al inventario oa las manos del jugador. Cualquier cosa que se recoja con éxito de la escena se elimina de ella, actualizando la versión del juego de la instancia del juego.
  • El comando de use le permitirá afectar el medio ambiente usando artículos en su inventario. Por ejemplo, recoger una llave en una habitación te permitirá usarla para abrir una puerta cerrada con llave en otra habitación.
  • Hay un comando especial, uno que no está relacionado con el juego, sino un comando de ayuda destinado a obtener información particular, como la ID del juego actual o el nombre del jugador. Este comando se llama get y los jugadores pueden usarlo para consultar el motor del juego. Por ejemplo: obtener gameid .
  • Finalmente, el último comando implementado para esta versión del motor es el comando de attack . Ya cubrí este; básicamente, tendrás que especificar tu objetivo y el arma con la que lo estás atacando. De esa forma, el sistema podrá verificar las debilidades del objetivo y determinar el resultado de su ataque.

Interacción cliente-motor

Para comprender cómo usar los puntos finales mencionados anteriormente, permítame mostrarle cómo cualquier posible cliente puede interactuar con nuestra nueva API.

Paso Descripción
Registrar cliente Lo primero es lo primero, la aplicación cliente debe solicitar una clave API para poder acceder a todos los demás puntos finales. Para obtener esa clave, necesita registrarse en nuestra plataforma. El único parámetro a proporcionar es el nombre de la aplicación, eso es todo.
crea un juego Después de obtener la clave API, lo primero que debe hacer (asumiendo que se trata de una interacción completamente nueva) es crear una instancia de juego completamente nueva. Piénselo de esta manera: el archivo JSON que creé en mi última publicación contiene la definición del juego, pero necesitamos crear una instancia solo para usted y su grupo (piense en clases y objetos, el mismo trato). Puedes hacer con esa instancia lo que quieras, y no afectará a otras partes.
Unirse al juego Después de crear el juego, obtendrá una identificación del juego del motor. Luego puede usar esa ID de juego para unirse a la instancia usando su nombre de usuario único. A menos que te unas al juego, no puedes jugar, porque unirte al juego también creará una instancia de estado del juego solo para ti. Aquí será donde se guardarán tu inventario, tu posición y tus estadísticas básicas en relación con el juego que estés jugando. Potencialmente podrías estar jugando varios juegos al mismo tiempo, y en cada uno tener estados independientes.
Enviar comandos En otras palabras: jugar el juego. El paso final es comenzar a enviar comandos. La cantidad de comandos disponibles ya se cubrió y se puede ampliar fácilmente (más sobre esto en un momento). Cada vez que envíe un comando, el juego devolverá el nuevo estado del juego para que su cliente actualice su vista en consecuencia.

Ensuciémonos las manos

He repasado todo el diseño que he podido, con la esperanza de que esa información te ayude a comprender la siguiente parte, así que entremos en los aspectos prácticos del motor del juego.

Nota : no mostraré el código completo en este artículo ya que es bastante grande y no todo es interesante. En su lugar, mostraré las partes más relevantes y un enlace al repositorio completo en caso de que desee más detalles.

El archivo principal

Lo primero es lo primero: este es un proyecto de Express y su código repetitivo basado se generó usando el propio generador de Express, por lo que el archivo app.js debería resultarle familiar. Solo quiero repasar dos ajustes que me gusta hacer en ese código para simplificar mi trabajo.

Primero, agrego el siguiente fragmento para automatizar la inclusión de nuevos archivos de ruta:

 const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })

En realidad, es bastante simple, pero elimina la necesidad de solicitar manualmente cada archivo de ruta que cree en el futuro. Por cierto, require-dir es un módulo simple que se encarga de requerir automáticamente cada archivo dentro de una carpeta. Eso es todo.

El otro cambio que me gusta hacer es ajustar un poco mi controlador de errores. Realmente debería comenzar a usar algo más robusto, pero para las necesidades actuales, siento que esto hace el trabajo:

 // 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); });

El código anterior se encarga de los diferentes tipos de mensajes de error con los que podríamos tener que lidiar, ya sean objetos completos, objetos de error reales generados por Javascript o mensajes de error simples sin ningún otro contexto. Este código lo tomará todo y lo formateará en un formato estándar.

Manejo de comandos

Este es otro de esos aspectos del motor que tenía que ser fácil de ampliar. En un proyecto como este, tiene mucho sentido suponer que aparecerán nuevos comandos en el futuro. Si hay algo que desea evitar, probablemente sería evitar realizar cambios en el código base cuando intente agregar algo nuevo dentro de tres o cuatro meses.

Ninguna cantidad de comentarios de código hará que la tarea de modificar el código que no ha tocado (o en el que no ha pensado) en varios meses sea fácil, por lo que la prioridad es evitar tantos cambios como sea posible. Por suerte para nosotros, hay algunos patrones que podemos implementar para resolver esto. En particular, usé una mezcla de los patrones Command y Factory.

Básicamente, encapsulé el comportamiento de cada comando dentro de una sola clase que hereda de una clase BaseCommand que contiene el código genérico para todos los comandos. Al mismo tiempo, agregué un módulo CommandParser que toma la cadena enviada por el cliente y devuelve el comando real para ejecutar.

El analizador es muy simple ya que todos los comandos implementados ahora tienen el comando real en cuanto a su primera palabra (es decir, "mover al norte", "recoger el cuchillo", etc.) es una simple cuestión de dividir la cadena y obtener la primera 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 : estoy usando el módulo require-dir una vez más para simplificar la inclusión de cualquier clase de comando existente y nueva. Simplemente lo agrego a la carpeta y todo el sistema puede recogerlo y usarlo.

Dicho esto, hay muchas maneras en que esto se puede mejorar; por ejemplo, poder agregar compatibilidad con sinónimos para nuestros comandos sería una gran característica (por lo que decir "mover al norte", "ir al norte" o incluso "caminar hacia el norte" significaría lo mismo). Eso es algo que podríamos centralizar en esta clase y afectar a todos los comandos al mismo tiempo.

No entraré en detalles sobre ninguno de los comandos porque, nuevamente, es demasiado código para mostrar aquí, pero puede ver en el siguiente código de ruta cómo logré generalizar el manejo de los comandos existentes (y futuros):

 /** 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) }) })

Todos los comandos solo requieren el método de run ; todo lo demás es adicional y está diseñado para uso interno.

Te animo a que vayas y revises el código fuente completo (¡incluso descárgalo y juega con él si quieres!). En la siguiente parte de esta serie, le mostraré la implementación real del cliente y la interacción de esta API.

Pensamientos finales

Es posible que no haya cubierto mucho de mi código aquí, pero aún espero que el artículo haya sido útil para mostrarle cómo afronto los proyectos, incluso después de la fase de diseño inicial. Siento que muchas personas intentan comenzar a codificar como su primera respuesta a una nueva idea y eso a veces puede terminar desanimando a un desarrollador, ya que no hay un plan real establecido ni objetivos que lograr, aparte de tener el producto final listo ( y ese es un hito demasiado grande para abordar desde el día 1). Nuevamente, mi esperanza con estos artículos es compartir una forma diferente de trabajar solo (o como parte de un grupo pequeño) en grandes proyectos.

¡Espero que hayas disfrutado de la lectura! Siéntase libre de dejar un comentario a continuación con cualquier tipo de sugerencia o recomendación, me encantaría leer lo que piensa y si está ansioso por comenzar a probar la API con su propio código del lado del cliente.

¡Nos vemos en la próxima!

Otras partes de esta serie

  • Parte 1: La Introducción
  • Parte 3: Creando el Cliente Terminal
  • Parte 4: Agregar chat a nuestro juego