Journées ISN 6 et 13 janvier 2015 : Canvas, JavaScript, animation et mathématiques simples

De $1

Version de 17:38, 24 Avr 2024

cette version.

Revenir à liste des archives.

Voir la version actuelle

Introduction

Cette journée a pour but de vous familiariser avec la programmation graphique en JavaScript. Lors de cette session nous utiliserons beaucoup l'élément <canvas> de HTML5 pour dessiner et une API (Application Programming Interface) standard elle aussi de HTML5 pour l'animation à 60 images/s (si possible), intitulée RequestAnimationFrame.

Ce sera l'occasion de rappeler les principales technologies du web, et voir comment à travers des exemples simples de programmation graphique, on peut mettre en application des outils mathématiques de niveau lycée.

JavaScript a l'avantage de fonctionner directement dans un navigateur web, les outils minimums nécessaires seront dont un navigateur web récent (> 2012). Optionnellement, un bon éditeur de code source comme Sublime Text pourra être un plus.

Nous travaillerons ensembles sur l'écriture d'un petit moteur d'animation reprenant les caractéristiques principales d'un moteur graphique pour écrire un jeu vidéo:

  • Boucle d'animation à 60 images / seconde,
  • Interaction avec l'utilisateur via clavier / souris,
  • Animation basée sur le temps (en effet, selon la machine cible, la complexité de la scène graphique, des calculs, on ne pourra pas toujours être exactement à 60 images/s, il faudra donc calculer les déplacements des objets graphiques à l'écran en fonction du temps réellement écoulé depuis la dernière image).
  • Détection de collisions,
  • Transformation d'un exemple à priori "simple et amusant" pour résoudre un vrai problème de recherche appliqué à la dépollution des océans !

Première partie : introduction des technologies web, panorama général

Seconde partie : écriture d'un moteur d'animation

Tout jeu, logiciel animé utilise ce qu'on appelle communément une "boucle d'animation" ou une "game loop" dans le jargon des créateurs de jeu vidéo.

Cette boucle est le composant principal. Elle sépare la logique du "jeu" et sa couche graphique des interactions de l'utilisateur via le clavier, souris ou manette.

Les applications classiques, comme un traitement de texte, une base de données, une calculatrice, ne font rien quand l'utilisateur ne fait rien. Ce sont des applications qui n'agissent que lorsque l'utilisateur interagit avec l'application, en tapant sur le clavier par exemple. Quand l'utilisateur ne fait rien, l'application en fait qu'attendre qu'il fasse quelque chose !

gameloop.png

Dans le cas d'un jeu c'est différent. Il attend toujours les interactions de l'utilisateur mais au lieu de ne rien faire d'autre que attendre, le jeu va déplacer, dessiner, animer des objets graphiques à une fréquence élevée, tester des collisions, jouer de la musique, etc. Pour des raisons historiques, on a fixé le seuil de fluidité à 60 images / secondes.

Il y a plusieurs manière de coder une "game loop" en JavaScript.

Utilisation de la fonction setInterval(...)

La fonction setInterval permet d'appeler une autre fonction à intervalle régulier (en ms). 

Voici un exemple en ligne: http://jsbin.com/yuhule/3/edit

setInterval example.jpg

Code source :

var addStarToTheBody = function(){
    document.body.innerHTML += "*";
};
 
//this will add one star to the document each 200ms (1/5s)
setInterval(addStarToTheBody, 200);

Beaucoup mieux: utilisation de RequestAnimationFrame

 HTML5 a introduit à la demande des développeurs de jeux et d'applications musicales une API permettant de faire une "game loop" à 60 images par seconde, avec de nombreuses optimisations :

  • La boucle sera exécutée à une fréquence la plus près possible de 60 images/s,
  • On pourra connaitre très précisément le temps écoulé entre deux frames d'animation,
  • Si la zone à animer n'est pas visible (onglet caché, navigateur iconifié, page scrolée), seuls les ordres non graphiques seront exécutés, cela permet d'économiser énormément de CPU, de faire des jeux sur mobiles qui ne videront pas la batterie,etc.

Exemple typique : http://jsbin.com/gixepe/2/edit

Code source :

window.onload = function init() {
   // called after the page is entirely loaded
   requestAnimationFrame(mainloop);
};  
 
function mainloop(time) {
   document.body.innerHTML += "*";
 
   // call back itself every 60th of second
   requestAnimationFrame(mainloop);
}

Notez que la dernière ligne de la boucle d'animation est simplement une demande au navigateur de re-exécuter cette même boucle d'animation (requestAnimationFrame = "je demande une nouvelle frame d'animation). Le paramètre est la fonction qui implémente la boucle d'animation. C'est une "sorte" d'appel récursif si ce n'est que cet appel est "asyncrhone" : en attendant le rappel, le navigateur n'est pas bloqué, d'autres insrtructions peuvent s'exécuter etc.

Notez qu'on peut avoir plusieurs game loops simultanément dans un même programme. Elles sont toutes "en tâche de fond", non blocantes et entre chaque exécution le programme peut réagir aux interactions clavier, souris, etc.

Implémentation d'un petit "moteur de jeu" 

Voici le petit squelette d'un jeu:

var GF = function(){
 
    var mainLoop = function(time){
        //main function, called each frame
        requestAnimationFrame(mainLoop);
    };
 
    var start = function(){
        requestAnimationFrame(mainLoop);
    };
 
    //our GameFramework returns a public API visible from outside its scope
    return {
        start: start
    };
};

Et voici comment avec ce squelette on créera un jeu :

 

var game = new GF();
 
// Launch the game, start the animation loop etc.
game.start();

Rajoutons donc quelque chose dans la game loop :

 

var mainLoop = function(time){
   //main function, called each frame
   document.body.innerHTML = Math.random();
 
   // call the animation loop every 1/60th of second
   requestAnimationFrame(mainLoop);
};

Et voici l'exemple complet testable :  http://jsbin.com/kafehi/3/edit

Compter le nombre d'images par seconde

Voici une fonction "measureFPS" qui permet de compter le nombre d'images par seconde :

 

// vars for counting frames/s, used by the measureFPS function
    var frameCount = 0;
    var lastTime;
    var fpsContainer;
    var fps; 
 
    var measureFPS = function(newTime){
 
         // test for the very first invocation
         if(lastTime === undefined) {
           lastTime = newTime; 
           return;
         }
 
        //calculate the difference between last & current frame
        var diffTime = newTime - lastTime; 
 
        if (diffTime >= 1000) {
            fps = frameCount;    
            frameCount = 0;
            lastTime = newTime;
        }
 
        //and display it in an element we appended to the 
        // document in the start() function
       fpsContainer.innerHTML = 'FPS: ' + fps; 
       frameCount++;
    };

On appellera cette fonction depuis la boucle principale :

 

var mainLoop = function(time){
        //main function, called each frame 
        measureFPS(time);
 
        // call the animation loop every 1/60th of second
        requestAnimationFrame(mainLoop);
    };

Dans la fonction start() du moteur de jeu, nous créeons un simple élément <div> html pour afficher le nombre d'images par seconde :

var start = function(){
        // adds a div for displaying the fps value
        fpsContainer = document.createElement('div');
        document.body.appendChild(fpsContainer);
 
        requestAnimationFrame(mainLoop);
    };

Exemple testable en ligne :  http://jsbin.com/kafehi/5/edit

Et oh ???? Où sont les graphismes ???? L'élément CANVAS de HTML5

Ah ah... pour faire du dessin il faut utiliser l'élément <canvas> de HTML5 et son API JavaScript.

Voici un exemple qui dessine un petit monstre dans la boucle d'animation, à l'aide de fonctions de dessin de rectangles : http://jsbin.com/pagipi/2/edit

monster.jpg

Code HTML :

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>JS Bin</title>
</head>
<body>
 
  <canvas id="myCanvas" width="200" height="200"></canvas>
 
</body>
</html>

Code CSS très simple, pour voir la bordure du canvas : 

canvas {
  border: 1px solid black;
}

Bonnes pratiques pour utiliser le canvas :

  1. Utiliser une fonction init() appelée uniquement quand la page est entièrement chargée,
  2. Dans cette fonction : récupérer un pointeur sur l'élément canvas à l'aide de l'API du DOM de HTML5,
  3. Récupérer un objet spécial appelé le "contexte graphique", qui lui seul permettra de dessiner, changer les couleurs, etc...
  4. Alors seulement on pourra dessiner.

Code JavaScript de l'exemple :

// useful to have them as global variables
var canvas, ctx, w, h; 
 
 
window.onload = function init() {
    // called AFTER the page has been loaded
    canvas = document.querySelector("#myCanvas");
 
    // often useful
    w = canvas.width; 
    h = canvas.height;  
 
    // important, we will draw with this object
    ctx = canvas.getContext('2d');
 
    // ready to go !
    drawMyMonster();
};
 
function drawMyMonster() {
    // draw a big monster !
    // head
    ctx.strokeRect(10, 10, 100, 100);
 
    // eyes
    ctx.fillRect(30, 30, 10, 10);
    ctx.fillRect(75, 30, 10, 10);
 
    // nose
    ctx.strokeRect(55, 50, 10, 40);
 
    // mouth
   ctx.strokeRect(45, 94, 30, 10);
 
   // teeth
   ctx.fillRect(48, 94, 10, 10);
   ctx.fillRect(62, 94, 10, 10);
}

Introduction aux transformations géométriques 2D

L'API du canvas contient des fonctions propres à la gestion des repères / aux transformations géométriques très pratiques. Par ailleurs, on peut empiler/sauvegarder et dépiler/retaurer des contextes graphiques, ce qui va permettra de travailler avec plusieurs repères.

Par exemple, le code ci-dessous est équivalent au code de l'exemple précédent, si ce n'est que le monstre est maintenant dessiné en (0, 0) mais on translate le repère avant de le dessiner :

function drawMyMonster(x, y) {
    // draw a big monster !
    // head
 
    // save the context
    ctx.save();
 
    // translate the coordinate system, draw relative to it
    ctx.translate(x, y);
 
    // (0, 0) is the top left corner of the monster.
    ctx.strokeRect(0, 0, 100, 100);
 
    // eyes
    ctx.fillRect(20, 20, 10, 10);
    ctx.fillRect(65, 20, 10, 10);
 
    // nose
    ctx.strokeRect(45, 40, 10, 40);
 
    // mouth
   ctx.strokeRect(35, 84, 30, 10);
 
   // teeth
   ctx.fillRect(38, 84, 10, 10);
   ctx.fillRect(52, 84, 10, 10);
 
   // restore the context
   ctx.restore();
}

L'appel de cette fonction est un peu différent maintenant :

// Try to change the parameter values to move
    // the monster
    drawMyMonster(10, 10);

Vous pouvez tester cette version ici : http://jsbin.com/pagipi/3/edit, essayez donc de changer les valeurs des paramètres !

Animer le monstre, l'inclure dans notre moteur d'animation

Nous allons modifier légèrement le moteur d'animation développé au début de ce tutorial :

  • Ajout du canvas à la page HTML,
  • Ajout de la fonction init()
  • Ajout de la fonction qui dessine le monstre,
  • Dans la boucle d'animation nous allons : 1) effacer le canvas, 2) dessiner le monstre, 3) rappeler la boucle d'animation

Version en ligne à tester : http://jsbin.com/kafehi/7/edit

Code HTML : 

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>JS Bin</title>
</head>
<body>
  <canvas id="myCanvas"  width="200" height="200"></canvas>
</body>
</html>

Code JavaScript complet :

// Inits
window.onload = function init() {
  var game = new GF();
  game.start();
};
 
 
// GAME FRAMEWORK STARTS HERE
var GF = function(){
    // Vars relative to the canvas
    var canvas, ctx, w, h; 
 
    // vars for counting frames/s, used by the measureFPS function
    var frameCount = 0;
    var lastTime;
    var fpsContainer;
    var fps; 
 
    var measureFPS = function(newTime){
 
         // test for the very first invocation
         if(lastTime === undefined) {
           lastTime = newTime; 
           return;
         }
 
        //calculate the difference between last & current frame
        var diffTime = newTime - lastTime; 
 
        if (diffTime >= 1000) {
            fps = frameCount;    
            frameCount = 0;
            lastTime = newTime;
        }
 
        //and display it in an element we appended to the 
        // document in the start() function
       fpsContainer.innerHTML = 'FPS: ' + fps; 
       frameCount++;
    };
 
     // clears the canvas content
     function clearCanvas() {
       ctx.clearRect(0, 0, w, h);
     }
 
     // Functions for drawing the monster and maybe other objects
     function drawMyMonster(x, y) {
       // draw a big monster !
       // head
 
       // save the context
       ctx.save();
 
       // translate the coordinate system, draw relative to it
       ctx.translate(x, y);
 
       // (0, 0) is the top left corner of the monster.
       ctx.strokeRect(0, 0, 100, 100);
 
       // eyes
       ctx.fillRect(20, 20, 10, 10);
       ctx.fillRect(65, 20, 10, 10);
 
       // nose
       ctx.strokeRect(45, 40, 10, 40);
 
       // mouth
       ctx.strokeRect(35, 84, 30, 10);
 
       // teeth
       ctx.fillRect(38, 84, 10, 10);
       ctx.fillRect(52, 84, 10, 10);
 
      // restore the context
      ctx.restore(); 
    }
 
    var mainLoop = function(time){
        //main function, called each frame 
        measureFPS(time);
 
        // Clear the canvas
        clearCanvas();
 
        // draw the monster
        drawMyMonster(10+Math.random()*10, 10+Math.random()*10);
 
        // call the animation loop every 1/60th of second
        requestAnimationFrame(mainLoop);
    };
 
    var start = function(){
        // adds a div for displaying the fps value
        fpsContainer = document.createElement('div');
        document.body.appendChild(fpsContainer);
 
        // Canvas, context etc.
        canvas = document.querySelector("#myCanvas");
 
        // often useful
        w = canvas.width; 
        h = canvas.height;  
 
        // important, we will draw with this object
        ctx = canvas.getContext('2d');
 
        // start the animation
        requestAnimationFrame(mainLoop);
    };
 
    //our GameFramework returns a public API visible from outside its scope
    return {
        start: start
    };
};

Remarquez que 99% de la fonction init() de l'exemple précédent a été déplacé dans la fonction start() du moteur d'animation. Notez aussi que pour voir le monstre bouger on a ajouté des valeurs aléatoires à sa position, ainsi il "tremble".

Interactions avec l'utilisateurs : clavier et souris

En JavaScript on définit des "écouteurs" que l'on associe à certains éléments HTML (par exemple le canvas) et qui vont écouter certains événements (appui sur une touche, déplacement ou click souris), de manière "non blocante" (on dit : "asynchrone"). Comme pendant ce temps, la boucle d'animation tourne, on va simplement changer des "états" que l'on stocke dans un objet JavaScript et ces états seront consultés 60 fois par seconde depuis la boucle d'animation. 

On veut aussi pouvoir détecter l'appui de touches simultanés et la souris (déplacements, click), en même temps, dans la boucle. On va donc utiliser un objet qui contiendra la liste des touches enfoncées, une valeur pour dire si un bouton de la souris est enfoncé ou non, la position du curseur de la souris, etc...

Détection de l'appui sur une touche :

window.addEventListener('keydown', function(event) {
    if (event.keyCode === 37) {
        //left arrow was pressed
    }
}, false);

Ici on a défini un écouteur sur l'objet "window" (la fenêtre entière, la page web complète), pour l'événement "keydown", et la fonction qui traitera cet événement est le dernier paramètre. Elle prend en paramètre l'événement détecté. C'est un objet JavaScript qui a dans ses propriétés des informations relatives à l'événement : ici le code de la touche pressée.

Liste des codes :

JSKeyCodes.jpg