Jouer avec canvas - épisode 1

C'est bientôt les fêtes de Noël et je vais donc profiter des quelques jours de vacances qui arrivent pour mettre en ligne quelques tutoriels sur l'utilisation de canvas avec le langage de script Javascript.

L'élement canvas

L'élément canvas est un composant faisant partie de la spécification HTML5 qui permet d'effectuer des rendus bitmap et de les manipuler via des scripts (la plupart en Javascript).

Il suffit tout simplement d'ajouter la balise suivante dans le body de notre document HTML.

<canvas id="zone" width="800" height="400">
    Texte à afficher si le navigateur ne prend pas en compte l'élement canvas
</canvas>

Création d'un petit jeu

La série des tutoriels à venir portera sur la création d'un petit jeu type Shoot'em up afin d'aborder plusieurs aspects des animations de la manipulation des images et des textes dans l'élement canvas via Javascript. Afin de nous aider dans la gestion des évenements, des potentielles futures requêtes AJAX et la manipulation du DOM, nous utiliserons la librairie jQuery mais nous pourrions très bien nous en passer ou utiliser une autre librairie.

Objectif du Tutoriel

Dans ce tutoriel, nous allons mettre en place un avion de chasse vu de coté qu'on pourra déplacer sur les deux axes via les flèches du clavier. A la fin, nous ajouterons un évenement clavier afin de faire trembler la carcasse de notre avion et ajouter un peu de fun dans notre animation.

Mise en place du canvas et récupération du contexte

Tout d'abord, on va créer un fichier html qui contiendra la balise canvas et le nécessaire pour utiliser nos scripts.

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Test Canvas Animation</title>
    <script type="text/javascript" src="jquery-1.7.1.min.js"></script>

    <script type="text/javascript">
        $(document).ready(function(){

             // Insérer votre code ici !

        });
    </script>
</head>

<body>
    <p id="precharge">Chargement de la démo ... veuillez patienter</p>
    <p id="button">
        <input type="button" id="start" value="Start" />
        <input type="button" id="stop" value="Stop" />
    </p>

    <p>
        <strong>UP DOWN LEFT RIGHT</strong> : se diriger<br/>
        Maintenir <strong>T</strong> : actionner le Turbo
    </p>

    <canvas id="zone" width="800" height="400">
        Votre Navigateur ne gère pas canvas .. dommage !!!
    </canvas>

</body></html>

Cette partie du code ne sera plus du tout modifiée. Elle contient un message à afficher lors du chargement de notre application, d'un bouton Start, d'un bouton Stop, de consignes pour jouer et notre balise canvas

Les variables

La seule chose que nous allons faire maintenant, est d'insérer du code Javascript là où il est indiqué Insérer votre code ici. Nous définissons des variables qui seront accessibles depuis toutes les méthodes de notre application.

/* On récupère du contexte */
var canvas = $("#zone"); /* objet jQuery associé au canvas */
var context = canvas.get(0).getContext("2d"); /* contexte du canvas */

/* On récupère les dimensions du canvas */
var canvasWidth = canvas.width();
var canvasHeight = canvas.height();

/* On définit les touches à utiliser dans notre jeu */
var arrowLeft = 37;     /* LEFT */
var arrowRight = 39;    /* RIGHT */
var arrowUp = 38;       /* UP */
var arrowDown = 40;     /* DOWN */
var turboKey = 84;      /* T */

/* Tag sur les déplacements */
var goUp = false;
var goDown = false;
var goRight = false;
var goLeft = false;
var turbo = false;

/* Position de notre avion */
var posX = 0;
var posY = 0;

On en profite pour ajouter une méthode permettant de rétablir la plupart de ces paramètres.

/**
 * Methode de réinitialisation des variables.
 */
var reset = function(){
    animationActiveFrame = false;

    goUp = false;
    goDown = false;
    goRight = false;
    goLeft = false;
    turbo = false;

    posX = 0;
    posY = 0;
}

Les commentaires parlent d'eux-mêmes :

  • nous récupèrons notre canvas, son contexte et quelques informations sur lui (sa taille).
  • nous définissons des KeyCode clavier ce qui nous permetra de pouvoir changer la configuration des touches de notre jeu. (Voir les KeyCode ici)
  • nous définissons des flags sur les mouvements que notre avion est susceptible de faire
  • nous enregistrons la position de l'avion durant toute la session de jeu

Les évenements clavier

Ensuite, on va définir des évenements sur chacune de nos touches aussi bien au moment de l'appui qu'au moment du relachement.

$(window).keydown(function(e){
    if (e.keyCode == arrowUp) { /* Mon avion doit monter */
        goUp = true;
        goDown = false;
    }

    if (e.keyCode == arrowDown) { /* Mon avion doit descendre */
        goUp = false;
        goDown = true;
    }

    if (e.keyCode == arrowLeft) { /* Mon avion va à gauche */
        goLeft = true;
        goRight = false;
    }

    if (e.keyCode == arrowRight) { /* Mon avion va à droite */
        goLeft = false;
        goRight = true;
    }

    if (e.keyCode == turboKey) { /* Mon avion active son turbo */
        turbo = true;
    }
});

$(window).keyup(function(e){
    if (e.keyCode == arrowUp) {
        goUp = false;
    }

    if (e.keyCode == arrowDown) {
        goDown = false;
    }

    if (e.keyCode == arrowLeft) {
        goLeft = false;
    }

    if (e.keyCode == arrowRight) {
        goRight = false;
    }

    if (e.keyCode == turboKey) {
        turbo = false;
    }
});

L'animation consistant en une boucle infinie, nous allons uniquement modifier des états avec nos évenements. C'est la boucle infine qui éxecutera l'action à faire en fonction des états définis.

Chargement des élements

Notre avion de 200x95 pixels

On définit ensuite notre avion qui est disponible sous la forme d'un PNG à fond transparent. Par contre, vu qu'il peut mettre un certain temps à être chargé par le navigateur, il faudra s'assurer qu'il soit bien complet avant de jouer avec notre image dans le canvas.

/* On affiche le message de chargement, on masque les boutons Start et Stop */
$("#precharge").show();
$("#button").hide();

/* On charge l'image de notre avion... */
var avion = new Image();
avion.src = "avion_200_95_1.png";
$(avion).load(function(){
    /* ... et fois l'image en mémoire, on remet en place le bouton Start
       et le bouton Stop et on masque le loading */
    $("#precharge").hide();
    $("#button").show();
});

La méthode de rendu

Pour afficher une nouvelle frame, il suffit de la redessiner complètement. On pourrait utiliser d'autres techniques comme effacer l'avion du canvas et le remplacer par le morceau de background qu'il a remplacé, et le redessiner ailleurs mais cette fois ci, on fera au plus simple.

var renderFrame = function(){
    context.clearRect(0, 0, canvasWidth, canvasHeight); /* On efface le canvas */
    context.fillStyle = "rgb(200, 200, 200)"; /* On définit la couleur de fond */
    context.fillRect(0, 0, canvasWidth, canvasHeight); /* On créé un rectangle plein de la taille du canevas*/
    context.drawImage(avion, 0, 0, 200, 95, posX, posY, 200, 95); /* On affiche l'avion à sa nouvelle position */
}

Controles de la boucle infinie

Bien entendu, il va falloir lancer cette méthode régulièrement. Nous allons donc utiliser la fonction setInterval qui permet de réaliser une action tous les X millisecondes sur une méthode qui lancera notre renderFrame

var animationFlag = null;
var delayBetweenFrames = 25 /* ms */

$("#start").click(function(){
    reset();
    if (animationFlag==null) {
        animationFlag = setInterval(function(){
            mainLoop();
        }, delayBetweenFrames);
    }
});

$("#stop").click(function(){
    reset();
    if (animationFlag!=null) {
        clearTimeout(animationFlag);
        animationFlag = null;
        context.clearRect(0, 0, canvasWidth, canvasHeight);
    }
});

/**
 * Main LOOP
 */
var mainLoop = function(){
    renderFrame();
}

Problèmes de frames

Si vous avez bien suivi le code précédent, la donnée importante, c'est le paramètre delayBetweenFrames (25ms) de la fonction setInterval. Cela veut dire que toutes les 25ms une nouvelle image sera calculée. Ce qui correspond à un 40 images / secondes (ce qui est largement suffisant voir même trop).

Un problème se pose : que faisons nous si le traitement dure plus de 25ms ? En effet, admettons que les traitement pour calculer la position soit trop long et qu'on demande à notre fonction de redessiner le canvas alors qu'elle n'avait pas fini la précédente. Ca risque de générer des soucis d'affichage.

Pour cela, on va ajouter un flag afin de savoir si la frame précédente est toujours en cours de rendu. Si un tel cas se produit, nous ne lancerons pas la frame suivante, mais nous la ferons au prochaine 25ms.

var animationActiveFrame = false; /* Flag pour savoir si une Frame est en cours de construction */

Nous ajoutons le changement d'état durant la réalisation d'une frame

var renderFrame = function(){
    /* On vérouille la frame */
    animationActiveFrame = true;

    /* Dessin de la nouvelle Frame */
    context.clearRect(0, 0, canvasWidth, canvasHeight);
    context.fillStyle = "rgb(200, 200, 200)";
    context.fillRect(0, 0, canvasWidth, canvasHeight);
    context.drawImage(avion, 0, 0, 200, 95, posX, posY, 200, 95);

    /* Dévérouillage de la frame */
    animationActiveFrame = false;
}

Enfin, nous modifions notre boucle infinie

/**
 * Main LOOP
 */
var mainLoop = function(){
    if (animationActiveFrame==false) {
        renderFrame();
    }
}

Calcul de la position de l'avion

Bien evidemment, l'animation peut se lancer mais notre avion ne bouge toujours pas. Il va falloir indiquer à notre boucle qu'à chaque itération, elle doit calculer la position de l'avion en fonction des mouvements détectés par les changements d'états. Quoiqu'il arrive, nous devons executer cette méthode toutes durant le delayBetweenFrames (25ms)

var calculPositions = function(){
    /* Calcul des nouvelles coordonées */
    if (goUp) {
        posY -= 3;
    }

    if (goDown) {
        posY += 3;
    }

    if (goLeft) {
        posX -= 3;
    }

    if (goRight) {
        posX += 3;
    }
}

et notre boucle devient :

var mainLoop = function(){
    calculPositions();
    if (animationActiveFrame==false) {
        renderFrame();
    }
}

Nous allons déplacer notre image de 3 pixels en fonction de la direction choisie toutes les 25ms. Cela va représenter 40 frames/sec x 3 pixels = 120 pixels par seconde de déplacement.

Premier test

Normalement, à ce stade, votre animation doit être fonctionnelle. Appuyez sur START et déplacez vous dans le canvas pour observer le résultat. Si vous n'avez rien foutu durant le tutoriel, allez voir ici.

Pour le fun : ajout du Turbo

Nous aimerions faire en sorte que notre avion tremble de manière progressive quand nous appuyons sur la touche T.

var rX = 0; /* Décalage sur X */
var rY = 0; /* Décalage sur Y */
var rI = 0.0; /* Pixel de décalage aléatoire */
var rIMax = 10.0; /* Pixel de décalage Max */
var rIAcc = 0.1; /* offset à chaque itération */

Nous allons calculer le décalage supplémentaire en X et Y à appliquer pour chaque frame

 var calculPositions = function(){
     /* Calcul des nouvelles coordonées */
     if (goUp) {
         posY -= 3;
     }

     if (goDown) {
         posY += 3;
     }

     if (goLeft) {
         posX -= 3;
     }

     if (goRight) {
         posX += 3;
     }

     /* Calcul des turbulences */
     if (turbo) {
         if (rI < rIMax) {
             rI  = rI + rIAcc;
         }
     } else {
         if (rI > 0) {
             rI = rI - rIAcc;
         }
     }
     rX = Math.round((Math.random()*rI)-rI/2.0);
     rY = Math.round((Math.random()*rI)-rI/2.0);
}

Dans renderFrame, nous remplaçons

context.drawImage(avion, 0, 0, 200, 95, posX, posY, 200, 95);

par

context.drawImage(avion, 0, 0, 200, 95, posX+rX, posY+rY, 200, 95);

Désormais un décalage aléatoire en X et Y a lieu et il est d'autant plus important que la touche T est pressée longtemps.

Une histoire de performance

Tout le problème de l'animation telle qu'on la réalise dans ce tutoriel, c'est qu'on part du principe qu'une image a toujours le temps de se construire dans les temps. Ce n'est pas aussi simple, et la rapidité d'execution va dépendre de la machine et du moteur javascript du navigateur. Dans notre cas, la seule chose importante est que le temps de calcul et de rendu soient inférieurs à delayBetweenFrames (25ms)

Ajout du monitoring

Afin de pouvoir tracer les temps d'execution, nous pouvons nous faire un log maison à la rache (de loin mes préférés !!).

Nous ajoutons une div dans notre fichier html

<div id="debug">
    <p>Fréquence : <span id="debug_frame"></span> frames/sec<br/>
    Temps execution : <span id="debug_execution"></span> ms</br/>
    Temps de rendu : <span id="debug_render"></span> ms<br/>
    Temps total par frame : <span id="debug_total"></span> ms<br/>
    Référence : <span id="debug_reference"></span> ms</p>
</div>

Nous ajoutons le code qui nous servira à l'analyse des temps

/**
 * Variables de Debuggage
 */
var exec_time = 0; /* Temps d'execution */
var render_time = 0; /* Temps de rendu */
var frame_count = 0; /* Compteur de frame */
var timeref = (new Date()).getTime();
var debugFlag = null; /* Nécessaire pour interrompre le setInterval */

/**
 *  Méthode pour déterminer le temps passer entre deux appels de la méthode
 */
var debug_gap = function(){
    var new_time = (new Date()).getTime();
    var diff = new_time - timeref;
    timeref = new_time;
    return diff;
}

/**
 * Affichage des informations de débugage
 */
function showDebug() {
    $("#debug_frame").get(0).innerHTML = frame_count;
    frame_count = 0; /* On réinitialise le compteur pour 1 seconde*/

    $("#debug_execution").get(0).innerHTML = exec_time;
    $("#debug_render").get(0).innerHTML = render_time;
    $("#debug_total").get(0).innerHTML = render_time + exec_time;
    $("#debug_reference").get(0).innerHTML = delayBetweenFrames;
}

Nous allons ajouter les méthodes dans les fonctions à mesurer

 var renderFrame = function(){
     frame_count++; /* On compte le nombre de frames durant 1 secondes */
     debug_gap();   /* Initialisation du temps de calcul */

     ....

     render_time = debug_gap(); /* Calcul du temps de rendu */
 }

 var calculPositions = function(){
     debug_gap(); /* Initialisation du temps de rendu */

     ....

     //for(var i=0; i<400000; i++) {} /* Facultatif : permet juste d'allonger artificiellement le tps d'execution */

     exec_time = debug_gap(); /* Calcul du temps de rendu */
}

Nous allons modifier les évenements START et STOP afin de gérer le debug, qui rafraichira l'affichage des informations toutes les secondes.

/**
 * Evenements sur bouton START ET STOP
 */
$("#start").click(function(){
    reset();
    if (animationFlag==null) { /* Création de la boucle infinie*/
        animationFlag = setInterval(function(){
            mainLoop();
        },delayBetweenFrames);
    }

    if (debugFlag==null) { /* Debug */
        setInterval(function(){
            showDebug();
        },1000);
    }
});

$("#stop").click(function(){
    if (animationFlag!=null) {
        /* Arrêt de la boucle infinie et effacement du canvas */
        clearTimeout(animationFlag);
        animationFlag=null;
        context.clearRect(0, 0, canvasWidth, canvasHeight);
    }

    if (debugFlag!=null) {
        /* Remise à zéro du mode debug */
        clearTimeout(debugFlag);
        debugFlag=null;
    }
    reset();
});

Interprétation des tests

Test réalisé sous Firefox 8.0 sous GNU/Linux (avec accélération graphique) Processeur : Core 2 Duo E8400 + 4 Go de RAM + Radeon HD4870

Fréquence : 40 frames/sec
Temps execution : 0 ms
Temps de rendu : 0 ms
Temps total par frame : 0 ms
Référence : 25 ms

On constate qu'on a bien les 40 frames par seconde et que les temps d'execution et de rendu sont inférieurs à la milliseconde. Notre animation se déroule dans le cadre idéal et on peut constater au lancement de l'animation que notre avion se déplace de manière très fluide.

Test réalisé sous un Android 2.1 - Sony Ericsson X10 mini pro

Fréquence : 8 frames/sec
Temps execution : 127 ms
Temps de rendu : 2 ms
Temps total par frame : 129 ms
Référence : 25 ms

Là par contre, c'est un peu plus problématique. Nous avons environ qu'une frame sur cinq de réalisée à cause d'un temps de calcul des positions trop long. Il faudra soit augmenter la référence (délai entre deux frames), soit penser le système de calcul et de rendu autrement. Ce qui est étonnant c'est que le rendu ne prend finalement pas beaucoup de temps par rapport à l'éxecution. A titre personnel, j'aurais cru le contraire.

Demo Live et code source

La démo qui utilise le code vu durant ce tutoriel est disponible ici.

Le code source de cette démo est ici. (dépôt Mercurial)

Prochains tutoriels à venir

  • Création d'animations du sprite en fonction des actions : montée, descente, turbo
  • Refactoring de la démo et modélisation de l'animation

Commentaires