Files
tribunal-app/src/app/icare/icarus-game.tsx
T
Valentin ROBIN 358bed0c2e Passe de polish visuel sur tout le site
Corrige deux incohérences trouvées en chemin : le compteur de passages dans le char en gold-bright sur marbre (même défaut de contraste déjà vu ailleurs), et le point du Crieur toujours rouge même pour un honneur positif. Réutilise les ornements existants mais jamais appelés (meander-divider entre le podium et le classement, DiamondDivider sur /profile avec une nouvelle convention de carte secondaire). Ajoute une ambiance discrète (fond qui respire très lentement, halo doré sur les cartes marbre, désactivés sous prefers-reduced-motion), étend les confettis et un nouveau son à Icare pour la parité avec la Corne d'Abondance, des loading.tsx sur les pages les plus consultées, et un petit ornement sur les états vides plutôt qu'un texte brut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:03:52 +02:00

719 lines
24 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
BOOST_DASH_DISTANCE,
BOOST_DASH_MS,
BOOST_MAX_CHARGES,
BOOST_MAX_COLUMN_INTERVAL,
BOOST_MIN_CHARGES,
BOOST_MIN_COLUMN_INTERVAL,
BOOST_RADIUS,
BOOST_SPEED_MULTIPLIER,
COLUMN_GAP,
COLUMN_GAP_MARGIN,
COLUMN_SPACING,
COLUMN_WIDTH,
DIFFICULTY_RAMP_SPAN,
DIFFICULTY_START_SCORE,
FIXED_DT_MS,
FLAP_IMPULSE,
FORWARD_SPEED,
GAP_MIN_MULTIPLIER,
GROUND_Y,
ICARUS_RADIUS,
ICARUS_X,
MAX_FRAME_DT_MS,
SHATTER_ANIM_MS,
SPEED_MAX_MULTIPLIER,
TILT_MAX_RADIANS,
VIEWPORT_HEIGHT,
VIEWPORT_WIDTH,
WING_FLAP_ANIM_MS,
WING_FLAP_MAX_RADIANS,
WING_IDLE_PEAK_RADIANS,
WING_IDLE_PERIOD_MS,
WING_REST_RADIANS,
} from "@/lib/icarus/constants";
import {
collidesWithBoost,
collidesWithColumn,
createInitialIcarus,
hasHitGround,
stepIcarus,
} from "@/lib/icarus/physics";
import type { Boost, Column, IcarusState, ShatterEffect } from "@/lib/icarus/types";
import { playBoostSound } from "@/lib/icarus/sound";
import { Confetti } from "@/components/confetti";
type Status = "idle" | "flying" | "dead";
// 0 avant DIFFICULTY_START_SCORE, monte jusqu'à 1 sur DIFFICULTY_RAMP_SPAN
// points, puis reste à 1 (palier, pour ne pas devenir injouable).
function difficultyProgress(score: number): number {
if (score <= DIFFICULTY_START_SCORE) return 0;
return Math.min(1, (score - DIFFICULTY_START_SCORE) / DIFFICULTY_RAMP_SPAN);
}
function randomGapCenter(gap: number): number {
const minCenter = gap / 2 + COLUMN_GAP_MARGIN;
const maxCenter = GROUND_Y - gap / 2 - COLUMN_GAP_MARGIN;
return minCenter + Math.random() * Math.max(0, maxCenter - minCenter);
}
// Prochain boost dans BOOST_MIN..MAX_COLUMN_INTERVAL colonnes (tirage
// entier inclusif des deux bornes).
function randomBoostInterval(): number {
return (
BOOST_MIN_COLUMN_INTERVAL +
Math.floor(Math.random() * (BOOST_MAX_COLUMN_INTERVAL - BOOST_MIN_COLUMN_INTERVAL + 1))
);
}
// Distance du boost (en colonnes), tirée à chaque cueillette (tirage
// entier inclusif des deux bornes) : le bouclier dure le temps des N
// prochaines colonnes rencontrées, qu'elles soient cassées (touchées) ou
// simplement franchies sans contact — pas un budget de casses.
function randomBoostDistance(): number {
return BOOST_MIN_CHARGES + Math.floor(Math.random() * (BOOST_MAX_CHARGES - BOOST_MIN_CHARGES + 1));
}
// Une aile, dessinée pointant vers -x ; side=-1 la reflète pour l'aile
// opposée. scale(side,1) inverse déjà le sens de rotation pour le côté
// reflété — passer le même wingAngle (sans le renverser à la main) aux deux
// appels donne un battement symétrique ; les deux ailes sont dessinées avec
// la même couleur et par-dessus le corps pour qu'aucune des deux ne soit
// partiellement cachée (ce qui donnait l'impression qu'une seule bougeait).
function drawWing(ctx: CanvasRenderingContext2D, side: 1 | -1, wingAngle: number) {
ctx.save();
ctx.scale(side, 1);
ctx.rotate(wingAngle);
ctx.fillStyle = "#F4ECD8";
ctx.beginPath();
ctx.moveTo(-1, -2);
ctx.quadraticCurveTo(-ICARUS_RADIUS * 1.6, -ICARUS_RADIUS * 1.9, -ICARUS_RADIUS * 2.3, -ICARUS_RADIUS * 0.8);
ctx.quadraticCurveTo(-ICARUS_RADIUS * 1.3, -ICARUS_RADIUS * 0.15, -ICARUS_RADIUS * 0.3, ICARUS_RADIUS * 0.55);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "#C9A227";
ctx.lineWidth = 1;
for (let i = 1; i <= 2; i++) {
ctx.beginPath();
ctx.moveTo(-1, -2);
ctx.lineTo(-ICARUS_RADIUS * (0.9 + i * 0.5), -ICARUS_RADIUS * (0.5 + i * 0.35));
ctx.stroke();
}
ctx.restore();
}
function drawIcarus(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
tilt: number,
wingAngle: number,
boosted: boolean,
) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(tilt);
if (boosted) {
ctx.save();
ctx.strokeStyle = "#E7C560";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, ICARUS_RADIUS + 5, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.ellipse(0, 0, ICARUS_RADIUS * 0.85, ICARUS_RADIUS, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(ICARUS_RADIUS * 0.5, -ICARUS_RADIUS * 0.4, ICARUS_RADIUS * 0.48, 0, Math.PI * 2);
ctx.fill();
drawWing(ctx, -1, wingAngle);
drawWing(ctx, 1, wingAngle);
ctx.restore();
}
function drawColumnShaft(
ctx: CanvasRenderingContext2D,
x: number,
top: number,
height: number,
capAtBottom: boolean,
) {
if (height <= 0) return;
ctx.fillStyle = "#F4ECD8";
ctx.fillRect(x, top, COLUMN_WIDTH, height);
ctx.strokeStyle = "#C9A227";
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const lineX = x + (COLUMN_WIDTH / 4) * i;
ctx.beginPath();
ctx.moveTo(lineX, top);
ctx.lineTo(lineX, top + height);
ctx.stroke();
}
const capHeight = Math.min(10, height);
const capY = capAtBottom ? top + height - capHeight : top;
ctx.fillStyle = "#E7C560";
ctx.fillRect(x - 4, capY, COLUMN_WIDTH + 8, capHeight);
}
function drawColumn(ctx: CanvasRenderingContext2D, column: Column) {
const gapTop = column.gapCenterY - column.gap / 2;
const gapBottom = column.gapCenterY + column.gap / 2;
drawColumnShaft(ctx, column.x, 0, gapTop, true);
drawColumnShaft(ctx, column.x, gapBottom, GROUND_Y - gapBottom, false);
}
// Éclair de Zeus ramassable : halo pulsant + éclair sombre par-dessus, pour
// rester lisible sur le halo doré.
function drawBoost(ctx: CanvasRenderingContext2D, boost: Boost, now: number) {
const pulse = 0.5 + 0.5 * Math.sin(now / 200);
ctx.save();
ctx.translate(boost.x, boost.y);
const glow = ctx.createRadialGradient(0, 0, 2, 0, 0, BOOST_RADIUS + 6 + pulse * 3);
glow.addColorStop(0, "#E7C560");
glow.addColorStop(1, "rgba(231, 197, 96, 0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(0, 0, BOOST_RADIUS + 6 + pulse * 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#C9A227";
ctx.beginPath();
ctx.arc(0, 0, BOOST_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#0A1B33";
ctx.beginPath();
ctx.moveTo(-2, -9);
ctx.lineTo(4, -1);
ctx.lineTo(0, -1);
ctx.lineTo(3, 9);
ctx.lineTo(-5, 0);
ctx.lineTo(-1, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Fragments qui s'écartent du centre d'une colonne détruite par un boost,
// en s'estompant — purement décoratif, dérivé du temps écoulé (pas de
// state React par effet).
function drawShatterEffect(ctx: CanvasRenderingContext2D, effect: ShatterEffect, now: number) {
const progress = Math.min(1, (now - effect.createdAtMs) / SHATTER_ANIM_MS);
if (progress >= 1) return;
ctx.save();
ctx.translate(effect.x, effect.y);
ctx.globalAlpha = 1 - progress;
ctx.strokeStyle = "#E7C560";
ctx.lineWidth = 2;
const pieces = 8;
for (let i = 0; i < pieces; i++) {
const angle = (i / pieces) * Math.PI * 2;
const dist = progress * 34;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * dist, Math.sin(angle) * dist);
ctx.lineTo(Math.cos(angle) * (dist + 9), Math.sin(angle) * (dist + 9));
ctx.stroke();
}
ctx.restore();
}
// Filets de vitesse continus tant que le bouclier est actif : Icare est
// figé (pas de mouvement propre), donc sans ça la sensation de vitesse ne
// venait que du défilement du décor — ces traits, qui naissent devant lui
// et filent vers l'arrière en boucle, rendent la vitesse lisible même à
// l'arrêt visuel d'Icare. Purement dérivé de "now" (pas de state), 6
// lignes déphasées pour ne pas paraître synchronisées.
function drawSpeedLines(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, now: number) {
const lineCount = 6;
const cycleMs = 260;
ctx.save();
ctx.strokeStyle = "#E7C560";
ctx.lineCap = "round";
ctx.lineWidth = 2;
for (let i = 0; i < lineCount; i++) {
const seed = i * 137;
const phase = ((now + seed) % cycleMs) / cycleMs;
const yOffset = ((i % 3) - 1) * 14 + (((i * 53) % 20) - 10);
const length = 14 + (i % 3) * 6;
const startX = centerX + 36 - phase * 100;
const alpha = 0.6 * Math.sin(phase * Math.PI);
if (alpha <= 0) continue;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(startX, centerY + yOffset);
ctx.lineTo(startX - length, centerY + yOffset);
ctx.stroke();
}
ctx.restore();
}
export type IcarusRunTelemetry = {
v: 1;
duration_ms: number;
actions: number;
};
export function IcarusGame({
onStart,
onFinish,
}: {
onStart: () => void;
onFinish: (score: number, run: IcarusRunTelemetry) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [score, setScore] = useState(0);
const onStartRef = useRef(onStart);
useEffect(() => {
onStartRef.current = onStart;
}, [onStart]);
const onFinishRef = useRef(onFinish);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
const icarusRef = useRef<IcarusState>(createInitialIcarus());
const columnsRef = useRef<Column[]>([]);
const statusRef = useRef<Status>("idle");
const scoreRef = useRef(0);
const flapRequestedRef = useRef(false);
const lastFlapAtRef = useRef<number | null>(null);
const boostsRef = useRef<Boost[]>([]);
const columnsSinceBoostRef = useRef(0);
const nextBoostThresholdRef = useRef(randomBoostInterval());
const boostColumnsRemainingRef = useRef(0);
const boostDashAtRef = useRef<number | null>(null);
const shatterEffectsRef = useRef<ShatterEffect[]>([]);
const nextEntityIdRef = useRef(0);
const runStartedAtRef = useRef<number | null>(null);
const actionCountRef = useRef(0);
const resetGame = useCallback(() => {
icarusRef.current = createInitialIcarus();
columnsRef.current = [];
scoreRef.current = 0;
statusRef.current = "idle";
flapRequestedRef.current = false;
lastFlapAtRef.current = null;
boostsRef.current = [];
columnsSinceBoostRef.current = 0;
nextBoostThresholdRef.current = randomBoostInterval();
boostColumnsRemainingRef.current = 0;
boostDashAtRef.current = null;
shatterEffectsRef.current = [];
runStartedAtRef.current = null;
actionCountRef.current = 0;
setStatus("idle");
setScore(0);
}, []);
const requestFlap = useCallback(() => {
if (statusRef.current === "dead") {
resetGame();
return;
}
if (statusRef.current === "idle") {
runStartedAtRef.current = Date.now();
actionCountRef.current = 1;
statusRef.current = "flying";
setStatus("flying");
onStartRef.current();
} else {
actionCountRef.current += 1;
}
flapRequestedRef.current = true;
}, [resetGame]);
// Sur ordinateur (pas de tap tactile) : la barre d'espace fait la même
// chose qu'un tap. event.repeat ignoré pour ne pas enchaîner des
// battements tant que la touche reste appuyée (même sémantique qu'un tap
// isolé) ; preventDefault pour empêcher le défilement de la page.
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.code !== "Space" || event.repeat) return;
event.preventDefault();
requestFlap();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [requestFlap]);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// "Contain" dans le conteneur (largeur ET hauteur, contrairement à avant
// où seule la largeur comptait) : nécessaire en plein écran, où la
// hauteur disponible varie et n'est plus déduite de la largeur.
function resize() {
const dpr = window.devicePixelRatio || 1;
const availableWidth = container!.clientWidth;
const availableHeight = container!.clientHeight;
let cssWidth = availableWidth;
let cssHeight = (cssWidth * VIEWPORT_HEIGHT) / VIEWPORT_WIDTH;
if (cssHeight > availableHeight) {
cssHeight = availableHeight;
cssWidth = (cssHeight * VIEWPORT_WIDTH) / VIEWPORT_HEIGHT;
}
canvas!.width = cssWidth * dpr;
canvas!.height = cssHeight * dpr;
canvas!.style.width = `${cssWidth}px`;
canvas!.style.height = `${cssHeight}px`;
}
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
let rafId: number;
let lastFrame: number | null = null;
let accumulator = 0;
function finishRun() {
statusRef.current = "dead";
setStatus("dead");
const startedAt = runStartedAtRef.current;
onFinishRef.current(scoreRef.current, {
v: 1,
duration_ms: startedAt === null ? 0 : Math.max(0, Date.now() - startedAt),
actions: actionCountRef.current,
});
}
function frame(now: number) {
if (lastFrame === null) lastFrame = now;
const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
lastFrame = now;
if (statusRef.current === "flying") {
accumulator += frameDt;
// Capturé puis effacé une fois pour toutes : évite qu'un même tap ne
// déclenche plusieurs battements si l'accumulateur itère plus d'une
// fois dans la même frame (après un ralentissement ponctuel).
let shouldFlap = flapRequestedRef.current;
flapRequestedRef.current = false;
while (accumulator >= FIXED_DT_MS) {
// Tant que le bouclier est actif, Icare est immobile (pas de
// gravité, les taps n'ont aucun effet) et le décor défile vite à
// sa place — impossible de toucher au mouvement pendant cette
// période.
if (boostColumnsRemainingRef.current > 0) {
shouldFlap = false;
} else {
if (shouldFlap) {
icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE };
lastFlapAtRef.current = now;
shouldFlap = false;
}
icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS);
}
// Difficulté progressive : vitesse en hausse et écart resserré au
// fil du score, jusqu'à un palier (voir difficultyProgress).
const progress = difficultyProgress(scoreRef.current);
const baseSpeed = FORWARD_SPEED * (1 + (SPEED_MAX_MULTIPLIER - 1) * progress);
// Défilement accéléré tant que le bouclier du boost est actif
// (sans risque : une colonne touchée casse au lieu de tuer).
const currentSpeed = boostColumnsRemainingRef.current > 0 ? baseSpeed * BOOST_SPEED_MULTIPLIER : baseSpeed;
const currentGap = COLUMN_GAP * (1 - (1 - GAP_MIN_MULTIPLIER) * progress);
for (const column of columnsRef.current) {
column.x -= currentSpeed * (FIXED_DT_MS / 1000);
if (!column.scored && column.x + COLUMN_WIDTH < ICARUS_X - ICARUS_RADIUS) {
column.scored = true;
scoreRef.current += 1;
setScore(scoreRef.current);
// Colonne franchie sans contact pendant le boost : compte
// quand même dans la distance du bouclier (voir plus bas).
if (boostColumnsRemainingRef.current > 0) {
boostColumnsRemainingRef.current -= 1;
}
}
}
columnsRef.current = columnsRef.current.filter((c) => c.x + COLUMN_WIDTH > 0);
const last = columnsRef.current[columnsRef.current.length - 1];
if (!last || last.x < VIEWPORT_WIDTH - COLUMN_SPACING) {
const spawned: Column = {
x: last ? last.x + COLUMN_SPACING : VIEWPORT_WIDTH,
gapCenterY: randomGapCenter(currentGap),
gap: currentGap,
scored: false,
};
columnsRef.current.push(spawned);
// Rare et exceptionnel : intervalle large (voir constants.ts),
// pas de plafond dur — placé au centre du trou de la colonne
// qui le porte, toujours atteignable en volant simplement au
// milieu du passage.
columnsSinceBoostRef.current += 1;
if (columnsSinceBoostRef.current >= nextBoostThresholdRef.current) {
columnsSinceBoostRef.current = 0;
nextBoostThresholdRef.current = randomBoostInterval();
boostsRef.current.push({
id: nextEntityIdRef.current++,
x: spawned.x + COLUMN_WIDTH / 2,
y: spawned.gapCenterY,
});
}
}
for (const boost of boostsRef.current) {
boost.x -= currentSpeed * (FIXED_DT_MS / 1000);
}
boostsRef.current = boostsRef.current.filter((b) => b.x + BOOST_RADIUS > 0);
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
if (collectedIndex !== -1) {
boostsRef.current.splice(collectedIndex, 1);
playBoostSound();
// Active le bouclier pour les N prochaines colonnes (distance
// aléatoire, voir plus bas) et déclenche l'animation de bond en
// avant. Icare se fige (vitesse verticale annulée, plus de
// gravité ni de prise en compte des taps tant que dure le
// bouclier, voir plus haut).
boostColumnsRemainingRef.current = randomBoostDistance();
boostDashAtRef.current = now;
icarusRef.current = { ...icarusRef.current, velocityY: 0 };
}
// Colonnes réellement touchées (mortelles en temps normal). Tant
// que la distance du bouclier n'est pas épuisée, une colonne
// touchée se casse au lieu de tuer et compte dans cette distance
// (comme une colonne franchie sans contact, voir plus haut) ;
// sinon mort normale.
const touchedColumns = columnsRef.current.filter((c) => collidesWithColumn(icarusRef.current.y, c));
if (touchedColumns.length > 0) {
if (boostColumnsRemainingRef.current > 0) {
const broken = touchedColumns.slice(0, boostColumnsRemainingRef.current);
boostColumnsRemainingRef.current -= broken.length;
for (const column of broken) {
if (!column.scored) {
column.scored = true;
scoreRef.current += 1;
}
shatterEffectsRef.current.push({
id: nextEntityIdRef.current++,
x: column.x + COLUMN_WIDTH / 2,
y: column.gapCenterY,
createdAtMs: now,
});
}
columnsRef.current = columnsRef.current.filter((c) => !broken.includes(c));
setScore(scoreRef.current);
} else {
finishRun();
break;
}
}
if (boostColumnsRemainingRef.current === 0 && hasHitGround(icarusRef.current)) {
finishRun();
break;
}
accumulator -= FIXED_DT_MS;
}
}
shatterEffectsRef.current = shatterEffectsRef.current.filter((e) => now - e.createdAtMs < SHATTER_ANIM_MS);
// Bond en avant à la cueillette : aller-retour (pic à mi-parcours),
// pas un déplacement qui persiste au-delà de l'animation.
const dashElapsed = boostDashAtRef.current === null ? Infinity : now - boostDashAtRef.current;
const dashProgress = Math.min(1, dashElapsed / BOOST_DASH_MS);
const dashOffset = dashProgress >= 1 ? 0 : Math.sin(dashProgress * Math.PI) * BOOST_DASH_DISTANCE;
// Battement continu en boucle (se voit même sans taper, dès l'écran de
// repos) : oscille doucement entre le repos et un pic modéré.
const idlePhase = (now % WING_IDLE_PERIOD_MS) / WING_IDLE_PERIOD_MS;
const idleWave = 0.5 - 0.5 * Math.cos(idlePhase * 2 * Math.PI);
const idleWingAngle = WING_REST_RADIANS + (WING_IDLE_PEAK_RADIANS - WING_REST_RADIANS) * idleWave;
// Battement plus ample déclenché par un tap, qui prend le dessus sur
// le cycle continu le temps de sa décrue.
const sinceFlapMs = now - (lastFlapAtRef.current ?? -Infinity);
const flapProgress = Math.max(0, 1 - sinceFlapMs / WING_FLAP_ANIM_MS);
const eased = flapProgress * flapProgress;
const tapWingAngle = WING_REST_RADIANS + (WING_FLAP_MAX_RADIANS - WING_REST_RADIANS) * eased;
const wingAngle = statusRef.current === "dead" ? WING_REST_RADIANS : Math.max(idleWingAngle, tapWingAngle);
render(
ctx!,
canvas!,
icarusRef.current,
columnsRef.current,
boostsRef.current,
shatterEffectsRef.current,
dashOffset,
boostColumnsRemainingRef.current > 0,
statusRef.current,
scoreRef.current,
wingAngle,
now,
);
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
return () => {
observer.disconnect();
cancelAnimationFrame(rafId);
};
}, []);
const prefersReducedMotion =
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return (
<div
ref={containerRef}
onPointerDown={requestFlap}
className="relative flex flex-1 touch-none items-center justify-center overflow-hidden bg-ink select-none"
>
<canvas ref={canvasRef} className="block" />
{status === "dead" && !prefersReducedMotion && <Confetti />}
{status === "idle" && (
<div className="pointer-events-none absolute inset-x-0 bottom-10 flex justify-center px-6">
<p className="marble-surface animate-reveal rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble shadow-lg">
Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes.
</p>
</div>
)}
{status === "dead" && (
<div className="absolute inset-0 flex items-center justify-center bg-ink/50">
<div className="marble-surface animate-reveal flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-6 py-4 text-center shadow-lg">
<p className="text-xs tracking-wide text-text-mut uppercase">Score</p>
<p className="font-heading text-3xl text-gold-bright">{score}</p>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={resetGame}
className="mt-1 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
>
Rejouer
</button>
</div>
</div>
)}
</div>
);
}
function render(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
icarus: IcarusState,
columns: Column[],
boosts: Boost[],
shatterEffects: ShatterEffect[],
dashOffset: number,
boosted: boolean,
status: Status,
score: number,
wingAngle: number,
now: number,
) {
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const worldScale = cssWidth / VIEWPORT_WIDTH;
ctx.save();
ctx.scale(dpr, dpr);
ctx.save();
ctx.scale(worldScale, worldScale);
ctx.clearRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
ctx.fillStyle = "#0A1B33";
ctx.fillRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
for (const column of columns) {
drawColumn(ctx, column);
}
for (const boost of boosts) {
drawBoost(ctx, boost, now);
}
for (const effect of shatterEffects) {
drawShatterEffect(ctx, effect, now);
}
ctx.fillStyle = "#2A2116";
ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y);
const icarusX = ICARUS_X + dashOffset;
if (boosted) {
drawSpeedLines(ctx, icarusX, icarus.y, now);
}
// Traînée de vitesse derrière Icare pendant le bond en avant.
if (dashOffset > 1) {
ctx.save();
ctx.translate(icarusX, icarus.y);
for (let i = 1; i <= 3; i++) {
ctx.globalAlpha = (dashOffset / BOOST_DASH_DISTANCE) * (0.3 / i);
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.ellipse(-i * 10, 0, ICARUS_RADIUS * 0.7, ICARUS_RADIUS * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
const tilt = Math.max(-1, Math.min(1, icarus.velocityY / 300)) * TILT_MAX_RADIANS;
drawIcarus(ctx, icarusX, icarus.y, tilt, wingAngle, boosted);
ctx.restore();
if (status === "flying") {
ctx.save();
ctx.fillStyle = "#F4ECD8";
ctx.font = "700 28px Cinzel, serif";
ctx.textAlign = "center";
ctx.fillText(String(score), cssWidth / 2, 44);
ctx.restore();
}
ctx.restore();
}