Développer un jeu avec JHipster, HTML 5 et LeapMotion

Lecteurs : vous êtes des développeurs aux compétences multiples, que diriez-vous de varier un peu votre quotidien rempli d’applications de gestion, d’objets métier ou de requêtes SQL ? Vous savez coder en Java ? Vous connaissez le framework Spring ? Vous avez déjà jeté un petit coup d’oeil à AngularJS ?

Je vous propose d’utiliser vos compétences pour mettre un peu de fun dans votre vie de développeur en devenant auteur de jeu vidéo multijoueur ! Je vous propose de n’utiliser que des compétences répandues (Java, Spring) et un petit peu d’AngularJS (mais rien de bien compliqué). Vous allez aussi apprendre quelques trucs sur les Canvas d'HTML5 et cerise sur le gâteau, nous rajouterons le nécessaire pour pouvoir interagir avec le jeu en utilisant Leapmotion.

Ok, un jeu vidéo multijoueur c’est bien ! Mais lequel ? Pour se simplifier la vie, nous partir sur une base d’Asteroids. Chaque joueur contrôlera un vaisseau et ces derniers évolueront dans un Canvas HTML5. La communication entre les joueurs et le serveur se fera avec des WebSockets.

Architecture

Un petit schéma valant beaucoup d’explications, voici comment nous allons organiser notre bébé : AsteroidsCôté client, nous allons utiliser :

  • AngularJS pour organiser notre code, pouvoir intégrer proprement notre jeu (gérer les utilisateurs, les scores, s’enregistrer, etc…)
  • HTML5 pour la gestion de l’affichage et des inputs utilisateurs (en complément du Leapmotion)
  • Leapmotion pour le contrôle du vaisseau (ou le clavier par défaut)

Côté serveur, nous allons partir sur :

  • une stack Java parce qu’on l’utilise tous les jours et qu’on est à l’aise avec
  • le framework Spring idem au-dessus
  • le framework Atmosphere (pour les WebSockets)

La communication entre les clients et le serveur se fera par 2 canaux :

  • 1er websocket permettant de connecter et déconnecter un joueur. Il doit aussi permettre au serveur d’attribuer un ID au joueur, de notifier tous les joueurs d’une déconnexion d’un participant
  • 2ième websocket permettant au joueur d’envoyer sa position et au serveur de broadcaster tous les mouvements de tous les joueurs à tout le monde

Maintenant que nos briques techniques ont été choisies, il faut se lancer. C’est là que JHipster va nous permettre de bootstrapper rapidement le projet : il va nous fournir un socle AngularJS / Java / Spring / Atmosphere complètement fonctionnel sur lequel nous n’aurons qu’à nous greffer.

Installer JHipster

Je pars du principe que Java et Maven sont déjà installés et que vous avez un IDE que vous maitrisez. 1ère étape : installer Node.js Il faut installer Node.js. L’installation se fait sans problème en suivant les instructions du site officiel. 2ième étape : installer Yeoman Encore une fois, aucun soucis grâce à Node.js. La commande suivante fait le job : npm install -g yo 3ième étape : installer JHipster Exactement comme Yeoman : npm install -g generator-jhipster

Créer le projet

On utilise JHipster, générateur Yeoman pour générer le projet : yo jhipster Il faut faire attention à bien activer l’intégration d’Atmosphere au projet lors des questions posées par le générateur Yeoman.

Importer le projet dans Intellij

Un projet Maven a été généré, je vous laisse l’importer dans votre IDE préféré

Let’s dive into code

Tout d’abord, un petit schéma pour s’y retrouver plus tard : Astroids_Archi

AngularJS - Separation of Concerns

Commençons par organiser proprement notre code. Pour cela, nous allons isoler le code de communication du code du jeu. Construisons un Service pour communiquer en websocket avec le serveur :

  • dans le répertoire src/main/webapp/scripts, ajoutons un fichier angular-atmosphere-service.js avec le contenu suivant :
angular.module('angular.atmosphere', [])
    .service('atmosphereService', function($rootScope){
        var responseParameterDelegateFunctions = ['onOpen', 'onClientTimeout', 'onReopen', 'onMessage', 'onClose', 'onError'];
        var delegateFunctions = responseParameterDelegateFunctions;
        delegateFunctions.push('onTransportFailure');
        delegateFunctions.push('onReconnect');

        return {
            subscribe: function(r){
                var result = {};
                angular.forEach(r, function(value, property){
                    if(typeof value === 'function' && delegateFunctions.indexOf(property) >= 0){
                        if(responseParameterDelegateFunctions.indexOf(property) >= 0)
                            result[property] = function(response){
                                $rootScope.$apply(function(){
                                    r[property](response);
                                });
                            };
                        else if(property === 'onTransportFailure')
                            result.onTransportFailure = function(errorMsg, request){
                                $rootScope.$apply(function(){
                                    r.onTransportFailure(errorMsg, request);
                                });
                            };
                        else if(property === 'onReconnect')
                            result.onReconnect = function(request, response){
                                $rootScope.$apply(function(){
                                    r.onReconnect(request, response);
                                });
                            };
                    }else
                        result[property] = r[property];
                });

                return atmosphere.subscribe(result);
            }
        };
    });

Ce script va nous fournir un Service AngularJS pour Atmosphere. On pourra injecter cet atmosphereService plus tard pour communiquer avec le serveur. (Source : Atmosphere and AngularJS). Maintenant que nous avons un atmosphereService, nous pouvons créer 2 Factory pour contenir le code de communication avec nos 2 websockets. Petite problèmatique de communication inter-composants : AngularJS ne donne pas accès au $scope dans les Factory (ce sont des services, elles sont donc découplés du DOM) : seul l’accès au $rootScope est possible. Nous allons donc utiliser le mécanisme de notification d’évènements d’AngularJS : les méthodes $emit() et $on() sur le $rootScope vous nous permettre de faire circuler des messages entre nos 2 Factories et le code du jeu. Avec toutes ces informations en poche, nous allons créer notre 1ière Factory :

angular.module('AstroidsModule')
    .factory('shipDataService', function($rootScope, atmosphereService) {

        var shipDataService = {};

        var socket = {};

        var request = {
                url: '/websocket/receiveShipData',
                contentType: 'application/json',
                logLevel: 'debug',
                transport: 'websocket',
                trackMessageLength: true,
                reconnectInterval: 30000,
                enableXDR: true,
                timeout: 60000
            };

            request.onOpen = function(response){};

            request.onClientTimeout = function(response) {
                setTimeout(function(){
                    socket = atmosphereService.subscribe(request);
                }, request.reconnectInterval);
            };

            request.onReopen = function(response){};

            request.onMessage = function(data){
                var responseBody = data.responseBody;
                var message = atmosphere.util.parseJSON(responseBody);
                $rootScope.$emit('shipMessage', message);
            };

            request.onClose = function(response){};

            request.onError = function(response){};

            request.onReconnect = function(request, response){};

            socket = atmosphereService.subscribe(request);

            shipDataService.send = function(message) {
                socket.push(atmosphere.util.stringifyJSON(message));
            };

            return shipDataService;
        }
    )

Elle va nous servir à communiquer avec le serveur via le websocket /websocket/receiveShipData et va notifier notre application AugularJS sur le canal shipMessage. Exactement de la même façon, nous allons créer une Factory pour le websocket dédié aux connexions / déconnexions :

.factory('connectionsService', function($rootScope, atmosphereService) {

        var connectionsService = {};

        var connectionSocket = {};

        var request = {
            url: '/websocket/connections',
            contentType: 'application/json',
            logLevel: 'debug',
            transport: 'websocket',
            trackMessageLength: true,
            reconnectInterval: 30000,
            enableXDR: true,
            timeout: 60000
        };

        request.onOpen = function(response){};

        request.onClientTimeout = function(response){
            setTimeout(function(){
                connectionSocket = atmosphereService.subscribe(request);
            }, request.reconnectInterval);
        };

        request.onReopen = function(response){};

        request.onMessage = function(data){
            var responseBody = data.responseBody;
            var message = atmosphere.util.parseJSON(responseBody);
            $rootScope.$emit('connections', message);
        };

        request.onClose = function(response){
            console.log('Closing socket connection for client ' + $rootScope.clientId);
            // socket.push(atmosphere.util.stringifyJSON({ action: 'disconnection', target: $rootScope.clientId }));
        };

        request.onError = function(response){};

        request.onReconnect = function(request, response){};

        connectionSocket = atmosphereService.subscribe(request);

        connectionsService.send = function(message) {
            connectionSocket.push(atmosphere.util.stringifyJSON(message));
        };

        return connectionsService;
    }
)

Celle-ci communique avec le serveur via le websocket /websocket/connections et notifie le canal connections d’AngularJS. Enfin, la communication avec le Leapmotion se fait aussi par websocket ! La documentation disponible ici nous indique que le Leapmotion agit comme un serveur Websocket et diffuse les informations liées aux mouvements détectés sur un canal. On peut donc créer une 3ième factory AngularJS utilisant le Leap.Controller, fournit par le SDK du Leapmotion :

.factory('leapService', function($rootScope) {
        var leapController = new Leap.Controller({
            host: '127.0.0.1',
            port: 6437,
            enableGestures: false,
            frameEventName: 'animationFrame',
            useAllPlugins: true
        });

        leapController.connect();

        return leapController;
    }

Affichage du jeu

L’affichage du jeu (vaisseaux, etc…) va se faire via un Canvas.

var canvas = document.getElementById('astroids');
canvas.width = $("#astroids").css("width").substr(0, $("#astroids").css("width").length - 2);
canvas.height = canvas.width * (9 / 16);

La mise à jour du canvas se fait frame par frame : chaque frame est redessinnée grâce à cette fonction qui sera appelée quasiment en boucle :

var drawCanvas = function() {
    context.fillStyle = 'rgb(16, 16, 16)';
    context.strokeStyle = 'rgb(16, 16, 16)';
    context.fillRect(0, 0, canvas.width, canvas.height);
    drawOtherShips();
    drawBullets();
};

Le chargement d’une image se fait de façon très simple (je stock mes images dans le répertoire /pictures du serveur):

// [name] image file name
function loadImage(name) {
    // create new image object
    var image = new Image();
    // load image
    image.src = "/pictures/" + name;
    // return image object
    return image;
}

Modèle et Contrôles

La modélisation du vaisseau et la gestion des contrôles (parce qu’il faut aussi penser aux personnes n’ayant pas de LeapMotion, nous ajoutons le support du clavier) se fait très simplement (notez la séparation des données 'métier' du vaisseau de la partie contrôle du vaisseau grâce à la méthode angular.extend(...)):

angular.extend($scope, {
    player: {
        id: 0,
        acceleration: 0.3,
        speed: false,
        dx: 0.0,
        dy: 0.0,
        rotation: 0,
        direction: 0.0,
        x: canvas.width / 2,
        y: canvas.height / 2,
        user: "",
        bullets: [],
        isHit: false,
        areMotorOn: false
    },
    keys: {
        w: false,
        a: false,
        d: false,
        space: false
    }
});

Ces 2 objets ont été ajoutés au $scope AngularJS. Pourquoi ? tout simplement pour pouvoir détecter un changement du modèle et déclencher des actions : par exemple si le vaisseau se déplace, il faut envoyer au serveur sa nouvelle position (pour un jeux simpliste, c’est gérable. Pour un jeu multijoueurs plus avancé, il faut que le serveur gère les positions des joueurs et que le client se cantonne à l’envoi des ordres du Leapmotion ou du clavier). Nous allons envoyer le contenu du modèle Player au serveur via le service Angular shipDataService créé au début de l’article :

$scope.$watch('player', function(newValue, oldValue) {
    shipDataService.send(newValue);
}, true);

Nous allons aussi gérer les missiles tirés par le vaisseau lors de l’appui sur la barre Espace de façon identique :

$scope.$watch('keys', function(newValue, oldValue, scope) {
    if (newValue.space) {
        $scope.player.bullets.push({
            x: $scope.player.x,
            y: $scope.player.y,
            direction: $scope.player.rotation
        });
    }
}, true);

Lorsque la touche Espace est appuyée, on ajoute au modèle Player un objet Bullet avec ses coordonnées et sa direction. La modification de l’objet Player va déclencher l’envoi vers le serveur grâce au watch précédent. Finalement, le contrôle ne serait pas complet sans la capture des touches appuyées. Capturons les touches grâce à une directive Angular :

AstroidsModule.directive('ngKeycontrol', function() {
    return function(scope, element, attrs) {
        element.bind('keydown', function(event) {
            scope.$apply(function() {
                switch (event.which) {
                    case 87:
                        scope.keys.w = true;
                        scope.player.areMotorOn = true;
                        break;
                    case 65:
                        scope.keys.a = true;
                        break;
                    case 68:
                        scope.keys.d = true;
                        break;
                    case 32:
                        scope.keys.space = true;
                        event.preventDefault();
                        break;
                }
            });
        });
        element.bind('keyup', function(event) {
            scope.$apply(function() {
                switch (event.which) {
                    case 87:
                        scope.keys.w = false;
                        scope.player.areMotorOn = false;
                        break;
                    case 65:
                        scope.keys.a = false;
                        break;
                    case 68:
                        scope.keys.d = false;
                        break;
                    case 32:
                        scope.keys.space = false;
                        event.preventDefault();
                        break;
                }
            });
        });
    };
});

La boucle de jeu

Un jeu vidéo est quelque chose de très simple s’exécutant de façon extrêmement rapide : celle-ci doit être exécuté au moins 15 fois par secondes, 30 fois par seconde est plus agréable et enfin au-dessus de 60 images par secondes, l’affichage se fait plus rapidement que la vitesse de rafraîchissement de nos écrans (60Hz).

var gameLoop = function() {
    debuggingDisplay();
    checkConnected();
    shipControl();
    bulletControl();
    drawCanvas();
    $scope.model.content = utils.getFPS() + " fps";
};

La fonction checkConnected() sert à vérifier que la connexion au serveur (websockets) est toujours active; On applique ensuite le mouvement du vaisseau à partir des mouvements détectés par le LeapMotion ou à partir des touches du clavier. On calcule aussi le mouvement des missiles qui ont été tirés et qui vivent désormais leur vie, indépendamment du vaisseau. Finalement, on affiche la nouvelle version du canvas après avoir déterminé tous les mouvements.

Et le serveur ?

Pour l’instant, je ne vous ai parlé que du code côté client. Or toutes les modifications (mouvement détecté, touche appuyée, un missil’ qui se déplace, etc…) déclenchent des mouvements et donc toutes les nouvelles positions doivent être diffusées aux autres clients. La contrainte de quasi-synchronisme entre tous les clients est une contrainte très forte pour le serveur.

Les Websockets côté serveur

Grâce à Atmosphere, créer un websocket côté serveur est très simple : il suffit de l’annoter avec @ManagedService et de lui spécifier en attribut le chemin sur lequel il doit écouter :

@ManagedService(path = "/websocket/receiveShipData")
public class ReceiveShipService {

    private GameService gameService;

    @Message(decoders = {ShipEncoderDecoder.class})
    public void onMessage(AtmosphereResource atmosphereResource, Ship ship) throws IOException {
        this.getGameService().addShipMessage(ship);
    }

    private GameService getGameService() {
        if (gameService == null) {
            this.gameService = ApplicationContextProvider.getApplicationContext().getBean(GameService.class);
        }
        return gameService;
    }
}

Ce service reçoit des Ships (on a écrit une classe ShipEncoderDecoder servant à encoder / décoder le JSON) et le serveur appelle le service Spring GameService qui gèrera l’arrivée de ce nouveau message. La méthode getGameService() est une astuce pour injecter un service Spring dans un objet non Spring. En effet le défaut des ManagedServices d’Atmosphere est de ne pas être intégré au cycle de vie des objets Spring : c’est Atmosphere qui les gère. L’injection d’un service Spring dans un service Atmosphere doit donc se faire à la main. La gestion des connexions / déconnexions au serveur se fait par un autre ManagedService (notre 2ième websockets utilisé par le client AngularJS) qui va utiliser l’annotation Atmosphere @Disconnect pour détecter les déconnexions des clients.

La boucle de jeu côté serveur

Le même principe de boucle de jeu du client se retrouve sur le serveur. On va donc retrouver une grosse boucle infinie pour gérer cette boucle de jeu :

// Main loop
while (true) {
   ….
}

Et oui, ça fait hurler Sonar, mais c’est diablement efficace pour un serveur simple. Ensuite le principe de Delta Time va devoir être utilisé. Si vous ne connaissez pas ce concept très spécifique aux jeux vidéos, je vous conseille de lire le lien ci-dessus.

// Main loop
while (true) {
    long elapsedTime = System.nanoTime() - lastStartTime;
    lastStartTime = System.nanoTime();

    // Tick
    tick(elapsedTime);

    // Have a break, have a Kitkat
    long processingTimeForCurrentFrame = System.nanoTime() - lastStartTime;
    if (processingTimeForCurrentFrame < maxWorkingTimePerFrame) {
        try {
            Thread.sleep(maxWorkingTimePerFrame - processingTimeForCurrentFrame);
        } catch(Exception e) {
            logger.error("Error while sleeping in the main game loop " + e);
        }
    }
}
  • 1ière étape : calculer le temps passé depuis le dernier tick.
  • 2ième étape : appeler la méthode principale du serveur (tick) en lui passant en paramètre le temps écoulé précédemment calculé.
  • 3ième étape : le serveur se repose jusqu’au prochain tick.

De cette façon, tant que la méthode tick() ne dure pas plus de temps que le temps possible pour arriver à un rythme de XXX itérations par secondes, le serveur va traiter les données à un rythme constant (XXX ticks par secondes). Peu importe la vitesse des clients, le serveur met tout le monde d’accord. La méthode tick() va être en charge de détecter les collisions entre entités. Il faut donc que le serveur conserve de son côté l’état du jeu en mémoire et qu’il le fasse évoluer. C’est important lorsque l’on veut éviter toute triche de la part des clients : Never trust clientside. La détection de collision se fait sans trop de problèmes car j’ai modélisé mes vaisseaux par des triangles. On trouve facilement des algorithmes pour détecter de façon optimal si 2 triangles se chevauchent dans un espace à 2 dimensions.

Next Steps

Quelques idées d'améliorations pour enrichir le jeu :

  • Comment repésenter une collision entre 2 vaisseaux ? Il y a surement un message à envoyer aux clients via un websocket pour déclencher une animation d’explosion de vaisseaux.
  • Même chose lorsque un missile entre en collision avec un vaisseau !
  • Ajouter des rochers : dans le jeu originel, chaque rocher touché par un missile explose en 2 morceaux plus petits.
  • Il faudrait gérer un score entre les joueurs.
  • En complément du score et pour ajouter un peu de challenge, il pourrait être sympa de faire grossir un vaisseau après que celui-ci ait détruit un vaisseau adverse.

Sources Les sources sont disponibles sur Github : https://github.com/jpbriend/JavascriptGame