Escrevendo um mecanismo de aventura de texto multijogador no Node.js: criando o cliente de terminal (parte 3)
Publicados: 2022-03-10Primeiro, mostrei a você como definir um projeto como este e dei a você o básico da arquitetura, bem como a mecânica por trás do mecanismo de jogo. Em seguida, mostrei a implementação básica do mecanismo — uma API REST básica que permite percorrer um mundo definido por JSON.
Hoje, mostrarei como criar um cliente de texto antigo para nossa API usando nada além do Node.js.
Outras partes desta série
- Parte 1: A Introdução
- Parte 2: Design do servidor do Game Engine
- Parte 4: Adicionando o bate-papo ao nosso jogo
Revendo o design original
Quando propus pela primeira vez um wireframe básico para a interface do usuário, propus quatro seções na tela:
Embora em teoria isso pareça certo, eu perdi o fato de que alternar entre o envio de comandos do jogo e mensagens de texto seria uma dor, então, em vez de nossos jogadores alternarem manualmente, faremos com que nosso analisador de comandos verifique se é capaz de discernir se estamos está tentando se comunicar com o jogo ou nossos amigos.
Então, em vez de ter quatro seções em nossa tela, agora teremos três:
Essa é uma captura de tela real do cliente final do jogo. Você pode ver a tela do jogo à esquerda e o bate-papo à direita, com uma única caixa de entrada comum na parte inferior. O módulo que estamos usando nos permite personalizar cores e alguns efeitos básicos. Você poderá clonar este código do Github e fazer o que quiser com a aparência.
Porém, uma ressalva: embora a captura de tela acima mostre o bate-papo funcionando como parte do aplicativo, manteremos este artigo focado na configuração do projeto e na definição de uma estrutura na qual podemos criar um aplicativo dinâmico baseado em interface de texto. Vamos nos concentrar em adicionar suporte por chat no próximo e último capítulo desta série.
As ferramentas que vamos precisar
Embora existam muitas bibliotecas por aí que nos permitem criar ferramentas CLI com Node.js, adicionar uma interface de usuário baseada em texto é uma fera completamente diferente de domar. Particularmente, consegui encontrar apenas uma biblioteca (muito completa, veja bem) que me permitiria fazer exatamente o que eu queria: Abençoado.
Esta biblioteca é muito poderosa e fornece muitos recursos que não usaremos para este projeto (como projeção de sombras, arrastar e soltar e outros). Basicamente, ele reimplementa toda a biblioteca ncurses (uma biblioteca C que permite aos desenvolvedores criar interfaces de usuário baseadas em texto) que não possui vínculos Node.js, e o faz diretamente em JavaScript; então, se tivéssemos que fazer, poderíamos muito bem verificar seu código interno (algo que eu não recomendaria a menos que você absolutamente precisasse).
Embora a documentação do Blessed seja bastante extensa, ela consiste principalmente em detalhes individuais sobre cada método fornecido (em vez de ter tutoriais explicando como realmente usar esses métodos juntos) e faltam exemplos em todos os lugares, por isso pode ser difícil investigar. se você tiver que entender como um método específico funciona. Com isso dito, uma vez que você entende por um, tudo funciona da mesma maneira, o que é uma grande vantagem, já que nem toda biblioteca ou mesmo linguagem (estou olhando para você, PHP) tem uma sintaxe consistente.
Mas documentação à parte; a grande vantagem dessa biblioteca é que ela funciona com base nas opções JSON. Por exemplo, se você quisesse desenhar uma caixa no canto superior direito da tela, faria algo assim:
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 você pode imaginar, outros aspectos da caixa também são definidos lá (como seu tamanho), que pode ser perfeitamente dinâmico com base no tamanho do terminal, tipo de borda e cores - mesmo para eventos de foco. Se você já fez desenvolvimento de front-end em algum momento, encontrará muita sobreposição entre os dois.
O ponto que estou tentando fazer aqui é que tudo relacionado à representação da caixa é configurado através do objeto JSON passado para o método box
. Isso, para mim, é perfeito porque posso facilmente extrair esse conteúdo em um arquivo de configuração e criar uma lógica de negócios capaz de lê-lo e decidir quais elementos desenhar na tela. Mais importante, isso nos ajudará a ter um vislumbre de como eles ficarão depois de desenhados.
Esta será a base para todo o aspecto da interface do usuário deste módulo ( mais sobre isso em um segundo! ).
Arquitetura do Módulo
A arquitetura principal deste módulo depende inteiramente dos widgets de interface do usuário que mostraremos. Um grupo desses widgets é considerado uma tela, e todas essas telas são definidas em um único arquivo JSON (que você encontra dentro da pasta /config
).
Este arquivo tem mais de 250 linhas, então mostrá-lo aqui não faz sentido. Você pode ver o arquivo completo online, mas um pequeno trecho dele se parece com isso:
"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", //... } } }
O elemento “screens” conterá a lista de telas dentro do aplicativo. Cada tela contém uma lista de widgets (que abordarei em breve) e cada widget tem sua definição específica de bênçãos e arquivos de tratamento relacionados (quando aplicável).
Você pode ver como cada elemento “params” (dentro de um widget específico) representa o conjunto real de parâmetros esperado pelos métodos que vimos anteriormente. O restante das chaves definidas ali ajudam a fornecer contexto sobre que tipo de widgets renderizar e seu comportamento.
Alguns pontos de interesse:
Manipuladores de tela
Cada elemento de tela possui uma propriedade de arquivo que faz referência ao código associado a essa tela. Este código nada mais é do que um objeto que deve ter um método init
(a lógica de inicialização para aquela tela específica ocorre dentro dele). Particularmente, o mecanismo de UI principal, chamará esse método init
de cada tela, que por sua vez, deve ser responsável por inicializar qualquer lógica que possa precisar (ou seja, configurar os eventos das caixas de entrada).
A seguir está o código para a tela principal, onde o aplicativo solicita que o jogador selecione uma opção para iniciar um novo jogo ou ingressar em um já 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 você pode ver, o método init
chama o método setupInput
que basicamente configura o retorno de chamada correto para lidar com a entrada do usuário. Esse retorno de chamada contém a lógica para decidir o que fazer com base na entrada do usuário (1 ou 2).
Manipuladores de widget
Alguns dos widgets (geralmente widgets de entrada) têm uma propriedade handlerPath
, que faz referência ao arquivo que contém a lógica por trás desse componente específico. Isso não é o mesmo que o manipulador de tela anterior. Eles não se importam muito com os componentes da interface do usuário. Em vez disso, eles lidam com a lógica de colagem entre a interface do usuário e qualquer biblioteca que estejamos usando para interagir com serviços externos (como a API do mecanismo de jogo).
Tipos de widget
Outra pequena adição à definição JSON dos widgets são seus tipos. Em vez de usar os nomes que os Abençoados definiram para eles, estou criando novos para me dar mais espaço de manobra quando se trata de seu comportamento. Afinal, um widget de janela nem sempre pode “apenas exibir informações”, ou uma caixa de entrada pode nem sempre funcionar da mesma maneira.
Este foi principalmente um movimento preventivo, apenas para garantir que eu tenha essa capacidade se eu precisar dela no futuro, mas como você está prestes a ver, eu não estou usando muitos tipos diferentes de componentes de qualquer maneira.
Várias telas
Embora a tela principal seja a que mostrei na captura de tela acima, o jogo requer algumas outras telas para solicitar coisas como seu nome de jogador ou se você está criando uma nova sessão de jogo ou até mesmo entrando em uma existente. A forma como lidei com isso foi, novamente, através da definição de todas essas telas no mesmo arquivo JSON. E para passar de uma tela para a próxima, usamos a lógica dentro dos arquivos do manipulador de tela.
Podemos fazer isso simplesmente usando a seguinte linha de código:
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
Mostrarei mais detalhes sobre a propriedade da interface do usuário em um segundo, mas estou apenas usando o método loadScreen
para renderizar novamente a tela e selecionar os componentes corretos do arquivo JSON usando a string passada como parâmetro. Muito simples.
Amostras de código
Agora é hora de conferir a carne e as batatas deste artigo: as amostras de código. Vou apenas destacar o que acho que são as pequenas jóias dentro dele, mas você sempre pode dar uma olhada no código-fonte completo diretamente no repositório a qualquer momento.
Usando arquivos de configuração para gerar automaticamente a interface do usuário
Já cobri parte disso, mas acho que vale a pena explorar os detalhes por trás desse gerador. A essência por trás dele (arquivo index.js dentro da pasta /ui
) é que ele é um wrapper em torno do objeto Blessed. E o método mais interessante dentro dele, é o método loadScreen
.
Este método pega a configuração (através do módulo config) para uma tela específica e percorre seu conteúdo, tentando gerar os widgets corretos com base no 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 você pode ver, o código é um pouco longo, mas a lógica por trás dele é simples:
- Carrega a configuração para a tela específica atual;
- Limpa quaisquer widgets existentes anteriormente;
- Examina cada widget e o instancia;
- Se um alerta extra foi passado como uma mensagem flash (que é basicamente um conceito que roubei do Web Dev no qual você configura uma mensagem para ser mostrada na tela até a próxima atualização);
- Renderize a tela real;
- E finalmente, requeira o manipulador de tela e execute seu método “init”.
É isso! Você pode conferir o resto dos métodos - eles estão principalmente relacionados a widgets individuais e como renderizá-los.
Comunicação entre UI e lógica de negócios
Embora em grande escala, a interface do usuário, o back-end e o servidor de bate-papo tenham uma comunicação baseada em camadas; o próprio front-end precisa de pelo menos uma arquitetura interna de duas camadas na qual os elementos de interface do usuário puros interagem com um conjunto de funções que representam a lógica central dentro desse projeto específico.
O diagrama a seguir mostra a arquitetura interna do cliente de texto que estamos construindo:
Deixe-me explicar um pouco mais. Como mencionei acima, o loadScreenMethod
criará apresentações de UI dos widgets (estes são objetos abençoados). Mas eles estão contidos como parte do objeto de lógica da tela que é onde configuramos os eventos básicos (como onSubmit
para caixas de entrada).
Permita-me dar-lhe um exemplo prático. Aqui está a primeira tela que você vê ao iniciar o cliente de interface do usuário:
Há três seções nesta tela:
- Solicitação de nome de usuário,
- Opções/informações do menu,
- Tela de entrada para as opções do menu.
Basicamente, o que queremos fazer é solicitar o nome de usuário e, em seguida, pedir que escolham uma das duas opções (iniciar um novo jogo ou ingressar em um já existente).
O código que cuida disso é o seguinte:
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 } }
Eu sei que é muito código, mas concentre-se apenas no método init
. A última coisa que ele faz é chamar o método setInput
que cuida de adicionar os eventos corretos às caixas de entrada corretas.
Portanto, com estas linhas:
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 acessando os objetos Blessed e pegando suas referências, para depois configurarmos os eventos submit
. Então, depois de enviarmos o nome de usuário, estamos mudando o foco para a segunda caixa de entrada (literalmente com input.focus()
).
Dependendo de qual opção escolhermos no menu, estamos chamando um dos métodos:
-
createNewGame
: cria um novo jogo interagindo com seu manipulador associado; -
moveToIDRequest
: renderiza a próxima tela encarregada de solicitar a entrada do ID do jogo.
Comunicação com o Game Engine
Por último, mas não menos importante (e seguindo o exemplo acima), se você acertar 2, você notará que o método createNewGame
usa os métodos do manipulador createNewGame
e depois joinGame
(ingressando no jogo logo após criá-lo).
Ambos os métodos visam simplificar a interação com a API do Game Engine. Aqui está o código para o manipulador desta tela:
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) }) } }
Lá você vê duas maneiras diferentes de lidar com esse comportamento. O primeiro método realmente usa a classe apiClient
, que novamente envolve as interações com o GameEngine em outra camada de abstração.
O segundo método executa a ação diretamente enviando uma solicitação POST para a URL correta com a carga útil correta. Nada extravagante é feito depois; estamos apenas enviando o corpo da resposta de volta para a lógica da interface do usuário.
Nota : Se você estiver interessado na versão completa do código-fonte para este cliente, você pode conferir aqui.
Palavras finais
Isso é tudo para o cliente baseado em texto para nossa aventura de texto. eu cobri:
- Como estruturar uma aplicação cliente;
- Como usei o Blessed como a tecnologia principal para criar a camada de apresentação;
- Como estruturar a interação com os serviços de back-end de um cliente complexo;
- E esperançosamente, com o repositório completo disponível.
E embora a interface do usuário possa não se parecer exatamente com a versão original, ela cumpre seu propósito. Espero que este artigo tenha lhe dado uma ideia de como arquitetar tal empreendimento e você estava inclinado a experimentá-lo no futuro. Abençoado é definitivamente uma ferramenta muito poderosa, mas você terá que ter paciência com ela enquanto aprende como usá-la e como navegar por seus documentos.
Na próxima e última parte, abordarei como adicionei o servidor de bate-papo tanto no back-end quanto para este cliente de texto.
Nos vemos na próxima!
Outras partes desta série
- Parte 1: A Introdução
- Parte 2: Design do servidor do Game Engine
- Parte 4: Adicionando o bate-papo ao nosso jogo