Sådan opbygges et kortspil med flere spillere med Phaser 3, Express og Socket.IO

Jeg er en spiludvikler på bordpladen og leder konstant efter måder til at digitalisere spiloplevelser. I denne vejledning skal vi opbygge et multiplayer-kortspil ved hjælp af Phaser 3, Express og Socket.IO.

Med hensyn til forudsætninger skal du sikre dig, at du har Node / NPM og Git installeret og konfigureret på din maskine. En vis erfaring med JavaScript vil være nyttigt, og du vil muligvis køre gennem den grundlæggende Phaser-tutorial, før du tackler denne.

Store kudos til Scott Westover for hans tutorial om emnet, Kal_Torak og Phaser-samfundet for at besvare alle mine spørgsmål, og min gode ven Mike for at hjælpe mig med at konceptualisere arkitekturen i dette projekt.

Bemærk: Vi bruger aktiver og farver fra mit kortspil på bordet, Entromancy: Hacker Battles . Hvis du foretrækker det, kan du bruge dine egne billeder (eller endda Phaser-rektangler) og farver, og du kan få adgang til hele projektkoden på GitHub.

Hvis du foretrækker en mere visuel tutorial, kan du også følge med ledsagervideoen til denne artikel:

Lad os komme igang!

Spillet

Vores enkle kortspil indeholder en Phaser-klient, der håndterer det meste af spillogikken og gør ting som omtale kort, giver træk-og-slip-funktionalitet osv.

I den bageste ende spinder vi en Express-server, der bruger Socket.IO til at kommunikere mellem klienter og gøre det, så når en spiller spiller et kort, vises det i en anden spillers klient og omvendt.

Vores mål for dette projekt er at skabe en grundlæggende ramme for et multiplayer-kortspil, som du kan bygge videre på og justere, så det passer til dit eget spils logik.

Lad os først tackle klienten!

Klienten

For at stilladsere vores klient skal vi klone den semi-officielle Phaser 3 Webpack Project Template på GitHub.

Åbn din yndlings kommandolinjegrænseflade, og opret en ny mappe:

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

Klon git-projektet:

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

Denne kommando downloader skabelonen i en mappe kaldet "phaser3-projekt-skabelon" inden for / multiplayer-kort-projekt. Hvis du vil følge med vores tutorials filstruktur, skal du gå videre og ændre navnet på denne skabelonmappe til "klient".

Naviger ind i den nye mappe, og installer alle afhængigheder:

cd client npm install

Din projektmappestruktur skal se sådan ud:

Før vi kaster med filerne, lad os gå tilbage til vores CLI og indtaste følgende kommando i / client-mappen:

npm start

Vores Phaser-skabelon bruger Webpack til at spin op på en lokal server, der igen serverer en simpel spilapp i vores browser (normalt på // localhost: 8080). Pænt!

Lad os åbne vores projekt i din foretrukne kodeditor og foretage nogle ændringer, der passer til vores kortspil. Slet alt i / client / src / assets, og erstat dem med kortbillederne fra GitHub.

I mappen / client / src skal du tilføje en mappe kaldet "scener" og en anden kaldet "hjælpere."

I / client / src / scenes skal du tilføje en tom fil kaldet "game.js".

I / client / src / helpers skal du tilføje tre tomme filer: "card.js", "dealer.js" og "zone.js".

Din projektstruktur skal nu se sådan ud:

Fedt nok! Din klient kaster muligvis fejl, fordi vi slettede nogle ting, men ikke for at bekymre dig. Åbn /src/index.js, som er det vigtigste indgangspunkt til vores frontend-app. Indtast følgende kode:

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

Alt, hvad vi har gjort her, er at omstrukturere kedelpladen til at udnytte Phaser's "scene" -system, så vi kan adskille vores spilscener i stedet for at prøve at klemme alt sammen i en fil. Scener kan være nyttige, hvis du opretter flere spilverdener, bygger ting som instruktionsskærme eller generelt prøver at holde tingene pæne.

Lad os gå til /src/scenes/game.js og skrive nogle kode:

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

Vi drager fordel af ES6-klasser til at oprette en ny spilscene, der indeholder forudindlæsning (), oprettelse () og opdatering () -funktioner.

preload () bruges til ... well ... preload alle aktiver, som vi bruger til vores spil.

create () køres, når spillet starter, og hvor vi etablerer meget af vores brugergrænseflade og spillogik.

opdatering () kaldes en gang pr. frame, og vi bruger ikke den i vores vejledning (men det kan være nyttigt i dit eget spil afhængigt af dets krav).

Inden for funktionen create () har vi oprettet en smule tekst, der siger "DEAL CARDS" og indstillet til at være interaktiv:

Meget sejt. Lad os oprette en smule pladsholderkode for at forstå, hvordan vi ønsker, at hele denne ting skal fungere, når den er i gang. Føj følgende til din create () -funktion:

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

Vi har tilføjet en masse struktur, men der er ikke sket meget. Nu, når vores mus svæver over teksten "DEAL CARDS", er den fremhævet i cyberpunk hot pink, og der er et tilfældigt kort på vores skærm:

Vi har placeret billedet ved (x, y) koordinaterne (300, 300), indstillet dets skala til at være lidt mindre og gjort det interaktivt og trækkeligt. Vi har også tilføjet en smule logik for at bestemme, hvad der skal ske, når der trækkes: det skal følge (x, y) koordinaterne for vores mus.

Vi har også oprettet en tom dealCards () -funktion, der kaldes, når vi klikker på vores "DEAL CARDS" -tekst. Derudover har vi gemt "dette" - hvilket betyder scenen, hvor vi i øjeblikket arbejder - i en variabel kaldet "selv", så vi kan bruge den i vores funktioner uden at bekymre os om omfanget.

Vores spil scene vil blive rodet hurtigt, hvis vi ikke begynder at flytte ting rundt, så lad os slette kodeblokken, der begynder med "dette.kort" og gå til /src/helpers/card.js for at skrive:

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

Vi har oprettet en ny klasse, der accepterer en scene som parameter og har en render () -funktion, der accepterer (x, y) koordinater og en sprite. Nu kan vi kalde denne funktion andre steder og give den de nødvendige parametre for at oprette kort.

Lad os importere kortet øverst på vores spilscene:

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

Og indtast følgende kode i vores tomme dealCards () -funktion:

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

Når vi klikker på knappen "DEAL CARDS", gentager vi nu en for-loop, der opretter kort og gengiver dem sekventielt på skærmen:

PÆN. Vi kan trække disse kort rundt på skærmen, men det kan være rart at begrænse, hvor de kan smides for at understøtte vores spillogik.

Lad os gå over til /src/helpers/zone.js og tilføje en ny klasse:

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.

MS Farzan, Ph.D. har skrevet og arbejdet for højt profilerede videospilvirksomheder og redaktionelle websteder som Electronic Arts, Perfect World Entertainment, Modus Games og MMORPG.com og har fungeret som Community Manager for spil som Dungeons & Dragons Neverwinter og Mass Effect: Andromeda . Han er Creative Director og Lead Game Designer af Entromancy: A Cyberpunk Fantasy RPG og forfatter af The Nightpath Trilogy . Find MS Farzan på Twitter @sominator.