TP2e année9 livrables
Code ton premier jeu
Pause dans le fil AppliFrais : on s'amuse. En une séance, tu codes un vrai petit jeu en JavaScript et Canvas — et tu réinvestis variables, fonctions, boucles et événements sans t'en rendre compte.
Par Josselyn C., prof de BTS SIO. Publié le , mis à jour le .
- Objectif
- dessiner sur un Canvas, animer une boucle de jeu, gérer le clavier, détecter une collision, compter un score
- On manipule
- une page HTML, un fichier JS, le navigateur
- Pré-requis
- les bases JS (variables, fonctions, conditions)
- Pour qui
- SLAM, 2e année
- Durée
- environ 3 h
Compétences : C2.1 Concevoir et développer une solution applicative
Un jeu, c'est juste : dessiner, bouger, écouter le clavier, vérifier des collisions — en boucle. On y va.
Avant de commencer
Pourquoi cette séance
Coder un jeu est l'un des meilleurs moyens d'ancrer la logique : tu utilises des variables (position, score), des fonctions (dessiner, mettre à jour), une boucle (le moteur du jeu) et des événements (le clavier) — le tout avec un retour visuel immédiat. Et c'est fun. À la fin, tu auras un jeu jouable, à montrer et à améliorer.
Le jeu qu'on construit
Un « attrape-justificatifs » : un panier que tu déplaces en bas de l'écran, des reçus qui tombent du haut. Attrape-les pour marquer des points, rate-en trop et c'est perdu. Simple, mais addictif.
| Brique | Tu sauras… |
|---|---|
| Canvas | dessiner des formes par programme |
| Boucle de jeu | animer 60 images par seconde |
| Clavier | déplacer le panier |
| Collision & score | détecter une prise, compter les points |
Mise en place
Tout se fait avec un éditeur de code (VS Code) et un navigateur (Chrome, Firefox). Aucune installation lourde, aucun serveur compliqué.
- Crée un dossier
attrape-justificatifs. - Dedans, deux fichiers côte à côte :
index.htmletjeu.js. - Pour lancer le jeu : soit tu double-cliques sur
index.html, soit (mieux) tu utilises l'extension Live Server de VS Code (clic droit → « Open with Live Server »). - Pour voir tes changements : enregistre (
Ctrl/Cmd + S) puis recharge l'onglet (F5). Avec Live Server, ça se recharge tout seul.
0. La toile (Canvas)
Le Canvas est une zone de dessin dans une page web : un rectangle où l'on dessine par programme (formes, images, texte). Crée deux fichiers côte à côte.
index.html
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="utf-8"><title>Attrape-justificatifs</title></head>
<body>
<canvas id="jeu" width="480" height="360" style="border:1px solid #333"></canvas>
<script src="jeu.js"></script>
</body>
</html>
jeu.js — accrocher le pinceau
const canvas = document.getElementById("jeu");
const ctx = canvas.getContext("2d");
console.log("Canvas prêt :", canvas.width, "x", canvas.height);
Chiffres-repères Canvas
| Propriété | Valeur / exemple | Ce que ça change |
|---|---|---|
canvas.width / .height (attribut HTML) | 480 × 360 px dans notre jeu | Définit la résolution de la toile (combien de pixels réels existent) |
CSS width/height sur le <canvas> | indépendant de l'attribut | Définit la taille d'affichage. Si différent de l'attribut → image étirée ou pixelisée |
window.devicePixelRatio | 1 (standard) · 2 (Retina) · 3 (mobile haut de gamme) | Sur Retina, doubler la résolution : canvas.width = 960; canvas.style.width = "480px"; → net |
| Coordonnées | (0,0) = coin haut-gauche ; y croît vers le bas | Inverse du repère mathématique classique |
fillStyle (couleur) | n'importe quelle valeur CSS | "#5b2d8e", "rgb(91,45,142)", "rgba(91,45,142,0.5)", "violet" — tous valides |
Dans la console, tu dois lire exactement :
Canvas prêt : 480 x 360
Flag B1
Production
Crée les deux fichiers et affiche le message dans la console. Rends : confirme que le canvas s'affiche et le message apparaît.
1. Dessiner
On dessine avec ctx. Le repère : (0,0) en haut à gauche, x vers la droite, y vers le bas.
ctx.fillStyle = "#5b2d8e"; // la couleur
ctx.fillRect(50, 40, 120, 30); // x, y, largeur, hauteur
Les méthodes de dessin essentielles — vue d'ensemble
| Méthode | Ce qu'elle fait | Signature / syntaxe | Notes pro |
|---|---|---|---|
ctx.fillRect(x,y,l,h) | Rectangle plein | coin haut-gauche + largeur + hauteur | Opération la plus rapide : pas de chemin (path) intermédiaire |
ctx.strokeRect(x,y,l,h) | Rectangle vide (contour) | Même signature ; épaisseur = ctx.lineWidth (défaut 1 px) | Astuce debug : dessine la hitbox autour de tes objets pour voir les collisions |
ctx.clearRect(x,y,l,h) | Efface une zone (→ transparent) | Même signature | clearRect(0,0,w,h) efface tout le canvas — obligatoire en début de boucle |
ctx.fillText(txt,x,y) | Texte plein | y = ligne de base (baseline), pas le haut du texte | Police via ctx.font = "16px monospace" (syntaxe CSS exacte) |
ctx.arc(cx,cy,r,0,Math.PI*2) | Cercle (chemin) | cx/cy = centre ; r = rayon ; 0→2π = cercle complet | Obligatoirement encadré par ctx.beginPath() et ctx.fill() ou ctx.stroke() |
ctx.drawImage(img,x,y,l,h) | Image ou sprite | img = objet Image déjà chargé | Précharger avant la boucle avec img.onload = () => boucle() — sinon image noire |
À l'écran
Un rectangle violet apparaît dans la toile. Change les nombres : tu déplaces et redimensionnes la forme. Tu viens de dessiner par code.
Le piège du y vers le bas
Contrairement aux maths, y augmente vers le BAS. y = 0 = tout en haut, y = 360 = tout en bas. C'est déroutant au début, puis naturel.
Flag B2
Production
Dessine un panier (rectangle large et plat) en bas de la toile, et un reçu (petit carré) en haut. Rends : ton code et une capture.
2. La boucle de jeu
Un jeu, c'est une image redessinée ~60 fois par seconde. À chaque image : on efface, on met à jour, on redessine. C'est la boucle de jeu.
function boucle() {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 1. effacer
ctx.fillStyle = "#5b2d8e"; // 2. (re)dessiner
ctx.fillRect(200, 320, 80, 16);
requestAnimationFrame(boucle); // 3. recommencer
}
boucle();
expl.
clearRect(…) — efface toute la toile (sinon les images se superposent).
requestAnimationFrame(boucle) — demande au navigateur de rappeler boucle à la prochaine image (~60/s). La fonction se relance elle-même : un moteur.
requestAnimationFrame vs setInterval — pourquoi rAF ?
requestAnimationFrame | setInterval(fn, 16) | |
|---|---|---|
| Cadence | Synchronisée sur l'écran (60, 90 ou 144 Hz selon le moniteur) | Fixe à ~16 ms, non synchronisée → déchirures visuelles (tearing) |
| Onglet en arrière-plan | S'arrête automatiquement → CPU économisé, batterie préservée | Continue à tourner → gaspillage inutile |
| Précision du timing | Fournit un timestamp haute résolution (performance.now()) pour calculer le deltaTime | Drift possible : le minuteur glisse sur le temps |
| Pause / reprise | Retourne un id → cancelAnimationFrame(id) pour stopper | clearInterval(id) — même principe |
Le budget de 16 ms
ce que ça veut dire concrètement
60 images/s = une image toutes les 16,7 ms. C'est le budget par image : tout ce que ton code fait dans boucle() doit tenir en moins de 16 ms, sinon des images sont sautées et le jeu saccade. Pour notre jeu (quelques fillRect), on est très loin de cette limite. C'est capital pour un jeu avec des centaines d'objets.
Mettre en pause
cancelAnimationFrame
requestAnimationFrame renvoie un identifiant numérique. Tu peux annuler la prochaine image avec cancelAnimationFrame(id) :
let animId;
function boucle() {
// ...toute la logique du jeu...
animId = requestAnimationFrame(boucle);
}
// Pour pauser :
cancelAnimationFrame(animId);
// Pour reprendre :
animId = requestAnimationFrame(boucle);
expl.
animId — le numéro de la prochaine image programmée. cancelAnimationFrame(animId) — annule cette image avant qu'elle s'exécute. La boucle s'arrête proprement. Utile pour implémenter une touche Pause dans le Chapitre 8.
Pourquoi effacer à chaque image
Sans clearRect, chaque dessin reste : un objet qui bouge laisserait une traînée. On repart d'une toile vierge à chaque image, et on redessine tout à sa nouvelle position. C'est l'illusion du mouvement.
Erreur courante
l'écran reste figé
Symptôme : rien ne s'anime, la toile reste vide ou bloquée.
Cause : tu as défini la fonction boucle mais tu ne l'as jamais appelée.
Solution : vérifie la ligne boucle(); tout en bas du fichier — c'est elle qui démarre le moteur.
Culture
vitesse et images par seconde
Ici on bouge « de tant de pixels par image ». Sur un écran à 60 images/s le jeu va à une vitesse ; sur un écran à 144 images/s il irait plus vite.
Les vrais moteurs multiplient les déplacements par le temps écoulé entre deux images (le deltaTime) pour une vitesse identique partout. Pour aujourd'hui on reste simple : « par image » suffit.
Exemple travaillé — vitesse indépendante du taux de rafraîchissement
requestAnimationFrame passe automatiquement un timestamp en millisecondes à ta fonction. La différence entre deux appels donne le deltaTime :
let dernierTs = 0;
function boucle(ts) { // ts = ms depuis le chargement de la page
if (dernierTs === 0) { dernierTs = ts; } // 1ère image : évite le saut initial
const dt = (ts - dernierTs) / 16.67; // 16,67 ms = 1 image à 60 Hz
dernierTs = ts;
ctx.clearRect(0, 0, canvas.width, canvas.height);
recu.y += 3 * dt; // 3 px/image à 60 Hz, identique sur un écran à 144 Hz
requestAnimationFrame(boucle);
}
requestAnimationFrame(boucle); // rAF injecte le ts tout seul
expl.
ts — timestamp haute résolution fourni par le navigateur (même source que performance.now(), précision ~0,001 ms). dt — facteur de correction : 1,0 à 60 Hz, ~0,42 à 144 Hz, ~1,67 à 36 Hz → la vitesse est toujours « 3 px/image virtuelle de 60 Hz ». Sans deltaTime sur un écran à 144 Hz, ton jeu serait 2,4 fois plus rapide.
| Moniteur | dt (≈) | déplacement effectif 3 * dt | Vitesse perçue |
|---|---|---|---|
| 60 Hz | 1,00 | 3,0 px | Référence |
| 90 Hz | 0,67 | 2,0 px | Identique (60 Hz × 0,67 = même px/s) |
| 144 Hz | 0,42 | 1,25 px | Identique |
| 36 Hz (lag) | 1,67 | 5,0 px | Identique — le jeu ralentit visuellement mais la physique reste correcte |
Piège
saut géant au premier appel
Sans la garde if (dernierTs === 0), le premier dt vaudrait timestamp_courant / 16,67, soit souvent 50 à 200 — tous les objets bondissent hors écran. Le if en début de fonction règle le problème : la première image vaut toujours dt = 0, aucun mouvement.
Flag A1
Checkpoint
Décris les 3 étapes d'une image de jeu, et explique pourquoi on doit effacer la toile à chaque tour. Rends : ta réponse.
3. Bouger le panier
La position du panier devient une variable. On la modifie, et la boucle le redessine au bon endroit.
let panierX = 200;
const panierY = 320, panierL = 80, panierH = 16;
Écouter le clavier
const touches = {};
document.addEventListener("keydown", (e) => {
touches[e.key] = true;
if (e.key.startsWith("Arrow")) e.preventDefault(); // pas de scroll
});
document.addEventListener("keyup", (e) => { touches[e.key] = false; });
expl.
On note dans touches quelles touches sont enfoncées : keydown met à true, keyup à false. La boucle lira cet état à chaque image.
e.preventDefault() quand la touche commence par Arrow — sans ça, les flèches font défiler la page en plus de bouger le panier. On annule ce comportement par défaut du navigateur.
Pourquoi un objet touches et pas bouger directement dans keydown ?
Si tu écrivais panierX -= 6 directement dans le gestionnaire keydown, le mouvement serait saccadé : les systèmes d'exploitation appliquent un délai de répétition automatique (key-repeat) — typiquement ~500 ms avant la première répétition, puis une touche toutes les ~30 ms. Le panier avancerait par à-coups, différemment selon les réglages de chaque machine.
Avec l'objet touches :
Mouvement dans keydown | Objet touches + boucle | |
|---|---|---|
| Rythme | ~500 ms pause, puis ~30 ms — dépend de l'OS | 16 ms (synchronisé à rAF) — identique partout |
| Fluidité | Saccadé, inégal | Fluide, régulier |
| Deux touches simultanées | Impossible à gérer proprement | Trivial : touches["ArrowLeft"] && touches["ArrowUp"] |
La technique de l'objet touches est le standard de tous les jeux en canvas : elle sépare la détection de l'état clavier (événements) et l'application du mouvement (boucle).
Erreur courante
le panier ne bouge pas
Symptôme : les flèches ne font rien.
Cause 1 : la page n'a pas le focus → clique une fois dans la page (les écouteurs sont sur document, il lui faut le focus). Variante : donner tabindex="0" au canvas et écouter sur lui.
Cause 2 : faute de casse. La clé est "ArrowLeft" (A et L majuscules), pas "arrowleft" ni "Left".
Astuce : ajoute console.log(e.key) dans le keydown pour lire le nom exact de chaque touche.
Déplacer dans la boucle
function boucle() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (touches["ArrowLeft"]) panierX -= 6;
if (touches["ArrowRight"]) panierX += 6;
// rester dans la toile
panierX = Math.max(0, Math.min(canvas.width - panierL, panierX));
ctx.fillStyle = "#5b2d8e";
ctx.fillRect(panierX, panierY, panierL, panierH);
requestAnimationFrame(boucle);
}
boucle();
expl.
Si la flèche gauche est enfoncée, on diminue panierX (de 6 px/image) ; à droite, on l'augmente. Math.max/min empêche le panier de sortir de la toile.
Math.max / Math.min — le clamp expliqué
Math.max(0, Math.min(canvas.width - panierL, panierX)) est un clamp : force une valeur dans un intervalle [min, max]. Voici les trois cas possibles :
| Situation | panierX brut | Résultat | Effet visible |
|---|---|---|---|
| Normal (dans la toile) | 200 | 200 | Pas touché — dans l'intervalle |
| Sorti à gauche | -10 | 0 | Bloqué au bord gauche |
| Sorti à droite (toile 480, panier 80) | 430 | 400 | Bloqué au bord droit |
Pourquoi canvas.width − panierL et pas canvas.width ?
Le panier commence à panierX et a une largeur de panierL = 80 px. Sa position la plus à droite possible est canvas.width - panierL = 480 - 80 = 400. Si on autorisait jusqu'à 480, le bord droit du panier dépasserait de 80 px hors toile.
Et si… l'élève joue sur mobile (écran tactile) ?
Les flèches clavier n'existent pas sur mobile. On peut écouter les événements touch en parallèle :
canvas.addEventListener("touchmove", (e) => {
e.preventDefault(); // pas de scroll de page
const rect = canvas.getBoundingClientRect(); // position du canvas dans la page
const touchX = e.touches[0].clientX - rect.left; // coord du doigt dans le canvas
panierX = touchX - panierL / 2; // centrer le panier sous le doigt
}, { passive: false });
expl.
getBoundingClientRect() — retourne la position et la taille du canvas dans la fenêtre. e.touches[0].clientX — position en X du premier doigt actif. On soustrait rect.left pour passer de coordonnées page à coordonnées canvas. panierL / 2 — centre le panier sous le doigt. Le clamp de la boucle continue de fonctionner sans modification.
À l'écran
Clique sur la page (pour le focus clavier), puis ← →. Le panier glisse en bas. Ton premier objet contrôlable !
Flag B3
Production
Fais bouger le panier au clavier, sans qu'il sorte de la toile. Rends : ton code et confirme que ← → fonctionnent et que le panier reste dans le cadre.
4. Faire tomber un reçu
Un reçu = un objet avec une position. Il descend à chaque image, et réapparaît en haut quand il sort en bas.
let recu = { x: 100, y: 0, taille: 20 };
function nouveauRecu() {
recu.x = Math.random() * (canvas.width - recu.taille);
recu.y = -recu.taille; // juste au-dessus de la toile
}
expl.
Math.random() retourne un flottant dans l'intervalle [0 ; 1[ (0 inclus, 1 exclu). Multiplié par canvas.width - recu.taille (ici 480 − 20 = 460), on obtient un x entre 0 et 459,999… : le reçu reste toujours dans la toile, son bord droit inclus.
recu.y = -recu.taille — le reçu démarre à y = -20, juste au-dessus de la toile (invisible). Sans ce décalage, il apparaîtrait brutalement en haut à chaque réinitialisation. En descendant de 3 px par image, il franchit y = 0 (bord supérieur visible) en ~7 images, soit ~110 ms : entrée naturelle et progressive.
Dans la boucle, après le panier :
recu.y += 3; // il tombe
if (recu.y > canvas.height) nouveauRecu(); // sorti en bas → on en relance un
ctx.fillStyle = "#e8923a";
ctx.fillRect(recu.x, recu.y, recu.taille, recu.taille);
expl.
recu.y += 3 — descend de 3 px par image. Math.random() * (largeur - taille) — une position horizontale aléatoire au sommet. Le reçu tombe sans fin.
Flag B4
Production
Fais tomber un reçu qui réapparaît en haut (à une position aléatoire) quand il atteint le bas. Rends : ton code et une capture.
5. Attraper : collision & score
Le cœur du jeu : détecter que le reçu touche le panier. Deux conditions : le reçu est à la hauteur du panier, ET aligné horizontalement avec lui.
let score = 0;
function attrape() {
return recu.y + recu.taille >= panierY && // descendu jusqu'au panier
recu.x + recu.taille >= panierX && // pas trop à gauche
recu.x <= panierX + panierL; // pas trop à droite
}
Dans la boucle, après avoir bougé le reçu :
if (attrape()) { score++; nouveauRecu(); }
ctx.fillStyle = "#1a1426";
ctx.font = "16px monospace";
ctx.fillText("Score : " + score, 10, 24);
expl.
attrape() compare les rectangles : si le reçu chevauche le panier, c'est une prise → score++ et on relance un reçu. fillText affiche le score.
AABB — la collision nommée
Cette technique s'appelle AABB (Axis-Aligned Bounding Box) — boîtes alignées sur les axes. C'est la détection de collision la plus simple (et la plus rapide). Deux rectangles A et B se chevauchent si et seulement si les 4 conditions sont vraies simultanément :
| Condition | Signification |
|---|---|
A.x + A.w >= B.x | Le bord droit de A dépasse le bord gauche de B |
A.x <= B.x + B.w | Le bord gauche de A n'a pas dépassé le bord droit de B |
A.y + A.h >= B.y | Le bord bas de A dépasse le bord haut de B |
A.y <= B.y + B.h | Le bord haut de A n'a pas dépassé le bord bas de B |
Dans notre jeu on simplifie : le panier ne bouge qu'horizontalement et a une hauteur fixe, donc on teste seulement quand recu.y + recu.taille >= panierY (bord bas du reçu atteint le haut du panier).
Piège d'ordre
attrape() AVANT le test de sortie en bas
Dans la boucle, l'ordre compte :
| Ordre dans la boucle | Comportement |
|---|---|
if (attrape(r)) puis if (r.y > canvas.height) | Correct : une prise est comptée même à la toute dernière image avant la sortie |
if (r.y > canvas.height) puis if (attrape(r)) | Bug : le reçu peut être réinitialisé (vie perdue) et aussitôt compté comme attrapé à sa nouvelle position |
Règle : les récompenses (attrape) avant les pénalités (vie perdue) — dans cet ordre.
Dessiner du texte
détails pro
ctx.font = "16px monospace" — règle taille et police (syntaxe CSS exacte : "bold 20px Arial", "italic 14px serif"…).
ctx.fillText(texte, x, y) — écrit à (x, y) dans la couleur de fillStyle.
Le y est la ligne de base (baseline) du texte — le bas des lettres minuscules, pas le haut. Pour une police de 16 px, la hauteur visible est ~12-14 px : d'où y = 24 pour un texte lisible à 10 px du bord supérieur.
Centrer du texte horizontalement (pour le Game Over, par exemple) :
const msg = "Game Over — " + score + " pts";
const largeurTexte = ctx.measureText(msg).width; // largeur exacte en pixels
const x = (canvas.width - largeurTexte) / 2; // centré
ctx.fillText(msg, x, canvas.height / 2);
expl.
ctx.measureText(msg) retourne un objet TextMetrics dont la propriété .width est la largeur en pixels du texte avec la police courante. C'est la seule façon fiable de centrer — une police monospace de 16 px ne fait pas exactement 9,6 px par caractère en pratique.
Limiter la longueur : fillText accepte un 4e paramètre optionnel maxWidth (en px) — le texte est compressé si plus large, sans coupure. Utile pour un pseudo joueur de longueur variable.
À l'écran
Déplace le panier sous les reçus : le score grimpe. Tu as un vrai jeu jouable. 🎉
Erreur courante
des traînées sur la toile
Symptôme : le panier et le reçu laissent une bouillie de pixels derrière eux.
Cause : le clearRect n'est pas (ou plus) en première ligne de la boucle.
Solution : ctx.clearRect(0, 0, canvas.width, canvas.height); doit être la toute première instruction de boucle(), avant tout dessin.
Flag B5
Production (le jeu marche !)
Implémente la collision et le score. Rends : ton code complet et une capture avec un score > 0.
6. Vies & game over
Un jeu sans enjeu est vite ennuyeux. On ajoute des vies : rater un reçu en coûte une.
let vies = 3;
// dans la boucle, à la place de "if (recu.y > canvas.height) nouveauRecu();"
if (recu.y > canvas.height) { vies--; nouveauRecu(); }
if (vies <= 0) {
ctx.fillStyle = "#b3261e";
ctx.font = "28px monospace";
ctx.fillText("Game Over — " + score + " pts", 90, 190);
return; // on arrête la boucle
}
expl.
Un reçu raté (sorti en bas sans être attrapé) → vies--. À zéro vie, on affiche « Game Over » et on fait return : la boucle ne se relance plus, le jeu s'arrête.
Affiche aussi les vies, à côté du score : ctx.fillText("Vies : " + vies, 380, 24);
Flag B6
Production
Ajoute les 3 vies, l'affichage, et l'écran « Game Over ». Rends : ton code et une capture du Game Over.
Le jeu complet — jeu.js en entier
Voici tout le fichier jeu.js assemblé, des chapitres 0 à 6. Compare-le au tien : si quelque chose cloche, l'erreur se voit ici. (Le index.html ne change pas.)
// --- 1. La toile ---
const canvas = document.getElementById("jeu");
const ctx = canvas.getContext("2d");
// --- 2. Le panier ---
let panierX = 200;
const panierY = 320, panierL = 80, panierH = 16;
// --- 3. Le clavier ---
const touches = {};
document.addEventListener("keydown", (e) => {
touches[e.key] = true;
if (e.key.startsWith("Arrow")) e.preventDefault();
});
document.addEventListener("keyup", (e) => { touches[e.key] = false; });
// --- 4. Le reçu ---
let recu = { x: 100, y: 0, taille: 20 };
function nouveauRecu() {
recu.x = Math.random() * (canvas.width - recu.taille);
recu.y = -recu.taille;
}
// --- 5. Score, vies, collision ---
let score = 0;
let vies = 3;
function attrape() {
return recu.y + recu.taille >= panierY &&
recu.x + recu.taille >= panierX &&
recu.x <= panierX + panierL;
}
// --- 6. La boucle de jeu ---
function boucle() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// déplacer le panier
if (touches["ArrowLeft"]) panierX -= 6;
if (touches["ArrowRight"]) panierX += 6;
panierX = Math.max(0, Math.min(canvas.width - panierL, panierX));
ctx.fillStyle = "#5b2d8e";
ctx.fillRect(panierX, panierY, panierL, panierH);
// faire tomber le reçu
recu.y += 3;
if (attrape()) { score++; nouveauRecu(); }
if (recu.y > canvas.height) { vies--; nouveauRecu(); }
ctx.fillStyle = "#e8923a";
ctx.fillRect(recu.x, recu.y, recu.taille, recu.taille);
// score & vies
ctx.fillStyle = "#1a1426";
ctx.font = "16px monospace";
ctx.fillText("Score : " + score, 10, 24);
ctx.fillText("Vies : " + vies, 380, 24);
// game over ?
if (vies <= 0) {
ctx.fillStyle = "#b3261e";
ctx.font = "28px monospace";
ctx.fillText("Game Over — " + score + " pts", 90, 190);
return;
}
requestAnimationFrame(boucle);
}
boucle();
À l'écran
Un panier violet en bas, un reçu orange qui tombe, le score en haut à gauche, les vies à droite, et « Game Over » quand les 3 vies sont parties. C'est ton jeu complet.
7. Plusieurs reçus (tableau + forEach)
Un seul reçu, c'est calme. Pour corser le jeu, on en gère plusieurs à la fois : au lieu d'un objet, un tableau d'objets, qu'on parcourt avec forEach.
let recus = [
{ x: 100, y: 0, taille: 20 },
{ x: 250, y: -150, taille: 20 },
{ x: 380, y: -300, taille: 20 },
];
function reset(r) {
r.x = Math.random() * (canvas.width - r.taille);
r.y = -r.taille;
}
function attrape(r) {
return r.y + r.taille >= panierY &&
r.x + r.taille >= panierX &&
r.x <= panierX + panierL;
}
expl.
recus est un tableau de 3 reçus, démarrés à des hauteurs différentes (y négatifs) pour qu'ils ne tombent pas en file.
reset(r) et attrape(r) prennent maintenant un reçu en paramètre (r) : la même logique, appliquée à n'importe quel reçu du tableau.
Dans la boucle, on remplace le bloc d'un seul reçu par un forEach :
recus.forEach((r) => {
r.y += 3;
if (attrape(r)) { score++; reset(r); }
if (r.y > canvas.height) { vies--; reset(r); }
ctx.fillStyle = "#e8923a";
ctx.fillRect(r.x, r.y, r.taille, r.taille);
});
expl.
recus.forEach((r) => { … }) — exécute le bloc pour chaque reçu r du tableau : on le fait tomber, on teste la collision, on le redessine. Trois reçus → le bloc tourne trois fois par image.
forEach, for, for...of — lequel choisir ?
| Syntaxe | Quand l'utiliser | Particularités |
|---|---|---|
arr.forEach(r => { … }) | Parcourir et muter chaque objet (changer ses propriétés) | Pas de break possible. Ne permet pas de supprimer des éléments en cours de route. |
for (let i = 0; i < arr.length; i++) | Quand tu as besoin de l'index, ou pour supprimer des éléments (splice) | Permet break. Le seul choix sûr si tu modifies la longueur du tableau. |
for (const r of arr) | Simple et lisible, pas besoin d'index | Permet break. Ne modifie pas la longueur du tableau. |
arr.filter(r => …) | Créer un nouveau tableau en excluant certains éléments | Retourne un nouveau tableau (l'original est intact). Utile pour supprimer des reçus rattrapés. |
Pourquoi la mutation fonctionne dans forEach
Dans recus.forEach((r) => { r.y += 3; }), r est une référence vers l'objet dans le tableau — pas une copie. Modifier r.y modifie directement l'objet. C'est le comportement des objets en JavaScript : toujours passés par référence. Avec un primitif (number, string, boolean), ce serait une copie et la modification serait perdue.
Et si… on voulait des reçus qui disparaissent (vagues d'ennemis) ?
Avec filter, on enlève les reçus sortis du tableau au lieu de les réinitialiser :
// À la fin de la boucle :
recus = recus.filter(r => r.y <= canvas.height);
if (recus.length === 0) {
// tous les reçus sont sortis → prochaine vague, ou fin de niveau
}
C'est ainsi que fonctionnent les ennemis dans la plupart des vrais jeux : un tableau épuré à chaque image. Pour notre jeu, la réinitialisation (reset(r)) est plus simple et évite de réallouer de la mémoire.
À l'écran
Trois reçus tombent en même temps, chacun de son côté. Le jeu devient nettement plus nerveux — et c'est exactement le code dont tu auras besoin pour le bonus.
Flag B7
Production
Passe le jeu à un tableau de reçus parcouru avec forEach. Rends : ton code et une capture avec plusieurs reçus à l'écran.
8. Mini-projet : ton jeu à toi
La mission
Tu as un jeu complet. Maintenant, améliore-le — c'est là que tu deviens créatif (et que tu progresses).
- La difficulté monte : la vitesse de chute augmente avec le score.
- Des reçus « piège » (couleur différente) qui font perdre des points si attrapés.
- Un rejeu : appuyer sur Espace après le Game Over relance une partie.
- Ta touche perso : son, image, thème… surprends-nous.
Flag B8
Mini-projet (production)
Ajoute au moins 2 améliorations de la liste (ou les tiennes). Rends : ton code et une courte démo (capture/vidéo) de ce que tu as ajouté.
Exemple travaillé — la difficulté qui monte
On remplace la vitesse fixe 3 par une variable calculée selon le score. Déclare en haut du fichier :
let vitesse = 3;
expl.
vitesse — la vitesse de chute en pixels par image. Au départ : 3. On va la faire croître.
Dans le forEach de la boucle, après score++ :
if (attrape(r)) {
score++;
vitesse = 3 + Math.floor(score / 5) * 0.5; // +0,5 px/image tous les 5 points
reset(r);
}
// Plus loin, la chute :
r.y += vitesse; // remplace r.y += 3
expl.
Math.floor(score / 5) — entier : combien de paliers de 5 points ont été franchis. * 0.5 — chaque palier ajoute 0,5 px/image. La vitesse ne peut que croître (le Math.floor donne 0, 1, 2…).
Score 0 → vitesse 3,0 px/image
Score 5 → vitesse 3,5 px/image
Score 10 → vitesse 4,0 px/image
Score 30 → vitesse 6,0 px/image
Exemple travaillé — rejouer (touche Espace)
Ajoute un booléen enJeu pour savoir si la partie est en cours ou terminée :
let enJeu = true;
Au Game Over, remplace le return seul par :
if (vies <= 0) {
enJeu = false;
ctx.fillStyle = "#b3261e";
ctx.font = "28px monospace";
ctx.fillText("Game Over — " + score + " pts", 60, 190);
ctx.fillStyle = "#1a1426";
ctx.font = "14px monospace";
ctx.fillText("Appuie sur Espace pour rejouer", 80, 230);
return; // ne pas appeler requestAnimationFrame → la boucle s'arrête
}
Dans le gestionnaire keydown, ajoute après la ligne e.preventDefault() :
if (e.key === " " && !enJeu) {
score = 0; vies = 3; vitesse = 3; enJeu = true;
recus.forEach(r => reset(r));
boucle(); // relance le moteur
}
expl.
!enJeu — on ne réagit à Espace que si le jeu est terminé (sinon Espace pendant la partie relancerait une deuxième boucle en parallèle). recus.forEach(r => reset(r)) — réinitialise la position de tous les reçus. boucle() — redémarre le moteur.
Erreur courante
le jeu s'emballe après le rejeu
Symptôme : après Espace, le jeu va deux fois plus vite ou les objets bougent de façon erratique.
Cause : deux boucles requestAnimationFrame tournent en parallèle (double moteur).
Solution : vérifie que le return du Game Over est bien avant le requestAnimationFrame(boucle) et que la condition !enJeu empêche de lancer une deuxième boucle. En cas de doute, utilise cancelAnimationFrame(animId) avant de rappeler boucle().
Piège production
écouteurs d'événements qui s'accumulent
Si tu appelles boucle() une deuxième fois (au rejeu) et que tes addEventListener sont à l'intérieur de la fonction de démarrage, chaque partie ajoute de nouveaux écouteurs sans supprimer les anciens. Après 5 parties, chaque appui sur une touche déclenche 5 callbacks → comportements étranges.
Solution : déclare les écouteurs une seule fois, en dehors de toute fonction d'initialisation (au niveau global du fichier). C'est déjà le cas dans notre code — mais si tu les déplaces lors d'une amélioration, garde ce principe en tête. Si tu as besoin de les retirer : document.removeEventListener("keydown", gestionnaire) — avec la même référence de fonction, pas une lambda anonyme.
// Mauvais : une nouvelle lambda à chaque appel → impossible à removeEventListener
function init() {
document.addEventListener("keydown", (e) => { touches[e.key] = true; });
}
// Correct : référence nommée → retirable
function onKeyDown(e) { touches[e.key] = true; }
document.addEventListener("keydown", onKeyDown);
// Plus tard si besoin :
// document.removeEventListener("keydown", onKeyDown);
Défi bonus
meilleur score sauvegardé
Garde le record entre deux parties avec localStorage (mémoire du navigateur) :
let record = Number(localStorage.getItem("record") || 0);
// au Game Over :
if (score > record) { record = score; localStorage.setItem("record", record); }
ctx.fillText("Record : " + record, 90, 230);
getItem lit la valeur stockée (texte → Number pour comparer), setItem l'enregistre. Recharge la page : le record reste.
| Caractéristique | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|
| Persistance | Permanente (jusqu'au clear manuel ou removeItem) | Session onglet seulement | Permanente |
| Limite de stockage | ~5 MB par origine (Chrome/Firefox/Safari — variable) | Idem | Plusieurs GB (avec permission utilisateur) |
| Type de données | Clé/valeur texte uniquement — JSON à sérialiser | Idem | Objets JS natifs, Blob, ArrayBuffer |
| Accès | Synchrone — bloque le thread principal | Synchrone | Asynchrone (promises) — ne bloque pas |
| Usage typique | Préférences légères, scores, tokens JWT | Données de session | Cache off-line, données volumineuses |
Pour un score de jeu (Number → quelques octets), localStorage est parfait. Pour stocker des replays ou des niveaux entiers, préfère IndexedDB.
Pour finir
Ce que tu as (re)mobilisé
| Notion | Dans le jeu |
|---|---|
| Variables | position, score, vies |
| Fonctions | boucle, attrape, nouveauRecu |
| Conditions | collision, bords, game over |
| Boucle de jeu | requestAnimationFrame |
| Événements | le clavier |
Mini-quiz (corrige-toi)
- Pourquoi appelle-t-on
clearRectau début de chaque tour de boucle ? - Que renvoie
canvas.getContext("2d"), et à quoi sert ce qu'il renvoie ? - Dans
fillRect(x, y, l, h), dans quel coin de la toile se trouve le point (0,0) et vers où augmentey? - À quoi sert l'objet
touchesalimenté parkeydownetkeyup? - Que se passe-t-il si tu oublies la ligne
boucle();tout en bas du fichier ?
Réponds de tête, puis vérifie avec le corrigé ci-dessous.
| Question | Réponse attendue |
|---|---|
1. Pourquoi clearRect en début de boucle ? | Sans effacement, chaque dessin s'accumule : les objets laissent une traînée car les images se superposent. On repart d'une toile vierge pour créer l'illusion du mouvement. |
2. Que retourne getContext("2d") ? | Un objet CanvasRenderingContext2D (ctx), le « pinceau » : toutes les méthodes de dessin (fillRect, fillText…) s'appellent sur lui. |
| 3. Où est (0,0) et vers où va y ? | (0,0) est le coin haut-gauche. x croît vers la droite, y croît vers le bas — inverse du repère mathématique. |
4. À quoi sert l'objet touches ? | Il mémorise l'état courant du clavier : true si la touche est enfoncée, false sinon. La boucle le consulte à chaque image plutôt que de réagir à des événements discrets → mouvement fluide. |
5. Que se passe-t-il si on oublie boucle(); ? | La fonction est définie mais jamais appelée : rien ne s'anime. La toile reste vide (ou ne dessine qu'une fois sans se relancer). |
Tu es bloqué ?
Rien ne s'affiche ? → le <script> est-il bien après le canvas ? Une erreur dans la console (F12) ?
Le clavier ne répond pas ? → clique d'abord dans la page (le focus), et vérifie "ArrowLeft" (avec la majuscule).
Traînées à l'écran ? → il manque clearRect au début de la boucle.
Le reçu traverse le panier ? → revois les comparaisons de attrape() (les bords).
Un mot incompris ? → glossaire ci-dessous.
Glossaire
| Mot | Définition |
|---|---|
| Canvas | Zone de dessin programmable dans une page web. Résolution fixée par les attributs width/height. |
Contexte 2D (ctx) | L'objet retourné par getContext("2d") : le « pinceau ». Toutes les méthodes de dessin lui appartiennent. |
| Boucle de jeu | Séquence effacer → mettre à jour → redessiner, répétée ~60 fois par seconde. |
| requestAnimationFrame (rAF) | Fonction du navigateur qui planifie le prochain appel de la boucle, synchronisé au rafraîchissement de l'écran. Retourne un id annulable. |
| cancelAnimationFrame | Annule une image planifiée par rAF avant qu'elle s'exécute. Permet de pauser le jeu. |
| AABB | Axis-Aligned Bounding Box — détection de collision par chevauchement de rectangles alignés sur les axes. |
| Clamp | Forcer une valeur dans un intervalle [min, max] avec Math.max(min, Math.min(max, valeur)). |
| deltaTime | Temps écoulé entre deux images (ms). Multiplier les déplacements par deltaTime / 16 rend la vitesse identique à 60, 90 ou 144 Hz. |
| devicePixelRatio | Rapport entre pixels physiques et pixels CSS (2 sur Retina). Doubler les attributs du canvas et réduire son affichage CSS donne un rendu net sur écrans haute densité. |
| localStorage | Stockage clé-valeur du navigateur, persistant entre recharges et sessions. Les valeurs sont des chaînes de caractères. |
| Référence (objet) | En JS, un objet passé à une fonction est passé par référence : modifier une propriété à l'intérieur de la fonction modifie l'objet original. |
| Collision | Test de chevauchement entre deux objets graphiques (voir AABB). |
| Événement | Action détectée par le navigateur (keydown, touchmove, click…) à laquelle on réagit avec un écouteur (addEventListener). |
La prochaine fois
Récréation terminée — mais tu as renforcé ta logique JS. On revient au fil AppliFrais avec le serveur : Node & Express, pour créer l'API qui servira les fiches de frais.
Compétences mobilisées
référentiel BTS SIO SLAM
Bloc 2 · Concevoir et développer une solution applicative
→ Développer des applications en respectant les standards de programmation