Escribir un motor de aventuras de texto multijugador en Node.js: diseño del servidor Game Engine (Parte 2)

 

 

 

  • Accesibilidad para diseñadores, con Stéphanie Walter
  • Implemente rápidamente. Implementar inteligentemente

  • Índice
    1. Mecánica de batalla
    2. Desencadenantes
  • La implementación
    1. La pila tecnológica
  • Definición de API
    1. Aplicaciones cliente
    2. Instancia de juego
    3. Estado del juego del jugador
    4. Comandos del jugador
    5. Interacción cliente-motor
  • Ensuciémonos las manos
    1. El archivo principal
    2. Manejo de comandos
  • Pensamientos finales
  • 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 cliente que permitirá a las personas definir y jugar sus propias aventuras de texto como 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 tu propios proyectos de hobby.

     

    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 cualquiera que haya trabajado alguna vez con un cliente entusiasta que sueña con un producto ideal pero que necesita la moderación del equipo de desarrollo.

    Una vez que se hayan implementado y probado las funciones, 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 explicarles primero qué ha cambiado con respecto al plan original .

     

    Otras partes de esta serie

    • Parte 1 : La Introducción
    • Parte 3 : Creación del 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 estilo DD en la que cada PC y NPC involucrados obtendría un valor de iniciativa y después de eso, ejecutaríamos un combate por turnos. Fue una buena idea, pero implementarla en un servicio basado en REST es un poco complicado ya que no se puede iniciar la comunicación desde el lado del servidor ni mantener el estado entre llamadas.

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

    El éxito o el fracaso de un ataque dependerá del tipo de arma utilizada y de 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.

    Desencadenantes

    Si prestaste mucha atención a la definición del juego JSON de mi artículo anterior , es posible que hayas notado la definición del disparador 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 el hecho de que funcionara como palanca proporcionaba una libertad limitada. Verá, en la forma en que se implementó (desde un punto de vista idiomático), podía establecer un estado pero desarmarlo no era una opción. En lugar de eso, reemplacé este efecto desencadenante con dos nuevos: addStatusy removeStatus. Esto le permitirá definir exactamente cuándo pueden producirse estos efectos, si es que se producen. Siento que esto es mucho más fácil de entender y razonar.

    Esto significa que los desencadenantes 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, configuramos un estado y al soltarlo, lo eliminamos. De esta manera, tener múltiples indicadores de estado a nivel de juego es completamente posible y fácil de administrar.

    La implementación

    Una vez eliminadas esas actualizaciones, podemos comenzar a cubrir la implementación real. Desde el 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 tecnológica

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

    Módulo Descripción
    expreso.js Obviamente, usaré Express como base para todo el motor.
    Winston Todo lo relacionado con el registro estará a cargo de Winston.
    configuración Cada variable constante y dependiente del entorno será manejada 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 Necesitaremos generar algunas identificaciones únicas; este módulo nos ayudará con esa tarea.

    En cuanto a otras tecnologías utilizadas aparte de Node.js, tenemos MongoDB y Redis . Me gusta usar Mongo debido a la falta del 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 conflictivos.

    Respecto a Redis, suelo 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 sean lo suficientemente pequeños y volátiles como para no merecer un almacenamiento permanente.

    También usaré la función de caducidad de claves 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 a las definiciones de flujo de datos, quiero repasar los puntos finales definidos para esta API. No son tantas, en su mayoría debemos cumplir con las características principales descritas en la Parte 1 :

    Característica Descripción
    Únete a un juego Un jugador podrá unirse a un juego especificando el ID del juego.
    Crea un nuevo juego Un jugador también puede crear una nueva instancia de juego. El motor debe devolver una identificación para que otros puedan usarla para unirse.
    escena de regreso Esta característica 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 que contiene, etc.).
    Interactuar con la escena Este será uno de los más complejos, porque tomará un comando del cliente y realizará esa acción: cosas como mover, empujar, tomar, mirar, leer, por nombrar solo algunas.
    comprobar inventario Aunque esta es una forma de interactuar con el juego, no se relaciona directamente con la escena. Por lo tanto, consultar el inventario de cada jugador se considerará una acción diferente.
    Registrar aplicación 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 utilizará 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 deberán obtener una clave de identificación 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 creado el juego, este punto final permitirá a los miembros del grupo unirse y comenzar a jugar.
    CONSEGUIR /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 en más detalles sobre algunos de los conceptos que describí en la lista anterior.

    Aplicaciones 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 mientras se utilicen o caducarán al mes de no utilizarse. Este comportamiento se controla almacenando la clave en Redis y estableciendo un TTL de un mes de duración.

    Instancia de juego

    Crear un juego nuevo 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 sólo afectará al partido. 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, y .lookpickupattack

    • El movecomando te permitirá recorrer el mapa. Podrás especificar la dirección hacia la que deseas moverte y el motor te informará el resultado. Si echas un vistazo rápido a la Parte 1 , podrás ver el enfoque que adopté 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, junto con la velocidad estándar a el jugador tiene; Ir de una habitación a otra puede no ser tan simple como dar una orden, pero también tendrás que recorrer la distancia. En la práctica, esto significa que para pasar de una habitación a otra se pueden necesitar varios comandos de movimiento). El otro aspecto interesante de este comando proviene del hecho de que este motor está diseñado para admitir grupos multijugador y el grupo no se puede dividir (al menos no en este momento). Fulares Portabebes

       

      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 lo hayan hecho más de la mitad de ellos, se utilizará la dirección más solicitada.

    • lookes bastante diferente de mover. Le permite al jugador especificar una dirección, un elemento o NPC que desea inspeccionar. La lógica clave detrás de este comando entra en consideración cuando se piensa en descripciones dependientes del estado.

      Por ejemplo, digamos que entras en una nueva habitación, pero está completamente oscura (no ves nada) y avanzas ignorándola. Unas cuantas habitaciones más tarde, recoges una antorcha encendida de una pared. Ahora puedes regresar y volver a inspeccionar ese cuarto oscuro. Desde que recogiste la antorcha, ahora puedes ver su interior y poder interactuar con cualquiera de los elementos y NPC que encuentres allí.

      Esto se logra manteniendo un conjunto de atributos de estado específicos para todo el juego y del jugador y permitiendo al creador del juego especificar 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 predeterminado.

      Además, este comando tiene una versión abreviada para look at room: look around; Esto se debe a que los jugadores intentarán inspeccionar una habitación con mucha frecuencia, por lo que tiene mucho sentido proporcionar un comando abreviado (o alias) que sea más fácil de escribir.

    • El pickupcomando juega un papel muy importante para el juego. Este comando se encarga de agregar elementos al inventario del jugador 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 o a las manos del jugador. Todo lo que se recoja con éxito de la escena se elimina de ella, actualizando la versión del juego de la instancia del juego.
    • El usecomando te permitirá afectar el medio ambiente utilizando elementos de tu inventario. Por ejemplo, recoger una llave en una habitación le 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 auxiliar 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 attackcomando. Ya cubrí este; Básicamente, tendrás que especificar tu objetivo y el arma con la que lo atacarás. De esa manera, el sistema podrá comprobar las debilidades del objetivo y determinar el resultado de su ataque.

    Interacción cliente-motor

    Para comprender cómo utilizar los puntos finales enumerados 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 es necesario registrarse en nuestra plataforma. El único parámetro a proporcionar es el nombre de la aplicación, eso es todo.
    crear un juego Una vez obtenida la clave API, lo primero que debe hacer (suponiendo que se trate 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, lo mismo). Puedes hacer con esa instancia lo que quieras y no afectará a otras partes.
    Unirse al juego Después de crear el juego, el motor te devolverá una identificación del juego. Luego puedes usar esa ID del juego para unirte a la instancia usando tu nombre de usuario único. A menos que te unas al juego, no podrás 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. Podrías estar jugando varios juegos al mismo tiempo y en cada uno de ellos tener estados independientes.
    enviar comandos En otras palabras: juega el juego. El último paso es comenzar a enviar comandos. La cantidad de comandos disponibles ya estaba cubierta y se puede ampliar fácilmente (más sobre esto en un momento). Cada vez que envías un comando, el juego devolverá el nuevo estado del juego para que tu cliente actualice su vista en consecuencia.

    Ensuciémonos las manos

    Repasé todo el diseño que pude, 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 les 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 vincularé al repositorio completo en caso de que desee más detalles.

    El archivo principal

    Lo primero es lo primero: este es un proyecto Express y su código repetitivo basado se generó utilizando el propio generador de Express, por lo que el archivo app.js debería resultarle familiar. Sólo 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-dires un módulo simple que se encarga de solicitar automáticamente cada archivo dentro de una carpeta. Eso es todo.

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

    // error handlerapp.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 ocupa 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 asumir que aparecerán nuevos comandos en el futuro. Si hay algo que desea evitar, entonces probablemente sería evitar realizar cambios en el código base cuando intente agregar algo nuevo tres o cuatro meses en el futuro.

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

    Básicamente, encapsulé el comportamiento de cada comando dentro de una única clase que hereda de una BaseCommandclase que contiene el código genérico de todos los comandos. Al mismo tiempo, agregué un CommandParsermódulo 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, "moverse hacia el norte", "recoger un 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 require-dirmódulo una vez más para simplificar la inclusión de clases de comando nuevas y existentes. Simplemente lo agrego a la carpeta y todo el sistema puede recogerlo y usarlo.

     

    Dicho esto, hay muchas maneras de mejorar esto; por ejemplo, poder agregar soporte de sinónimos para nuestros comandos sería una gran característica (por lo que decir "moverse hacia el norte", "ir hacia el 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 puedes ver en el siguiente código de ruta cómo logré generalizar ese 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 runmétodo; todo lo demás es adicional y está destinado a uso interno.

    Te animo a que revises el código fuente completo (¡incluso lo descargues y juegues 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

    Puede que no haya cubierto gran parte de mi código aquí, pero todavía espero que el artículo haya sido útil para mostrarle cómo abordo 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 desalentando a un desarrollador ya que no hay un plan real establecido ni objetivos que alcanzar, aparte de tener el producto final listo ( y ese es un hito demasiado grande para abordarlo 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 la lectura! No dude en 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 : Creación del cliente terminal
    • Parte 4 : Agregar chat a nuestro juego

    (dm, yk, il)Explora más en

    • Nodo.js
    • javascript





    Tal vez te puede interesar:

    1. ¿Qué es Redux? Una guía para el diseñador
    2. Diseño y construcción de una aplicación web progresiva sin marco (Parte 2)
    3. Diseño y construcción de una aplicación web progresiva sin marco (Parte 3)
    4. Componentes de diseño en React

    Escribir un motor de aventuras de texto multijugador en Node.js: diseño del servidor Game Engine (Parte 2)

    Escribir un motor de aventuras de texto multijugador en Node.js: diseño del servidor Game Engine (Parte 2)

    Accesibilidad para diseñadores, con Stéphanie Walter Implemente rápidamente. Implementar inteligentemente Índice

    programar

    es

    https://aprendeprogramando.es/static/images/programar-escribir-un-motor-de-aventuras-de-texto-multijugador-en-node-1003-0.jpg

    2024-05-21

     

    Escribir un motor de aventuras de texto multijugador en Node.js: diseño del servidor Game Engine (Parte 2)
    Escribir un motor de aventuras de texto multijugador en Node.js: diseño del servidor Game Engine (Parte 2)

    Si crees que alguno de los contenidos (texto, imagenes o multimedia) en esta página infringe tus derechos relativos a propiedad intelectual, marcas registradas o cualquier otro de tus derechos, por favor ponte en contacto con nosotros en el mail [email protected] y retiraremos este contenido inmediatamente

     

     

    Top 20