Peaufine Le Vol d'Icare : animation, overlay, contraste
Build and deploy / deploy (push) Successful in 36s

- Écran de fin (score + Rejouer) déplacé en overlay par-dessus le jeu
  (fond assombri, carte animée, confettis) plutôt qu'en dessous.
- Battement d'ailes repensé : cycle continu en boucle (visible même au
  repos, sans taper) + battement plus ample au tap qui prend le dessus
  brièvement. L'ancienne version ne bougeait qu'au moment du tap et
  retombait trop vite pour être perceptible en jeu réel.
- Corrige une aile qui semblait ne pas bouger : les deux ailes utilisaient
  des teintes différentes et l'une passait partiellement derrière le
  corps ; elles sont maintenant identiques et toutes deux dessinées
  au-dessus du corps. Corrige au passage un signe de rotation inversé.
- Corrige le contraste de plusieurs textes (« Ton record », instructions,
  pied de page) qui utilisaient une couleur prévue pour du texte sur
  marbre alors qu'ils s'affichent sur le fond sombre de la page.
This commit is contained in:
Valentin ROBIN
2026-07-30 00:59:15 +02:00
parent 33d2476ef6
commit 7c5ce9dcb9
4 changed files with 174 additions and 34 deletions
+17
View File
@@ -164,4 +164,21 @@ body {
.animate-reveal { .animate-reveal {
animation: reveal-pop 700ms cubic-bezier(0.2, 0.7, 0.3, 1) both; 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;
}
} }
+2 -2
View File
@@ -93,14 +93,14 @@ export function IcareView({
)} )}
{ownBestScore !== null && ( {ownBestScore !== null && (
<p className="text-center font-heading text-sm tracking-wide text-text-marble"> <p className="text-center font-heading text-sm tracking-wide text-marble">
Ton record : <span className="text-gold-bright">{ownBestScore}</span> Ton record : <span className="text-gold-bright">{ownBestScore}</span>
</p> </p>
)} )}
<IcarusGame onFinish={handleFinish} /> <IcarusGame onFinish={handleFinish} />
<p className="text-center text-xs text-text-mut"> <p className="text-center text-xs text-marble/60">
{isFrozen {isFrozen
? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal." ? "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."} : "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."}
+145 -32
View File
@@ -16,41 +16,69 @@ import {
TILT_MAX_RADIANS, TILT_MAX_RADIANS,
VIEWPORT_HEIGHT, VIEWPORT_HEIGHT,
VIEWPORT_WIDTH, 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"; } from "@/lib/icarus/constants";
import { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics"; import { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics";
import type { Column, IcarusState } from "@/lib/icarus/types"; import type { Column, IcarusState } from "@/lib/icarus/types";
type Status = "idle" | "flying" | "dead"; type Status = "idle" | "flying" | "dead";
const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
function randomGapCenter(): number { function randomGapCenter(): number {
const minCenter = COLUMN_GAP / 2 + COLUMN_GAP_MARGIN; const minCenter = COLUMN_GAP / 2 + COLUMN_GAP_MARGIN;
const maxCenter = GROUND_Y - 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); 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.save();
ctx.translate(ICARUS_X, y); ctx.translate(ICARUS_X, y);
ctx.rotate(tilt); 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.fillStyle = "#E7C560";
ctx.beginPath(); 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.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(); ctx.restore();
} }
@@ -106,12 +134,15 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const statusRef = useRef<Status>("idle"); const statusRef = useRef<Status>("idle");
const scoreRef = useRef(0); const scoreRef = useRef(0);
const flapRequestedRef = useRef(false); const flapRequestedRef = useRef(false);
const lastFlapAtRef = useRef<number | null>(null);
function resetGame() { function resetGame() {
icarusRef.current = createInitialIcarus(); icarusRef.current = createInitialIcarus();
columnsRef.current = []; columnsRef.current = [];
scoreRef.current = 0; scoreRef.current = 0;
statusRef.current = "idle"; statusRef.current = "idle";
flapRequestedRef.current = false;
lastFlapAtRef.current = null;
setStatus("idle"); setStatus("idle");
setScore(0); setScore(0);
} }
@@ -158,10 +189,17 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
if (statusRef.current === "flying") { if (statusRef.current === "flying") {
accumulator += frameDt; 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) { while (accumulator >= FIXED_DT_MS) {
if (flapRequestedRef.current) { if (shouldFlap) {
icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE }; icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE };
lastFlapAtRef.current = now;
shouldFlap = false;
} }
icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS); icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS);
@@ -196,8 +234,22 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
} }
} }
flapRequestedRef.current = false; // Battement continu en boucle (se voit même sans taper, dès l'écran de
render(ctx!, canvas!, icarusRef.current, columnsRef.current, statusRef.current, scoreRef.current); // 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); 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 ( return (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div <div
@@ -217,25 +272,82 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
className="relative w-full max-w-xs touch-none select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl" className="relative w-full max-w-xs touch-none select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
> >
<canvas ref={canvasRef} className="block h-full w-full" /> <canvas ref={canvasRef} className="block h-full w-full" />
{status === "dead" && !prefersReducedMotion && <Confetti />}
{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> </div>
<p className="text-center font-heading text-2xl text-gold-bright">{score}</p>
{status === "idle" && ( {status === "idle" && (
<p className="text-center text-sm text-text-mut"> <p className="text-center text-sm text-marble/60">
Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes. Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes.
</p> </p>
)} )}
</div>
);
}
{status === "dead" && ( type ConfettiPiece = {
<button id: number;
type="button" left: number;
onClick={resetGame} color: string;
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink" delay: number;
> duration: number;
Rejouer spin: number;
</button> 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 (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{pieces.map((piece) => (
<span
key={piece.id}
className="animate-confetti-fall absolute top-0 h-2.5 w-1.5 rounded-[1px]"
style={
{
left: `${piece.left}%`,
backgroundColor: piece.color,
animationDelay: `${piece.delay}ms`,
animationDuration: `${piece.duration}ms`,
"--confetti-spin": `${piece.spin}deg`,
"--confetti-drift": `${piece.drift}px`,
} as React.CSSProperties
}
/>
))}
</div> </div>
); );
} }
@@ -247,6 +359,7 @@ function render(
columns: Column[], columns: Column[],
status: Status, status: Status,
score: number, score: number,
wingAngle: number,
) { ) {
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr; const cssWidth = canvas.width / dpr;
@@ -269,7 +382,7 @@ function render(
ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y); 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; 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(); ctx.restore();
+10
View File
@@ -17,6 +17,16 @@ export const FLAP_IMPULSE = -300; // vitesse verticale imposée à chaque battem
export const MAX_FALL_SPEED = 480; export const MAX_FALL_SPEED = 480;
export const TILT_MAX_RADIANS = (45 * Math.PI) / 180; 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 FORWARD_SPEED = 130; // unités/seconde, vitesse de défilement des colonnes
export const COLUMN_WIDTH = 46; export const COLUMN_WIDTH = 46;
export const COLUMN_GAP = 128; export const COLUMN_GAP = 128;