Cómo crear un juego de cartas multijugador con Phaser 3, Express y Socket.IO

Soy un desarrollador de juegos de mesa y continuamente busco formas de digitalizar las experiencias de los juegos. En este tutorial, vamos a crear un juego de cartas multijugador usando Phaser 3, Express y Socket.IO.

En cuanto a los requisitos previos, querrá asegurarse de tener Node / NPM y Git instalados y configurados en su máquina. Sería útil algo de experiencia con JavaScript, y es posible que desee ejecutar el tutorial básico de Phaser antes de abordar este.

Felicitaciones a Scott Westover por su tutorial sobre el tema, Kal_Torak y la comunidad Phaser por responder a todas mis preguntas, y a mi buen amigo Mike por ayudarme a conceptualizar la arquitectura de este proyecto.

Nota: usaremos recursos y colores de mi juego de cartas de mesa, Entromancy: Hacker Battles . Si lo prefiere, puede usar sus propias imágenes (o incluso rectángulos Phaser) y colores, y puede acceder a todo el código del proyecto en GitHub.

Si prefiere un tutorial más visual, también puede seguir el video complementario de este artículo:

¡Empecemos!

El juego

Nuestro sencillo juego de cartas contará con un cliente Phaser que manejará la mayor parte de la lógica del juego y hará cosas como repartir cartas, proporcionar la funcionalidad de arrastrar y soltar, etc.

En el back-end, activaremos un servidor Express que utilizará Socket.IO para comunicarse entre clientes y hacer que cuando un jugador juegue una carta, aparezca en el cliente de otro jugador, y viceversa.

Nuestro objetivo para este proyecto es crear un marco básico para un juego de cartas multijugador sobre el que puedas construir y ajustar para adaptarse a la lógica de tu propio juego.

Primero, ¡abordemos al cliente!

El cliente

Para ampliar nuestro cliente, vamos a clonar la plantilla de proyecto de paquete web semioficial de Phaser 3 en GitHub.

Abra su interfaz de línea de comandos favorita y cree una nueva carpeta:

mkdir multiplayer-card-project cd multiplayer-card-project

Clona el proyecto git:

git clone //github.com/photonstorm/phaser3-project-template.git

Este comando descargará la plantilla en una carpeta llamada "phaser3-project-template" dentro de / multiplayer-card-project. Si desea seguir la estructura de archivos de nuestro tutorial, siga adelante y cambie el nombre de esa carpeta de plantilla a "cliente".

Navegue a ese nuevo directorio e instale todas las dependencias:

cd client npm install

La estructura de la carpeta de su proyecto debería verse así:

Antes de jugar con los archivos, regresemos a nuestra CLI e ingresemos el siguiente comando en la carpeta / client:

npm start

Nuestra plantilla Phaser utiliza Webpack para activar un servidor local que, a su vez, ofrece una aplicación de juego simple en nuestro navegador (generalmente en // localhost: 8080). ¡Ordenado!

Abramos nuestro proyecto en su editor de código favorito y hagamos algunos cambios para adaptarnos a nuestro juego de cartas. Elimine todo en / client / src / assets y reemplácelos con las imágenes de la tarjeta de GitHub.

En el directorio / client / src, agregue una carpeta llamada "escenas" y otra llamada "ayudantes".

En / client / src / scenes, agregue un archivo vacío llamado "game.js".

En / client / src / helpers, agregue tres archivos vacíos: "card.js", "dealer.js" y "zone.js".

La estructura de su proyecto ahora debería verse así:

¡Frio! Es posible que su cliente le esté arrojando errores porque eliminamos algunas cosas, pero no se preocupe. Abra /src/index.js, que es el principal punto de entrada a nuestra aplicación de interfaz. Ingrese el siguiente 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);

Todo lo que hemos hecho aquí es reestructurar el modelo estándar para utilizar el sistema de "escenas" de Phaser, de modo que podamos separar las escenas de nuestro juego en lugar de intentar meter todo en un solo archivo. Las escenas pueden ser útiles si está creando múltiples mundos de juego, construyendo cosas como pantallas de instrucciones o, en general, tratando de mantener las cosas ordenadas.

Vayamos a /src/scenes/game.js y escribamos un 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() { } }

Aprovechamos las clases de ES6 para crear una nueva escena de juego, que incorpora funciones de precarga (), creación () y actualización ().

preload () se usa para ... bueno ... precargar los activos que usaremos para nuestro juego.

create () se ejecuta cuando se inicia el juego, y donde estableceremos gran parte de nuestra interfaz de usuario y lógica del juego.

update () se llama una vez por cuadro, y no lo usaremos en nuestro tutorial (pero puede ser útil en tu propio juego dependiendo de sus requisitos).

Dentro de la función create (), hemos creado un poco de texto que dice "TARJETAS DE OFERTA" y lo configuramos para que sea interactivo:

Muy genial. Creemos un poco de código de marcador de posición para comprender cómo queremos que funcione todo esto una vez que esté en funcionamiento. Agregue lo siguiente a su función 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; })

Hemos agregado mucha estructura, pero no ha sucedido mucho. Ahora, cuando nuestro mouse se desplaza sobre el texto "TARJETAS DE OFERTA", se resalta en rosa intenso cyberpunk y hay una tarjeta aleatoria en nuestra pantalla:

Colocamos la imagen en las coordenadas (x, y) de (300, 300), configuramos su escala para que sea un poco más pequeña y la hicimos interactiva y arrastrable. También hemos agregado un poco de lógica para determinar qué debe suceder cuando se arrastra: debe seguir las coordenadas (x, y) de nuestro mouse.

También hemos creado una función dealCards () vacía que se llamará cuando hagamos clic en nuestro texto "DEAL CARDS". Además, hemos guardado "esto", es decir, la escena en la que estamos trabajando actualmente, en una variable llamada "self" para que podamos usarla en todas nuestras funciones sin preocuparnos por el alcance.

Nuestra escena de Juego se volverá complicada rápidamente si no empezamos a mover las cosas, así que eliminemos el bloque de código que comienza con "this.card" y vayamos a /src/helpers/card.js para escribir:

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

Hemos creado una nueva clase que acepta una escena como parámetro y presenta una función render () que acepta coordenadas (x, y) y un sprite. Ahora, podemos llamar a esta función desde otro lugar y pasarle los parámetros necesarios para crear tarjetas.

Importemos la tarjeta en la parte superior de nuestra escena de Juego:

import Card from '../helpers/card';

E ingrese el siguiente código dentro de nuestra función dealCards () vacía:

 this.dealCards = () => { for (let i = 0; i < 5; i++) { let playerCard = new Card(this); playerCard.render(475 + (i * 100), 650, 'cyanCardFront'); } }

Cuando hacemos clic en el botón "REPARTIR TARJETAS", ahora iteramos a través de un bucle for que crea tarjetas y las renderiza secuencialmente en la pantalla:

BONITO. Podemos arrastrar esas cartas por la pantalla, pero sería bueno limitar dónde se pueden colocar para apoyar nuestra lógica de juego.

Pasemos a /src/helpers/zone.js y agreguemos una nueva clase:

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

Phaser has built-in dropzones that allow us to dictate where game objects can be dropped, and we've set up one here and provided it with an outline.  We've also added a tiny bit of data called "cards" to the dropzone that we'll use later.

Let's import our new zone into the Game scene:

import Zone from '../helpers/zone';

And call it in within the create() function:

 this.zone = new Zone(this); this.dropZone = this.zone.renderZone(); this.outline = this.zone.renderOutline(this.dropZone);

Not too shabby!

We need to add a bit of logic to determine how cards should be dropped into the zone.  Let's do that below the "this.input.on('drag')" function:

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

Starting at the bottom of the code, when a card is dropped, we increment the "cards" data value on the dropzone, and assign the (x, y) coordinates of the card to the dropzone based on how many cards are already on it.  We also disable interactivity on cards after they're dropped so that they can't be retracted:

We've also made it so that our cards have a different tint when dragged, and if they're not dropped over the dropzone, they'll return to their starting positions.

Although our client isn't quite complete, we've done as much as we can before implementing the back end.  We can now deal cards, drag them around the screen, and drop them in a dropzone. But to move forward, we'll need to set up a server than can coordinate our multiplayer functionality.

The Server

Let's open up a new command line at our root directory (above /client) and type:

npm init npm install --save express socket.io nodemon

We've initialized a new package.json and installed Express, Socket.IO, and Nodemon (which will watch our server and restart it upon changes).

In our code editor, let's change the "scripts" section of our package.json to say:

 "scripts": { "start": "nodemon server.js" },

Excellent.  We're ready to put our server together!  Create an empty file called "server.js" in our root directory and enter the following code:

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!'); });

We're importing Express and Socket.IO, asking for the server to listen on port 3000. When a client connects to or disconnects from that port, we'll log the event to the console with the client's socket id.

Open a new command line interface and start the server:

npm run start

Our server should now be running on localhost:3000, and Nodemon will watch our back end files for any changes.  Not much else will happen except for the console log that the "Server started!"

In our other open command line interface, let's navigate back to our /client directory and install the client version of Socket.IO:

cd client npm install --save socket.io-client

We can now import it in our Game scene:

import io from 'socket.io-client';

Great!  We've just about wired up our front and back ends.  All we need to do is write some code in the create() function:

 this.socket = io('//localhost:3000'); this.socket.on('connect', function () { console.log('Connected!'); }); 

We're initializing a new "socket" variable that points to our local port 3000 and logs to the browser console upon connection.

Open and close a couple of browsers at //localhost:8080 (where our Phaser client is being served) and you should see the following in your command line interface:

YAY.  Let's start adding logic to our server.js file that will serve the needs of our card game.  Replace the existing code with the following:

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!'); });

We've initialized an empty array called "players" and add a socket id to it every time a client connects to the server, while also deleting the socket id upon disconnection.

If a client is the first to connect to the server, we ask Socket.IO to "emit" an event that they're going to be Player A.  Subsequently, when the server receives an event called "dealCards" or "cardPlayed", it should emit back to the clients that they should update accordingly.

Believe it or not, that's all the code we need to get our server working!  Let's turn our attention back to the Game scene.  Right at the top of the create() function, type the following:

 this.isPlayerA = false; this.opponentCards = [];

Under the code block that starts with "this.socket.on(connect)", write:

 this.socket.on('isPlayerA', function () { self.isPlayerA = true; })

Now, if our client is the first to connect to the server, the server will emit an event that tells the client that it will be Player A.  The client socket receives that event and turns our "isPlayerA" boolean from false to true.

Note: from this point forward, you may need to reload your browser page (set to //localhost:8080), rather than having Webpack do it automatically for you, for the client to correctly disconnect from and reconnect to the server.

We need to reconfigure our dealCards() logic to support the multiplayer aspect of our game, given that we want the client to deal us a certain set of cards that may be different from our opponent's.  Additionally, we want to render the backs of our opponent's cards on our screen, and vice versa.

We'll move to the empty /src/helpers/dealer.js file, import card.js, and create a new class:

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

With this new class, we're checking whether the client is Player A, and determining what sprites should be used in either case.

Then, we deal cards to our client, while rendering the backs of our opponent's cards at the top the screen and adding them to the opponentCards array that we initialized in our Game scene.

In /src/scenes/game.js, import the Dealer:

import Dealer from '../helpers/dealer';

Then replace our dealCards() function with:

 this.dealer = new Dealer(this);

Under code block that begins with "this.socket.on('isPlayerA')", add the following:

 this.socket.on('dealCards', function () { self.dealer.dealCards(); self.dealText.disableInteractive(); })

We also need to update our dealText function to match these changes:

 this.dealText.on('pointerdown', function () { self.socket.emit("dealCards"); })

Phew!  We've created a new Dealer class that will handle dealing cards to us and rendering our opponent's cards to the screen.  When the client socket receives the "dealcards" event from the server, it will call the dealCards() function from this new class, and disable the dealText so that we can't just keep generating cards for no reason.

Finally, we've changed the dealText functionality so that when it's pressed, the client emits an event to the server that we want to deal cards, which ties everything together.

Fire up two separate browsers pointed to //localhost:8080 and hit "DEAL CARDS" on one of them.  You should see different sprites on either screen:

Note again that if you're having issues with this step, you may have to close one of your browsers and reload the first one to ensure that both clients have disconnected from the server, which should be logged to your command line console.

We still need to figure out how to render our dropped cards in our opponent's client, and vice-versa.  We can do all of that in our game scene!  Update the code block that begins with "this.input.on('drop')" with one line at the end:

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

When a card is dropped in our client, the socket will emit an event called "cardPlayed", passing the details of the game object and the client's isPlayerA boolean (which could be true or false, depending on whether the client was the first to connect to the server).

Recall that, in our server code, Socket.IO simply receives the "cardPlayed" event and emits the same event back up to all of the clients, passing the same information about the game object and isPlayerA from the client that initiated the event.

Let's write what should happen when a client receives a "cardPlayed" event from the server, below the "this.socket.on('dealCards')" code block:

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

The code block first compares the isPlayerA boolean it receives from the server against the client's own isPlayerA, which is a check to determine whether the client that is receiving the event is the same one that generated it.

Let's think that through a bit further, as it exposes a key component to how our client - server relationship works, using Socket.IO as the connector.

Suppose that Client A connects to the server first, and is told through the "isPlayerA" event that it should change its isPlayerA boolean to true.  That's going to determine what kind of cards it generates when a user clicks "DEAL CARDS" through that client.

If Client B connects to the server second, it's never told to alter its isPlayerA boolean, which stays false.  That will also determine what kind of cards it generates.

When Client A drops a card, it emits a "cardPlayed" event to the server, passing information about the card that was dropped, and its isPlayerA boolean, which is true.  The server then relays all that information back up to all clients with its own "cardPlayed" event.

Client A receives that event from the server, and notes that the isPlayerA boolean from the server is true, which means that the event was generated by Client A itself. Nothing special happens.

Client B receives the same event from the server, and notes that the isPlayerA boolean from the server is true, although Client B's own isPlayerA is false.  Because of this difference, it executes the rest of the code block.  

The ensuing code stores the "texturekey" - basically, the image - of the game object that it receives from the server into a variable called "sprite". It destroys one of the opponent card backs that are rendered at the top of the screen, and increments the "cards" data value in the dropzone so that we can keep placing cards from left to right.  

The code then generates a new card in the dropzone that uses the sprite variable to create the same card that was dropped in the other client (if you had data attached to that game object, you could use a similar approach to attach it here as well).

Your final /src/scenes/game.js code should look like this:

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('//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() { } }

Save everything, open two browsers, and hit "DEAL CARDS".  When you drag and drop a card in one client, it should appear in the dropzone of the other, while also deleting a card back, signifying that a card has been played:

That's it!  You should now have a functional template for your multiplayer card game, which you can use to add your own cards, art, and game logic.

One first step could be to add to your Dealer class by making it shuffle an array of cards and return a random one (hint: check out Phaser.Math.RND.shuffle([array])).

Happy coding!

If you enjoyed this article, please consider checking out my games and books, subscribing to my YouTube channel, or joining the Entromancy Discord.

M. S. Farzan, Ph.D. has written and worked for high-profile video game companies and editorial websites such as Electronic Arts, Perfect World Entertainment, Modus Games, and MMORPG.com, and has served as the Community Manager for games like Dungeons & Dragons Neverwinter and Mass Effect: Andromeda. He is the Creative Director and Lead Game Designer of Entromancy: A Cyberpunk Fantasy RPG and author of The Nightpath Trilogy. Find M. S. Farzan on Twitter @sominator.