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;