diff --git a/src/app/globals.css b/src/app/globals.css index b2052e8..210e234 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -164,4 +164,21 @@ body { .animate-reveal { animation: reveal-pop 700ms cubic-bezier(0.2, 0.7, 0.3, 1) both; } + + /* Confettis de fin de partie (Le Vol d'Icare) : chute + rotation + fondu, + dérive latérale et vitesse propres à chaque confetti via variables CSS. */ + @keyframes confetti-fall { + 0% { + transform: translate(0, -10%) rotate(0deg); + opacity: 1; + } + 100% { + transform: translate(var(--confetti-drift, 0px), 340px) rotate(var(--confetti-spin, 360deg)); + opacity: 0; + } + } + + .animate-confetti-fall { + animation: confetti-fall 1200ms ease-in both; + } } diff --git a/src/app/icare/icare-view.tsx b/src/app/icare/icare-view.tsx index 3aca68e..d0ccb37 100644 --- a/src/app/icare/icare-view.tsx +++ b/src/app/icare/icare-view.tsx @@ -93,14 +93,14 @@ export function IcareView({ )} {ownBestScore !== null && ( -

+

Ton record : {ownBestScore}

)} -

+

{isFrozen ? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal." : "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."} diff --git a/src/app/icare/icarus-game.tsx b/src/app/icare/icarus-game.tsx index 4642600..bfcea5d 100644 --- a/src/app/icare/icarus-game.tsx +++ b/src/app/icare/icarus-game.tsx @@ -16,41 +16,69 @@ import { 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 { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics"; import type { Column, IcarusState } from "@/lib/icarus/types"; type Status = "idle" | "flying" | "dead"; +const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"]; + function randomGapCenter(): number { const minCenter = COLUMN_GAP / 2 + COLUMN_GAP_MARGIN; const maxCenter = GROUND_Y - COLUMN_GAP / 2 - COLUMN_GAP_MARGIN; return minCenter + Math.random() * Math.max(0, maxCenter - minCenter); } -function drawIcarus(ctx: CanvasRenderingContext2D, y: number, tilt: number) { +// 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, y: number, tilt: number, wingAngle: number) { ctx.save(); ctx.translate(ICARUS_X, y); ctx.rotate(tilt); - ctx.fillStyle = "#F4ECD8"; - ctx.beginPath(); - ctx.moveTo(-2, -2); - ctx.lineTo(-ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3); - ctx.lineTo(-ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5); - ctx.closePath(); - ctx.fill(); - ctx.beginPath(); - ctx.moveTo(2, -2); - ctx.lineTo(ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3); - ctx.lineTo(ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5); - ctx.closePath(); - ctx.fill(); - ctx.fillStyle = "#E7C560"; ctx.beginPath(); - ctx.arc(0, 0, ICARUS_RADIUS, 0, Math.PI * 2); + 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(); } @@ -106,12 +134,15 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) const statusRef = useRef("idle"); const scoreRef = useRef(0); const flapRequestedRef = useRef(false); + const lastFlapAtRef = useRef(null); function resetGame() { icarusRef.current = createInitialIcarus(); columnsRef.current = []; scoreRef.current = 0; statusRef.current = "idle"; + flapRequestedRef.current = false; + lastFlapAtRef.current = null; setStatus("idle"); setScore(0); } @@ -158,10 +189,17 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) 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) { - if (flapRequestedRef.current) { + if (shouldFlap) { icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE }; + lastFlapAtRef.current = now; + shouldFlap = false; } icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS); @@ -196,8 +234,22 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) } } - flapRequestedRef.current = false; - render(ctx!, canvas!, icarusRef.current, columnsRef.current, statusRef.current, scoreRef.current); + // 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, statusRef.current, scoreRef.current, wingAngle); rafId = requestAnimationFrame(frame); } @@ -209,6 +261,9 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) }; }, []); + const prefersReducedMotion = + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + return (

void }) className="relative w-full max-w-xs touch-none select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl" > + + {status === "dead" && !prefersReducedMotion && } + + {status === "dead" && ( +
+
+

Score

+

{score}

+ +
+
+ )}
-

{score}

- {status === "idle" && ( -

+

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

)} +
+ ); +} - {status === "dead" && ( - - )} +type ConfettiPiece = { + id: number; + left: number; + color: string; + delay: number; + duration: number; + spin: number; + drift: number; +}; + +function generateConfettiPieces(): ConfettiPiece[] { + return Array.from({ length: 26 }, (_, index) => ({ + id: index, + left: Math.random() * 100, + color: CONFETTI_COLORS[index % CONFETTI_COLORS.length], + delay: Math.random() * 250, + duration: 900 + Math.random() * 700, + spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360), + drift: (Math.random() - 0.5) * 70, + })); +} + +function Confetti() { + // Initialiseur paresseux de useState (n'exécute Math.random() qu'une + // seule fois, au montage — Confetti est remonté à chaque nouvelle partie + // terminée) plutôt qu'un appel impur dans le corps du composant. + const [pieces] = useState(generateConfettiPieces); + + return ( +
+ {pieces.map((piece) => ( + + ))}
); } @@ -247,6 +359,7 @@ function render( columns: Column[], status: Status, score: number, + wingAngle: number, ) { const dpr = window.devicePixelRatio || 1; const cssWidth = canvas.width / dpr; @@ -269,7 +382,7 @@ function render( ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y); const tilt = Math.max(-1, Math.min(1, icarus.velocityY / 300)) * TILT_MAX_RADIANS; - drawIcarus(ctx, icarus.y, tilt); + drawIcarus(ctx, icarus.y, tilt, wingAngle); ctx.restore(); diff --git a/src/lib/icarus/constants.ts b/src/lib/icarus/constants.ts index 025febb..e1a105a 100644 --- a/src/lib/icarus/constants.ts +++ b/src/lib/icarus/constants.ts @@ -17,6 +17,16 @@ export const FLAP_IMPULSE = -300; // vitesse verticale imposée à chaque battem export const MAX_FALL_SPEED = 480; export const TILT_MAX_RADIANS = (45 * Math.PI) / 180; +// Animation des ailes (indépendante de l'inclinaison du corps) : un battement +// continu en boucle (pour que ça se voie même sans taper, dès l'écran de +// repos) + un battement plus ample et net à chaque tap, qui prend le dessus +// brièvement sur le cycle continu. +export const WING_REST_RADIANS = -(20 * Math.PI) / 180; +export const WING_IDLE_PEAK_RADIANS = (15 * Math.PI) / 180; +export const WING_IDLE_PERIOD_MS = 480; +export const WING_FLAP_MAX_RADIANS = (75 * Math.PI) / 180; +export const WING_FLAP_ANIM_MS = 260; + export const FORWARD_SPEED = 130; // unités/seconde, vitesse de défilement des colonnes export const COLUMN_WIDTH = 46; export const COLUMN_GAP = 128;