Escrevendo um mecanismo de aventura de texto multijogador no Node.js: design do servidor do Game Engine (parte 2)

Publicados: 2022-03-10
Resumo rápido ↬ Bem-vindo à segunda parte desta série. Na primeira parte, abordamos a arquitetura de uma plataforma baseada em Node.js e um aplicativo cliente que permitirá que as pessoas definam e joguem suas próprias aventuras de texto como um grupo. Desta vez, abordaremos a criação de um dos módulos que Fernando definiu da última vez (o mecanismo de jogo) e também nos concentraremos no processo de design para esclarecer o que precisa acontecer antes de começar a codificar seu projetos próprios de hobby.

Após algumas considerações cuidadosas e implementação real do módulo, algumas das definições que fiz durante a fase de design tiveram que ser alteradas. Esta deve ser uma cena familiar para quem já trabalhou com um cliente ansioso que sonha com um produto ideal, mas precisa ser contido pela equipe de desenvolvimento.

Depois que os recursos forem implementados e testados, sua equipe começará a perceber que algumas características podem diferir do plano original, e tudo bem. Basta notificar, ajustar e seguir em frente. Então, sem mais delongas, permita-me primeiro explicar o que mudou em relação ao plano original.

Outras partes desta série

  • Parte 1: A Introdução
  • Parte 3: Criando o Terminal Client
  • Parte 4: Adicionando o bate-papo ao nosso jogo

Mecânica de Batalha

Esta é provavelmente a maior mudança em relação ao plano original. Eu sei que disse que iria com uma implementação do tipo D&D na qual cada PC e NPC envolvidos receberiam um valor de iniciativa e depois disso, realizaríamos um combate baseado em turnos. Foi uma boa ideia, mas implementá-lo em um serviço baseado em REST é um pouco complicado, pois você não pode iniciar a comunicação do lado do servidor nem manter o status entre as chamadas.

Então, em vez disso, vou aproveitar a mecânica simplificada do REST e usá-la para simplificar nossa mecânica de batalha. A versão implementada será baseada no jogador em vez de baseada em grupo, e permitirá que os jogadores ataquem NPCs (personagens não-jogadores). Se o ataque for bem-sucedido, os NPCs serão mortos ou atacarão de volta, causando dano ou matando o jogador.

O sucesso ou fracasso de um ataque será determinado pelo tipo de arma usada e pelas fraquezas que um NPC pode ter. Então, basicamente, se o monstro que você está tentando matar é fraco contra sua arma, ele morre. Caso contrário, não será afetado e – provavelmente – muito irritado.

Gatilhos

Se você prestou atenção na definição do jogo JSON do meu artigo anterior, deve ter notado a definição do gatilho encontrada nos itens da cena. Um em particular envolvia a atualização do status do jogo ( statusUpdate ). Durante a implementação, percebi que tê-lo funcionando como uma alternância oferecia liberdade limitada. Você vê, da forma como foi implementado (do ponto de vista idiomático), você conseguiu definir um status, mas desarmar não era uma opção. Então, em vez disso, substituí este efeito de gatilho por dois novos: addStatus e removeStatus . Isso permitirá que você defina exatamente quando esses efeitos podem ocorrer – se é que ocorrerão. Eu sinto que isso é muito mais fácil de entender e raciocinar.

Isso significa que os gatilhos agora se parecem com isso:

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

Ao pegar o item, estamos configurando um status e, ao soltá-lo, estamos removendo-o. Dessa forma, ter vários indicadores de status no nível do jogo é completamente possível e fácil de gerenciar.

Mais depois do salto! Continue lendo abaixo ↓

A implementação

Com essas atualizações fora do caminho, podemos começar a cobrir a implementação real. Do ponto de vista arquitetônico, nada mudou; ainda estamos construindo uma API REST que conterá a lógica do mecanismo principal do jogo.

A pilha de tecnologia

Para este projeto em particular, os módulos que vou usar são os seguintes:

Módulo Descrição
Express.js Obviamente, usarei o Express para ser a base de todo o mecanismo.
Winston Tudo em relação ao registro será tratado por Winston.
Configuração Todas as constantes e variáveis ​​dependentes do ambiente serão tratadas pelo módulo config.js, o que simplifica bastante a tarefa de acessá-las.
Mangusto Este será o nosso ORM. Vou modelar todos os recursos usando o Mongoose Models e usar isso para interagir diretamente com o banco de dados.
uuid Precisaremos gerar alguns IDs exclusivos — este módulo nos ajudará nessa tarefa.

Quanto a outras tecnologias utilizadas além do Node.js, temos MongoDB e Redis . Eu gosto de usar o Mongo devido à falta de esquema necessário. Esse simples fato me permite pensar no meu código e nos formatos de dados, sem ter que me preocupar em atualizar a estrutura das minhas tabelas, migrações de esquema ou tipos de dados conflitantes.

Em relação ao Redis, costumo usá-lo como sistema de suporte o máximo que posso em meus projetos e neste caso não é diferente. Usarei o Redis para tudo o que pode ser considerado informação volátil, como números de membros do grupo, solicitações de comando e outros tipos de dados pequenos e voláteis o suficiente para não merecerem armazenamento permanente.

Também usarei o recurso de expiração de chave do Redis para gerenciar automaticamente alguns aspectos do fluxo (mais sobre isso em breve).

Definição de API

Antes de passar para a interação cliente-servidor e as definições de fluxo de dados, quero examinar os endpoints definidos para essa API. Eles não são muitos, principalmente precisamos cumprir as principais características descritas na Parte 1:

Funcionalidade Descrição
Junte-se a um jogo Um jogador poderá entrar em um jogo especificando o ID do jogo.
Criar um novo jogo Um jogador também pode criar uma nova instância do jogo. O mecanismo deve retornar um ID, para que outros possam usá-lo para ingressar.
Cena de retorno Este recurso deve retornar a cena atual onde a festa está localizada. Basicamente, ele retornará a descrição, com todas as informações associadas (possíveis ações, objetos nela, etc.).
Interagir com a cena Este será um dos mais complexos, porque receberá um comando do cliente e executará essa ação - coisas como mover, empurrar, pegar, olhar, ler, para citar apenas alguns.
Verificar inventário Embora esta seja uma forma de interagir com o jogo, ela não se relaciona diretamente com a cena. Portanto, verificar o inventário de cada jogador será considerado uma ação diferente.
Registrar aplicativo cliente As ações acima requerem um cliente válido para executá-las. Esse endpoint verificará o aplicativo cliente e retornará um Client ID que será usado para fins de autenticação em solicitações subsequentes.

A lista acima se traduz na seguinte lista de endpoints:

Verbo Ponto final Descrição
PUBLICAR /clients Os aplicativos cliente precisarão obter uma chave de ID do cliente usando esse ponto de extremidade.
PUBLICAR /games Novas instâncias do jogo são criadas usando esse endpoint pelos aplicativos cliente.
PUBLICAR /games/:id Depois que o jogo for criado, esse endpoint permitirá que os membros do grupo se juntem a ele e comecem a jogar.
PEGAR /games/:id/:playername Esse endpoint retornará o estado atual do jogo para um jogador específico.
PUBLICAR /games/:id/:playername/commands Por fim, com este endpoint, o aplicativo cliente poderá enviar comandos (em outras palavras, esse endpoint será usado para jogar).

Deixe-me entrar em um pouco mais de detalhes sobre alguns dos conceitos que descrevi na lista anterior.

Aplicativos cliente

Os aplicativos cliente precisarão se registrar no sistema para começar a usá-lo. Todos os endpoints (exceto o primeiro da lista) são protegidos e exigirão que uma chave de aplicativo válida seja enviada com a solicitação. Para obter essa chave, os aplicativos cliente precisam simplesmente solicitar uma. Uma vez fornecidos, eles durarão enquanto forem usados ​​ou expirarão após um mês sem serem usados. Esse comportamento é controlado armazenando a chave no Redis e definindo um TTL de um mês para ela.

Instância do jogo

Criar um novo jogo basicamente significa criar uma nova instância de um jogo específico. Esta nova instância conterá uma cópia de todas as cenas e seu conteúdo. Quaisquer modificações feitas no jogo afetarão apenas o grupo. Dessa forma, muitos grupos podem jogar o mesmo jogo de maneira individual.

Estado de jogo do jogador

Este é semelhante ao anterior, mas exclusivo para cada jogador. Enquanto a instância do jogo mantém o estado do jogo para todo o grupo, o estado do jogo do jogador mantém o status atual de um jogador em particular. Principalmente, isso contém inventário, posição, cena atual e HP (pontos de saúde).

Comandos do jogador

Depois que tudo estiver configurado e o aplicativo cliente estiver registrado e se juntar a um jogo, ele pode começar a enviar comandos. Os comandos implementados nesta versão do motor incluem: move , look , pickup e attack .

  • O comando de move permitirá que você atravesse o mapa. Você poderá especificar a direção para a qual deseja se mover e o mecanismo informará o resultado. Se você der uma olhada rápida na Parte 1, poderá ver a abordagem que usei para lidar com mapas. (Em resumo, o mapa é representado como um gráfico, onde cada nó representa uma sala ou cena e está conectado apenas a outros nós que representam salas adjacentes.)

    A distância entre os nós também está presente na representação e acoplada à velocidade padrão que um jogador possui; ir de sala em sala pode não ser tão simples quanto declarar seu comando, mas você também terá que percorrer a distância. Na prática, isso significa que ir de uma sala para outra pode exigir vários comandos de movimento). O outro aspecto interessante deste comando vem do fato de que este motor destina-se a suportar grupos multiplayer, e o grupo não pode ser dividido (pelo menos não neste momento).

    Portanto, a solução para isso é semelhante a um sistema de votação: cada membro do partido enviará uma solicitação de comando de movimento sempre que quiser. Uma vez que mais da metade deles o tenha feito, a direção mais solicitada será usada.
  • look é bem diferente de movimento. Ele permite que o jogador especifique uma direção, um item ou NPC que deseja inspecionar. A lógica principal por trás desse comando é levada em consideração quando você pensa em descrições dependentes de status.

    Por exemplo, digamos que você entra em uma nova sala, mas está completamente escura (você não vê nada), e você avança enquanto a ignora. Alguns quartos depois, você pega uma tocha acesa de uma parede. Então agora você pode voltar e inspecionar novamente aquele quarto escuro. Desde que você pegou a tocha, agora você pode ver dentro dela e interagir com qualquer um dos itens e NPCs que encontrar lá.

    Isso é conseguido mantendo um conjunto de atributos de status específico para o jogador e para todo o jogo e permitindo que o criador do jogo especifique várias descrições para nossos elementos dependentes de status no arquivo JSON. Cada descrição é então equipada com um texto padrão e um conjunto de condicionais, dependendo do status atual. Os últimos são opcionais; o único que é obrigatório é o valor padrão.

    Além disso, este comando tem uma versão abreviada para look at room: look around ; isso ocorre porque os jogadores tentarão inspecionar uma sala com muita frequência, portanto, fornecer um comando abreviado (ou alias) que seja mais fácil de digitar faz muito sentido.
  • O comando pickup desempenha um papel muito importante para a jogabilidade. Este comando se encarrega de adicionar itens ao inventário dos jogadores ou suas mãos (se estiverem livres). Para entender onde cada item deve ser armazenado, sua definição possui uma propriedade de “destino” que especifica se é destinado ao inventário ou às mãos do jogador. Qualquer coisa que seja retirada com sucesso da cena é removida dela, atualizando a versão do jogo da instância do jogo.
  • O comando use permitirá que você afete o ambiente usando itens em seu inventário. Por exemplo, pegar uma chave em uma sala permitirá que você a use para abrir uma porta trancada em outra sala.
  • Existe um comando especial, que não está relacionado à jogabilidade, mas sim um comando auxiliar destinado a obter informações específicas, como o ID do jogo atual ou o nome do jogador. Esse comando é chamado get e os jogadores podem usá-lo para consultar o mecanismo do jogo. Por exemplo: obtenha gameid .
  • Por fim, o último comando implementado para esta versão do mecanismo é o comando de attack . Eu já cobri este; basicamente, você terá que especificar seu alvo e a arma com a qual o está atacando. Dessa forma, o sistema poderá verificar as fraquezas do alvo e determinar a saída do seu ataque.

Interação cliente-motor

Para entender como usar os endpoints listados acima, deixe-me mostrar como qualquer cliente em potencial pode interagir com nossa nova API.

Degrau Descrição
Registrar cliente Primeiramente, o aplicativo cliente precisa solicitar uma chave de API para poder acessar todos os outros endpoints. Para obter essa chave, ele precisa se registrar em nossa plataforma. O único parâmetro a ser fornecido é o nome do aplicativo, só isso.
Crie um jogo Depois que a chave da API é obtida, a primeira coisa a fazer (supondo que esta seja uma interação totalmente nova) é criar uma nova instância de jogo. Pense desta forma: o arquivo JSON que criei no meu último post contém a definição do jogo, mas precisamos criar uma instância dele apenas para você e seu grupo (pense em classes e objetos, mesmo negócio). Você pode fazer com essa instância o que quiser, e isso não afetará outras partes.
Junte-se ao jogo Depois de criar o jogo, você receberá um ID do jogo de volta do mecanismo. Você pode usar esse ID do jogo para ingressar na instância usando seu nome de usuário exclusivo. A menos que você entre no jogo, não poderá jogar, porque entrar no jogo também criará uma instância de estado do jogo apenas para você. Este será o local onde seu inventário, sua posição e suas estatísticas básicas serão salvas em relação ao jogo que você está jogando. Você poderia potencialmente estar jogando vários jogos ao mesmo tempo e em cada um deles ter estados independentes.
Enviar comandos Em outras palavras: jogue o jogo. A etapa final é começar a enviar comandos. A quantidade de comandos disponíveis já foi coberta e pode ser facilmente estendida (mais sobre isso daqui a pouco). Toda vez que você enviar um comando, o jogo retornará o novo estado do jogo para que seu cliente atualize sua visualização de acordo.

Vamos sujar as mãos

Eu revisei o máximo de design possível, na esperança de que essas informações ajudem você a entender a parte a seguir, então vamos aos detalhes do mecanismo de jogo.

Nota : não mostrarei o código completo neste artigo, pois é muito grande e nem todo é interessante. Em vez disso, mostrarei as partes mais relevantes e um link para o repositório completo caso você queira mais detalhes.

O arquivo principal

Primeiras coisas primeiro: este é um projeto Express e seu código clichê baseado foi gerado usando o próprio gerador do Express, então o arquivo app.js deve ser familiar para você. Eu só quero revisar dois ajustes que gosto de fazer nesse código para simplificar meu trabalho.

Primeiro, adiciono o seguinte trecho para automatizar a inclusão de novos arquivos de rota:

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

É bastante simples, mas elimina a necessidade de exigir manualmente cada arquivo de rota que você criar no futuro. A propósito, require-dir é um módulo simples que cuida de exigir automaticamente todos os arquivos dentro de uma pasta. É isso.

A outra mudança que gosto de fazer é ajustar um pouco meu manipulador de erros. Eu realmente deveria começar a usar algo mais robusto, mas para as necessidades em questão, sinto que isso faz o trabalho:

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

O código acima cuida dos diferentes tipos de mensagens de erro com as quais podemos ter que lidar - objetos completos, objetos de erro reais lançados por Javascript ou mensagens de erro simples sem qualquer outro contexto. Este código vai pegar tudo e formatá-lo em um formato padrão.

Comandos de manipulação

Este é outro daqueles aspectos do motor que tinha que ser fácil de estender. Em um projeto como este, faz todo o sentido supor que novos comandos aparecerão no futuro. Se houver algo que você deseja evitar, provavelmente evitaria fazer alterações no código base ao tentar adicionar algo novo três ou quatro meses no futuro.

Nenhuma quantidade de comentários de código tornará fácil a tarefa de modificar o código que você não tocou (ou sequer pensou) em vários meses, então a prioridade é evitar o maior número possível de alterações. Para nossa sorte, existem alguns padrões que podemos implementar para resolver isso. Em particular, usei uma mistura dos padrões Command e Factory.

Basicamente, encapsulei o comportamento de cada comando dentro de uma única classe que herda de uma classe BaseCommand que contém o código genérico para todos os comandos. Ao mesmo tempo, adicionei um módulo CommandParser que pega a string enviada pelo cliente e retorna o comando real a ser executado.

O analisador é muito simples, pois todos os comandos implementados agora têm o comando real quanto à primeira palavra (ou seja, “mover para o norte”, “pegar faca” e assim por diante) é uma simples questão de dividir a string e obter a primeira 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 : Estou usando o módulo require-dir mais uma vez para simplificar a inclusão de qualquer classe de comando existente e nova. Eu simplesmente o adiciono à pasta e todo o sistema é capaz de pegá-lo e usá-lo.

Com isso dito, há muitas maneiras de melhorar isso; por exemplo, poder adicionar suporte a sinônimos para nossos comandos seria um ótimo recurso (assim, dizer “mover para o norte”, “ir para o norte” ou mesmo “andar para o norte” significaria o mesmo). Isso é algo que poderíamos centralizar nesta classe e afetar todos os comandos ao mesmo tempo.

Não entrarei em detalhes sobre nenhum dos comandos porque, novamente, é muito código para mostrar aqui, mas você pode ver no código de rota a seguir como consegui generalizar esse tratamento dos comandos existentes (e 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 os comandos requerem apenas o método run — qualquer outra coisa é extra e destinada ao uso interno.

Eu encorajo você a revisar todo o código-fonte (até mesmo baixá-lo e brincar com ele, se quiser!). Na próxima parte desta série, mostrarei a implementação real do cliente e a interação dessa API.

Considerações finais

Posso não ter coberto muito do meu código aqui, mas ainda espero que o artigo tenha sido útil para mostrar como eu lidero projetos — mesmo após a fase inicial de design. Sinto que muitas pessoas tentam começar a codificar como sua primeira resposta a uma nova ideia e isso às vezes pode acabar desanimando um desenvolvedor, pois não há um plano real definido nem metas a serem alcançadas - além de ter o produto final pronto ( e isso é um marco muito grande para enfrentar desde o primeiro dia). Então, novamente, minha esperança com esses artigos é compartilhar uma maneira diferente de trabalhar sozinho (ou como parte de um pequeno grupo) em grandes projetos.

Espero que tenham gostado da leitura! Sinta-se à vontade para deixar um comentário abaixo com qualquer tipo de sugestão ou recomendação, adoraria ler o que você pensa e se estiver ansioso para começar a testar a API com seu próprio código do lado do cliente.

Nos vemos na próxima!

Outras partes desta série

  • Parte 1: A Introdução
  • Parte 3: Criando o Terminal Client
  • Parte 4: Adicionando o bate-papo ao nosso jogo