Créez un jeux en ligne (MMO) en HTML5 avec Phaser et Socket.IO – partie 2

Sommaire

Introduction

Dans la première partie on a créé la base de la partie client de notre jeux en ligne avec phaser, on a abordé comment gérer la map, les sprites, et les mouvements en utilisant la librairie Astar.

Dans cet article on va aborder la partie serveur, on va voir comment créer la base d’un serveur de jeux en ligne en utilisant socket.io.

Le but de cette partie du tuto est de comprendre l’architecture d’un jeux en ligne, je ne vais mettre dans cet article que le code qui couvre la partie communication réseau, le reste est disponible sur le code sur github.

Je vous conseille donc de télécharger le code source sur votre pc et tester le code tout en lisant 🙂

Demo

Voici une petite demo du résultat final ( ça inclut un chat qu’on va voir dans la partie suivante), vous pouvez voir les autres internautes qui lisent maintenant ce tuto se déplacer, si ce n’est pas le cas ( vous êtes le seul lecteur ), ouvrez cette page sur un deuxième navigateur pour constater que les actions effectuées sur un navigateur se répercute sur l’autre.

Le code source sur GitHub

Architecture d’un jeu en ligne

Pour créer un jeux en ligne, il faut mettre en place une architecture de type client-serveur, ce qui veux dire que toutes les communications entre les différents joueurs devra passer par un serveur central, le rôle principale du serveur dans cette architecture est d’assurer la synchronisation de l’état du jeux pour tout les joueurs connectés.

Architecture client serveur

Architecture client serveur

Distribution du code source

image structure projet

Structure du projet


Le code source de notre jeux est divisé en deux parties selon l’environnement d’exécution :

  • partie client : qui se trouve dans le dossier public/, ce code sera exécuté dans le navigateur web de tout les joueurs.
  • Partie serveur : qui se trouve dans le dossier server/, ce code sera exécuté dans le serveur central du jeu.

Pour rappel, le contenu du dossier public/ est généré à partir du dossier client/ par brunch, l’avantage de cette approche est qu’on a la possibilité de minifier et optimiser le code à destination du navigateur (avec la commande : brunch -P), et ainsi gagner en temps de chargements.

Rôle du serveur dans un jeux en ligne

Le serveur d’un jeux en ligne gère tout ce qui est commun à tout les joueurs de la partie, il va donc gérer des informations comme :

  • Le score des joueurs
  • La position de chaque joueur dans la map
  • Les attributs de chaque joueur ( points de vie, point de magie, …)

La nature des événements et informations qu’un serveur de jeux gère diffèrent d’un jeu à l’autre, cependant on peux dire que tout les serveurs font les deux choses suivantes:

  • Notifier les joueurs quand un nouveau joueur est connecté ou bien déconnecté
  • Notifier les joueurs sur les actions qu’un joueur effectue (mouvement par exemple)

Donc dans cette partie du tuto on va se restreindre à ces deux points, le serveur de notre petit jeux va donc faire les choses suivantes :

  • Gérer les connexions et déconnexions
  • Quand un joueur bouge, notifier l’action de mouvement à tout les autres joueurs de la partie.

Une communication en temps réel avec socket.io

Avant de voir comment utiliser la librairie socket.io, définissons d’abord ce qu’est une socket.

Pour rester simple une socket est un lien entre un client et un serveur utilisé pour communiquer en temps réel et sans interruption.

A première vue on est tenté de dire que ça ressemble à de l’HTTP, et on pourrait même dire que finalement les sockets ça sert à rien et qu’on pourrait utiliser AJAX pour la partie réseau de notre jeux , sauf qu’il y a une différence essentielle entre l’utilisation du protocole http et des sockets :

  • le protocole HTTP est sans état : ça veux dire que le client (le navigateur) et le serveur ne sont connectés que le temps de la requête.
  • Les socket sont avec état: la connexion reste étable pendent toute la durée de la session, et ça c’est très utile pour des applications qui nécessitent un transfert de données en temps réel, comme un jeux en ligne par exemple 🙂

Ainsi les connexions entre le serveur et les différents client devront ressembler à ça:

client server architecture

Un seul serveur pour tout les joueurs

Dès qu’un client est connecté, il peux communiquer avec le serveur en temps réel, ainsi le client pourra notifier le serveur sur les actions du joueur, et le serveur transmettra cette information aux autres clients, notez que la communication entre les clients est possible, mais pour la faire il faut passer par un serveur.

Communiquer avec des Evénements

Après l’établissement de la connexion, le serveur et le client vont communiquer en utilisant des Evénements, pour faire simple vous pouvez voir un événement comme un message, il a un nom, et un contenu, c’est tout 😀

Vous pouvez nommer vos messages comme vous voulez, mais pour évitez de vous embrouiller, commencez d’abord par établir une règle de nommage , voici par exemple la règle de nommage que j’ai choisi pour ce mini jeux, [Source]_NOM_DE_MON_EVENNEMENT, voici quelques exemples:

  • CLIENT_REQUEST_PLAYER_LIST : à partir du client, demander au serveur la liste des joueurs connectés.
  • SERVER_PLAYER_LIST : à partir du serveur, envoyer au client la liste des joueurs connectés.

L’image ci dessous est une vulgarisation de ce qui se passe durant une connexion dans notre jeux :

Evennements sockets dans une partie

Evennements sockets dans une partie

Implémentation du serveur

Le serveur de notre jeux sera divisé en 2 parties :

  • Serveur Web (http) : cette partie sera utilisée pour servir les fichiers du dossier public/ au client.
  • Serveur du jeux (socket) : dans cette partie on va gérer toutes les communications en temps réel, on va gérer ici les mécaniques de notre jeux/

Serveur web avec express

Pour notre serveur web on va utiliser le framework express, ce n’est pas nécessaire mais je l’ai utilisé pour aller plus vite :

» Afficher le fichier : server/httpServer.js

'use strict';

var express    = require('express');
var http       = require('http');
var logger     = require('morgan');
var Path       = require('path');

var GameServer = require('./game/gameServer');

exports.startServer = function startServer(port, path, callback) {

    var app = express();

    var httpServer = http.createServer(app);
    var io = require('socket.io')(httpServer);
    var gameServer = GameServer(io);

    app.use(express.static(Path.join(__dirname + "/../" + path)));

    app.use(logger('dev'));

    app.get('/', function(req, res){
        res.sendFile('index.html');
    });

    gameServer.start();
    httpServer.listen(port, callback);
};

» Cacher

dans le code ci-dessus on crée un serveur http, on le configure pour chercher les ressources statiques ( images, css, javascript) dans le dossier spécifié dans la variable path, cette variable sera remplie par la valeur public/ par brunch quand on lance la commande brunch w.

Notez que notre serveur web ne contient qu’une seule route ( / = racine du site) qui renvoi le fichier index.html qui est le point d’entrée du jeux.

Ensuite on initialise socket.io pour gérer les connexions sockets, afin de rendre le code plus lisible, on va mettre tout le code de gestion des événements sockets dans le fichier : server/game/gameServer.js

Ou est ce que les valeurs port et path sont remplies ?

les valeurs de ces 2 variables sont remplies par brunch, mais on peux choisir quel valeur utiliser dans le fichier config.coffee, vous pouvez voir dans le fichier de configuration que j’ai surchargé la valeur du port pour devenir 9192.

Le serveur de notre jeux avec Socket.io

Vous trouverez ci dessous le code source de notre serveur :

» Afficher le fichier : server/game/gameServer.js

    
// game server : handle socket communication related to the game mechanics

var socketIO, listPlayers = [];

var GameServer = function(io){
    socketIO = io;
    return {
        start: function(){
            socketIO.on('connection', onClientConnected);
        }
    };
};

function onClientConnected(client){
    console.log('Client connected ...');
    client.on('CLIENT_REQUEST_ID', onRequestId);
    client.on('CLIENT_NOTIFY_PLAYER_MOVEMENT', onNotifyPlayerMovement);
    client.on('CLIENT_REQUEST_PLAYER_LIST', onRequestPlayerList);

    client.on('disconnect', onDisconnected);

    function onRequestId(playerInfo) {
        // respond the connected player with his ID
        client.emit('SERVER_PLAYER_ID', client.id);

        // notify all the other players that a new player is connected
        notifyConnectedPlayer(client, playerInfo);
    }

    function notifyConnectedPlayer(client, playerInfo){
        playerInfo.uid = client.id;
        listPlayers.push(playerInfo);
        client.broadcast.emit('SERVER_PLAYER_CONNECTED', playerInfo);
    }

    function onNotifyPlayerMovement(movementInfo){
        client.broadcast.emit('SERVER_OTHER_PLAYER_MOVED', movementInfo);
        // update state on server
        var concernedPlayer = getPlayerById(movementInfo.uid);
        if(concernedPlayer){
            concernedPlayer.x = movementInfo.x;
            concernedPlayer.y = movementInfo.y;
        }
    }

    function onRequestPlayerList(){
        client.emit('SERVER_PLAYER_LIST', listPlayers);
    }

    function onDisconnected(){
        listPlayers = removeElementById(listPlayers, client.id);
        client.broadcast.emit('SERVER_PLAYER_LIST', listPlayers);
    }
}

function getPlayerById( id){
    for(var i = 0, max = listPlayers.length; i < max; i++){
        if(listPlayers[i].uid === id){
            return listPlayers[i];
        }
    }
    return undefined;
}

function removeElementById(array, id){
    return array.filter(function( obj ) {
        return obj.uid !== id;
    });
}


module.exports = GameServer;
  

» Cacher

Comme vous pouvez le voir notre serveur ne fait que répondre aux « messages » envoyées par les clients, ainsi quand on développe notre serveur, on se met en tête qu’on va créer une sorte de répondeur automatique qui va réagir en fonction de l’événement (ou message) qu’il reçoit.

Le premier événement auquel notre serveur doit répondre est la connexion, cet événement est déclenché automatiquement par socket.io lors de la connexion du joueur, on va donc faire en sorte que notre serveur exécute une fonction développée par nos soins pour réagir à la connexion d’un joueur:

  ...
  socketIO.on('connection', onClientConnected);
  ...
  

Pour l’événement de connexion, socket.io s’attend à exécuter une fonction qui prend en paramètre une variable qui pointe vers la socket du client connecté, dans notre exemple on appele cette fonction onClientConnected.

Dans la fonction onClientConnected on va configurer la socket qui va communiquer avec le client pour répondre aux messages du joueur, les messages que le joueur peux envoyer dans cette partie du tutorial sont :

  • CLIENT_REQUEST_ID : Le joueur demande son identifiant unique
  • CLIENT_NOTIFY_PLAYER_MOVEMENT : Le joueur notifie le serveur qu’il s’est déplacé.
  • CLIENT_REQUEST_PLAYER_LIST : Le joueur souhaite avoir la liste des joueurs connectés.
  • disconnect : comme la connexion, cet événement est déclenché par socket.io lors de la déconnexion d’un joueur.

Pour répondre au client, le serveur devra à son tour envoyer des messages, voici la liste des messages que le serveur renvoi au joueur :

  • SERVER_PLAYER_ID : envoi au joueur son identifiant unique.
  • SERVER_PLAYER_CONNECTED : Notifie à tout les autres joueurs qu’un nouveaux joueur viens de se connecter.
  • SERVER_OTHER_PLAYER_MOVED : Notifie les autres joueurs que le joueur référencé par la variable client s’est déplacé.
  • SERVER_PLAYER_LIST : Envoi au joueur qui a demandé la liste des joueurs l’information qu’il souhaite.

Pour bien comprendre cette partie il faut garder en tête que pour envoyer un message du serveur au client socket.io nous donne deux options :

  • client.emit : Le serveur envoi le message au joueur référencé par la variable client.
  • client.broadcast : Le serveur envoi le message à tout les autres joueurs connectés, en excluent le joueur référencé par la variable client.

La partie serveur est terminée, ne stressez pas si vous ne comprenez pas tout, tout sera plus clair quand on verra comment connecter le client qu’on a créé dans la partie 1 du tuto à notre serveur.

Connecter notre client au serveur

Pour faciliter la gestion de la connexion au serveur, on va centraliser toutes les interactions socket dans un Objet qu’on va appeler NetworkManager.

» Afficher le fichier : client/utils/NetworkManager.js

'use stric';

var serverSocket, mainPlayer;
var onOtherPlayerConnectedCallback;
var onOtherPlayerMove;
var onUpdatePlayerListCallback;

var networkManager = {
    connected: false,
    connect: function (player) {
        mainPlayer = player;
        serverSocket = io.connect('http://localhost:9192');
        serverSocket.on('connect', onConnectedToServer);

        this.configureIncomingTraffic();

    },
    configureIncomingTraffic: function(){
        serverSocket.on('SERVER_PLAYER_ID', onReceivePlayerId);

        serverSocket.on('SERVER_PLAYER_CONNECTED', onPlayerConnected);
        serverSocket.on('SERVER_PLAYER_LIST', onReceivePlayerList);
        serverSocket.on('SERVER_OTHER_PLAYER_MOVED', onOtherPlayerMoved);
    },
    onOtherPlayerConnected: function(callback){
        onOtherPlayerConnectedCallback = callback;
    },
    onOtherPlayerMove: function(callback){
        onOtherPlayerMove = callback;
    },
    notifyMovement: function(movementInfo){
        serverSocket.emit('CLIENT_NOTIFY_PLAYER_MOVEMENT', movementInfo);
    },
    onUpdatePlayerList: function(callback){
        onUpdatePlayerListCallback = callback;
    }

};

function onConnectedToServer() {
    networkManager.connected = true;
    serverSocket.emit('CLIENT_REQUEST_ID', mainPlayer.getInfo());
    serverSocket.emit('CLIENT_REQUEST_PLAYER_LIST');
}

function onReceivePlayerId(mainPlayerID) {
    mainPlayer.uid = mainPlayerID;
    console.log("mon id", mainPlayerID)
}

function onPlayerConnected(otherPlayer){
    console.log('a player is connected', otherPlayer);
    onOtherPlayerConnectedCallback(otherPlayer);
}

function onOtherPlayerMoved(movementInfo){
    onOtherPlayerMove(movementInfo);
}

function onReceivePlayerList(listPlayers){
    onUpdatePlayerListCallback(listPlayers);
}


module.exports = networkManager;

» Cacher

Tout comme on a fait dans le serveur, on va commencer par configurer les réponses aux messages envoyés depuis le serveur, on crée donc la fonction configureIncomingTraffic pour centraliser la configuration des messages entrants, ainsi le client va répondre aux messages suivants :

  • connect : Cet événement est déclenché par socket.io automatiquement après la connexion du joueur au serveur.
  • SERVER_PLAYER_ID : Contient l’identifiant unique du joueur.
  • SERVER_PLAYER_CONNECTED : Le serveur informe le joueur qu’un nouveaux joueur viens de se connecter.
  • SERVER_PLAYER_LIST : Le serveur a envoyé au joueur la liste de tout les joueurs connectés.
  • SERVER_OTHER_PLAYER_MOVED : Le serveur préviens le client qu’un joueur a bougé

Après la configuration des message entrants, on va donner la possibilité de configurer des fonctions externes au NetworkManager qui pourront être déclenchées par les messages du serveur, vous verrez leurs utilité un peu plus bas dans le fichier client/states/play.js , cette configuration se fait à travers les fonctions suivantes :

  • onOtherPlayerMove : configure une fonction pour s’exécuter dès que le serveur préviens le client qu’un joueur a bougé.
  • onReceivePlayerList : configure une fonction pour s’éxécuter quand le client reçoit la liste des joueurs depuis le serveur.

Vous pouvez voir ces fonctions en action dans le fichier client/game/states/play.js

» Afficher le fichier : client/game/states/play.js


...

var NetworkManager = require('client/utils/NetworkManager');

function Play(){}

Play.prototype = {
    create: function(){
        ...

        this.connectToServer();
    },

    ...

    connectToServer: function(){
        var me = this;
        NetworkManager.connect(this.mainPlayer);
        NetworkManager.onOtherPlayerConnected(function(otherPlayerInfo){
            me.addOtherPlayer(otherPlayerInfo);
        });
        NetworkManager.onOtherPlayerMove(function(movementInfo){
            var otherPlayerToMove = searchById(me.otherPlayers, movementInfo.uid);
            if(otherPlayerToMove){
                otherPlayerToMove.moveTo(movementInfo.x, movementInfo.y);
            }
        });

        NetworkManager.onUpdatePlayerList(function(receivedList){
            me.removeDisconnected(receivedList);
            me.addConnected(receivedList);

        });
        this.otherPlayers = [];
    }

    ...
};

» Cacher

Finalement NetworkManager contient la fonction notifyMovement qui sera utilisée pour notifier au serveur le mouvement du joueur, cette fonction sera utilisée dans l’objet CharacterObj.

» Afficher le fichier : client/game/gameObjects/CharacterObj.js


...

var NetworkManager = require('client/utils/NetworkManager');

...

CharacterObj.prototype.moveTo = function(targetX, targetY, pathReadyCallback){

    ...

    if(this.isMainPlayer) {
        NetworkManager.notifyMovement({x: targetX, y: targetY, uid: this.uid})
    }

    ...
};

...


» Cacher

Conclusion

Voila, on n’a pas encore un vrai jeu en ligne, mais on approche du but.

Je suis conscient que cet article est assez chargé, je vous avoue que ce n’est pas évident d’expliquer l’utilisation des sockets avec un discourt qui parle à tout le monde, donc toute question ou proposition d’amélioration est la bienvenue.

je vous recommande vivement de tester le code sur votre pc (en utilisant plusieurs navigateurs en même temps) pour bien comprendre tout les concepts, car en maîtrisant l’utilisation des sockets vous pourrez vous attaquer à n’importe quel type de jeux en ligne.

Dans le prochain article on va voir comment intégrer un système de chat en temps réel à notre jeux 🙂

Le code source sur GitHub

Partie 3 : intégrer un système de chat ( prochainement )

10 commentaires

  • Costa Alexandre

    C’est décidé je vais me lancer dans le développement d’un jeu de combat en ligne ! Merci pour la suite du tuto !

  • KaptanTo

    Really good Tutorial. Go on, plz.
    and thanks to google translator 😉

    My proposal for the 4th part: NPCs — or perhaps interaction between the players?
    KaptanTo

  • Matthieu

    Merci pour ton tuto et pour m’avoir fait découvrir brunch !

    J’aurai bien aimé avoir différentes versions de ton code et d’avantage de descriptions sur ton site pour y aller pas à pas, parce que sur ton github je ne retrouve que la version finale, mais c’est déjà super de partager ce que tu as fait !

    Du coup j’essaie de reproduire ton exemple en plus simple, et je n’arrive pas à comprendre d’où vient ton objet io quand tu gères la connection au socket côté client dans NetworkManager.js, pour contourner ce problème j’ai réussi à l’obtenir en rajoutant un dans mon index.html mais s’il y a une autre méthode ça m’intéresse.

    Bon courage pour la suite 😉

    • Salut Matthieu,
      C’est vrai que je n’ai pas trop détaillé la partie code, je me suis trop concentré sur les concepts de communication client-serveur avec les sockets, je note la remarque pour après 🙂

      Sinon pour répondre à ta question concernant l’objet io :

      L’objet io est présent dans le fichier vendor/socket.io-1.3.5.js, il faut garder en tête que brunch va compiler tout le code se trouvant dans le dossier vendor/ et le mettre dans le fichier public/js/vendor.js (en ignorant les fichiers commencant par _ ), et donc pas besoin de toucher index.html.

      Je vous encourage vivement à prendre le temps de lire cet article pour comprendre comment brunch fonctionne, et pourquoi il est génial.

      J’espère que ça répond à ta question, n’hésite pas à revenir vers moi si tu as d’autres questions.

      Bon courage 🙂

      • Matthieu

        Oui merci ça m’a aidé et j’ai pu me passer de modifier l’index.html, je n’avais pas pensé à regarder dans le dossier vendor et je comprends progressivement la logique de brunch que tu présentes vraiment bien dans ton article qui lui est dédié 😉

        Je l’ai suivi en parallèle du guide github qui permet de prendre l’outil en main rapidement et c’est juste génial !

  • Super ton tuto,

    je suis que programmeur amateur.
    j aime bien les mmo, j y ai beaucoup jouer et je me suis bien amusé.
    j ai crée quelques jeux avec phaser., je pense que je dessine pas trop mal.
    Je pourrais peut etre essayer faire un mmo sympa, mais ca me semble encore trop compliquer de configurer un serveur.

  • ju

    Bonjour,
    Merci pour le tuto.
    Pourriez vous expliquer le fonctionnement de la fonction searchById suivante que vous utilisez.
    Je pense que j’ai du mal à la comprendre à cause de l’opérateur ternaire. Je souhaite adapter une partie de votre code mais je bloque à cause de cette fonction.

    function searchById(array, id){
    for(var i = 0, max = array.length; i < max; i++){
    var uid = array[i].getInfo ? array[i].getInfo().uid : array[i].uid;
    if(array[i] != null && uid === id){
    return array[i];
    }
    }
    return undefined;
    }

    Merci de votre aide.

  • Merci pour le tuto.
    La technique des callbacks m’a sauvé la vie pour faire communiquer mon client websocket avec Phaser 🙂

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *