Chat em tempo real em um jogo de Phaser com MongoDB e Socket.io
Avalie esse Tutorial
Ao criar um jogo multijogador, você provavelmente vai querer implementar uma maneira de interagir com outros jogadores além da experiência geral do jogo. Isso pode ser na forma de vídeo, áudio ou bate-papo escrito dentro de um jogo.
Então, como você gerenciaria essa interação em tempo real e como a armazenaria em seu banco de dados?
Para ter uma ideia melhor do que esperamos realizar, dê uma olhada na imagem animada a seguir:
O aspecto real do jogo na animação acima é um pouco fraco, mas o mais importante é a funcionalidade de bate-papo. No exemplo acima, as mensagens de chat e a entrada de chat são incorporadas ao jogo Faser. Quando inserimos uma mensagem, ela é enviada ao servidor por meio de soquetes e o servidor salva as mensagens no MongoDB. Além de salvar, o servidor também transmite as mensagens de volta para cada cliente e recupera todas as mensagens para novos clientes.
Existem algumas partes móveis quando se trata deste exemplo, no entanto, não há muitos requisitos. Precisaremos do seguinte para ter sucesso:
- Node.js 12+
A maior parte do que fazemos será realizada usando pacotes JavaScript prontamente disponíveis.
Começaremos criando o backend do nosso jogo. Ele fará todo o trabalho pesado para nós que não esteja relacionado ao visual.
No seu computador, crie um novo diretório e execute os seguintes comandos de dentro do diretório:
1 npm init -y 2 npm install mongodb express cors socket.io --save
Os comandos acima instalarão o driver MongoDB Node.js, Express, Socket.io e uma biblioteca para lidar com o compartilhamento de recursos de origem cruzada entre o jogo e o servidor.
Em seguida, crie um arquivoprincipal.js dentro do diretório do projeto e adicione o seguinte código JavaScript:
1 const express = require("express")(); 2 const cors = require("cors"); 3 const http = require("http").createServer(express); 4 const io = require("socket.io")(http); 5 const { MongoClient } = require("mongodb"); 6 7 const client = new MongoClient(process.env["ATLAS_URI"]); 8 9 express.use(cors()); 10 11 var collection; 12 13 io.on("connection", (socket) => { 14 socket.on("join", async (gameId) => {}); 15 socket.on("message", (message) => {}); 16 }); 17 18 express.get("/chats", async (request, response) => {}); 19 20 http.listen(3000, async () => { 21 try { 22 await client.connect(); 23 collection = client.db("gamedev").collection("chats"); 24 console.log("Listening on port :%s...", http.address().port); 25 } catch (e) { 26 console.error(e); 27 } 28 });
Boa parte do código acima é padronizada quando se trata de configurar o Express e o MongoDB. Faremos um rápido detalhamento das peças que são importantes a partir de agora.
Primeiro, você notará a seguinte linha:
1 const client = new MongoClient(process.env["ATLAS_URI"]);
O
ATLAS_URI
é uma variável de ambiente no meu computador. Você pode obter o valor para esta variável dentro do painel do MongoDB Atlas. O valor será algo assim:1 mongodb+srv://<username>:<password>@cluster0-yyarb.mongodb.net/<database>?retryWrites=true&w=majority
Você pode optar por codificar seu valor ou usar uma variável de ambiente como eu. Não importa, desde que você saiba o que está escolhendo. O uso de uma variável de ambiente é vantajoso, pois facilita o compartilhamento do projeto sem o risco de expor informações potencialmente confidenciais que possam estar codificadas.
A próxima coisa que você notará é o seguinte:
1 http.listen(3000, async () => { 2 try { 3 await client.connect(); 4 collection = client.db("gamedev").collection("chats"); 5 console.log("Listening on port :%s...", http.address().port); 6 } catch (e) { 7 console.error(e); 8 } 9 });
Depois que nos conectamos ao MongoDB Atlas cluster, obtemos a coleção que planejamos usar. Nesse caso, o banco de dados que planejamos usar é
gamedev
e a coleção é chats
, nenhum dos quais precisa existir antes de iniciar seu aplicativo.Com os fundamentos adicionados ao aplicativo, vamos nos concentrar nas coisas mais importantes, começando pelo endpoint da REST API:
1 express.get("/chats", async (request, response) => { 2 try { 3 let result = await collection.findOne({ "_id": request.query.room }); 4 response.send(result); 5 } catch (e) { 6 response.status(500).send({ message: e.message }); 7 } 8 });
Embora estejamos usando o Socket.io para a maior parte de nossa comunicação, faz sentido ter um endpoint para obter inicialmente quaisquer dados de chat. Não é acessado com frequência e evitará muito estresse na camada de soquete.
O que estamos dizendo no endpoint é que queremos encontrar um único documento com base no valor
room
que foi passado com a solicitação. Esse valor representará nossa sala de jogos ou de bate-papo, conforme você quiser interpretá-lo. Esse único documento terá todas as nossas conversas de chat anteriores para a sala específica. Esses dados serão usados para atualizar os clientes quando eles entrarem.Agora podemos entrar nas coisas realmente interessantes!
Vamos revisar a lógica Socket.io dentro do arquivo principal.js:
1 io.on("connection", (socket) => { 2 socket.on("join", async (gameId) => { 3 try { 4 let result = await collection.findOne({ "_id": gameId }); 5 if(!result) { 6 await collection.insertOne({ "_id": gameId, messages: [] }); 7 } 8 socket.join(gameId); 9 socket.emit("joined", gameId); 10 socket.activeRoom = gameId; 11 } catch (e) { 12 console.error(e); 13 } 14 }); 15 socket.on("message", (message) => {}); 16 });
Você perceberá que, desta vez, temos alguma lógica para o evento
join
.Quando uma carga útil
join
é recebida do cliente, o gameId
que o cliente fornece com a carga é usado para tentar localizar um documento MongoDB existente. Se um documento existir, significa que a sala de chat existe. Se não funcionar, devemos criar um. Depois de recuperar ou criar um documento no MongoDB, podemos ingressar na sala de soquete com Socket.io, emitir um evento de volta ao cliente ao qual nos associamos e especificar que a sala ativa é a do gameId
que acabamos de passar .Um soquete pode fazer parte de várias salas, mas só nos preocupamos com uma, a ativa.
Agora vamos dar uma olhada no evento
message
para nossos soquetes:1 io.on("connection", (socket) => { 2 socket.on("join", async (gameId) => { 3 // Logic here ... 4 }); 5 socket.on("message", (message) => { 6 collection.updateOne({ "_id": socket.activeRoom }, { 7 "$push": { 8 "messages": message 9 } 10 }); 11 io.to(socket.activeRoom).emit("message", message); 12 }); 13 });
Quando o cliente envia uma mensagem, queremos anexá-la ao documento definido pela sala ativa. Lembre-se de que a sala, o ID do jogo e o ID do documento são a mesma coisa e cada soquete também mantém essas informações.
Depois que a mensagem é salva, ela é transmitida para todos os soquetes que fazem parte da mesma sala.
O backend agora é totalmente capaz de lidar com o sistema de chat do nosso jogo.
Com o backend sob controle, agora podemos nos concentrar no jogo que podemos distribuir aos clientes, também conhecidos como outros jogadores.
Crie um novo diretório em seu computador e, dentro desse diretório, crie um arquivoindex.html com a seguinte marcação HTML:
1 2 <html> 3 <head></head> 4 <body> 5 <div id="game"></div> 6 <script src="//cdn.jsdelivr.net/npm/socket.io-client@2/dist/socket.io.js"></script> 7 <script src="//cdn.jsdelivr.net/npm/phaser@3.24.1/dist/phaser.min.js"></script> 8 <script> 9 10 const phaserConfig = { 11 type: Phaser.AUTO, 12 parent: "game", 13 width: 1280, 14 height: 720, 15 backgroundColor: "#E7F6EF", 16 physics: { 17 default: "arcade", 18 arcade: { 19 gravity: { y: 200 } 20 } 21 }, 22 dom: { 23 createContainer: true 24 }, 25 scene: { 26 init: initScene, 27 preload: preloadScene, 28 create: createScene 29 } 30 }; 31 32 const game = new Phaser.Game(phaserConfig); 33 34 function initScene() {} 35 function preloadScene() {} 36 function createScene() {} 37 38 </script> 39 </body> 40 </html>
Pode parecer que muita coisa está acontecendo no HTML acima, mas na verdade é apenas a configuração do Phaser.
Primeiro, você notará as duas linhas a seguir:
1 <script src="//cdn.jsdelivr.net/npm/socket.io-client@2/dist/socket.io.js"></script> 2 <script src="//cdn.jsdelivr.net/npm/phaser@3.24.1/dist/phaser.min.js"></script>
Nas linhas acima, estamos incluindo a estrutura de desenvolvimento de jogos Phaser e a biblioteca cliente Socket.io. Essas bibliotecas podem ser usadas diretamente de uma CDN ou baixadas para o diretório do seu projeto.
A maior parte do nosso padrão concentra-se no objeto
phaserConfig
:1 const phaserConfig = { 2 type: Phaser.AUTO, 3 parent: "game", 4 width: 1280, 5 height: 720, 6 backgroundColor: "#E7F6EF", 7 physics: { 8 default: "arcade", 9 arcade: { 10 gravity: { y: 200 } 11 } 12 }, 13 dom: { 14 createContainer: true 15 }, 16 scene: { 17 init: initScene, 18 preload: preloadScene, 19 create: createScene 20 } 21 };
Como se trata de um jogo, vamos ativar a física do jogo. Em particular, usaremos a física de arcade e a gravidade do ambiente será específica no eixo y. Embora não façamos muito em termos de física neste jogo, você pode aprender mais sobre física de arcade em um tutorial anterior que escrevi, intituladoHandle Collisions Between Sprites in Phaser with Arcade Physics.
No objeto
phaserConfig
, você notará o campodom
. Isso nos permitirá aceitar a entrada de texto do usuário diretamente em nosso jogo Phaser. Faremos algo semelhante ao que vimos em meu tutorial, Maintaining a Geolocation Specific Game Leaderboard with Phaser and MongoDB.Isso nos leva às nossas funções de ciclo de vida da cena.
A função
initScene
é onde inicializamos nossas variáveis e nossa conexão com o servidor backend:1 function initScene() { 2 this.socket = io("http://localhost:3000", { autoConnect: false }); 3 this.chatMessages = []; 4 }
Estamos especificando
autoConnect
como falso porque não queremos nos conectar até que nossa cena termine de ser criada. Faremos a conexão manualmente em uma função de ciclo de vida diferente.A próxima função do ciclo de vida é a função
preloadScene
, mas antes de chegarmos lá, provavelmente devemos criar nosso formulário de entrada.No diretório do seu projeto de jogo, crie um novo arquivo form.html com o seguinte HTML:
1 2 <html> 3 <head> 4 <style> 5 #input-form input { 6 padding: 10px; 7 font-size: 20px; 8 width: 250px; 9 } 10 </style> 11 </head> 12 <body> 13 <div id="input-form"> 14 <input type="text" name="chat" placeholder="Enter a message" /> 15 </div> 16 </body> 17 </html>
As informações de estilo não são muito importantes, mas a tag
<input>
real é. Preste atenção ao atributoname
em nossa tag, pois isso será importante quando quisermos trabalhar com os dados fornecidos pelo usuário no formulário.Na função
preloadScene
do arquivoindex.html, adicione o seguinte:1 function preloadScene() { 2 this.load.html("form", "form.html"); 3 }
Agora podemos usar nosso formulário de entrada do usuário dentro do jogo!
Para exibir texto e interagir com o formulário de entrada do usuário, precisamos alterar a
createScene
função no arquivoindex.html :1 function createScene() { 2 3 this.textInput = this.add.dom(1135, 690).createFromCache("form").setOrigin(0.5); 4 this.chat = this.add.text(1000, 10, "", { lineSpacing: 15, backgroundColor: "#21313CDD", color: "#26924F", padding: 10, fontStyle: "bold" }); 5 this.chat.setFixedSize(270, 645); 6 7 this.enterKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER); 8 9 this.enterKey.on("down", event => { 10 let chatbox = this.textInput.getChildByName("chat"); 11 if (chatbox.value != "") { 12 this.socket.emit("message", chatbox.value); 13 chatbox.value = ""; 14 } 15 }) 16 17 this.socket.connect(); 18 19 this.socket.on("connect", async () => { 20 this.socket.emit("join", "mongodb"); 21 }); 22 23 this.socket.on("joined", async (gameId) => { 24 let result = await fetch("http://localhost:3000/chats?room=" + gameId).then(response => response.json()); 25 this.chatMessages = result.messages; 26 this.chatMessages.push("Welcome to " + gameId); 27 if (this.chatMessages.length > 20) { 28 this.chatMessages.shift(); 29 } 30 this.chat.setText(this.chatMessages); 31 }); 32 33 this.socket.on("message", (message) => { 34 this.chatMessages.push(message); 35 if(this.chatMessages.length > 20) { 36 this.chatMessages.shift(); 37 } 38 this.chat.setText(this.chatMessages); 39 }); 40 41 }
Há muita coisa acontecer na função
createScene
acima e ela pode ser um pouco difícil de entender. Vamos descriptografá-lo para facilitar.Dê uma olhada nas seguintes linhas:
1 this.textInput = this.add.dom(1135, 690).createFromCache("form").setOrigin(0.5); 2 this.chat = this.add.text(1000, 10, "", { lineSpacing: 15, backgroundColor: "#21313CDD", color: "#26924F", padding: 10, fontStyle: "bold" }); 3 this.chat.setFixedSize(270, 645); 4 5 this.enterKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER);
A primeira linha diz que estamos carregando e posicionando o formulário de entrada que adicionamos ao nosso projeto na função
preloadScene
. Em seguida, estamos criando uma caixa de bate-papo para conter nosso texto. Estamos posicionando e estilizando a caixa de bate-papo a nosso gosto. Por fim, estamos dando ao Phaser o controle da tecla enter, algo que usaremos para enviar o formulário em nosso jogo.Antes de analisarmos o que acontece com a tecla enter, dê uma olhada nas seguintes linhas:
1 this.socket.connect(); 2 3 this.socket.on("connect", async () => { 4 this.socket.emit("join", "mongodb"); 5 });
Nas linhas acima, estamos nos conectando manualmente ao nosso servidor de soquete. Quando o soquete do cliente diz que nos conectamos, tentamos entrar em um jogo. Lembra-se do
join
que estamos esperando no servidor? É para lá que o estamos enviando e, neste caso, estamos dizendo que queremos entrar no jogomongodb
.Depois que o servidor diz que entramos, ele nos envia de volta uma mensagem que podemos pegar aqui:
1 this.socket.on("joined", async (gameId) => { 2 let result = await fetch("http://localhost:3000/chats?room=" + gameId).then(response => response.json()); 3 this.chatMessages = result.messages; 4 this.chatMessages.push("Welcome to " + gameId); 5 if (this.chatMessages.length > 20) { 6 this.chatMessages.shift(); 7 } 8 this.chat.setText(this.chatMessages); 9 });
Quando soubermos que ingressamos, podemos executar uma solicitação HTTP no endpoint da API e fornecer a sala, que novamente é o ID do nosso jogo. A resposta será todas as nossas mensagens de chat que adicionaremos a uma array. Para eliminar o risco de mensagens exibidas fora da tela, estamos exibindo itens da parte superior da matriz se o comprimento for maior que nosso limite.
Quando chamamos
setText
usando um valor de array, o Faser separará automaticamente cada item da array com um novo caractere de linha.Até o momento, entramos em um jogo e recebemos todas as mensagens anteriores. Agora queremos enviar mensagens:
1 this.enterKey.on("down", event => { 2 let chatbox = this.textInput.getChildByName("chat"); 3 if (chatbox.value != "") { 4 this.socket.emit("message", chatbox.value); 5 chatbox.value = ""; 6 } 7 })
Quando a tecla Enter é pressionada, obtemos a
<input>
marcação do arquivoformulário.html pelo name
atributo . Se o valor não estiver vazio, podemos enviar uma mensagem para o servidor e limpar o campo.Em nossa configuração, nossas próprias mensagens não são exibidas imediatamente. Deixamos o servidor decidir o que deve ser exibido. Isso significa que precisamos ouvir as mensagens que chegam:
1 this.socket.on("message", (message) => { 2 this.chatMessages.push(message); 3 if(this.chatMessages.length > 20) { 4 this.chatMessages.shift(); 5 } 6 this.chat.setText(this.chatMessages); 7 });
Se uma mensagem chegar do servidor, nós a colocamos em nossa array. Se essa array for maior do que nosso limite, retiramos um item do topo antes de renderizá-lo na tela. Estamos fazendo isso porque não queremos a rolagem dentro do jogo e não queremos que as mensagens saiam da tela. Não é à prova de erros, mas funcionará bem o suficiente para nós.
Se você executasse o jogo agora, você teria algo assim:
Como você pode ver, temos apenas uma área de bate-papo à direita da tela. Não é particularmente interessante para um jogo, se você me perguntar.
O que vamos fazer é adicionar um pouco de brilho ao jogo. Nada exorbitante, mas apenas um pouco para nos lembrar que, na verdade, estamos desenvolvendo jogos com o MongoDB.
Antes de fazer isso, Go em frente e baixe a seguinte imagem:
Não importa qual imagem você usa, mas estou economizando o trabalho de ter que encontrar uma para este jogo.
Com a imagem dentro do seu projeto, precisamos alterar a função
preloadScene
do arquivoindex.html:1 function preloadScene() { 2 this.load.html("form", "form.html"); 3 this.load.image("leaf", "leaf.png"); 4 }
Observe que agora estamos carregando a imagem que acabamos de baixar. O objetivo aqui é ter muitas dessas imagens circulando pela tela.
Na função
createScene
, vamos fazer a seguinte modificação:1 function createScene() { 2 3 this.leafGroup = this.physics.add.group({ 4 defaultKey: "leaf", 5 maxSize: 15 6 }); 7 8 for(let i = 0; i < 15; i++) { 9 let randomX = Math.floor(Math.random() * 1000); 10 let randomY = Math.floor(Math.random() * 600); 11 let randomVelocityX = Math.floor(Math.random() * 2); 12 this.leafGroup.get(randomX, randomY) 13 .setScale(0.2) 14 .setVelocity([-100, 100][randomVelocityX], 200) 15 .setBounce(1, 1) 16 .setCollideWorldBounds(true) 17 } 18 19 // Chat and socket logic ... 20 }
No código acima, estamos criando um pool de objetos com os sprites possíveis 15. Em seguida, estamos fazendo um loop e pegando cada um de nossos 15 sprites do pool de objetos e colocando-os aleatoriamente na tela. Estamos adicionando algumas informações físicas a cada sprite e dizendo que eles colidem com as bordas do nosso jogo.
Isso deve causar 15 sprites de folhas circulando pela tela.
Você acabou de ver como adicionar um chat em tempo real ao seu jogoFaser, em que cada mensagem de chat é armazenada no MongoDB para que possa ser acessada posteriormente por novos jogadores no jogo. As tecnologias que usamos foram Faser para o mecanismo de jogo, Socket.io para a interação em tempo real entre cliente e servidor e MongoDB para persistir nossos dados.
Mencionei anteriormente neste tutorial que havia criado um jogo diferente que usava entradas do usuário. Este artigo, intitulado Maintaining a Geolocation Specific Game Leaderboard with Phaser and MongoDB, concentrou-se na criação de tabelas de classificação para jogos. Também escrevi um tutorial intitulado Creating a Multiplayer Drawing Game with Phaser and MongoDB (Criando um jogo de desenho para vários jogadores com Phaser e MongoDB), que demonstrava a jogabilidade interativa, não apenas o bate-papo.