Jeux HTML5: construction des objets principaux & gestion des collisions avec EaselJS

Nous avons vu dans l’article précédent comment animer des sprites avec EaselJS : Jeux HTML5: animation de sprites dans l’élément Canvas grâce à EaselJS

Nous allons maintenant voir comment créer certains des objets de base de notre jeu comme les ennemies ou le héro de notre jeu de plateforme. Nous verrons aussi comment implémenter un mécanisme basique de collision entre les 2. Cette fois-ci ce tutoriel sera principalement basé sur l’exemple suivant: EaselJS Game sample

Vous trouverez également un exemple fonctionnel à la fin de cet article. C’est la base d’un petit jeu.

PlatformerTutorial2 

Cette article est le 2ème d’une série de 3 articles :

- Jeux HTML5: animation de sprites dans l’élément Canvas grâce à EaselJS
- Jeux HTML5: construction des objets principaux & gestion des collisions avec EaselJS
- HTML5 Platformer: portage complet du jeu XNA vers <canvas> grâce à EaselJS

Construction de l’objet Monster

Cet objet dispose de 2 états :

1 – En train de courir sur la longueur de l’écran
2 – Etre en état de repos une fois l’un des bords de l’écran atteint avant de courir à nouveau.

Oui, je sais, la vie d’un monstre n’est pas palpitante. Mais c’est comme ça ! Attention, aussi stupide soit-il, si vous le touchez vous mourrez.

Cette fois-ci, j’ai fusionné en un unique fichier PNG les sprites venant de l’exemple XNA Platformer définissant les séquences où le personnage court et où il reste immobile. Voici par exemple le PNG associé au monstre MonsterC :

Notre objet monstre est défini au sein du fichier Monster.js et utilise l’objet BitmapAnimation comme prototype qui se prête en effet très bien à ce genre de scénarios. Il contient tout ce dont nous avons besoin: une méthode tick() , un mécanisme de “hit testing” pour vérifier les collisions et une manière de gérer nos sprites sous la forme de plusieurs animations séparées.

Il nous faut juste y ajouter de la logique spécifique à notre monstre comme la gestion d’un timer gérant l’état “au repos” et cela devrait faire l’affaire. Voici ainsi le code du fichier Monster.js définissant donc nos ennemies dans le jeu :

 (function (window) {
    function Monster(monsterName, imgMonster, x_end) {
        this.initialize(monsterName, imgMonster, x_end);
    }
    Monster.prototype = new BitmapAnimation();

    // propriétés publiques 
    Monster.prototype.IDLEWAITTIME = 40;
    Monster.prototype.bounds = 0; 
    //taille du cercle visible 
    Monster.prototype.hit = 0;

    // constructeur: 
    // unique pour éviter d'écraser celui de la classe de base 
    Monster.prototype.BitmapAnimation_initialize = Monster.prototype.initialize;

    // variables membres pour gérer l’état au repos 
    // et le temps à attendre avant de courir à nouveau 
    this.isInIdleMode = false;
    this.idleWaitTicker = 0;

    var quaterFrameSize;

    Monster.prototype.initialize = function (monsterName, imgMonster, x_end) {
        var localSpriteSheet = new SpriteSheet({
            images: [imgMonster], //image à utiliser 
            frames: {width: 64, height: 64, regX: 32, regY: 32},
            animations: {
                walk: [0, 9, "walk", 4],
                idle: [10, 20, "idle", 4]
            }
        });

        SpriteSheetUtils.addFlippedFrames(localSpriteSheet, true, false, false);

        this.BitmapAnimation_initialize(localSpriteSheet);
        this.x_end = x_end;

        quaterFrameSize = this.spriteSheet.getFrame(0).rect.width / 4;

        // on commence à jouer la 1ère séquence 
        this.gotoAndPlay("walk_h");

        // mise en place d’une ombre portée. Attention, gros impact sur les performances 
        // en fonction des navigateurs/plateformes matérielles. 
        this.shadow = new Shadow("#000", 3, 2, 2);

        this.name = monsterName;
        // 1 = droite & -1 = gauche 
        this.direction = 1;
        // vitesse 
        this.vX = 1;
        this.vY = 0;
        // on saute directement à la 1ère frame de la séquence walk_h 
        this.currentFrame = 21;
    }

    Monster.prototype.tick = function () {
        if (!this.isInIdleMode) {
            // On bouge le sprite en fonction de la direction et de la vitesse 
            this.x += this.vX * this.direction;
            this.y += this.vY * this.direction;

            // On teste les bords de l’écran sinon notre sprite partirait vivre ailleurs (dans le cloud?) 
            if (this.x >= this.x_end - (quaterFrameSize + 1) || this.x < (quaterFrameSize + 1)) {
                this.gotoAndPlay("idle");
                this.idleWaitTicker = this.IDLEWAITTIME;
                this.isInIdleMode = true;
            }
        }
        else {
            this.idleWaitTicker--;

            if (this.idleWaitTicker == 0) {
                this.isInIdleMode = false;

                if (this.x >= this.x_end - (quaterFrameSize + 1)) {
                    // Nous avons atteint le côté droit de l'écran 
                    // Nous devons maintenant marcher vers la gauche 
                    this.direction = -1;
                    this.gotoAndPlay("walk");
                }

                if (this.x < (quaterFrameSize + 1)) {
                    // Nous avons atteint le côté gauche de l'écran 
                    // Nous devons maintenant marcher vers la droite 
                    this.direction = 1;
                    this.gotoAndPlay("walk_h");
                }
            }
        }
    }

    Monster.prototype.hitPoint = function (tX, tY) {
        return this.hitRadius(tX, tY, 0);
    }

    Monster.prototype.hitRadius = function (tX, tY, tHit) {
        if (tX - tHit > this.x + this.hit) { return; }
        if (tX + tHit < this.x - this.hit) { return; }
        if (tY - tHit > this.y + this.hit) { return; }
        if (tY + tHit < this.y - this.hit) { return; }

        //test basé sur une distance à base de cercle 
        return this.hit + tHit > Math.sqrt(Math.pow(Math.abs(this.x - tX), 2) + Math.pow(Math.abs(this.y - tY), 2));
    }

    window.Monster = Monster;
} (window));

La collision est gérée par les méthodes hitPoint() et hitRadius() . Le “hit testing” est fait via des cercles ce qui est moins précis qu’un modèle de boites.

Construction de l’objet Player

En tant que digne représentant du joueur humain qui l’anime, la logique associée au personnage principal du jeu est un peu différente des monstres et bien entendu plus évoluée (quoique…) !

Par exemple, les positions x & y sont normalement contrôlées par l’utilisateur souhaitant bouger son personnage avec son clavier. Par ailleurs, notre héro dispose de plus d’animations que nos monstres puisqu’il peut mourir, sauter, bouger, célébrer sa victoire et être immobile.

Voici ainsi le PNG qui lui est dédié :

Bon, dans ce tutoriel, on va rester simple. Nous allons uniquement gérer les séquence où il court, il meurt et où il reste immobile. Cependant, chargeons malgré tout toutes les animations pour un potentiel usage futur. Voici le code du fichier Player.js. La lecture du code et de ses commentaires devraient vous fournir suffisamment de détails pour en comprendre son fonctionnement :

 (function (window) {
    function Player(imgPlayer, x_start, x_end) {
        this.initialize(imgPlayer, x_start, x_end);
    }
    Player.prototype = new BitmapAnimation();

    // propriétés publiques 
    Player.prototype.bounds = 0;
    Player.prototype.hit = 0;
    Player.prototype.alive = true;

    // constructeur: 
    // unique pour éviter d'écraser celui de la classe de base 
    Player.prototype.BitmapAnimation_initialize = Player.prototype.initialize;
 
    //unique to avoid overiding base class 
    var quaterFrameSize;
   
    Player.prototype.initialize = function (imgPlayer, x_end) {
        var localSpriteSheet = new SpriteSheet({
            images: [imgPlayer], // image à utiliser 
            frames: { width:64, height:64, regX:32, regY: 32 },
            animations: {
                walk: [0, 9, "walk", 4],
                die: [10, 21, false, 4],
                jump: [22, 32],
                celebrate: [33, 43],
                idle: [44, 44]
            }
        });

        SpriteSheetUtils.addFlippedFrames(localSpriteSheet, true, false, false);

        this.BitmapAnimation_initialize(localSpriteSheet);
        this.x_end = x_end;

        quaterFrameSize = this.spriteSheet.getFrame(0).rect.width / 4;

        // La 1ère séquence jouée est celle "au repos" 
        this.gotoAndPlay("idle");     
        this.isInIdleMode = true;

        // mise en place des ombres 
        this.shadow = new Shadow("#000", 3, 2, 2);
        this.name = "Hero";
        // 1 = droite & -1 = gauche 
        this.direction = 1;

        // vitesse 
        this.vX = 4;
        this.vY = 0;
        // on commence directement à la 1ère frame de la séquence walk_h (droite) 
        this.currentFrame = 66;

        // Taille des bords du cercle pour les tests de collisions 
        this.bounds = 28;
        this.hit = this.bounds;
    }

    Player.prototype.tick = function () {
        if (this.alive && !this.isInIdleMode) {
            // Test sur les bords de l’écran 
            // Le joueur est bloqué sur chacun des bords mais on souhaite qu'il continue 
            // de courir quand même 
            if ((this.x + this.direction > quaterFrameSize) && (this.x + (this.direction * 2) < this.x_end - quaterFrameSize + 1)) {
                // On bouge le sprite en fonction de sa direction et sa vitesse 
                this.x += this.vX * this.direction;
                this.y += this.vY * this.direction;
            }
        }
    }

    window.Player = Player;
} (window));

Le héro sera contrôlé à distance depuis la page principale.

Construction de l’objet Content Manager

En général, la première étape d’un jeu HTML5 est de tout simplement télécharger les ressources dont il a besoin avant de démarrer le jeu. Dans mon cas, vous trouverez un gestionnaire de téléchargements et de ressources très basique au sein du fichier ContentManager.js :

En voici le code :

 // Utilisé pour télécharger toutes les ressources utiles 
// hébergées sur le serveur web 
function ContentManager() {
    // fonction qui sera rappelée une fois que tous les éléments 
    // auront été téléchargés (fonction de callback) 
    var ondownloadcompleted;

    // Nombre d'éléments à télécharger 
    var NUM_ELEMENTS_TO_DOWNLOAD = 15;

    // Pour l'affectation de la méthode de callback 
    this.SetDownloadCompleted = function (callbackMethod) {
        ondownloadcompleted = callbackMethod;
    };

    // Nous avons 4 types d’ennemies, 1 héro & 1 type de bloc 
    this.imgMonsterA = new Image();
    this.imgMonsterB = new Image(); 
    this.imgMonsterC = new Image(); 
    this.imgMonsterD = new Image();
    this.imgTile = new Image();
    this.imgPlayer = new Image();

    // le fond du jeu peut être créé à partir de 3 claques différents 
    // ces 3 calques existent en 3 versions 
    this.imgBackgroundLayers = new Array();

    var numImagesLoaded = 0;

    // méthode publique pour lancer le téléchargement 
    this.StartDownload = function () {
        SetDownloadParameters(this.imgPlayer, "img/Player.png", handleImageLoad, handleImageError);
        SetDownloadParameters(this.imgMonsterA, "img/MonsterA.png", handleImageLoad, handleImageError);
        SetDownloadParameters(this.imgMonsterB, "img/MonsterB.png", handleImageLoad, handleImageError);
        SetDownloadParameters(this.imgMonsterC, "img/MonsterC.png", handleImageLoad, handleImageError);
        SetDownloadParameters(this.imgMonsterD, "img/MonsterD.png", handleImageLoad, handleImageError);
        SetDownloadParameters(this.imgTile, "img/Tiles/BlockA0.png", handleImageLoad, handleImageError);

        // téléchargement des 3 claques * 3 versions 
        for (var i = 0; i < 3; i++) {
            this.imgBackgroundLayers[i] = new Array();
            for (var j = 0; j < 3; j++) {
                this.imgBackgroundLayers[i][j] = new Image();
                SetDownloadParameters(this.imgBackgroundLayers[i][j], "img/Backgrounds/Layer" + i 
                                      + "_" + j + ".png", handleImageLoad, handleImageError);
            }
        }
    }

    function SetDownloadParameters(imgElement, url, loadedHandler, errorHandler) {
        imgElement.src = url;
        imgElement.onload = loadedHandler;
        imgElement.onerror = errorHandler;
    }

    // notre gestionnaire d'évènement en cas de succès 
    function handleImageLoad(e) {
        numImagesLoaded++

        // Si tous les éléments ont été téléchargés avec succès 
        if (numImagesLoaded == NUM_ELEMENTS_TO_DOWNLOAD) {
            numImagesLoaded = 0;
            // On rappelle la méthode de callback mise en place par SetDownloadCompleted 
            ondownloadcompleted();
        }
    }

    // appelée si une erreur survient pendant le téléchargement (une 404 par exemple) 
    function handleImageError(e) {
        console.log("Error Loading Image : " + e.target.src);
    }
}

Je sais. Il manque pas mal de choses pour en faire un vrai bon gestionnaire de contenu : une barre de progression sur l’état des téléchargements, un meilleur gestionnaire d’erreurs, l’utilisation éventuelle du localStorage, un code peut-être plus générique, etc. Mais bon, j’ai essayé d’en faire un le plus simple et facile à comprendre. Clignement d'œil

Rassemblons toutes les pièces dans la page principale

Maintenant que nous avons les blocs de base de notre jeu, on peut commencer à s’amuser à les assembler pour construire un jeu de plateforme extrêmement simple. Pour cela, je vous propose de revoir chacune des parties de la page principale hébergeant notre petit jeu.

Dans la méthode init() , nous instancions l’objet Stage d’EaselJS puis nous utilisons l’objet ContentManager vu juste précédemment pour télécharger l’ensemble de nos fichier PNG:

 function init() {
    // on récupère l’instance du canvas puis on charge les images 
    canvas = document.getElementById("testCanvas");

    // création de l’objet Stage que l’on fait pointer vers notre canvas 
    stage = new Stage(canvas);

    // on récupère la largeur et la hauteur du canvas pour de futurs calculs savants 
    screen_width = canvas.width;
    screen_height = canvas.height;

    contentManager = new ContentManager();
    contentManager.SetDownloadCompleted(startGame);
    contentManager.StartDownload();
}

Une fois le téléchargement fini, la fonction startGame() est appelée. La 1ère chose qu’elle fait est d’appeler la fonction CreateAndAddRandomBackground() . Cette fonction créée simplement un arrière-plan aléatoire en utilisant les possibilités offertes par les 3 claques. Puis, notre héro est créé et sa position verticale Y est déterminée de manière aléatoire. Juste en dessous du héro, on construit une plateforme très simple sur laquelle notre héro pourra faire son petit jogging. Pour finir, on construit les 4 objets de type Monster() à l’intérieur du tableau Monsters et on ajoute l’ensemble au jeu/au stage :

 function startGame() {
    // Nombre aléatoire pour positionner verticalement notre 
    // Héro & ses méchants ennemies sur 8 paliers possibles 
    var randomY;
    
    CreateAndAddRandomBackground();

    // Notre héro peut être déplacé avec les flèches (gauche, droite) 
    document.onkeydown = handleKeyDown;
    document.onkeyup = handleKeyUp;

    // On créé le héro 
    randomY = 32 + (Math.floor(Math.random() * 7) * 64);
    Hero = new Player(contentManager.imgPlayer, screen_width);
    Hero.x = 400;
    Hero.y = randomY;

    // Bloc sur lequel notre héro et éventuellement quelques ennemies pourront marcher 
    bmpSeqTile = new Bitmap(contentManager.imgTile);
    bmpSeqTile.regX = bmpSeqTile.frameWidth / 2 | 0;
    bmpSeqTile.regY = bmpSeqTile.frameHeight / 2 | 0;

    // On prends le même motif que l'on duplique sur toute la largeur de l'écran 
    for (var i = 0; i < 20; i++) {
        // On clone le motif original 
        var bmpSeqTileCloned = bmpSeqTile.clone();

        // On positionne ses propriétés d’affichage 
        bmpSeqTileCloned.x = 0 + (i * 40);
        bmpSeqTileCloned.y = randomY + 32;

        // puis on l'ajoute dans les objets à afficher 
        stage.addChild(bmpSeqTileCloned);
    }

    // Notre collection personnelle de monstres 
    Monsters = new Array();

    // Création du 1er type de monstres 
    randomY = 32 + (Math.floor(Math.random() * 7) * 64);
    Monsters[0] = new Monster("MonsterA", contentManager.imgMonsterA, screen_width);
    Monsters[0].x = 20;
    Monsters[0].y = randomY;

    // Création du 2nd type de monstres 
    randomY = 32 + (Math.floor(Math.random() * 7) * 64);
    Monsters[1] = new Monster("MonsterB", contentManager.imgMonsterB, screen_width);
    Monsters[1].x = 750;
    Monsters[1].y = randomY;

    // Création du 3eme type de monstres 
    randomY = 32 + (Math.floor(Math.random() * 7) * 64);
    Monsters[2] = new Monster("MonsterC", contentManager.imgMonsterC, screen_width);
    Monsters[2].x = 100;
    Monsters[2].y = randomY;

    // Alors d'après vous, on fait quoi là ? :) 
    randomY = 32 + (Math.floor(Math.random() * 7) * 64);
    Monsters[3] = new Monster("MonsterD", contentManager.imgMonsterD, screen_width);
    Monsters[3].x = 650;
    Monsters[3].y = randomY;

    // On ajoute tous les monstres à l'écran 
    for (var i=0; i<Monsters.length;i++){
        stage.addChild(Monsters[i]);
    }
    // Puis on ajoute la compagnie 
    stage.addChild(Hero);

    Ticker.addListener(window);
    // On vise le meilleur taux possible (60 FPS) 
    // Et on utiliser requestAnimationFrame si disponible 
    Ticker.useRAF = true;
    Ticker.setFPS(60);
}

A la fin de la page, vous trouverez 2 gestionnaires de clavier évident qui s’occupe de jouer les animations walk ou walk_h de notre héro en fonction des touches sur lesquelles vous presserez. Pour finir, la logique principale de notre jeu n’est finalement contenue que dans les quelques lignes de code présentes au sein de la méthode tick() :

 function tick() {
    // on parcourt notre collection de monstres 
    for (monster in Monsters) {
        var m = Monsters[monster];
        // On appelle explicitement la méthode tick 
        // de chacun des monstres pour appeler leur logique de mise à jour 
        m.tick();

        // Si notre héro est toujours vivant mais s'il est trop proche 
        // de l'un des monstres... 
        if (Hero.alive && m.hitRadius(Hero.x, Hero.y, Hero.hit)) {
            //...il doit mourir malheureusement! (la morale des jeux est ignoble) 
            Hero.alive = false;

            // On joue alors l'animation de mort en fonction de 
            // la direction dans laquelle le héro courait 
            if (Hero.direction == 1) {
                Hero.gotoAndPlay("die_h");
            }
            else {
                Hero.gotoAndPlay("die");
            }

            continue;
        }
    }

    // Mise à jour du héro 
    Hero.tick();

    // Et on met le tout à jour 
    stage.update();
}

Ainsi, on vérifie à chaque tick (donc potentiellement toutes les 17 ms) si l’un des monstres ne serait pas en train de toucher notre héro à partir des paramètres de collisions que nous avons vu plus tôt. Si l’un des monstres est trop proche, notre malheureux héro doit alors mourir.

Jouez avec l’exemple complet !

Si vous avez tout lu jusque ici, je pense que vous avez amplement mérité de pouvoir jouer avec l’exemple qui est juste en dessous. A chaque fois que vous presserez le bouton “Start”, un nouvel arrière-plan sera généré et chacun des personnages (ennemies comme héro) seront placés à des positions différentes de manière aléatoire. Vous pouvez également faire bouger le héro avec les flèches gauche et droite de votre clavier.

Au fait, ne vous inquiétez pas. Comme vous ne pouvez pas sauter, il y a actuellement aucun moyen de gagner dans ce jeu. C’est donc un jeu 100% looser (le premier du genre ? Clignement d'œil). Ce jeu est donc vivement déconseillé aux mauvais perdants.

Note : comme je vous l’ai indiqué plus haut, il n’y a pas de barre de progression sur l’état du téléchargement. Vous devrez donc attendre un “certain temps” avant de pouvoir jouer après avoir appuyé sur le bouton “Start”. C’est ce que l’on appelle communément une EUP (expérience utilisateur pourrie). Sourire Mais cela sera ensuite immédiat une fois le tout présent dans le cache du navigateur.

Vous pouvez également y jouer via ce lien : easelJSCoreObjectsAndCollision

Pour finir le jeu, il nous reste à gérer la séquence de saut du héro en utilisant un moteur physique assez simple et des collisions un peu plus poussées, à charger la musique et les effets sonores et finalement à charger les niveaux. Mais la base est bien là si vous souhaitez écrire votre propre petit jeu. Vous avez désormais toutes les cartes en main ! Sourire

Mais si vous souhaitez analyser le jeu entier avec l’ensemble du code source disponible, rendez-vous dans le prochain article : HTML5 Platformer: portage complet du jeu XNA vers <canvas> grâce à EaselJS

David

Note : ce tutoriel a été écrit à l’origine pour EaselJS 0.3.2 en Juillet 2010 et a été mis à jour pour EaselJS 0.4. Pour ceux d’entre vous qui aviez lu la version 0.3.2, voici les changements principaux de la v0.4 à connaitre ayant un impact sur cet article :

  1. BitmapSequence n’est désormais plus disponible et a été remplacé par BitmapAnimation
  2. Vous pouvez désormais simplement ralentir la boucle d’animation des sprites via la propriété frequency du SpriteSheet
  3. EaselJS 0.4 peut utiliser désormais requestAnimationFrame pour des animations potentiellement plus efficaces sur les navigateurs le supportant (comme IE10+, Firefox 4.0+ & Chrome via les préfixes appropriés)
  4. Vous devez appeler explicitement la méthode tick() de chacun des objets dans un gestionnaire d’évènements global plutôt que de laisser un Ticker global appeler automatiquement chacun des implémentations du tick.