Vamos começar!
O jogo
Nosso simples jogo de cartas apresentará um cliente Phaser que lidará com a maior parte da lógica do jogo e fará coisas como distribuir cartas, fornecer a funcionalidade de arrastar e soltar e assim por diante.
No back-end, criaremos um servidor Express que utilizará o Socket.IO para se comunicar entre clientes e fazer com que, quando um jogador jogue uma carta, ela apareça no cliente de outro jogador e vice-versa.
Nosso objetivo para este projeto é criar uma estrutura básica para um jogo de cartas multiplayer que você pode construir e ajustar para se adequar à lógica do seu próprio jogo.
Primeiro, vamos abordar o cliente!
O cliente
Para montar nosso cliente, clonaremos o semi-oficial modelo de projeto Phaser 3 Webpack em GitHub.
Abra sua interface de linha de comando favorita e crie uma nova pasta:
mkdir multiplayer-card-projectcd multiplayer-card-project
Clone o projeto git:
git clone https://github.com/photonstorm/phaser3-project-template.git
Este comando fará o download do modelo em uma pasta chamada “phaser3-project-template” em / multiplayer-card-project. Se você deseja acompanhar a estrutura de arquivos do nosso tutorial, vá em frente e altere o nome da pasta do modelo para “cliente”.
Navegue para esse novo diretório e instale todas as dependências:
cd clientnpm install
A estrutura de pastas do seu projeto deve se parecer com isso:
Antes de mexer nos arquivos, vamos voltar para a nossa CLI e inserir o seguinte comando na pasta / client:
npm start
Nosso modelo Phaser utiliza o Webpack para ativar um servidor local que, por sua vez, oferece um aplicativo de jogo simples em nosso navegador (geralmente em http: // localhost: 8080). Arrumado!
Vamos abrir nosso projeto no seu editor de código favorito e fazer algumas alterações para se ajustar ao nosso jogo de cartas. Exclua tudo em / client / src / assets e substitua-o pelas imagens do cartão de GitHub.
No diretório / client / src, adicione uma pasta chamada “scenes” e outra chamada “helpers”.
Em / client / src / scenes, adicione um arquivo vazio chamado “game.js”.
Em / client / src / helpers, adicione três arquivos vazios: “card.js”, “dealer.js” e “zone.js”.
A estrutura do seu projeto agora deve ficar assim:
Legal! Seu cliente pode estar gerando erros porque excluímos algumas coisas, mas não se preocupe. Abra /src/index.js, que é o principal ponto de entrada do nosso aplicativo front-end. Digite o seguinte código:
import Phaser from "phaser";import Game from "./scenes/game";const config = { type: Phaser.AUTO, parent: "phaser-example", width: 1280, height: 780, scene: [ Game ]};const game = new Phaser.Game(config);
Tudo o que fizemos aqui é reestruturar o padrão para utilizar o sistema de “cena” da Phaser, de modo que possamos separar as cenas do jogo em vez de tentar compactar tudo em um arquivo. As cenas podem ser úteis se você estiver criando vários mundos de jogo, construindo coisas como telas de instruções ou geralmente tentando manter as coisas organizadas.
Vamos para /src/scenes/game.js e escrevemos um código:
export default class Game extends Phaser.Scene { constructor() { super({ key: 'Game' }); } preload() { this.load.image('cyanCardFront', 'src/assets/CyanCardFront.png'); this.load.image('cyanCardBack', 'src/assets/CyanCardBack.png'); this.load.image('magentaCardFront', 'src/assets/MagentaCardFront.png'); this.load.image('magentaCardBack', 'src/assets/MagentaCardBack.png'); } create() { this.dealText = this.add.text(75, 350, ['DEAL CARDS']).setFontSize(18).setFontFamily('Trebuchet MS').setColor('#00ffff').setInteractive(); } update() { }}
Estamos aproveitando Classes ES6 para criar uma nova cena do jogo, que incorpora as funções preload (), create () e update ().
preload () é usado para … bem … pré-carregar qualquer recurso que usaremos para o nosso jogo.
create () é executado quando o jogo é iniciado e onde estabeleceremos grande parte de nossa interface com o usuário e lógica do jogo.
update () é chamado uma vez por quadro, e não o usaremos em nosso tutorial (mas pode ser útil em seu próprio jogo, dependendo dos requisitos).
Dentro da função create (), criamos um pouco de texto que diz “DEAL CARDS” e o configuramos para ser interativo:
Muito legal. Vamos criar um pouco de código de espaço reservado para entender como queremos que tudo funcione assim que estiver em funcionamento. Adicione o seguinte à sua função create ():
let self = this; this.card = this.add.image(300, 300, 'cyanCardFront').setScale(0.3, 0.3).setInteractive(); this.input.setDraggable(this.card); this.dealCards = () => { } this.dealText.on('pointerdown', function () { self.dealCards(); }) this.dealText.on('pointerover', function () { self.dealText.setColor('#ff69b4'); }) this.dealText.on('pointerout', function () { self.dealText.setColor('#00ffff'); }) this.input.on('drag', function (pointer, gameObject, dragX, dragY) { gameObject.x = dragX; gameObject.y = dragY; })
Adicionamos muita estrutura, mas não aconteceu muita coisa. Agora, quando o mouse passa o mouse sobre o texto “DEAL CARDS”, ele é destacado em cyberpunk hot pink e há um cartão aleatório em nossa tela:
Colocamos a imagem nas coordenadas (x, y) de (300, 300), definimos sua escala para ser um pouco menor e a tornamos interativa e arrastável. Também adicionamos um pouco de lógica para determinar o que deve acontecer quando arrastado: ele deve seguir as coordenadas (x, y) do nosso mouse.
Também criamos uma função dealCards () vazia que será chamada quando clicarmos no texto “DEAL CARDS”. Ademais, salvamos “this” – significando a cena em que estamos trabalhando – em uma variável chamada “self”, para que possamos usá-la em todas as nossas funções sem nos preocuparmos com o escopo.
Nossa cena do jogo ficará confusa rapidamente se não começarmos a mudar as coisas, então vamos excluir o bloco de código que começa com “this.card” e passar para /src/helpers/card.js para escrever:
export default class Card { constructor(scene) { this.render = (x, y, sprite) => { let card = scene.add.image(x, y, sprite).setScale(0.3, 0.3).setInteractive(); scene.input.setDraggable(card); return card; } }}
Criamos uma nova classe que aceita uma cena como parâmetro e apresenta uma função render () que aceita coordenadas (x, y) e um sprite. Agora, podemos chamar essa função de outro lugar e passar os parâmetros necessários para criar cartões.
Vamos importar o cartão na parte superior da nossa cena do jogo:
import Card from '../helpers/card';
E insira o código a seguir na nossa função dealCards () vazia:
this.dealCards = () => { for (let i = 0; i < 5; i++) { let playerCard = new Card(this); playerCard.render(475 + (i * 100), 650, 'cyanCardFront'); } }
Quando clicamos no botão "DEAL CARDS", agora iteramos através de um loop for que cria cartões e os processa sequencialmente na tela:
LEGAIS. Podemos arrastar essas cartas pela tela, mas pode ser bom limitar onde elas podem ser descartadas para suportar nossa lógica de jogo.
Vamos passar para /src/helpers/zone.js e adicionar uma nova classe:
export default class Zone { constructor(scene) { this.renderZone = () => { let dropZone = scene.add.zone(700, 375, 900, 250).setRectangleDropZone(900, 250); dropZone.setData({ cards: 0 }); return dropZone; }; this.renderOutline = (dropZone) => { let dropZoneOutline = scene.add.graphics(); dropZoneOutline.lineStyle(4, 0xff69b4); dropZoneOutline.strokeRect(dropZone.x - dropZone.input.hitArea.width / 2, dropZone.y - dropZone.input.hitArea.height / 2, dropZone.input.hitArea.width, dropZone.input.hitArea.height) } }}
A Phaser possui dropzones integrados que nos permitem determinar onde os objetos do jogo podem ser descartados, e montamos um aqui e fornecemos um esboço. Também adicionamos um pouquinho de dados chamado "cards" ao dropzone que usaremos mais tarde.
Vamos importar nossa nova zona para a cena do jogo:
import Zone from '../helpers/zone';
E chame-o dentro da função create ():
this.zone = new Zone(this); this.dropZone = this.zone.renderZone(); this.outline = this.zone.renderOutline(this.dropZone);
Não é muito pobre!
Precisamos adicionar um pouco de lógica para determinar como os cartões devem ser descartados na zona. Vamos fazer isso abaixo da função "this.input.on ('drag')":
this.input.on('dragstart', function (pointer, gameObject) { gameObject.setTint(0xff69b4); self.children.bringToTop(gameObject); }) this.input.on('dragend', function (pointer, gameObject, dropped) { gameObject.setTint(); if (!dropped) { gameObject.x = gameObject.input.dragStartX; gameObject.y = gameObject.input.dragStartY; } }) this.input.on('drop', function (pointer, gameObject, dropZone) { dropZone.data.values.cards++; gameObject.x = (dropZone.x - 350) + (dropZone.data.values.cards * 50); gameObject.y = dropZone.y; gameObject.disableInteractive(); })
Começando na parte inferior do código, quando um cartão é descartado, aumentamos o valor dos dados "cards" no dropzone e atribuímos as coordenadas (x, y) do cartão ao dropzone com base em quantos cards já estão nele . Também desabilitamos a interatividade nos cartões depois que eles são descartados, para que não possam ser recolhidos:
Também fizemos isso para que nossas cartas tenham uma tonalidade diferente quando arrastadas e, se não forem derrubadas sobre o dropzone, elas retornarão às suas posições iniciais.
Embora nosso cliente não esteja completo, fizemos o máximo possível antes de implementar o back-end. Agora podemos distribuir cartões, arrastá-los pela tela e soltá-los em um dropzone. Mas, para avançar, precisaremos configurar um servidor que possa coordenar nossa funcionalidade multiplayer.
O servidor
Vamos abrir uma nova linha de comando em nosso diretório raiz (acima / client) e digite:
npm initnpm install --save express socket.io nodemon
Inicializamos um novo package.json e instalamos o Express, Socket.IO e Nodemon (que observará nosso servidor e o reinicia após alterações).
No nosso editor de código, vamos mudar a seção "scripts" do nosso package.json para dizer:
"scripts": { "start": "nodemon server.js" },
Excelente. Estamos prontos para montar nosso servidor! Crie um arquivo vazio chamado "server.js" em nosso diretório raiz e digite o seguinte código:
const server = require('express')();const http = require('http').createServer(server);const io = require('socket.io')(http);io.on('connection', function (socket) { console.log('A user connected: ' + socket.id); socket.on('disconnect', function () { console.log('A user disconnected: ' + socket.id); });});http.listen(3000, function () { console.log('Server started!');});
Estamos importando Express e Socket.IO, solicitando que o servidor escute na porta 3000. Quando um cliente se conectar ou desconectar dessa porta, registraremos o evento no console com o ID do soquete do cliente.
Abra uma nova interface de linha de comandos e inicie o servidor:
npm run start
Nosso servidor agora deve estar em execução no localhost: 3000, e o Nodemon observará nossos arquivos de back-end para quaisquer alterações. Não acontecerá muita coisa, exceto no log do console em que o "Servidor iniciou!"
Em nossa outra interface de linha de comando aberta, vamos voltar ao diretório / client e instalar a versão do cliente do Socket.IO:
cd clientnpm install --save socket.io-client
Agora podemos importá-lo em nossa cena do jogo:
import io from 'socket.io-client';
Ótimo! Acabamos de ligar nossa parte frontal e traseira. Tudo o que precisamos fazer é escrever algum código na função create ():
this.socket = io('http://localhost:3000'); this.socket.on('connect', function () { console.log('Connected!'); });
Estamos inicializando uma nova variável "socket" que aponta para a porta 3000 local e faz logon no console do navegador após a conexão.
Abra e feche alguns navegadores em http: // localhost: 8080 (onde nosso cliente Phaser está sendo atendido) e você deverá ver o seguinte na interface da linha de comandos:
YAY. Vamos começar a adicionar lógica ao nosso arquivo server.js que atenderá às necessidades do nosso jogo de cartas. Substitua o código existente pelo seguinte:
const server = require('express')();const http = require('http').createServer(server);const io = require('socket.io')(http);let players = [];io.on('connection', function (socket) { console.log('A user connected: ' + socket.id); players.push(socket.id); if (players.length === 1) { io.emit('isPlayerA'); }; socket.on('dealCards', function () { io.emit('dealCards'); }); socket.on('cardPlayed', function (gameObject, isPlayerA) { io.emit('cardPlayed', gameObject, isPlayerA); }); socket.on('disconnect', function () { console.log('A user disconnected: ' + socket.id); players = players.filter(player => player !== socket.id); });});http.listen(3000, function () { console.log('Server started!');});
Inicializamos um array vazio chamado "players" e adicionamos um ID de soquete toda vez que um cliente se conecta ao servidor, além de excluir o ID de soquete ao desconectar.
Se um cliente for o primeiro a se conectar ao servidor, solicitamos ao Socket.IO que "emitir"um evento que eles serão o Jogador A. Posteriormente, quando o servidor receber um evento chamado" dealCards "ou" cardPlayed ", ele deverá emitir de volta aos clientes que eles devem atualizar de acordo.
Acredite ou não, esse é todo o código necessário para que o servidor funcione! Vamos voltar nossa atenção para a cena do jogo. Bem na parte superior da função create (), digite o seguinte:
this.isPlayerA = false; this.opponentCards = [];
Sob o bloco de código que começa com "this.socket.on (connect)", escreva:
this.socket.on('isPlayerA', function () { self.isPlayerA = true; })
Agora, se o nosso cliente for o primeiro a se conectar ao servidor, o servidor emitirá um evento que informa ao cliente que será o Jogador A. O soquete do cliente recebe esse evento e transforma nosso booleano "isPlayerA" de falso para verdadeiro.
Nota: deste ponto em diante, pode ser necessário recarregar a página do navegador (definida como http: // localhost: 8080), em vez de o Webpack fazer automaticamente para você, para que o cliente desconecte e reconecte corretamente ao servidor.
Precisamos reconfigurar nossa lógica dealCards () para suportar o aspecto multiplayer do nosso jogo, já que queremos que o cliente nos dê um certo conjunto de cartas que podem ser diferentes das do oponente. Ademais, queremos renderizar as costas das cartas de nossos oponentes em nossa tela e vice-versa.
Iremos para o arquivo /src/helpers/dealer.js vazio, importaremos card.js e criaremos uma nova classe:
import Card from './card';export default class Dealer { constructor(scene) { this.dealCards = () => { let playerSprite; let opponentSprite; if (scene.isPlayerA) { playerSprite = 'cyanCardFront'; opponentSprite = 'magentaCardBack'; } else { playerSprite = 'magentaCardFront'; opponentSprite = 'cyanCardBack'; }; for (let i = 0; i < 5; i++) { let playerCard = new Card(scene); playerCard.render(475 + (i * 100), 650, playerSprite); let opponentCard = new Card(scene); scene.opponentCards.push(opponentCard.render(475 + (i * 100), 125, opponentSprite).disableInteractive()); } } }}
Com essa nova classe, estamos verificando se o cliente é o Jogador A e determinando quais sprites devem ser usados em ambos os casos.
Então, negociamos cartas para o nosso cliente, enquanto reproduzimos as costas das cartas dos nossos oponentes na parte superior da tela e as adicionamos à matriz dos cartões oponentes que inicializamos em nossa cena do jogo.
Em /src/scenes/game.js, importe o revendedor:
import Dealer from '../helpers/dealer';
Em seguida, substitua nossa função dealCards () por:
this.dealer = new Dealer(this);
No bloco de código que começa com "this.socket.on ('isPlayerA')", adicione o seguinte:
this.socket.on('dealCards', function () { self.dealer.dealCards(); self.dealText.disableInteractive(); })
Também precisamos atualizar nossa função dealText para corresponder a essas alterações:
this.dealText.on('pointerdown', function () { self.socket.emit("dealCards"); })
Ufa! Criamos uma nova classe de Dealer que lidará com as cartas e distribuirá as cartas de nossos oponentes na tela. Quando o soquete do cliente recebe o evento "dealcards" do servidor, ele chama a função dealCards () dessa nova classe e desabilita o dealText para que não possamos continuar gerando cartões sem motivo.
Por fim, alteramos a funcionalidade dealText para que, quando pressionado, o cliente emita um evento para o servidor no qual queremos distribuir cartões, que une tudo.
Inicie dois navegadores separados apontados para http: // localhost: 8080 e pressione "DEAL CARDS" em um deles. Você deve ver diferentes sprites em qualquer tela:
Observe novamente que, se você estiver tendo problemas com esta etapa, talvez seja necessário fechar um dos navegadores e recarregar o primeiro para garantir que os dois clientes se desconectaram do servidor, que deve ser registrado no console da linha de comando.
Ainda precisamos descobrir como processar nossas cartas descartadas no cliente do oponente e vice-versa. Podemos fazer tudo isso em nossa cena do jogo! Atualize o bloco de código que começa com "this.input.on ('drop')" com uma linha no final:
this.input.on('drop', function (pointer, gameObject, dropZone) { dropZone.data.values.cards++; gameObject.x = (dropZone.x - 350) + (dropZone.data.values.cards * 50); gameObject.y = dropZone.y; gameObject.disableInteractive(); self.socket.emit('cardPlayed', gameObject, self.isPlayerA); })
Quando um cartão é descartado em nosso cliente, o soquete emite um evento chamado "cardPlayed", transmitindo os detalhes do objeto do jogo e o isPlayerA booleano do cliente (que pode ser verdadeiro ou falso, dependendo se o cliente foi o primeiro a se conectar) para o servidor).
Lembre-se de que, no código do servidor, o Socket.IO simplesmente recebe o evento "cardPlayed" e emite o mesmo evento para todos os clientes, passando as mesmas informações sobre o objeto do jogo e isPlayerA do cliente que iniciou o evento.
Vamos escrever o que deve acontecer quando um cliente recebe um evento "cardPlayed" do servidor, abaixo do bloco de código "this.socket.on ('dealCards')":
this.socket.on('cardPlayed', function (gameObject, isPlayerA) { if (isPlayerA !== self.isPlayerA) { let sprite = gameObject.textureKey; self.opponentCards.shift().destroy(); self.dropZone.data.values.cards++; let card = new Card(self); card.render(((self.dropZone.x - 350) + (self.dropZone.data.values.cards * 50)), (self.dropZone.y), sprite).disableInteractive(); } })
O bloco de código compara primeiro o booleano isPlayerA que recebe do servidor ao isPlayerA do cliente, que é uma verificação para determinar se o cliente que está recebendo o evento é o mesmo que o gerou.
Vamos pensar um pouco mais, pois expõe um componente-chave de como funciona o relacionamento cliente-servidor, usando o Socket.IO como conector.
Suponha que o Cliente A se conecte ao servidor primeiro e seja informado pelo evento "isPlayerA" que ele deve alterar seu booleano isPlayerA para verdade. Isso determinará que tipo de cartão ele gera quando um usuário clica em "DEAL CARDS" através desse cliente.
Se o Cliente B se conectar ao servidor em segundo lugar, nunca será solicitado que você altere seu booleano isPlayerA, que permanece falso. Isso também determinará que tipo de cartão ele gera.
Quando o Cliente A descarta um cartão, ele emite um evento "cardPlayed" para o servidor, passando informações sobre o cartão que foi descartado e seu isPlayerA booleano, que é verdade. O servidor retransmite todas essas informações para todos os clientes com seu próprio evento "cardPlayed".
O cliente A recebe esse evento do servidor e observa que o booleano isPlayerA do servidor é verdade, o que significa que o evento foi gerado pelo próprio cliente A. Nada de especial acontece.
O cliente B recebe o mesmo evento do servidor e observa que o booleano isPlayerA do servidor é verdade, embora o isPlayerA do cliente B seja falso. Por causa dessa diferença, ele executa o restante do bloco de código.
O código resultante armazena a "texturekey" - basicamente, a imagem - do objeto do jogo que ele recebe do servidor em uma variável chamada "sprite". Destrói um dos versos de cartas do oponente que são renderizados na parte superior da tela e incrementa o valor dos dados de "cartas" no dropzone para que possamos continuar colocando as cartas da esquerda para a direita.
O código gera um novo cartão no dropzone que usa a variável sprite para criar o mesmo cartão que foi descartado no outro cliente (se você tiver dados anexados ao objeto do jogo, poderá usar uma abordagem semelhante para anexá-lo aqui também )
Seu código /src/scenes/game.js final deve ficar assim:
import io from 'socket.io-client';import Card from '../helpers/card';import Dealer from "../helpers/dealer";import Zone from '../helpers/zone';export default class Game extends Phaser.Scene { constructor() { super({ key: 'Game' }); } preload() { this.load.image('cyanCardFront', 'src/assets/CyanCardFront.png'); this.load.image('cyanCardBack', 'src/assets/CyanCardBack.png'); this.load.image('magentaCardFront', 'src/assets/magentaCardFront.png'); this.load.image('magentaCardBack', 'src/assets/magentaCardBack.png'); } create() { this.isPlayerA = false; this.opponentCards = []; this.zone = new Zone(this); this.dropZone = this.zone.renderZone(); this.outline = this.zone.renderOutline(this.dropZone); this.dealer = new Dealer(this); let self = this; this.socket = io('http://localhost:3000'); this.socket.on('connect', function () { console.log('Connected!'); }); this.socket.on('isPlayerA', function () { self.isPlayerA = true; }) this.socket.on('dealCards', function () { self.dealer.dealCards(); self.dealText.disableInteractive(); }) this.socket.on('cardPlayed', function (gameObject, isPlayerA) { if (isPlayerA !== self.isPlayerA) { let sprite = gameObject.textureKey; self.opponentCards.shift().destroy(); self.dropZone.data.values.cards++; let card = new Card(self); card.render(((self.dropZone.x - 350) + (self.dropZone.data.values.cards * 50)), (self.dropZone.y), sprite).disableInteractive(); } }) this.dealText = this.add.text(75, 350, ['DEAL CARDS']).setFontSize(18).setFontFamily('Trebuchet MS').setColor('#00ffff').setInteractive(); this.dealText.on('pointerdown', function () { self.socket.emit("dealCards"); }) this.dealText.on('pointerover', function () { self.dealText.setColor('#ff69b4'); }) this.dealText.on('pointerout', function () { self.dealText.setColor('#00ffff'); }) this.input.on('drag', function (pointer, gameObject, dragX, dragY) { gameObject.x = dragX; gameObject.y = dragY; }) this.input.on('dragstart', function (pointer, gameObject) { gameObject.setTint(0xff69b4); self.children.bringToTop(gameObject); }) this.input.on('dragend', function (pointer, gameObject, dropped) { gameObject.setTint(); if (!dropped) { gameObject.x = gameObject.input.dragStartX; gameObject.y = gameObject.input.dragStartY; } }) this.input.on('drop', function (pointer, gameObject, dropZone) { dropZone.data.values.cards++; gameObject.x = (dropZone.x - 350) + (dropZone.data.values.cards * 50); gameObject.y = dropZone.y; gameObject.disableInteractive(); self.socket.emit('cardPlayed', gameObject, self.isPlayerA); }) } update() { }}
Salve tudo, abra dois navegadores e pressione "DEAL CARDS". Quando você arrasta e solta um cartão em um cliente, ele deve aparecer na zona do outro, além de excluir um cartão de volta, significando que um cartão foi jogado:
É isso aí! Agora você deve ter um modelo funcional para o seu jogo de cartas para vários jogadores, que pode ser usado para adicionar suas próprias cartas, arte e lógica de jogo.
Um primeiro passo pode ser adicionar à sua classe de Dealer, fazendo com que embaralhe uma série de cartas e retorne uma aleatória (dica: confira Phaser.Math.RND.shuffle ([array]))
Feliz codificação!
Se você gostou deste artigo, considere verificando meus jogos e livros ou se inscrevendo no meu canal do YouTube.
M. S. Farzan, Ph.D. escreveu e trabalhou para empresas de videogame de alto nível e sites editoriais como Electronic Arts, Perfect World Entertainment, Modus Games e MMORPG.com e atuou como gerente de comunidade em jogos como Dungeons & Dragons Neverwinter e Efeito de massa: Andrômeda. Ele é o diretor criativo e designer de jogos líder da Entromancy: Um RPG de Fantasia Cyberpunk e autor de A Trilogia Caminho Noturno. Encontre M. S. Farzan no Twitter @sominator.