Fazekit

Code

"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>
  );
}

More in Games

View all →