"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(null); const containerRef = useRef(null); const [status, setStatus] = useState("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(createInitialIcarus()); const columnsRef = useRef([]); const statusRef = useRef("idle"); const scoreRef = useRef(0); const flapRequestedRef = useRef(false); const lastFlapAtRef = useRef(null); const boostsRef = useRef([]); const columnsSinceBoostRef = useRef(0); const nextBoostThresholdRef = useRef(randomBoostInterval()); const boostColumnsRemainingRef = useRef(0); const boostDashAtRef = useRef(null); const shatterEffectsRef = useRef([]); const nextEntityIdRef = useRef(0); const runStartedAtRef = useRef(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 (
{status === "dead" && !prefersReducedMotion && } {status === "idle" && (

Touche l'écran pour battre des ailes et t'envoler entre les colonnes.

)} {status === "dead" && (

Score

{score}

)}
); } 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(); }