5. Un peu d’animation

Adrien Foucart

Retour à l’index

Précédent: 4. Des tests

Pour commencer, correction d’une petite erreur dans les tests: la classe qui mock CanvasRenderingContext2D pour les tests ne tient pour l’instant pas du tout compte des reset pour indiquer si elle affiche pour l’instant quelque chose ou pas. C’est le rôle de la fonction isRendered, qui pour l’instant se contente de voir si on a appelé stroke, fill, fillRect ou strokeRect. Ce qui veut dire que si on fait:

ctx.fill();
ctx.reset();

Dans un vrai canvas, on va supprimer tout ce qui est actuellement affiché, mais isRendered() dans le mock renverra tout de même vrai. Modifions donc cela, en ajoutant au passage aussi une vérification qu’on a bien au moins un chemin dans le contexte:

public isRendered(){
        return this.nbPaths > 0 && (
               this.calls.some((e, i) => i > this.last_reset && e.method === "stroke") || 
               this.calls.some((e, i) => i > this.last_reset && e.method === "fill") || 
               this.calls.some((e, i) => i > this.last_reset && e.method === "fillRect") || 
               this.calls.some((e, i) => i > this.last_reset && e.method === 'strokeRect')
            );
    }

Bon, après ce préambule, entrons dans le vif du sujet et essayons d’amener un peu de mouvement. De quoi avons-nous besoin?

Passer le temps

canvas propose une méthode, requestAnimationFrame, qui permet d’appeler un callback lors du prochain rafraîchissement d’écran. C’est un moyen assez simple d’animer du contenu. Le callback recevra en argument un timestamp en millisecondes.

Utilisons ça dans un premier temps, juste pour tester, pour afficher sur le board de manière dynamique le temps écoulé depuis le chargement de la page.

On va commencer par ajouter une méthode step dans DrawingBoard. Cette méthode sera appelée depuis le main:

// ./src/pongekke.ts
function main() {
    // ...
    requestAnimationFrame((t) => {board.step(t);});
}

Au prochain rafraîchissement de l’écran, board.step sera appelé avec le timestamp en argument.

Dans DrawingBoard, on va ajouter un attribut start dans lequel on retiendra le premier timestamp reçu, et un attribut delta, dans lequel on gardera le temps écoulé depuis le début. La méthode step va mettre tout ça à jour, supprimer tous les dessins avec this.ctx.reset(), redessiner les éléments avec this.draw(), et terminer par un nouvel appel à requestAnimationFrame pour pouvoir recommencer la procédure au prochain rafraîchissement.

// ./src/board.ts
export class DrawingBoard {
    // ...
    public step(timestamp: number): void {
        if( this.start === null ){
            this.start = timestamp;
        }
        this.delta = timestamp - this.start;

        this.ctx.reset();
        this.draw();

        requestAnimationFrame((t) => {this.step(t)});
    }
}

Pour afficher le compteur de temps, il nous faut un élément de texte. On peut rajouter ça dans drawables, en suivant l’interface utilisée pour le rectangle et la balle:

// ./src/drawables.ts
export class TextElement implements Drawable {
    constructor(private x: number,
                private y: number,
                private s: string,
                private font: string = "14px bold 'Courier New'",
                private fillColor: string = "white"){}
    
    public draw(ctx: CanvasRenderingContext2D): void {
        ctx.font = this.font;
        ctx.fillStyle = this.fillColor;
        ctx.fillText(this.s, this.x, this.y);
    }
}

On peut maintenant l’utiliser dans le board.draw:

// ./src/board.ts
public draw(): void {
    this.drawings.forEach((e) => {
        e.draw(this.ctx);
    });
    const timer = new TextElement(200, 50, `${this.delta}`);
    timer.draw(this.ctx);
}

Et on voit un petit compteur bien moche qui nous indique le temps qui passe. Très clairement, il y a des problèmes avec nos programme actuel. Le TextElement hard-codé comme ça dans draw pique un peu les yeux, surtout avec les coordonnées fixées. Pour l’instant, le board ne connait en effet pas ses dimensions, ce qui rend les choses un peu compliquées à gérer.

Clairement, il va falloir refactoriser les choses. Mais avant de se lancer, il faut décider un peu mieux ce qu’on veut faire, au juste.

Un plan ?

J’étais parti sur les bases d’un “truc qui ressemble à Pong”, mais je ne veux pas non plus me limiter à ça. L’objectif est de faire des choses qui vont me permettre d’apprendre sur le langage, ses bonnes pratiques, les patterns qui fonctionnent ou pas.

Je veux pouvoir suivre un peu mes envies et ne pas avoir un plan complet et détaillé, mais je vais mettre quelques principes qui me paraissent intéressant de prime abord:

Tout ça devrait nous occuper quelques temps.

Refactorisation du dessin

D’abord le plus simple: se débarrasser de Drawable et des classes Rectangle, Ball et TextElement. À la place, des fonctions de dessin, en renommant au passage Ball en Circle:

// ./src/drawables.ts
export function drawRectangle(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, borderStyle: string = "white", fillStyle: string = "black") {
    ctx.beginPath();
    ctx.rect(x, y, w, h);
    ctx.strokeStyle = borderStyle;
    ctx.fillStyle = fillStyle;
    ctx.stroke();
    ctx.fill();
}

export function drawCircle(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, borderStyle: string = "whilte", fillStyle: string = "black"): void {
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, 2*Math.PI);
    ctx.strokeStyle = borderStyle;
    ctx.fillStyle = fillStyle;
    ctx.stroke();
    ctx.fill();
}

export function drawText(ctx: CanvasRenderingContext2D, x: number, y: number, s: string, font: string = "14px bold 'Courier New'", fillStyle: string = "white"){
    ctx.font = font;
    ctx.fillStyle = fillStyle;
    ctx.fillText(s, x, y);
}

Il faut évidemment aussi modifier les tests associés dans test_drawables.

Vecteurs

On rajoute un fichier vectors.ts, avec une interface Vector et des fonctions de calcul vectoriel, en commençant par l’addition et la multiplication par un scalaire:

// ./src/vectors.ts
export interface Vector {
    x: number;
    y: number;
}

export function add(a: Vector, b: Vector) {
    return {x: a.x + b.x, y: a.y + b.y}
}

export function scale(a: Vector, s: number) {
    return {x: a.x*s, y: a.y*s}
}

Éléments

On peut maintenant commencer à définir ce que sont nos “éléments” de jeu. On va décrire un élément comme quelque chose qui peut être dessiné (a une méthode draw) et qui peut être mis à jour (a une méthode update).

// ./src/elements.ts
export interface Element {
    draw(ctx: CanvasRenderingContext2D): void;
    update(timestep: number): void;
}

On définit ensuite nos deux éléments de base, Paddle et Ball. On va donner à ces éléments une position, une vitesse, une accélération et une masse, pour anticiper le modèle physique. On donnera aussi les informations nécessaires au dessin. La seule “règle” physique qu’on va mettre pour l’instant est que, dans l’update, on va mettre à jour la position en fonction de la vitesse, et la vitesse en fonction de l’accélération:

// ./src/elements.ts
// ...
export class Paddle implements Element {
    constructor(private position: Vector,
                private size: Vector,
                private strokeStyle: string = 'yellow',
                private fillStyle: string = 'white',
                private speed: Vector = {x: 0, y: 0},
                private acceleration: Vector = {x: 0, y: 0},
                private mass: number = 1){}

    public draw(ctx: CanvasRenderingContext2D): void {
        drawRectangle(ctx, this.position.x, this.position.y, this.size.x, this.size.y, this.strokeStyle, this.fillStyle);
    }

    public update(timestep: number): void{
        this.position = addVector(this.position, scaleVector(this.speed, timestep));
        this.speed = addVector(this.speed, scaleVector(this.acceleration, timestep));
    }
}

export class Ball implements Element {
    constructor(private position: Vector,
                private radius: number,
                private strokeStyle: string = 'yellow',
                private fillStyle: string = 'white',
                private speed: Vector = {x: 0, y: 0},
                private acceleration: Vector = {x: 0, y: 0},
                private mass: number = 1){}

    public draw(ctx: CanvasRenderingContext2D): void {
        drawCircle(ctx, this.position.x, this.position.y, this.radius, this.strokeStyle, this.fillStyle);
    }

    public update(timestep: number){
        this.position = addVector(this.position, scaleVector(this.speed, timestep));
        this.speed = addVector(this.speed, scaleVector(this.acceleration, timestep));
    }
}

Le update prend le timestep en argument, ce qui permet d’avoir une “vitesse” constante des éléments même si le framerate change. Le fait de recopier ainsi la même chose dans les deux méthodes update indique clairement qu’on n’a pas fini le travail ici: il faudra découpler le fait de donner un “modèle physique” à un élément de l’élément lui-même, pour pouvoir juste facilement appliquer les mêmes règles à des éléments différents. Mais je n’ai pas encore une vision assez claire de comment je veux faire ça, et là mon objectif est simplement de voir que je peux faire (enfin!) bouger quelque chose.

Pour ça, il nous reste juste à modifier le DrawingBoard pour qu’il utilise des Element et pas des Drawable. Il pourra ainsi appeler les update à chaque mise à jour, en plus des draw. À chaque step, on calculera donc le temps écoulé depuis le dernier step, et on appelle les méthodes draw et update de chaque élément.

Enfin, on modifier le main pour avoir un Paddle immobile et une balle qui s’enfuit vers l’infini à vitesse constante:

function main(){
    const w = window.innerWidth*0.8;
    const h = window.innerHeight*0.6;
    const canvas = createCanvas(w, h);
    const board = new DrawingBoard(canvas.getContext("2d")!);
    board.add(new Paddle({x: 2*w/100, y: 4*h/10}, {x: w/100, y: h/10}, 'yellow', 'white'));
    board.add(new Ball({x: w/2, y: h/2}, 5, 'blue', 'blue', {x: 0.01, y: 0}));

    requestAnimationFrame((t) => {board.step(t);});
}
Une balle qui bouge, quelle prouesse technique!

Tests

Avant de passer à la suite, on termine le petit nettoyage en modifiant les tests du DrawingBoard pour bien utilise des éléments, que l’on va du coup mocker aussi:

// ./tests/test_board.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import { DrawingBoard } from '../src/board.ts';
import { Element } from '../src/elements.ts';
import { CanvasRenderingContext2D } from './mock_context.ts';

class MockElement implements Element {
    public isDrawn: boolean = false;
    public isUpdated: boolean = false; 

    public draw(ctx: CanvasRenderingContext2D): void {
        this.isDrawn = true;
    }
    public update(timestep: number): void {
        this.isUpdated = true;
    }
}


test('all elements are drawn', () => {
    const ctx = new CanvasRenderingContext2D();
    const board = new DrawingBoard(ctx);
    const elements = [new MockElement(), new MockElement(), new MockElement()];

    elements.forEach((e) => {board.add(e)});
    board.oneStep(0);

    elements.forEach((e) => {assert(e.isDrawn)});
});

À suivre…

Le code au 01/12/2025