Peaufine Le Vol d'Icare : animation, overlay, contraste
Build and deploy / deploy (push) Successful in 36s
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:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."}
|
||||||
|
|||||||
+142
-29
@@ -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,26 +272,83 @@ 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" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center font-heading text-2xl text-gold-bright">{score}</p>
|
{status === "dead" && !prefersReducedMotion && <Confetti />}
|
||||||
|
|
||||||
{status === "idle" && (
|
|
||||||
<p className="text-center text-sm text-text-mut">
|
|
||||||
Touche l'écran pour battre des ailes et t'envoler entre les colonnes.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "dead" && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onClick={resetGame}
|
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"
|
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
|
Rejouer
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status === "idle" && (
|
||||||
|
<p className="text-center text-sm text-marble/60">
|
||||||
|
Touche l'écran pour battre des ailes et t'envoler entre les colonnes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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[],
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user