358bed0c2e
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>
719 lines
24 KiB
TypeScript
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'écran pour battre des ailes et t'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();
|
|
}
|