Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, Flag, ListOrdered, Pause, Play, RotateCcw, Trophy, Volume2, VolumeX, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { DAY, GolfEngine, MAX_STROKES, NIGHT, type GolfPalette, type GolfSound, type HoleResult, type Phase } from "./engine";
import { HOLES } from "./holes";
import { keyBelongsTo, loadJSON, saveJSON, useAutoPause, useFitCanvas, useIsDark, useTones } from "./kit";

export type { GolfPalette } from "./engine";

export interface MiniGolfPhysicsProps {
  /** Hole to tee off on (1–9). The round still visits all nine. */
  initialLevel?: number;
  /** Seed for cosmetic randomness (sand speckles, lip-outs). */
  seed?: number;
  /** Called with the round's total strokes after the 9th hole. */
  onGameOver?: (score: number) => void;
  /** Override canvas colours (hex) on top of the day / night palette. */
  theme?: Partial<GolfPalette>;
  /** localStorage key for best round and per-hole bests. */
  storageKey?: string;
  title?: string;
  className?: string;
}

interface Saved {
  bestRound: number | null;
  bestHoles: (number | null)[];
  rounds: number;
}
interface Hud {
  phase: Phase;
  paused: boolean;
  index: number;
  strokes: number;
  card: (number | null)[];
  total: number;
  parSoFar: number;
}

const EMPTY_SAVE: Saved = { bestRound: null, bestHoles: HOLES.map(() => null), rounds: 0 };
const TOTAL_PAR = HOLES.reduce((s, h) => s + h.par, 0);

function toPar(n: number) {
  return n === 0 ? "E" : n > 0 ? `+${n}` : `${n}`;
}

export function MiniGolfPhysics({ initialLevel = 1, seed = 11, onGameOver, theme, storageKey = "mini-golf-physics:v1", title = "Tiny Fairways", className }: MiniGolfPhysicsProps) {
  const reduce = useReducedMotion() ?? false;
  const dark = useIsDark();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const sizeRef = useFitCanvas(canvasRef);
  const engineRef = React.useRef<GolfEngine | null>(null);
  const aimRef = React.useRef({ angle: -Math.PI / 2, power: 0.45, visible: false, dragging: false, pointer: -1 });
  const [muted, setMuted] = React.useState(true);
  const tone = useTones(muted);
  const [saved, setSaved] = React.useState<Saved>(EMPTY_SAVE);
  const [result, setResult] = React.useState<HoleResult | null>(null);
  const [cardOpen, setCardOpen] = React.useState(false);
  const [newBest, setNewBest] = React.useState(false);
  const [hud, setHud] = React.useState<Hud>({ phase: "ready", paused: false, index: 0, strokes: 0, card: HOLES.map(() => null), total: 0, parSoFar: 0 });

  const palette = React.useMemo(() => ({ ...(dark ? NIGHT : DAY), ...theme }), [dark, theme]);
  const paletteRef = React.useRef(palette);
  React.useEffect(() => {
    paletteRef.current = palette;
  }, [palette]);

  const cb = React.useRef({ onGameOver, tone, storageKey });
  React.useEffect(() => {
    cb.current = { onGameOver, tone, storageKey };
  }, [onGameOver, tone, storageKey]);

  React.useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate persisted scores after mount
    setSaved(loadJSON<Saved>(storageKey, EMPTY_SAVE));
  }, [storageKey]);

  const sound = React.useCallback((s: GolfSound, k: number) => {
    const t = cb.current.tone;
    if (s === "putt") t(180 + k * 120, 0.08, "triangle", 0.06, -60);
    else if (s === "wall") t(260, 0.05, "square", 0.02 * k + 0.01, -80);
    else if (s === "bump") t(520, 0.14, "sine", 0.05, 380);
    else if (s === "cup") {
      t(660, 0.12, "sine", 0.05, 0);
      setTimeout(() => t(880, 0.2, "sine", 0.05, 0), 110);
    } else if (s === "splash") t(140, 0.35, "sawtooth", 0.03, -80);
    else if (s === "lip") t(420, 0.1, "square", 0.03, -200);
  }, []);

  const aimAtCup = React.useCallback(() => {
    const e = engineRef.current;
    if (!e) return;
    const [cx, cy] = e.hole.cup;
    aimRef.current.angle = Math.atan2(cy - e.ball.y, cx - e.ball.x);
  }, []);

  const makeEngine = React.useCallback(() => {
    const eng = new GolfEngine({
      seed,
      startHole: initialLevel,
      reduced: reduce,
      onSound: sound,
      onHoleDone: (index, r) => {
        setResult(r);
        const key = cb.current.storageKey;
        const prev = loadJSON<Saved>(key, EMPTY_SAVE);
        const bestHoles = HOLES.map((_, i) => (i === index ? (prev.bestHoles[i] === null || prev.bestHoles[i] === undefined ? r.strokes : Math.min(prev.bestHoles[i]!, r.strokes)) : (prev.bestHoles[i] ?? null)));
        const next = { ...prev, bestHoles };
        saveJSON(key, next);
        setSaved(next);
      },
      onRoundDone: (total) => {
        const key = cb.current.storageKey;
        const prev = loadJSON<Saved>(key, EMPTY_SAVE);
        const better = prev.bestRound === null || total < prev.bestRound;
        const next = { ...prev, rounds: prev.rounds + 1, bestRound: better ? total : prev.bestRound };
        saveJSON(key, next);
        setSaved(next);
        setNewBest(better);
        cb.current.onGameOver?.(total);
      },
    });
    engineRef.current = eng;
    return eng;
  }, [initialLevel, reduce, seed, sound]);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;
    let raf = 0;
    let last = performance.now();
    let lastHud = "";
    let lastPhase: Phase = "ready";
    const frame = (now: number) => {
      const dt = now - last;
      last = now;
      const eng = engineRef.current ?? makeEngine();
      eng.update(dt);
      if (eng.phase === "aim" && lastPhase !== "aim" && !aimRef.current.dragging) {
        aimAtCup();
      }
      lastPhase = eng.phase;
      const { w, h, dpr } = sizeRef.current;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      eng.render(ctx, w, h, paletteRef.current, aimRef.current);
      const v = eng.view(w, h);
      canvas.dataset.s = v.s.toFixed(4);
      canvas.dataset.ox = v.ox.toFixed(2);
      canvas.dataset.oy = v.oy.toFixed(2);
      canvas.dataset.ball = `${eng.ball.x.toFixed(1)},${eng.ball.y.toFixed(1)}`;
      const next: Hud = { phase: eng.phase, paused: eng.paused, index: eng.index, strokes: eng.strokes, card: [...eng.card], total: eng.total, parSoFar: eng.parSoFar };
      const key = JSON.stringify(next);
      if (key !== lastHud) {
        lastHud = key;
        setHud(next);
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [aimAtCup, makeEngine, sizeRef]);

  const focusRoot = () => rootRef.current?.focus({ preventScroll: true });

  const begin = React.useCallback(() => {
    const e = engineRef.current;
    if (!e) return;
    if (e.phase === "ready") e.start();
    e.paused = false;
    focusRoot();
  }, []);
  const restart = React.useCallback(() => {
    engineRef.current?.restartRound();
    setResult(null);
    setNewBest(false);
    aimRef.current.visible = false;
    focusRoot();
  }, []);
  const next = React.useCallback(() => {
    const e = engineRef.current;
    if (!e || e.phase !== "holed") return;
    e.nextHole();
    setResult(null);
    aimRef.current.visible = false;
    focusRoot();
  }, []);
  const togglePause = React.useCallback(() => {
    const e = engineRef.current;
    if (!e || e.phase === "ready" || e.phase === "complete") return;
    e.paused = !e.paused;
  }, []);
  const shoot = React.useCallback(() => {
    const e = engineRef.current;
    const a = aimRef.current;
    if (e?.shoot(a.angle, a.power)) a.visible = false;
  }, []);

  useAutoPause(
    React.useCallback(() => {
      const e = engineRef.current;
      if (e && e.phase !== "ready" && e.phase !== "complete") e.paused = true;
    }, []),
  );

  React.useEffect(() => {
    const onKey = (ev: KeyboardEvent) => {
      if (!keyBelongsTo(ev, rootRef.current)) return;
      const e = engineRef.current;
      if (!e) return;
      const onButton = ev.target instanceof HTMLButtonElement;
      const a = aimRef.current;
      const k = ev.key;
      if (k === "ArrowLeft" || k === "ArrowRight" || k === "ArrowUp" || k === "ArrowDown") {
        if (onButton && (k === "ArrowUp" || k === "ArrowDown")) return;
        ev.preventDefault();
        if (e.phase === "ready") e.start();
        if (e.phase !== "aim" || e.paused) return;
        a.visible = true;
        const fine = ev.shiftKey ? 0.4 : 1;
        if (k === "ArrowLeft") a.angle -= 0.05 * fine;
        if (k === "ArrowRight") a.angle += 0.05 * fine;
        if (k === "ArrowUp") a.power = Math.min(1, a.power + 0.04 * fine);
        if (k === "ArrowDown") a.power = Math.max(0.05, a.power - 0.04 * fine);
        return;
      }
      if ((k === " " || k === "Enter") && !onButton) {
        ev.preventDefault();
        if (e.phase === "ready") begin();
        else if (e.paused) begin();
        else if (e.phase === "holed") next();
        else if (e.phase === "complete") restart();
        else if (e.phase === "aim") {
          if (!a.visible) a.visible = true;
          else shoot();
        }
      } else if (k === "p" || k === "P") togglePause();
      else if (k === "Escape") {
        if (cardOpen) setCardOpen(false);
        else if (a.visible) a.visible = false;
        else togglePause();
      } else if (k === "m" || k === "M") setMuted((m) => !m);
      else if (k === "r" || k === "R") restart();
      else if (k === "c" || k === "C") setCardOpen((o) => !o);
      else if (k === "n" || k === "N") next();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [begin, cardOpen, next, restart, shoot, togglePause]);

  const worldPoint = (ev: React.PointerEvent) => {
    const e = engineRef.current;
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!e || !rect) return null;
    return e.toWorld(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
  };
  const updateDrag = (ev: React.PointerEvent) => {
    const e = engineRef.current;
    const p = worldPoint(ev);
    if (!e || !p) return;
    const dx = e.ball.x - p[0];
    const dy = e.ball.y - p[1];
    const len = Math.hypot(dx, dy);
    const a = aimRef.current;
    a.visible = len > 10;
    if (len > 10) {
      a.angle = Math.atan2(dy, dx);
      a.power = Math.min(1, (len - 10) / 150);
    }
  };

  const phase = hud.phase;
  const hole = HOLES[hud.index];
  const overPar = hud.total - hud.parSoFar;
  const paused = hud.paused;
  const inPlay = phase !== "ready" && phase !== "complete";

  return (
    <div
      ref={rootRef}
      tabIndex={0}
      data-phase={phase}
      data-paused={paused}
      data-hole={hud.index + 1}
      data-strokes={hud.strokes}
      data-total={hud.total}
      aria-label={`${title} game`}
      className={cn("relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset", className)}
    >
      <header className="relative z-10 flex h-14 shrink-0 items-center gap-3 border-b bg-background/85 px-3 backdrop-blur sm:px-5">
        <div className="flex min-w-0 items-center gap-2.5">
          <GolfMark className="size-8 shrink-0" />
          <div className="min-w-0 leading-none">
            <p className="hidden truncate text-sm font-black tracking-tight sm:block">{title}</p>
            <p className="text-sm font-black sm:hidden">Hole {hud.index + 1}</p>
            <p className="mt-1 truncate text-[11px] text-muted-foreground">
              <span className="hidden sm:inline">Hole {hud.index + 1} · </span>
              {hole.name}
            </p>
          </div>
        </div>
        <div className="ml-auto flex items-center gap-3 sm:gap-5">
          <Stat label="Par" value={hole.par} />
          <Stat label="Strokes" value={hud.strokes} highlight={hud.strokes > hole.par} />
          <Stat label="Total" value={toPar(overPar)} className="hidden sm:flex" />
          <div className="flex items-center gap-0.5">
            <IconButton label={paused || !inPlay ? "Resume (P)" : "Pause (P)"} onClick={paused || phase === "ready" ? begin : togglePause} disabled={phase === "complete"}>
              {paused || phase === "ready" ? <Play className="size-4" /> : <Pause className="size-4" />}
            </IconButton>
            <IconButton label={muted ? "Unmute (M)" : "Mute (M)"} onClick={() => setMuted((m) => !m)} pressed={!muted}>
              {muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
            </IconButton>
            <IconButton label="Scorecard (C)" onClick={() => setCardOpen((o) => !o)} pressed={cardOpen} className="lg:hidden">
              <ListOrdered className="size-4" />
            </IconButton>
            <IconButton label="Restart round (R)" onClick={restart}>
              <RotateCcw className="size-4" />
            </IconButton>
          </div>
        </div>
      </header>

      <div className="flex min-h-0 flex-1">
        <div
          className="relative min-h-0 min-w-0 flex-1 touch-none select-none"
          onPointerDown={(ev) => {
            const e = engineRef.current;
            if (!e || e.paused) return;
            if (e.phase === "ready") e.start();
            if (e.phase !== "aim") return;
            aimRef.current.dragging = true;
            aimRef.current.pointer = ev.pointerId;
            (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
            updateDrag(ev);
          }}
          onPointerMove={(ev) => {
            if (aimRef.current.dragging && aimRef.current.pointer === ev.pointerId) updateDrag(ev);
          }}
          onPointerUp={(ev) => {
            const a = aimRef.current;
            if (!a.dragging || a.pointer !== ev.pointerId) return;
            a.dragging = false;
            updateDrag(ev);
            if (a.visible && a.power > 0.04) shoot();
            a.visible = false;
          }}
          onPointerCancel={() => {
            aimRef.current.dragging = false;
            aimRef.current.visible = false;
          }}
        >
          <canvas ref={canvasRef} className="absolute inset-0 block" role="img" aria-label={`Hole ${hud.index + 1}, ${hole.name}. Par ${hole.par}. ${hud.strokes} strokes so far.`} />
          {phase === "aim" && !paused && hud.strokes === 0 && (
            <p className="pointer-events-none absolute inset-x-3 top-3 mx-auto w-fit max-w-[92%] rounded-full bg-background/85 px-3 py-1.5 text-center text-xs text-muted-foreground shadow-sm backdrop-blur">
              <span className="font-semibold text-foreground">{hole.tip}</span>
              <span className="hidden sm:inline"> · drag back or ←→ aim, ↑↓ power, Space putt</span>
            </p>
          )}
          {hud.strokes >= MAX_STROKES - 2 && phase === "aim" && <p className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-amber-500/90 px-3 py-1 text-xs font-bold text-amber-950">{MAX_STROKES - hud.strokes} {MAX_STROKES - hud.strokes === 1 ? "stroke" : "strokes"} before pickup</p>}

          <AnimatePresence>
            {result && phase === "holed" && (
              <motion.div
                key={`r${hud.index}`}
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 30, scale: 0.94 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, y: 10 }}
                transition={{ type: "spring", stiffness: 280, damping: 24 }}
                className="absolute inset-x-3 bottom-4 z-10 mx-auto max-w-sm rounded-2xl border bg-card/95 p-4 text-center shadow-2xl backdrop-blur"
                onPointerDown={(e) => e.stopPropagation()}
                role="status"
              >
                <p className="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">Hole {hud.index + 1} · par {result.par}</p>
                <p className={cn("mt-1 text-3xl font-black", result.strokes < result.par ? "text-emerald-500" : result.strokes === result.par ? "text-sky-500" : "text-amber-500")}>{result.label}</p>
                <p className="mt-1 text-sm text-muted-foreground">
                  {result.strokes} {result.strokes === 1 ? "stroke" : "strokes"} · round {toPar(overPar)}
                </p>
                <button type="button" onClick={next} className="mt-3 inline-flex h-10 items-center gap-2 rounded-full bg-emerald-600 px-5 text-sm font-bold text-white shadow-lg shadow-emerald-600/30 transition hover:bg-emerald-500 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none active:scale-95">
                  {hud.card.filter((c) => c === null).length === 0 ? "See scorecard" : "Next hole"} <ArrowRight className="size-4" />
                </button>
              </motion.div>
            )}
          </AnimatePresence>

          <AnimatePresence>
            {(phase === "ready" || paused || phase === "complete") && (
              <Overlay key={phase === "complete" ? "c" : paused ? "p" : "r"} reduce={reduce}>
                {phase === "ready" && (
                  <>
                    <GolfMark className="mx-auto mb-4 size-16" />
                    <h2 className="text-3xl font-black tracking-tight sm:text-5xl">{title}</h2>
                    <p className="mx-auto mt-3 max-w-sm text-sm text-muted-foreground">Nine hand-built holes of bumpers, slopes, sliding gates and sneaky water. Real rolling friction, real bounces — and a very judgemental flag.</p>
                    <PrimaryButton onClick={begin}>
                      <Flag className="size-4" /> Tee off
                    </PrimaryButton>
                    <p className="mt-4 text-xs text-muted-foreground">
                      Par {TOTAL_PAR} · best round {saved.bestRound ?? "—"}
                    </p>
                  </>
                )}
                {paused && phase !== "ready" && phase !== "complete" && (
                  <>
                    <p className="text-xs font-semibold tracking-[0.3em] text-muted-foreground uppercase">Caddie break</p>
                    <h2 className="mt-2 text-4xl font-black">Paused</h2>
                    <div className="mt-6 flex justify-center gap-2">
                      <PrimaryButton onClick={begin} className="mt-0">
                        <Play className="size-4" /> Resume
                      </PrimaryButton>
                      <button type="button" onClick={restart} className="inline-flex h-11 items-center gap-2 rounded-full border bg-background px-5 text-sm font-semibold transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
                        <RotateCcw className="size-4" /> New round
                      </button>
                    </div>
                  </>
                )}
                {phase === "complete" && (
                  <>
                    <p className="text-xs font-bold tracking-[0.3em] text-emerald-500 uppercase">Round complete</p>
                    <h2 className="mt-2 text-5xl font-black tabular-nums">{hud.total}</h2>
                    <p className="mt-1 text-sm text-muted-foreground">
                      {toPar(hud.total - TOTAL_PAR)} against par {TOTAL_PAR}
                    </p>
                    {newBest ? (
                      <motion.p initial={reduce ? false : { scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="mx-auto mt-3 inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-emerald-500 to-lime-400 px-3 py-1 text-xs font-bold text-emerald-950">
                        <Trophy className="size-3.5" /> New best round!
                      </motion.p>
                    ) : (
                      <p className="mt-3 text-xs text-muted-foreground">Best round {saved.bestRound ?? "—"}</p>
                    )}
                    <div className="mt-5 text-left">
                      <Scorecard card={hud.card} best={saved.bestHoles} current={-1} compact />
                    </div>
                    <PrimaryButton onClick={restart}>
                      <RotateCcw className="size-4" /> Play again
                    </PrimaryButton>
                  </>
                )}
              </Overlay>
            )}
          </AnimatePresence>

          <AnimatePresence>
            {cardOpen && (
              <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                className="absolute inset-0 z-30 grid place-items-center bg-background/70 p-4 backdrop-blur-sm lg:hidden"
                onPointerDown={(e) => e.stopPropagation()}
                role="dialog"
                aria-label="Scorecard"
              >
                <div className="w-full max-w-sm rounded-2xl border bg-card p-4 shadow-2xl">
                  <div className="mb-3 flex items-center justify-between">
                    <p className="text-sm font-bold">Scorecard</p>
                    <button type="button" aria-label="Close scorecard" onClick={() => setCardOpen(false)} className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
                      <X className="size-4" />
                    </button>
                  </div>
                  <Scorecard card={hud.card} best={saved.bestHoles} current={hud.index} compact />
                  <p className="mt-3 text-center text-xs text-muted-foreground">
                    Round {toPar(overPar)} · best round {saved.bestRound ?? "—"}
                  </p>
                </div>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <aside className="hidden w-72 shrink-0 flex-col gap-5 overflow-y-auto border-l bg-muted/30 p-5 lg:flex">
          <section>
            <h3 className="mb-2 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Now playing</h3>
            <div className="rounded-xl border bg-card p-3">
              <p className="text-xs text-muted-foreground">Hole {hud.index + 1} · Par {hole.par}</p>
              <p className="text-lg font-black">{hole.name}</p>
              <p className="mt-1 text-xs text-muted-foreground">{hole.tip}</p>
              <div className="mt-3 flex gap-1" aria-label={`${hud.strokes} strokes`}>
                {Array.from({ length: MAX_STROKES }, (_, i) => (
                  <span key={i} className={cn("h-1.5 flex-1 rounded-full", i < hud.strokes ? (i < hole.par ? "bg-emerald-500" : "bg-amber-500") : "bg-muted")} />
                ))}
              </div>
            </div>
          </section>
          <section>
            <h3 className="mb-2 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Scorecard</h3>
            <Scorecard card={hud.card} best={saved.bestHoles} current={hud.index} />
            <div className="mt-3 grid grid-cols-3 gap-2">
              <MiniStat label="Round" value={toPar(overPar)} />
              <MiniStat label="Strokes" value={hud.total} />
              <MiniStat label="Best" value={saved.bestRound ?? "—"} />
            </div>
          </section>
          <section className="mt-auto">
            <h3 className="mb-2 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Controls</h3>
            <ul className="space-y-1.5 text-xs text-muted-foreground">
              <li className="flex justify-between"><span>Aim + power</span><span>drag back from ball</span></li>
              <li className="flex justify-between"><span>Keyboard aim</span><span><Kbd>←→</Kbd> <Kbd>↑↓</Kbd> <Kbd>Shift</Kbd> fine</span></li>
              <li className="flex justify-between"><span>Putt</span><Kbd>Space</Kbd></li>
              <li className="flex justify-between"><span>Pause · restart</span><span><Kbd>P</Kbd> <Kbd>R</Kbd></span></li>
            </ul>
          </section>
        </aside>
      </div>
      <p className="sr-only" aria-live="polite">
        {phase === "holed" && result ? `${result.label} ${result.strokes} strokes on hole ${hud.index + 1}.` : phase === "complete" ? `Round complete with ${hud.total} strokes.` : paused ? "Paused" : ""}
      </p>
    </div>
  );
}

function Scorecard({ card, best, current, compact }: { card: (number | null)[]; best: (number | null)[]; current: number; compact?: boolean }) {
  return (
    <div className="overflow-hidden rounded-xl border bg-card">
      <table className="w-full table-fixed text-center text-xs tabular-nums">
        <thead>
          <tr className="bg-muted/60 text-[10px] text-muted-foreground">
            <th scope="col" className="w-12 py-1.5 text-left pl-2 font-semibold">Hole</th>
            {HOLES.map((_, i) => (
              <th key={i} scope="col" className={cn("py-1.5 font-semibold", i === current && "text-foreground")}>
                {i + 1}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          <tr className="border-t">
            <th scope="row" className="pl-2 text-left text-[10px] font-semibold text-muted-foreground">Par</th>
            {HOLES.map((h, i) => (
              <td key={i} className="py-1.5 text-muted-foreground">
                {h.par}
              </td>
            ))}
          </tr>
          <tr className="border-t">
            <th scope="row" className="pl-2 text-left text-[10px] font-semibold text-muted-foreground">You</th>
            {card.map((v, i) => {
              const d = v === null ? 0 : v - HOLES[i].par;
              return (
                <td key={i} className={cn("py-1.5 font-bold", i === current && v === null && "bg-primary/10")}>
                  {v === null ? <span className="text-muted-foreground/50">·</span> : <span className={cn("inline-grid size-5 place-items-center", d < 0 && "rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", d > 0 && "rounded-sm bg-amber-500/15 text-amber-700 dark:text-amber-400")}>{v}</span>}
                </td>
              );
            })}
          </tr>
          {!compact || best.some((b) => b !== null) ? (
            <tr className="border-t">
              <th scope="row" className="pl-2 text-left text-[10px] font-semibold text-muted-foreground">Best</th>
              {best.map((v, i) => (
                <td key={i} className="py-1.5 text-muted-foreground">
                  {v ?? "–"}
                </td>
              ))}
            </tr>
          ) : null}
        </tbody>
      </table>
    </div>
  );
}

function Overlay({ children, reduce }: { children: React.ReactNode; reduce: boolean }) {
  return (
    <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduce ? 0 : 0.2 }} className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-background/65 p-5 backdrop-blur-[3px]" onPointerDown={(e) => e.stopPropagation()}>
      <motion.div initial={reduce ? false : { y: 18, scale: 0.97 }} animate={{ y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 300, damping: 26 }} className="w-full max-w-md text-center">
        {children}
      </motion.div>
    </motion.div>
  );
}

function PrimaryButton({ children, onClick, className }: { children: React.ReactNode; onClick: () => void; className?: string }) {
  return (
    <button type="button" onClick={onClick} className={cn("mt-6 inline-flex h-11 items-center gap-2 rounded-full bg-gradient-to-r from-emerald-500 to-lime-500 px-6 text-sm font-bold text-emerald-950 shadow-lg shadow-emerald-500/30 transition hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none active:scale-95", className)}>
      {children}
    </button>
  );
}

function IconButton({ label, onClick, children, disabled, pressed, className }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean; pressed?: boolean; className?: string }) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      aria-pressed={pressed}
      disabled={disabled}
      onClick={onClick}
      className={cn("grid size-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:opacity-40", pressed && "bg-accent text-foreground", className)}
    >
      {children}
    </button>
  );
}

function Stat({ label, value, highlight, className }: { label: string; value: React.ReactNode; highlight?: boolean; className?: string }) {
  return (
    <div className={cn("flex flex-col items-end leading-none", className)}>
      <span className="text-[9px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">{label}</span>
      <span className={cn("mt-1 text-lg font-black tabular-nums", highlight && "text-amber-500")}>{value}</span>
    </div>
  );
}

function MiniStat({ label, value }: { label: string; value: React.ReactNode }) {
  return (
    <div className="rounded-lg border bg-card px-2 py-2 text-center">
      <p className="text-[9px] tracking-wider text-muted-foreground uppercase">{label}</p>
      <p className="mt-0.5 text-base font-bold tabular-nums">{value}</p>
    </div>
  );
}

function Kbd({ children }: { children: React.ReactNode }) {
  return <kbd className="rounded border bg-card px-1.5 py-0.5 font-mono text-[10px] text-foreground">{children}</kbd>;
}

function GolfMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 40 40" className={className} aria-hidden>
      <rect width="40" height="40" rx="11" fill="#16a34a" />
      <path d="M0 26 Q20 18 40 26 V40 H0Z" fill="#15803d" />
      <ellipse cx="25" cy="27" rx="5" ry="2.2" fill="#052e16" />
      <path d="M25 27 V9" stroke="#f8fafc" strokeWidth="1.8" strokeLinecap="round" />
      <path d="M25.6 9 L34 12.5 L25.6 16Z" fill="#ef4444" />
      <circle cx="12" cy="29" r="3.6" fill="#fff" />
      <circle cx="11" cy="28" r="1" fill="#e5e7eb" />
    </svg>
  );
}

More in Games

View all →