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?
- une façon de faire passer le temps et de mettre à jour l’état des éléments à dessiner.
- un modèle de mouvement.
- un moyen de contrôler des éléments.
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:
- Je veux pouvoir commander la palette, mais aussi potentiellement d’autres éléments du jeu. Il faudra donc avoir “un moyen de piloter des éléments”, pas juste un contrôle pour la palette.
- On essayera d’avoir autant que possible un découplage de l’affichage, des contrôles et de l’animation (au sens: mise à jour des informations sur les différents éléments).
- Refactorisation évidente à faire découlant du point précédent: les
Drawables actuels pourraient avantageusement être remplacés par des fonctions, car tout ce qu’on veut c’est pouvoir dessiner des éléments de base. On a d’ailleurs pour l’instant une confusion:Rectanglequi est juste une forme, etBallqui semble indiquer déjà un élément de jeu. On se contentera d’avoir des “primitives” à dessiner, et on décrira les éléments de jeu ailleurs. - On aura quelque part une interface de “contrôle” qui décrira les
actions qu’on peut avoir sur les objets (
up,left,right,down,spacepour commencer). - On aura quelque part un “moteur physique” qui implémente les règles
physiques de mouvement (
position,speed,acceleration,force…) et les collisions. - Pour se faciliter les notations, on va aussi introduire des vecteurs
plutôt que de se balader avec des
x, ypartout.
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);});
}
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…
- Ajouter les contrôles utilisateurs et le “moteur physique”, avec peut-être, soyons fou, des collisions élastiques?
- Mieux chercher comment correctement faire le “mocking” dans les tests, parce que ma solution actuelle est clairement bricolée et n’utilise pas les outils fourni par la bibliothèque de tests.
- Intégration du DOM dans les tests?