Tutorial : Création d’un jeu multi-joueurs basique avec Phaser et Eureca.io
L’objectif de ce tutorial est de vous montrer comment créer un jeux HTML5 multijoueurs basique.
Pour celà, je vais utiliser Phaser pour le développement du jeu, et Eureca.io pour les communications client/serveur.
Ce tutorial suppose que vous avez déjà des connaissances de base en développement de jeux HTML5 (avec Phaser de préférence)
Il suppose également que vous avez quelques connaissances de nodejs, et que vous l’ayez déjà installé sur votre PC.
Je vais partir d’un code fourni en exemple sur la page officielle de Phaser, c’est une jeu de tank simple.
Voici une vidéo qui montre le résultat final que nous allons obtenir à la fin du tutorial
c’est parti 🙂
Première étape : refactoring du code
Quelques modifications et simplifications sont nécessaire pour rendre le jeu adaptable au multi-joueurs.
Ici j’ai factorisé le code des Tanks du joueur humain et ennemi dans une unique classe nommée Tank (je suis parti de la classe EnemyTank fournie dans le code original), car en mode multi-joueurs, on ne distinguera pas le Tank Ennemi du Tank humain, il dériveront de la même classe ; après tout, le Tank ennemi n’est autre qu’un humain de l’autre coté du réseau 😉
La création et « l’update » ont été déplacé dans le code de la classe Tank
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
// Tank = function (index, game, player) { this.cursor = { left:false, right:false, up:false, fire:false } this.input = { left:false, right:false, up:false, fire:false } var x = 0; var y = 0; this.game = game; this.health = 30; this.player = player; this.bullets = game.add.group(); this.bullets.enableBody = true; this.bullets.physicsBodyType = Phaser.Physics.ARCADE; this.bullets.createMultiple(20, 'bullet', 0, false); this.bullets.setAll('anchor.x', 0.5); this.bullets.setAll('anchor.y', 0.5); this.bullets.setAll('outOfBoundsKill', true); this.bullets.setAll('checkWorldBounds', true); this.currentSpeed =0; this.fireRate = 500; this.nextFire = 0; this.alive = true; this.shadow = game.add.sprite(x, y, 'enemy', 'shadow'); this.tank = game.add.sprite(x, y, 'enemy', 'tank1'); this.turret = game.add.sprite(x, y, 'enemy', 'turret'); this.shadow.anchor.set(0.5); this.tank.anchor.set(0.5); this.turret.anchor.set(0.3, 0.5); this.tank.id = index; game.physics.enable(this.tank, Phaser.Physics.ARCADE); this.tank.body.immovable = false; this.tank.body.collideWorldBounds = true; this.tank.body.bounce.setTo(0, 0); this.tank.angle = 0; game.physics.arcade.velocityFromRotation(this.tank.rotation, 0, this.tank.body.velocity); }; Tank.prototype.update = function() { for (var i in this.input) this.cursor[i] = this.input[i]; if (this.cursor.left) { this.tank.angle -= 1; } else if (this.cursor.right) { this.tank.angle += 1; } if (this.cursor.up) { // The speed we'll travel at this.currentSpeed = 300; } else { if (this.currentSpeed > 0) { this.currentSpeed -= 4; } } if (this.cursor.fire) { this.fire({x:this.cursor.tx, y:this.cursor.ty}); } if (this.currentSpeed > 0) { game.physics.arcade.velocityFromRotation(this.tank.rotation, this.currentSpeed, this.tank.body.velocity); } else { game.physics.arcade.velocityFromRotation(this.tank.rotation, 0, this.tank.body.velocity); } this.shadow.x = this.tank.x; this.shadow.y = this.tank.y; this.shadow.rotation = this.tank.rotation; this.turret.x = this.tank.x; this.turret.y = this.tank.y; }; |
J’ai également ajouté une methode Tank.kill(), qui permer de supprimer un Tank de la scène du jeu, et déplacé la méthode fire() dans Tank.fire()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// Tank.prototype.fire = function(target) { if (!this.alive) return; if (this.game.time.now > this.nextFire && this.bullets.countDead() > 0) { this.nextFire = this.game.time.now + this.fireRate; var bullet = this.bullets.getFirstDead(); bullet.reset(this.turret.x, this.turret.y); bullet.rotation = this.game.physics.arcade.moveToObject(bullet, target, 500); } } Tank.prototype.kill = function() { this.alive = false; this.tank.kill(); this.turret.kill(); this.shadow.kill(); } |
J’ai aussi supprimer la gestion des dommages sur un Tank quand il est touché, histoire de simplifier le code
et enfin, j’ai modifié la manière dans les controles claviers sont effectués :
le code original utilise phaser.input (via game.input.keyboard.createCursorKeys())
mais dans notre cas, le client ne devra pas gérer les entrée clavier directement, j’expliquerais celà plus loin.
J’ai donc créé des objets Tank.input et Tank.cursor pour gérer les commandes clavier.
ce qui donne ce code modifié de la méthode update()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
// function update () { player.input.left = cursors.left.isDown; player.input.right = cursors.right.isDown; player.input.up = cursors.up.isDown; player.input.fire = game.input.activePointer.isDown; player.input.tx = game.input.x+ game.camera.x; player.input.ty = game.input.y+ game.camera.y; turret.rotation = game.physics.arcade.angleToPointer(turret); land.tilePosition.x = -game.camera.x; land.tilePosition.y = -game.camera.y; for (var i in tanksList) { if (!tanksList[i]) continue; var curBullets = tanksList[i].bullets; var curTank = tanksList[i].tank; for (var j in tanksList) { if (!tanksList[j]) continue; if (j!=i) { var targetTank = tanksList[j].tank; game.physics.arcade.overlap(curBullets, targetTank, bulletHitPlayer, null, this); } if (tanksList[j].alive) { tanksList[j].update(); } } } } |
Vous pouvez télécharger le code modifié ici, ainsi vous pourrez suivre les prochaines étapes du tutorial.
Télécharger le code modifié du jeu de Tanks
Maintenant que nous avons un code simple qui fonctionne, nous allons le transformer en un jeu multi-joueurs;
Je vais utiliser Eureca.io, une librairie pour le RPC que j’ai dévelopé pour des usages internes à Ezelia, et que j’ai décidé de rendre publique (Code source disponnible ici https://github.com/Ezelia/eureca.io)
Les même principes qui seront utilisé ici sont applicables à n’importe quelle autre librairie réseau (socket.io, engine.io …etc), mais nous allons voir comment Eureca.io simplifie le travail 😉
Le serveur web
Nous commençons par créer un simple serveur web pour notre jeu
pour celà nous allons installer la librairie expressjs pour nodejs agin de gérer plus simplement la partie web, de plus expressjs est compatible avec Eureca.io.
expressjs est très efficace aussi pour la création de pages web dynamiques sous nodejs, au cas ou vous souhaitez habiller le site web qui héberge votre jeu.
1 |
npm install express |
maintenant on va créer un fichier server.js dans le répertoire racine du jeu, avec le contenu suivant
1 2 3 4 5 6 7 8 9 10 11 12 |
// var express = require('express') , app = express(app) , server = require('http').createServer(app); // serve static files from the current directory app.use(express.static(__dirname)); server.listen(8000); |
reste à lancer le serveur en tapant la commande
1 |
node server.js |
ouvrez votre navigateur et allez à l’adresse : http://localhost:8000/
Si le jeu fonctionne vous êtes prêt pour l’étape suivante
Vous pouvez télécharger le code de cette étape ici 🙂
Tanks Game Etape 1
Installation et preparation de eureca.io
Nous allons commencer à jouer avec Eureca.io maintenant
Eureca.io peut aussi bien utiliser engine.io ou sockjs comme couche de transport réseau, par defaut c’est engine.io qui est utilisée.
afin d’utiliser eureca.io avec la configuration par defaut, nous avons besoins d’installer eureca.io et engine.io.
1 2 |
npm install engine.io npm install eureca.io |
Maintenant, nous allons modifier le code de notre serveur pour ajouter quelques routines eureca.io.
juste avant : server.listen(8000)
on créé une instance d’eureca.io Server, et nous l’attachons au serveur HTTP comme le montre ce code :
1 2 3 4 5 6 7 8 9 10 |
// //classe EurecaServer var EurecaServer = require('eureca.io').EurecaServer; //création d'une instance EurecaServer var eurecaServer = new EurecaServer(); //attachement de eureca.io au serveur http eurecaServer.attach(server); |
Ensuite on ajoute des Event listeners pour detecter les connexions/deconnexion de clients.
1 2 3 4 5 6 7 8 9 10 11 |
// //detection d'une connexion client eurecaServer.onConnect(function (conn) { console.log('New Client id=%s ', conn.id, conn.remoteAddress); }); //detection d'une deconnexion eurecaServer.onDisconnect(function (conn) { console.log('Client disconnected ', conn.id); }); |
Coté client nous allons faire aussi des modifications
d’abord, il faut ajouter la ligne suivante dans le fichier index.html avant le script tanks.js
1 |
<script src="/eureca.js"></script> |
cette ligne rend eureca.io accessible au client.
maintenant on édite le fichier tanks.js pour ajouter le code suivant au tout début du fichier
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// var ready = false; var eurecaServer; //this function will handle client communication with the server var eurecaClientSetup = function() { //create an instance of eureca.io client var eurecaClient = new Eureca.Client(); eurecaClient.ready(function (proxy) { eurecaServer = proxy; //we temporary put create function here so we make sure to launch the game once the client is ready create(); ready = true; }); } |
ici nous avons créé une methode eurecaClientSetup, qui instantie le client eureca.io, attend que ce dernier soit prêt (établissement de connexion avec le serveur) puis appel la methode de création du jeu (create());
la méthode create(), était initiallement appellée par Phaser.Game() lors de l’instanciation du jeu, nous devons modifier ceci afin que ce soit la méthode d’instanciation devienne eurecaClientSetup et non create()
1 |
var game = new Phaser.Game(800, 600, Phaser.AUTO, 'phaser-example', { preload: preload, create: eurecaClientSetup, update: update, render: render }); |
Important : quand on créé un jeu multijoueurs, il est souvent nécessaire de s’assurer que le serveur est disponnible avant de lancer le code du jeu, c’est exactement ce que fait eurecaClientSetup ici
une dernière chose
vous avez due noter la présence de la variable « ready » initialisée à Falce par defaut ; ce flag nous permet de savoir si l’initialisation client/serveur est terminée, et que le jeu est créé.
nous utilisons ce flag pour empêcher phaser d’appeler la methode update avant que l’appel à create() ne soit fait.
nous avons donc besoin d’ajouter ceci à la méthode update()
1 2 3 4 5 |
// function update () { //do not update if client not ready if (!ready) return; |
Le code de cette étape est disponible ici :
Télécharger Tanks Game Etape 2relancez server.js (node server.js) puis allez à l’adresse http://localhost:8000/
le jeu devrait se lancer, et le serveur devrait détecter des connexions client.
rafraichissez la page du navigateur ; vous allez voir que le serveur detecte une déconnexion/reconnexion du client.
si celà marche, on peut passer à l’étape suivante.
Spawn/Kill de joueurs distants
pour notre jeux basique, le serveur doit garder une trace de tout les clients connectés.
pour distinguer les clients, nous avons également besoin d’un identifiant unique pour chaque joueur (nous utiliserons un ID unique généré par eureca.io)
cet identifiant sera partagé entre les clients et le serveur ; il leur permettera de synchroniser les données des joueurs, et faire la correspondance entre les clients distants et leur Tanks.
Voici l’implémentation que nous allons réaliser
- Quand un client se connecte, le serveur lui créé un id unique (c’est le session ID d’eureca.io qui sera utilisé)
- Le serveur envoi cet id unique au client.
- Le client créé la scène du jeu avec le Tank du joueur et assigne l’id unique à ce Tank.
- Le client notifie le serveur que tout est pret de son coté (c’est ce qu’on appel un « handshake »)
- Le serveur reçoit cette notification, et appel la méthode spawn() sur tout les clients connectés
- Chaque client créé une instance pour chaque Tank pas encore créé.
- Quand un client se déconnecte, le serveur l’identifie et le retire de la liste des clients connectés
- Le serveur appel la methode kill() chez tout les clients connectés, en passant l’id du client qui vient de quitter le jeu.
- Chaque client execute le kill() et supprime l’instance du Tank identifié.
Voici ce que celà donne en pratique
Coté client
Les instances (client et serveur) d’Eureca.io ont un namespace appelé « exports » ; toutes les méthodes définies sous ce namespace deviennent accessible en RPC.
Nous allons voir comment celà marche concrètement.
nous modifions la méthode eurecaClientSetup comme ceci.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
// var eurecaClientSetup = function() { //create an instance of eureca.io client var eurecaClient = new Eureca.Client(); eurecaClient.ready(function (proxy) { eurecaServer = proxy; }); //methods defined under "exports" namespace become available in the server side eurecaClient.exports.setId = function(id) { //create() is moved here to make sure nothing is created before uniq id assignation myId = id; create(); eurecaServer.handshake(); ready = true; } eurecaClient.exports.kill = function(id) { if (tanksList[id]) { tanksList[id].kill(); console.log('killing ', id, tanksList[id]); } } eurecaClient.exports.spawnEnemy = function(i, x, y) { if (i == myId) return; //this is me console.log('SPAWN'); var tnk = new Tank(i, game, tank); tanksList[i] = tnk; } } |
dans le code ci-dessus, nous avons trois méthodes qui seront appellable depuis le serveur : setId, kill et spawnEnemy.
Notez aussi que notre client appel une méthode coté serveur : eurecaServer.handshake().
Coté Serveur
La première des choses à faire coté serveur est de lui dire que les méthodes client (setId, kill and spawnEnemy), sont de confiance et qu’elles peuvent être appellées, autrement, eureca.io refusera ces appels.
quand on fait du client/serveur, le serveur ne dois jamais faire confiance au client.
le code suivant demande à Eureca.io de faire confiance aux methodes et de créer un objet clientList qui stockera les données des clients.
1 2 3 4 |
// var eurecaServer = new EurecaServer({allow:['setId', 'spawnEnemy', 'kill']}); var clients = {}; |
On modifie ensuite les méthodes onConnect et onDisconnect
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// //detect client connection eurecaServer.onConnect(function (conn) { console.log('New Client id=%s ', conn.id, conn.remoteAddress); //the getClient method provide a proxy allowing us to call remote client functions var remote = eurecaServer.getClient(conn.id); //register the client clients[conn.id] = {id:conn.id, remote:remote} //here we call setId (defined in the client side) remote.setId(conn.id); }); //detect client disconnection eurecaServer.onDisconnect(function (conn) { console.log('Client disconnected ', conn.id); var removeId = clients[conn.id].id; delete clients[conn.id]; for (var c in clients) { var remote = clients[c].remote; //here we call kill() method defined in the client side remote.kill(conn.id); } }); |
notez comment le serveur appel simplement les méthodes que nous avons définies coté client : remote.setId(conn.id) et remote.kill(conn.id);
Si vous avez suivi, le client devait aussi appeller une méthode coté serveur, nous devons donc la déclarer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// eurecaServer.exports.handshake = function() { //var conn = this.connection; for (var c in clients) { var remote = clients[c].remote; for (var cc in clients) { remote.spawnEnemy(clients[cc].id, 0, 0); } } } |
Maintenant, lancer le serveur et ouvrez une première fenêtre du navigateur sur http://localhost:8000/
déplacez le tank puis ouvrez une deuxième fenêtre sur http://localhost:8000/
vous devez voir un deuxième Tank aparraitre dans la première fenêtre.
fermez la deuxième fenêtre … et le tank dois disparraitre.
c’est déjà pas mal hein ? 🙂 mais ce n’est pas encore du multijoueurs
Comme vous le remarquez, les déplacements des tanks ne sont pas encore répliqués, et c’est ce qu’on va faire dans la prochaine étape.
au passage, vous trouvez ci-dessous le code de cett étape 😀
Télécharger Tanks Game Etape 3
Gestion des commandes clavier / Synchronisation des états
Dans un jeu multijoueurs, le serveur doit controler les états des clients, la seul entitée de confiance dans une telle configuration c’est le serveur (il existe d’autres configuration comme le P2P par exemple … mais ce n’est pas le sujet de ce tutorial)
une implémentation idéal d’un jeu client/serveur, consisterait en une simulation coté client ET serveur des mouvement, ensuite le serveur envoi un état au client qui corrige/compense sa simulation locale.
dans notre exemple nous alons synchroniser un ensemble minimum d’information et « faire confiance » à la simulation client. (en gros, il n y aura pas de simulation coté serveur)
Voici l’implémentation :
- Quand un joueur fait une commande (déplacement ou tir), celle-ci ne sera pas géré directement par le client.
- La commande sera envoyée au serveur, le serveur transmettera cette commande à tout les clients connectés, y compris celui qui vient de lui envoyer la commande.
- Chaque client applique la commande à l’instance locale du Tank en question.
- Le Tank gère la commande reçues du serveur comme si c’était une commande locale
en plus de ça, à chaque fois qu’une commande est envoyée, nous allons aussi transmettre la position du Tank, cette information sera utilisée par le client pour se synchroniser avec le serveur.
on passe au code !
Coté client
On edite encore une fois eurecaClientSetup pour ajouter une nouvelle méthode coté client.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// eurecaClient.exports.updateState = function(id, state) { if (tanksList[id]) { tanksList[id].cursor = state; tanksList[id].tank.x = state.x; tanksList[id].tank.y = state.y; tanksList[id].tank.angle = state.angle; tanksList[id].turret.rotation = state.rot; tanksList[id].update(); } } |
rappel: les méthodes du namespace exports sont appellables depuis le serveur.
la méthode updateState va mettre à jour Tank.cursor avec la commande transmise par un joueur.
elle va aussi rectifier la position du Tank et son angle si besoin. (synchronisation)
nous devons gérer les commandes transmises dans Tank.update.
pour celà on édite Tank.prototype.update pour remplacer la ligne :
1 |
for (var i in this.input) this.cursor[i] = this.input[i]; |
par ce code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// var inputChanged = ( this.cursor.left != this.input.left || this.cursor.right != this.input.right || this.cursor.up != this.input.up || this.cursor.fire != this.input.fire ); if (inputChanged) { //Handle input change here //send new values to the server if (this.tank.id == myId) { // send latest valid state to the server this.input.x = this.tank.x; this.input.y = this.tank.y; this.input.angle = this.tank.angle; this.input.rot = this.turret.rotation; eurecaServer.handleKeys(this.input); } } |
ici nous vérifions que le joueur a entré une commande (client ou touche clavier), si c’est le cas, nous envoyons cette commande au serveur via eurecaServer.handleKeys.
la méthode handleKeys coté serveur va transmettre la commande reçue à tout les client, comme on va le voir juste après.
Coté serveur
Nous devons d’abord autoriser la nouvelle méthode ajoutée coté client (updateStates)
1 |
var eurecaServer = new EurecaServer({allow:['setId', 'spawnEnemy', 'kill', 'updateState']}); |
ensuite on déclare la méthode handleKeys
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// eurecaServer.exports.handleKeys = function (keys) { var conn = this.connection; var updatedClient = clients[conn.id]; for (var c in clients) { var remote = clients[c].remote; remote.updateState(updatedClient.id, keys); //keep last known state so we can send it to new connected clients clients[c].laststate = keys; } } |
puis une petite modification à la méthode handshake existante
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// eurecaServer.exports.handshake = function() { for (var c in clients) { var remote = clients[c].remote; for (var cc in clients) { //send latest known position var x = clients[cc].laststate ? clients[cc].laststate.x: 0; var y = clients[cc].laststate ? clients[cc].laststate.y: 0; remote.spawnEnemy(clients[cc].id, x, y); } } } |
Si vous avez bien suivi toutes les étapes (ou que vous avez téléchargé le code final ci-dessous 😀 )
Vous pouvez lancer le serveur et ouvrir deux ou plusieurs fenêtres de votre navigateur.
Maintenant quand vous déplacez un tank ou que vous tirez, les mouvements et les tirs sont répliqué partout !
Pour aller plus loin
Vous avez maintenant un code basique et des notions d’une implémentation multi-joueurs.
Pour vous exercez vous pouvez essayez de gérer les dommages sur un tank, les kill et respawn … vous pouvez également tenter de mieux gérer la synchronisation des mouvement, par exemple au lieu de changer brusquement la position du tank, le faire déplacer doucement vers la position qu’il est censé avoir …etc
si vous avez aimé ce tutorial je vous invite à le partager
et bien entendu, vos commentaires et suggestions sont les bienvenus.
Télécharger Le code final