"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bot, ChevronLeft, Music2, Pause, Play, RotateCcw, Trophy, Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { BeatEngine } from "./audio";
import { DEFAULT_TRACKS, buildChart, gradeFor, multiplierFor, type Chart, type TrackDef } from "./chart";
import { Game, draw, laneFromX, layoutFor, type Palette } from "./game";
export type { TrackDef } from "./chart";
export interface RhythmTapTheme {
/** Four lane colours (any CSS colour). */
lanes?: [string, string, string, string];
}
export interface RhythmTapResult {
trackId: string;
score: number;
accuracy: number;
grade: string;
maxCombo: number;
cleared: boolean;
}
export interface RhythmTapProps {
tracks?: TrackDef[];
/** Index of the track selected on the menu (0 = Easy, 1 = Normal, 2 = Hard). */
initialLevel?: number;
storageKey?: string | null;
onGameOver?: (score: number, result: RhythmTapResult) => void;
theme?: RhythmTapTheme;
className?: string;
}
type Phase = "menu" | "playing" | "paused" | "resuming" | "results";
const LANE_DEFAULT: [string, string, string, string] = ["#ff5d8f", "#ffb020", "#2dd4a3", "#5b8cff"];
const KEYMAP: Record<string, number> = { d: 0, f: 1, j: 2, k: 3, arrowleft: 0, arrowdown: 1, arrowup: 2, arrowright: 3 };
interface Best {
score: number;
grade: string;
}
function readBests(key: string | null): Record<string, Best> {
if (!key) return {};
try {
return JSON.parse(window.localStorage.getItem(key) ?? "{}") as Record<string, Best>;
} catch {
return {};
}
}
function writeBests(key: string | null, v: Record<string, Best>) {
if (!key) return;
try {
window.localStorage.setItem(key, JSON.stringify(v));
} catch {
/* ignore */
}
}
/** Resolve any CSS colour (incl. oklch/var) to rgb() via a 1×1 canvas. */
function resolveColor(css: string, el: HTMLElement) {
const probe = document.createElement("span");
probe.style.color = css;
probe.style.display = "none";
el.appendChild(probe);
const computed = getComputedStyle(probe).color;
probe.remove();
const c = document.createElement("canvas");
c.width = c.height = 1;
const x = c.getContext("2d");
if (!x) return "rgb(128 128 128)";
x.fillStyle = computed;
x.fillRect(0, 0, 1, 1);
const [r, g, b] = x.getImageData(0, 0, 1, 1).data;
return `rgb(${r} ${g} ${b})`;
}
function readPalette(el: HTMLElement, lanes: string[]): Palette {
const bg = resolveColor("var(--background)", el);
const m = bg.match(/\d+/g)?.map(Number) ?? [255, 255, 255];
const dark = (m[0] * 299 + m[1] * 587 + m[2] * 114) / 1000 < 128;
return {
bg,
fg: resolveColor("var(--foreground)", el),
muted: resolveColor("var(--muted-foreground)", el),
card: resolveColor("var(--card)", el),
border: resolveColor("var(--border)", el),
lanes: lanes.map((c) => resolveColor(c, el)),
dark,
};
}
export function RhythmTap({ tracks = DEFAULT_TRACKS, initialLevel = 0, storageKey = "fazekit:rhythm-tap:v1", onGameOver, theme, className }: RhythmTapProps) {
const reduce = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const stageRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const progressRef = React.useRef<HTMLDivElement>(null);
const lanes = theme?.lanes ?? LANE_DEFAULT;
const [phase, setPhase] = React.useState<Phase>("menu");
const [trackIdx, setTrackIdx] = React.useState(Math.max(0, Math.min(tracks.length - 1, initialLevel)));
const [autoplay, setAutoplay] = React.useState(false);
const [sound, setSound] = React.useState(false);
const [bests, setBests] = React.useState<Record<string, Best>>({});
const [hud, setHud] = React.useState({ score: 0, combo: 0, life: 1, mult: 1 });
const [result, setResult] = React.useState<(RhythmTapResult & { counts: { perfect: number; great: number; miss: number }; newBest: boolean }) | null>(null);
const [countdown, setCountdown] = React.useState<number | null>(null);
const [live, setLive] = React.useState<{ chart: Chart; autoplay: boolean } | null>(null);
const charts = React.useMemo(() => tracks.map((t) => buildChart(t)), [tracks]);
const chart: Chart = charts[trackIdx];
const gameRef = React.useRef<Game | null>(null);
const demoRef = React.useRef<Game | null>(null);
const engineRef = React.useRef<BeatEngine | null>(null);
const clockRef = React.useRef({ start: 0, pausedAt: 0, pausedTotal: 0 });
const paletteRef = React.useRef<Palette | null>(null);
const phaseRef = React.useRef<Phase>("menu");
const soundRef = React.useRef(false);
const layoutRef = React.useRef(layoutFor(400, 600));
const pointerLanes = React.useRef(new Map<number, number>());
const coarseRef = React.useRef(false);
React.useEffect(() => {
coarseRef.current = window.matchMedia("(pointer: coarse)").matches;
}, []);
React.useEffect(() => {
phaseRef.current = phase;
}, [phase]);
React.useEffect(() => {
soundRef.current = sound;
}, [sound]);
React.useEffect(() => {
setBests(readBests(storageKey));
}, [storageKey]);
const songTime = React.useCallback(() => {
const c = clockRef.current;
const now = phaseRef.current === "paused" || phaseRef.current === "resuming" ? c.pausedAt : performance.now();
return (now - c.start - c.pausedTotal) / 1000;
}, []);
// palette follows light/dark theme changes
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const read = () => (paletteRef.current = readPalette(el, lanes));
read();
const mo = new MutationObserver(read);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", read);
return () => {
mo.disconnect();
mq.removeEventListener("change", read);
};
}, [lanes]);
// canvas sizing
React.useEffect(() => {
const stage = stageRef.current;
const canvas = canvasRef.current;
if (!stage || !canvas) return;
const fit = () => {
const r = stage.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
canvas.style.width = `${r.width}px`;
canvas.style.height = `${r.height}px`;
const ctx = canvas.getContext("2d");
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);
layoutRef.current = layoutFor(r.width, r.height);
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(stage);
return () => ro.disconnect();
}, []);
const finish = React.useCallback(
(g: Game) => {
const cleared = g.ended === "clear";
const accuracy = g.accuracy;
const grade = cleared ? gradeFor(accuracy) : "F";
const track = g.chart.track;
let newBest = false;
if (!g.autoplay) {
const prev = bests[track.id];
if (!prev || g.score > prev.score) {
newBest = g.score > 0;
const next = { ...bests, [track.id]: { score: g.score, grade: cleared ? grade : (prev?.grade ?? "F") } };
if (prev && !cleared) next[track.id].grade = prev.grade;
setBests(next);
writeBests(storageKey, next);
}
}
const r = { trackId: track.id, score: g.score, accuracy, grade, maxCombo: g.maxCombo, cleared, counts: { ...g.counts }, newBest };
setResult(r);
setPhase("results");
engineRef.current?.suspend();
onGameOver?.(g.score, r);
},
[bests, storageKey, onGameOver],
);
const finishRef = React.useRef(finish);
React.useEffect(() => {
finishRef.current = finish;
}, [finish]);
const chartRef = React.useRef(chart);
React.useEffect(() => {
chartRef.current = chart;
}, [chart]);
// main render loop (always on: attract mode on menu)
React.useEffect(() => {
let raf = 0;
let last = performance.now();
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const P = paletteRef.current;
const L = layoutRef.current;
if (ctx && P) {
const ph = phaseRef.current;
const live = gameRef.current;
if ((ph === "playing" || ph === "paused" || ph === "resuming" || ph === "results") && live) {
if (ph === "playing") {
const t = songTime();
live.update(t);
if (soundRef.current && engineRef.current) engineRef.current.schedule(live.chart, t);
if (progressRef.current) progressRef.current.style.transform = `scaleX(${Math.min(1, Math.max(0, t / live.chart.duration))})`;
if (live.dirty) {
live.dirty = false;
setHud({ score: live.score, combo: live.combo, life: live.life, mult: multiplierFor(live.combo) });
}
if (live.ended) finishRef.current(live);
}
draw(ctx, live, L, P, { reduce: !!reduce, dt: ph === "playing" ? dt : 0, showKeys: L.w >= 300 && !coarseRef.current, dim: ph !== "playing" ? 0.55 : 0 });
} else {
let demo = demoRef.current;
if (!demo || demo.chart !== chartRef.current || demo.t > demo.chart.duration) {
demo = new Game(chartRef.current, true);
demo.t = chartRef.current.lead - 0.4;
demoRef.current = demo;
}
demo.update(demo.t + dt);
draw(ctx, demo, L, P, { reduce: !!reduce, dt, showKeys: false, dim: 0.74 });
}
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [reduce, songTime]);
const start = React.useCallback(() => {
const g = new Game(chart, autoplay);
g.onJudge = (kind, note) => {
if (!soundRef.current) return;
if (kind === "miss") engineRef.current?.missThud();
else engineRef.current?.tone(note.pitch, kind);
};
gameRef.current = g;
setLive({ chart, autoplay });
clockRef.current = { start: performance.now(), pausedAt: 0, pausedTotal: 0 };
engineRef.current?.resetSchedule();
if (soundRef.current) {
if (!engineRef.current) engineRef.current = new BeatEngine();
engineRef.current.ensure();
}
setHud({ score: 0, combo: 0, life: 1, mult: 1 });
setResult(null);
setPhase("playing");
stageRef.current?.focus();
}, [chart, autoplay]);
const pause = React.useCallback(() => {
if (phaseRef.current !== "playing") return;
clockRef.current.pausedAt = performance.now();
phaseRef.current = "paused";
setPhase("paused");
engineRef.current?.suspend();
for (let i = 0; i < 4; i++) gameRef.current?.release(i);
}, []);
const resume = React.useCallback(() => {
if (phaseRef.current !== "paused") return;
setPhase("resuming");
let n = 3;
setCountdown(n);
const tick = () => {
n -= 1;
if (n <= 0) {
const c = clockRef.current;
c.pausedTotal += performance.now() - c.pausedAt;
setCountdown(null);
phaseRef.current = "playing";
setPhase("playing");
engineRef.current?.resume();
stageRef.current?.focus();
} else {
setCountdown(n);
window.setTimeout(tick, 450);
}
};
window.setTimeout(tick, 450);
}, []);
const toggleSound = () => {
const next = !sound;
setSound(next);
soundRef.current = next;
if (next) {
if (!engineRef.current) engineRef.current = new BeatEngine();
engineRef.current.ensure();
engineRef.current.resetSchedule();
if (phaseRef.current !== "playing") engineRef.current.suspend();
} else engineRef.current?.suspend();
};
React.useEffect(() => () => engineRef.current?.close(), []);
// pause on tab blur / hide
React.useEffect(() => {
const onHide = () => document.hidden && pause();
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", onHide);
return () => {
window.removeEventListener("blur", pause);
document.removeEventListener("visibilitychange", onHide);
};
}, [pause]);
// keyboard
React.useEffect(() => {
const onDown = (e: KeyboardEvent) => {
const root = rootRef.current;
const active = document.activeElement;
if (!root || (active && active !== document.body && !root.contains(active))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key.toLowerCase();
const ph = phaseRef.current;
if (k in KEYMAP && ph === "playing") {
e.preventDefault();
if (!e.repeat) gameRef.current?.press(KEYMAP[k]);
return;
}
if (k === "escape" || k === "p" || (k === " " && ph !== "menu" && ph !== "results")) {
e.preventDefault();
if (ph === "playing") pause();
else if (ph === "paused") resume();
return;
}
if (ph === "menu") {
if (k === "arrowdown" || k === "arrowright") {
e.preventDefault();
setTrackIdx((i) => (i + 1) % tracks.length);
} else if (k === "arrowup" || k === "arrowleft") {
e.preventDefault();
setTrackIdx((i) => (i - 1 + tracks.length) % tracks.length);
} else if (k === "enter" && !(active instanceof HTMLButtonElement)) start();
} else if (ph === "results" && k === "enter" && !(active instanceof HTMLButtonElement)) start();
};
const onUp = (e: KeyboardEvent) => {
const k = e.key.toLowerCase();
if (k in KEYMAP) gameRef.current?.release(KEYMAP[k]);
};
window.addEventListener("keydown", onDown);
window.addEventListener("keyup", onUp);
return () => {
window.removeEventListener("keydown", onDown);
window.removeEventListener("keyup", onUp);
};
}, [pause, resume, start, tracks.length]);
// touch / mouse lanes
const onPointerDown = (e: React.PointerEvent) => {
if (phaseRef.current !== "playing") return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const lane = laneFromX(layoutRef.current, e.clientX - rect.left);
pointerLanes.current.set(e.pointerId, lane);
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
gameRef.current?.press(lane);
};
const onPointerUp = (e: React.PointerEvent) => {
const lane = pointerLanes.current.get(e.pointerId);
if (lane === undefined) return;
pointerLanes.current.delete(e.pointerId);
gameRef.current?.release(lane);
};
const track = tracks[trackIdx];
const style = { "--rt-l0": lanes[0], "--rt-l1": lanes[1], "--rt-l2": lanes[2], "--rt-l3": lanes[3] } as React.CSSProperties;
const inGame = phase !== "menu";
const liveTrack = live?.chart.track ?? track;
return (
<div ref={rootRef} style={style} className={cn("@container/rt relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
{/* HUD */}
<header className="relative z-10 flex h-14 shrink-0 items-center gap-3 border-b bg-background/70 px-3 backdrop-blur @2xl/rt:px-5">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-[conic-gradient(from_200deg,var(--rt-l0),var(--rt-l1),var(--rt-l2),var(--rt-l3),var(--rt-l0))] text-white shadow-sm">
<Music2 className="size-4" aria-hidden />
</span>
{inGame ? (
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{liveTrack.name}</p>
<p className="truncate text-[11px] leading-tight text-muted-foreground">
{liveTrack.difficulty} · {liveTrack.bpm} BPM{live?.autoplay ? " · Autoplay" : ""}
</p>
</div>
) : (
<p className="text-sm font-bold tracking-tight">Rhythm Tap</p>
)}
{inGame && (
<div className="flex shrink-0 items-center gap-3 @md/rt:gap-5">
<div className="text-right">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Score</p>
<p className="text-lg font-extrabold leading-none tabular-nums">{hud.score.toLocaleString("en-US")}</p>
</div>
<div className="hidden text-right @sm/rt:block">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combo</p>
<p className="text-lg font-extrabold leading-none tabular-nums">
{hud.combo}
<motion.span key={hud.mult} initial={reduce ? false : { scale: 1.6 }} animate={{ scale: 1 }} className={cn("ml-1 inline-block rounded-md px-1 text-xs", hud.mult > 1 ? "bg-[var(--rt-l1)] text-black" : "text-muted-foreground")}>
×{hud.mult}
</motion.span>
</p>
</div>
</div>
)}
<div className={cn("flex items-center gap-1.5", !inGame && "ml-auto")}>
<button type="button" onClick={toggleSound} aria-label={sound ? "Mute music" : "Play music"} aria-pressed={sound} title={sound ? "Mute" : "Sound on"} className={cn("grid size-9 place-items-center rounded-full border outline-none transition focus-visible:ring-2 focus-visible:ring-ring", sound ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground")}>
{sound ? <Volume2 className="size-4" /> : <VolumeX className="size-4" />}
</button>
{inGame && (
<button type="button" onClick={() => (phase === "playing" ? pause() : phase === "paused" ? resume() : undefined)} disabled={phase === "results" || phase === "resuming"} aria-label={phase === "paused" ? "Resume" : "Pause"} className="grid size-9 place-items-center rounded-full border text-muted-foreground outline-none transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40">
{phase === "paused" ? <Play className="size-4" /> : <Pause className="size-4" />}
</button>
)}
</div>
</header>
{/* life + progress */}
<div className="relative z-10 h-1.5 shrink-0 bg-muted">
{inGame && (
<motion.div
className={cn("absolute inset-y-0 left-0", hud.life < 0.3 ? "bg-destructive" : "bg-[linear-gradient(90deg,var(--rt-l2),var(--rt-l3))]")}
animate={{ width: `${hud.life * 100}%` }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
role="meter"
aria-label="Life"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(hud.life * 100)}
style={{ opacity: 0.9, mixBlendMode: "normal" }}
/>
)}
</div>
{/* stage */}
<div
ref={stageRef}
tabIndex={-1}
className="relative min-h-0 flex-1 touch-none select-none outline-none"
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
aria-label="Note highway. Press D, F, J, K or tap the lanes."
>
<canvas ref={canvasRef} className="absolute inset-0 block" aria-hidden />
{inGame && (
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-0.5 bg-foreground/10" aria-hidden>
<div ref={progressRef} className="h-full origin-left bg-foreground/60" style={{ transform: "scaleX(0)" }} />
</div>
)}
{/* menu */}
<AnimatePresence>
{phase === "menu" && (
<motion.div key="menu" className="absolute inset-0 z-20 flex items-center justify-center overflow-y-auto p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
<div className="w-full max-w-md">
<motion.h2 initial={reduce ? false : { y: -10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="text-center text-3xl font-black tracking-tight @md/rt:text-4xl">
Rhythm{" "}
<span className="bg-[linear-gradient(90deg,var(--rt-l0),var(--rt-l1),var(--rt-l2),var(--rt-l3))] bg-clip-text text-transparent">Tap</span>
</motion.h2>
<p className="mt-1 text-center text-sm text-muted-foreground">Hit the notes as they cross the line. Chain them for multipliers.</p>
<div className="mt-5 grid gap-2" role="radiogroup" aria-label="Track">
{tracks.map((t, i) => {
const b = bests[t.id];
const sel = i === trackIdx;
return (
<motion.button
key={t.id}
type="button"
role="radio"
aria-checked={sel}
onClick={() => setTrackIdx(i)}
onDoubleClick={start}
initial={reduce ? false : { x: -14, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.05 * i }}
className={cn("relative flex items-center gap-3 rounded-2xl border bg-card/85 p-3 text-left outline-none backdrop-blur transition focus-visible:ring-2 focus-visible:ring-ring", sel ? "border-foreground/40 shadow-lg" : "hover:border-foreground/20")}
>
{sel && <motion.span layoutId="rt-sel" className="absolute inset-0 rounded-2xl ring-2 ring-[var(--rt-l3)]" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
<span className="grid size-11 shrink-0 place-items-center rounded-xl text-sm font-black text-white" style={{ background: `var(--rt-l${[2, 1, 0][i % 3]})` }}>
{"I".repeat(Math.min(3, i + 1))}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold">{t.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{t.difficulty} · {t.bpm} BPM · {Math.round((charts[i].duration - charts[i].lead) / 1)}s · {charts[i].notes.length} notes
</span>
</span>
<span className="text-right text-xs">
{b ? (
<>
<span className="block font-black tabular-nums">{b.score.toLocaleString("en-US")}</span>
<span className="block text-muted-foreground">Best · {b.grade}</span>
</>
) : (
<span className="text-muted-foreground">New</span>
)}
</span>
</motion.button>
);
})}
</div>
<div className="mt-4 flex items-center gap-2">
<button type="button" onClick={start} autoFocus className="inline-flex h-12 flex-1 items-center justify-center gap-2 rounded-2xl bg-foreground text-sm font-bold text-background shadow-lg outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Play className="size-4" /> Play {track.name}
</button>
<button type="button" role="switch" aria-checked={autoplay} onClick={() => setAutoplay((a) => !a)} title="Autoplay (watch mode, no best score)" className={cn("inline-flex h-12 items-center gap-1.5 rounded-2xl border px-3 text-xs font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-ring", autoplay ? "bg-[var(--rt-l3)] text-white" : "bg-card/85 text-muted-foreground hover:text-foreground")}>
<Bot className="size-4" /> Auto
</button>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{["D", "F", "J", "K"].map((k, i) => (
<kbd key={k} className="grid size-6 place-items-center rounded-md border bg-card font-sans text-[11px] font-bold text-foreground" style={{ boxShadow: `inset 0 -2px 0 var(--rt-l${i})` }}>
{k}
</kbd>
))}
<span className="ml-1">or tap lanes</span>
</span>
<span>
<kbd className="rounded border bg-card px-1 font-sans">Esc</kbd> pause
</span>
{!sound && <span>Sound is off — tap the speaker for the beat</span>}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* start countdown (song lead-in) */}
{phase === "playing" && <LeadIn chart={live?.chart ?? chart} songTime={songTime} />}
{/* pause */}
<AnimatePresence>
{(phase === "paused" || phase === "resuming") && (
<motion.div key="pause" className="absolute inset-0 z-20 grid place-items-center p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
{phase === "resuming" ? (
<motion.p key={countdown} initial={reduce ? false : { scale: 1.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-7xl font-black tabular-nums">
{countdown}
</motion.p>
) : (
<div className="w-full max-w-xs rounded-3xl border bg-card/95 p-6 text-center shadow-2xl backdrop-blur" role="dialog" aria-label="Paused">
<p className="text-xl font-extrabold">Paused</p>
<p className="mt-1 text-sm text-muted-foreground">
{hud.score.toLocaleString("en-US")} pts · combo {hud.combo}
</p>
<button type="button" autoFocus onClick={resume} className="mt-5 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-foreground text-sm font-bold text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Play className="size-4" /> Resume
</button>
<div className="mt-2 grid grid-cols-2 gap-2">
<button type="button" onClick={start} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<RotateCcw className="size-3.5" /> Restart
</button>
<button type="button" onClick={() => setPhase("menu")} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<ChevronLeft className="size-3.5" /> Tracks
</button>
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* results */}
<AnimatePresence>
{phase === "results" && result && (
<motion.div key="results" className="absolute inset-0 z-20 grid place-items-center overflow-y-auto p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
<motion.div role="dialog" aria-label="Results" className="w-full max-w-sm rounded-3xl border bg-card/95 p-6 text-center shadow-2xl backdrop-blur" initial={reduce ? false : { y: 24, scale: 0.95 }} animate={{ y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 300, damping: 26 }}>
<p className={cn("text-xs font-bold uppercase tracking-[0.2em]", result.cleared ? "text-[var(--rt-l2)]" : "text-destructive")}>{result.cleared ? "Track clear" : "Track failed"}</p>
<motion.p
initial={reduce ? false : { scale: 3, rotate: -12, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 220, damping: 14, delay: 0.15 }}
className="mt-1 bg-[linear-gradient(135deg,var(--rt-l0),var(--rt-l1),var(--rt-l3))] bg-clip-text text-8xl font-black leading-none text-transparent"
>
{result.grade}
</motion.p>
<p className="mt-2 text-3xl font-extrabold tabular-nums">{result.score.toLocaleString("en-US")}</p>
{result.newBest && (
<motion.p initial={reduce ? false : { scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.4 }} className="mx-auto mt-1 inline-flex items-center gap-1 rounded-full bg-[var(--rt-l1)] px-2.5 py-0.5 text-xs font-bold text-black">
<Trophy className="size-3.5" /> New best
</motion.p>
)}
<dl className="mt-4 grid grid-cols-3 gap-2 text-xs">
{[
["Accuracy", `${Math.round(result.accuracy * 1000) / 10}%`],
["Max combo", String(result.maxCombo)],
["Grade", result.grade],
["Perfect", String(result.counts.perfect)],
["Great", String(result.counts.great)],
["Miss", String(result.counts.miss)],
].map(([k, v]) => (
<div key={k} className="rounded-lg bg-muted/70 px-2 py-2">
<dt className="text-muted-foreground">{k}</dt>
<dd className="font-bold tabular-nums">{v}</dd>
</div>
))}
</dl>
<div className="mt-5 grid grid-cols-2 gap-2">
<button type="button" autoFocus onClick={start} className="inline-flex h-11 items-center justify-center gap-1.5 rounded-xl bg-foreground text-sm font-bold text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<RotateCcw className="size-4" /> Retry
</button>
<button type="button" onClick={() => setPhase("menu")} className="inline-flex h-11 items-center justify-center gap-1.5 rounded-xl border text-sm font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<ChevronLeft className="size-4" /> Tracks
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
/** 3-2-1 overlay synced to the chart's silent lead-in. */
function LeadIn({ chart, songTime }: { chart: Chart; songTime: () => number }) {
const [n, setN] = React.useState<number | null>(3);
React.useEffect(() => {
let raf = 0;
const loop = () => {
const left = chart.lead - songTime();
const v = left > 0.45 ? Math.ceil((left - 0.45) / 0.65) : left > 0 ? 0 : null;
setN((prev) => (prev === v ? prev : v));
if (left > -0.3) raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [chart, songTime]);
if (n === null) return null;
return (
<div className="pointer-events-none absolute inset-x-0 top-[28%] z-10 grid place-items-center">
<motion.p key={n} initial={{ scale: 1.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-6xl font-black tracking-tight drop-shadow-sm">
{n === 0 ? "Go!" : n}
</motion.p>
</div>
);
}