Jouer avec canvas - épisode 2

L'an dernier, j'avais rédigé un billet Quick & Dirty sur l'animation d'une image/sprite dans l'élement canvas. Il s'agissait d'un petit avion qui se déplaçait dans un bête rectangle. La programmation n'était pas vraiment structurée afin de suivre la logique de mise en oeuvre rapide et de montrer des principes simples d'animations.

Pour ce deuxième billet, le principe sera la même : nous allons reprendre le code précédent et le modifier afin d'y inclure l'animation d'un fond sans trop se préoccuper de l'évolution du code et de sa structure optimale. Nous resterons en Quick & Dirty.

Premiers travaux après un an

Le code repris, il est nécessaire de faire un peu de ménage :

  • certaines méthodes sont en franglais (méthode La Rache)
  • certaines méthodes sont en CamelCase d'autres avec des underscores (selon l'humeur ?)
  • certains traitements ne sont pas isolés et on retrouve trop de choses au sein d'une même fonction

Le but n'est pas de fournir une structure organisée (cela fera l'objet d'un billet spécial refactoring). Par contre, ça ne coûte pas grand chose de nettoyer un peu.

Première chose : nous allons faire en sorte que notre avion ne sorte plus du canvas.

La méthode qui s'occupait de calculer les positions de l'avion était la suivante

 var calculPositions = function(){
     debug_gap();

     /* 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);

     /* Opération bidon */
     /* for(var i=0; i<400000; i++) {} */

     /* Calcul du temps d'execution */
     exec_time = debug_gap();
}

Après nettoyage, nous changeons de nom et ajoutons le contrôle sur la position vis à vis du canvas. Rien de plus simple.

var computePlanePosition = function(){
    /* Calcul des nouvelles coordonées */
    if (goUp && posY > - plane.height / 2) {
        posY -= 3;
    }

    if (goDown && posY < canvasHeight - plane.height / 2) {
        posY += 3;
    }

    if (goLeft && posX > - plane.width / 2) {
        posX -= 3;
    }

    if (goRight && posX < canvasWidth - plane.width / 2) {
        posX += 3;
    }

    /* Turbulences */
    rX = Math.round((Math.random()*rI)-rI/2.0);
    rY = Math.round((Math.random()*rI)-rI/2.0);
}

Nous en profitons pour retirer tout ce qui était lié au calcul des accélérations pour le mettre dans une méthode séparée

var computeAcceleration = function() {
    if (turbo) {
        if (rI < rIMax) {
            rI  = rI + rIAcc;
        }
    } else {
        if (rI > 0) {
            rI = rI - rIAcc;
        }
    }
}

Comme cela, si un autre élément dépend du mode turbo, il suffira d'inclure la valeur de rI calculée.

Deuxième étape : séparer le rendu de l'élement avion de la méthode de rendu générique

La méthode de rendu générique devient :

var renderFrame = function(){
    frame_count++;
    countGap();

    /* On vérouille la frame */
    animationActiveFrame = true;

    /* Dessin de la nouvelle Frame */
    context.clearRect(0, 0, canvasWidth, canvasHeight);
    context.fillStyle = "rgb(220, 220, 220)";
    context.fillRect(0, 0, canvasWidth, canvasHeight);

    /* Différents rendus */
    renderPlane();

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

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

avec la nouvelle méthode renderPlane

var renderPlane = function() {
    context.drawImage(plane,
        0, 0, plane.width, plane.height,
        posX+rX, posY+rY, plane.width, plane.height);
}

Troisième étape : préparer le fait que plusieurs ressources devront être chargées :

Notre avion ne sera pas à terme, le seul élement à charger dans notre canvas. Du coup, le pré-loading des images va légerement changer pour devenir ceci.

$("#precharge").show();
$("#button").hide();

var nb_of_ressources = 0;
var nb_of_ressources_loaded = 0;

nb_of_ressources++;
var plane = new Image();
plane.src = "plane_200_95_1.png";
$(plane).load(function(){
    nb_of_ressources_loaded++;
    updateRessourcesLoading();
});

var updateRessourcesLoading = function(){
    if (nb_of_ressources_loaded == nb_of_ressources) {
        $("#precharge").hide();
        $("#button").show();
    } else {
        $("#precharge").show();
        $("#button").hide();
    }
}

Tout ceci pourra aussi être structuré de manière plus propre, personne n'en doute. Si nous rajoutons des ressources, il faudra rajouter un block concernant la nouvelle ressource et incrémenter nb_of_ressources.

En gros, on a la structure suivante :

  • des variable pour les états des différents élements (position, état de l'accélération, etc ...)
  • une partie dédiée au préchargement des ressources utilisées par le canvas
  • Une méthode de calcul des accélérations
  • Une méthode de calcul des positions
  • Une méthode de rendu de l'avion
  • Une méthode de rendu général
  • Une boucle principale d'animation

L'application va lancer une boucle infinie qui calcule les accélérations nécessaires pour l'ensemble des élements, les positions des élements, puis créer le rendu pour l'affichage. La contrainte est toujours que l'ensemble des deux opérations se fassent en moins de 25 millisecondes, le rendu étant "facultatif". Les positions seront systématiquement calculées, ce qui ne sera pas le cas des images en cas de latence. Ce choix implique qu'en cas de lag, l'image "saute" des frames.

Le fond défile

Dans notre exemple, nous voulons faire en sorte que notre fond défile de droite à gauche pour simuler le déplacement de notre petit avion.

Le canvas et son background

Le but sera de déplacer le fond sur la gauche et de gérer la façon d'enchainer la suite du décor à partir du même fond. Il est donc nécessaire de faire en sorte que la gauche de notre image et sa droite sont raccords.

notre fond raccord des deux côtés

Notre fond sera plus grand que le canvas qui fait office de fenêtre sur le décor utilisé. Ici, comme pour l'avion, nous allons charger les ressources avant toute opération sur le canvas.

nb_of_ressources++;
var background1 = new Image();
background1.src = "background1.png";
$(background1).load(function(){
    nb_of_ressources_loaded++;
    updateRessourcesLoading();
});

Ensuite, nous définissons une variable pour la position de notre fond sur l'axe X

var background1_posX = 0;

Puis, nous pouvons mettre en place le calcul de la position du background

var computeBackgroundsPositions = function() {
    background1_posX -= 4;
}

Enfin, nous nous occupons de son rendu

var renderBackgrounds = function() {
    // trame principal
    context.drawImage(background1,
        0, background1.height-canvasHeight, background1.width, background1.height,
        background1_posX, 0, background1.width, background1.height);
}

Bien sûr, on ajoute tout ceci à la méthode de rendu principale, en lançant renderBackgrounds avant renderPlane.

var renderFrame = function(){
    frame_count++;
    countGap();

    /* On vérouille la frame */
    animationActiveFrame = true;

    /* Dessin de la nouvelle Frame */
    context.clearRect(0, 0, canvasWidth, canvasHeight);
    context.fillStyle = "rgb(220, 220, 220)";
    context.fillRect(0, 0, canvasWidth, canvasHeight);

    /* Différents rendus */
    renderBackgrounds();
    renderPlane();

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

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

Au lancement de l'animation, pas vraiment de surprises : le décor défile lentement et disparait sur notre gauche pour ne plus revenir.

Le fond qui s'arrête

Pour corriger cette disparition, nous allons déjà faire en sorte que dès que le fond entier est passé, il revienne à sa position de départ.

var computeBackgroundsPositions = function() {
    background1_posX -= Math.round(4 + rI);
    background1_posX = background1_posX % background1.width;
}

Grâce à ça, nous obtenons ce que j'appellerais, le "reste à afficher". S'il n'y a plus rien à afficher, c'est qu'il faut tout réafficher car nous avons fait une boucle.

Voilà le résultat :

Le fond qui saute

Le soucis est que nous avons toujours un blanc à combler lorsque le "reste à afficher" est plus petit que la longueur du canvas. Il faut donc modifier la fonction de rendu afin d'afficher le complément provenant de la même image mais décalé après le "reste à afficher".

La boucle du fond

Nous pouvons réaliser ceci avec la modification suivante:

var renderBackgrounds = function() {
    // trame principal
    context.drawImage(background1,
        0, background1.height-canvasHeight, background1.width, background1.height,
        background1_posX, 0, background1.width, background1.height);

    // position de la copie décalée
    var size_of_visible = background1_posX + background1.width;
    if (size_of_visible <= canvasWidth) {
        context.drawImage(background1,
            0, background1.height-canvasHeight, background1.width, background2.height,
            size_of_visible, 0, background1.width, background1.height);
    }
}

C'est gagné ?! Oui ... mais non ... uniquement sur Firefox !! Mais sous des navigateurs utilisant WebKit, le fond ne s'affiche pas.

Retour sur une façon dégueulasse de faire

La façon de procéder n'est pas propre car au final, nous gèrons une image bien plus grande que le canvas ne peut supporter. Firefox étant bien foutu, il s'accomode de cet situation en n'affichant que ce qui est visible dans canvas. Mais dans Webkit, c'est comme si nous cherchons à obtenir des dépassements de tableaux. Le canvas ne doit gérer que ce qui fait parti de son cadre d'affichage. Au delà, c'est inutile.

Pour corriger cela, nous allons "cropper" chaque image afin de ne garder que ce qui est destiné à l'affichage. Expérimentons un peu :

var renderBackgrounds = function()
    // Calcul du reste à afficher
    var background1_shownX = background1.width + background1_posX;
    if (background1_shownX > canvasWidth) {
        background1_shownX = canvasWidth;
    }

    // Affichage trame principal
    context.drawImage(background1,
        -background1_posX, background1.height-canvasHeight, background1_shownX, canvasHeight,
        0, 0, background1_shownX, canvasHeight);
}

Cela résoud le problème. Bien entendu, cette variable background1_shownX ne sert à rien car nous avions déjà défini la même pour le calcul de la copie décalée, nommée size_of_visible

Nous allons donc utiliser la même technique de "cropping" pour la copie décalée

var renderBackgrounds = function() {
    // Calcul de l'affichable
    ...

    // Affichage trame principal
    ...

    // position de la copie décalée
    var size_of_visible = background1_posX + background1.width;
    if (size_of_visible < canvasWidth) {
        context.drawImage(background1,
            0, background1.height-canvasHeight, canvasWidth-size_of_visible, canvasHeight,
            size_of_visible, 0, canvasWidth-size_of_visible, canvasHeight);
    }
}

Ce qui peut donner après factorisation de l'ensemble :

var renderBackgrounds = function() {
    // Calcul de l'affichable pour le fond 1
    var size_of_visible = background1.width + background1_posX;
    if (size_of_visible > canvasWidth) {
        size_of_visible = canvasWidth;
    }

    // Affichage trame principal
    context.drawImage(background1,
        -background1_posX, background1.height-canvasHeight, size_of_visible, canvasHeight,
        0, 0, size_of_visible, canvasHeight);

    // position de la copie décalée
    if (size_of_visible < canvasWidth) {
        context.drawImage(background1,
            0, background1.height-canvasHeight, canvasWidth-size_of_visible, canvasHeight,
            size_of_visible, 0, canvasWidth-size_of_visible, canvasHeight);
    }
}

Nous relançons la démonstration disponible ici

Et là, c'est bon ! Cela fonctionne aussi bien sous Firefox que sous Chrome et compagnie.

Améliorer l'effet avec un double fond

Comme vous l'avez constaté en lançant la démonstration, nous avons un double décor. Dans de nombreux jeux, plusieurs fonds sont utilisés pour donner un effet de profondeur. Celui en arrière plan défile plus vite que ceux qui sont placés plus en avant plan de la scène afin de rendre cet effet.

Pour cela, rien de plus simple : il suffit d'ajouter un deuxième fond comme on peut le voir sur l'image suivante.

notre deuxième fond sur le premier

Une fois l'image définie, il ne restera qu'à :

  • ajouter une variable background2_posX pour la position du fond avant
  • pré-charger le fond via l'objet background2
  • ajouter le calcul de cette position dans computeBackgroundsPositions (le deuxième fond allant plus vite)
  • ajouter le rendu de ce fond avant dans renderBackgrounds

Se servir du turbo pour accélérer le défilement

Vous souvenez vous de la variable rI, qui permet d'ajouter des unités de déplacement en prenant en compte une accélération sommaire ? Nous pouvons l'utiliser pour accélérer nos fonds lors d'une pression sur la touche TURBO. Il suffit de l'ajouter dans le traitement des positions

var computeBackgroundsPositions = function() {
    background1_posX -= Math.round(4 + rI);
    background1_posX = background1_posX % background1.width;

    background2_posX -= Math.round(6 + rI);
    background2_posX = background2_posX % background2.width;
}

La suite ?

Nous voyons bien qu'en ajoutant des ressources et des fonds animés, nous pourrons, lors du refactoring, prévoir pas mal de petites choses :

  • créer un manager de ressources
  • un autre pour le calcul des positions
  • un autre pour les animations

Ce sera l'objet d'un prochain billet attaquant le refactoring de l'existant.

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)

Commentaires