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 {
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 && (
<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>
</p>
)}
<IcarusGame onFinish={handleFinish} />
<p className="text-center text-xs text-text-mut">
<p className="text-center text-xs text-marble/60">
{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."}
+145 -32
View File
@@ -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<Status>("idle");
const scoreRef = useRef(0);
const flapRequestedRef = useRef(false);
const lastFlapAtRef = useRef<number | null>(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 (
<div className="flex flex-col items-center gap-3">
<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"
>
<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>
<p className="text-center font-heading text-2xl text-gold-bright">{score}</p>
{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.
</p>
)}
</div>
);
}
{status === "dead" && (
<button
type="button"
onClick={resetGame}
className="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>
)}
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 (
<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>
);
}
@@ -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();