"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronLeft, ChevronRight, Crosshair, Gauge, Heart, Infinity as InfinityIcon, MoveHorizontal, Pause, Play, RotateCcw, Rocket, Snowflake, Trophy, Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { DARK_PALETTE, LIGHT_PALETTE, OrbitEngine, POWER_DURATION, type OrbitPalette, type PowerKind, type SoundKind, type Status } from "./engine";
import { keyIsForGame, readBest, useBlips, useCanvasFit, useIsDark, usePauseOnBlur, writeBest } from "./kit";
import { LEVELS } from "./levels";
export type { OrbitPalette } from "./engine";
export type { LevelSpec, RingSpec } from "./levels";
export interface OrbitBreakerProps {
/** Level to start on: 1–5 are hand-made, 6+ is endless mode. */
initialLevel?: number;
seed?: number;
onGameOver?: (score: number) => void;
onLevelClear?: (level: number, score: number) => void;
/** Override canvas colours (hex). */
theme?: Partial<OrbitPalette>;
storageKey?: string;
title?: string;
className?: string;
}
interface Hud {
status: Status;
score: number;
lives: number;
level: number;
levelName: string;
left: number;
total: number;
balls: number;
active: Record<"laser" | "wide" | "slow", number>;
}
const POWERS: { kind: PowerKind; name: string; blurb: string; Icon: typeof Rocket; tone: string }[] = [
{ kind: "multi", name: "Multiball", blurb: "Every ball splits in three", Icon: Crosshair, tone: "bg-teal-500/15 text-teal-600 dark:text-teal-300" },
{ kind: "laser", name: "Lasers", blurb: "Arc fires twin beams inward", Icon: Rocket, tone: "bg-pink-500/15 text-pink-600 dark:text-pink-300" },
{ kind: "wide", name: "Wide arc", blurb: "Paddle grows by half", Icon: MoveHorizontal, tone: "bg-amber-500/15 text-amber-600 dark:text-amber-300" },
{ kind: "slow", name: "Slow time", blurb: "Balls drift at 70% speed", Icon: Snowflake, tone: "bg-sky-500/15 text-sky-600 dark:text-sky-300" },
{ kind: "life", name: "Extra life", blurb: "Rare. Up to five lives", Icon: Heart, tone: "bg-rose-500/15 text-rose-600 dark:text-rose-300" },
];
export function OrbitBreaker({ initialLevel = 1, seed, onGameOver, onLevelClear, theme, storageKey = "orbit-breaker:best", title = "Orbit Breaker", className }: OrbitBreakerProps) {
const reduce = useReducedMotion() ?? false;
const dark = useIsDark();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const sizeRef = useCanvasFit(canvasRef);
const engineRef = React.useRef<OrbitEngine | null>(null);
const runRef = React.useRef(0);
const [muted, setMuted] = React.useState(true);
const [best, setBest] = React.useState(0);
const [newBest, setNewBest] = React.useState(false);
const [startLevel, setStartLevel] = React.useState(Math.max(1, initialLevel));
const blip = useBlips(muted);
const [hud, setHud] = React.useState<Hud>({ status: "ready", score: 0, lives: 3, level: startLevel, levelName: "", left: 0, total: 1, balls: 0, active: { laser: 0, wide: 0, slow: 0 } });
const palette = React.useMemo(() => ({ ...(dark ? DARK_PALETTE : LIGHT_PALETTE), ...theme }), [dark, theme]);
const paletteRef = React.useRef(palette);
React.useEffect(() => {
paletteRef.current = palette;
}, [palette]);
const cbRef = React.useRef({ onGameOver, onLevelClear, blip, storageKey });
React.useEffect(() => {
cbRef.current = { onGameOver, onLevelClear, blip, storageKey };
}, [onGameOver, onLevelClear, blip, storageKey]);
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate from localStorage after mount
setBest(readBest(storageKey));
}, [storageKey]);
const sound = React.useCallback((k: SoundKind) => {
const b = cbRef.current.blip;
const map: Record<SoundKind, [number, number, OscillatorType, number, number]> = {
paddle: [330, 0.06, "triangle", 0.06, 110],
brick: [520, 0.05, "square", 0.03, 0],
break: [700, 0.09, "square", 0.04, 300],
steel: [180, 0.05, "square", 0.03, 0],
power: [440, 0.25, "sawtooth", 0.04, 660],
lose: [260, 0.4, "sawtooth", 0.05, -200],
clear: [520, 0.5, "triangle", 0.06, 780],
laser: [1200, 0.04, "sine", 0.02, -600],
over: [200, 0.7, "sawtooth", 0.05, -150],
};
b(...map[k]);
}, []);
const startLevelRef = React.useRef(startLevel);
React.useEffect(() => {
startLevelRef.current = startLevel;
}, [startLevel]);
const makeEngine = React.useCallback(
(level: number) => {
runRef.current += 1;
engineRef.current = new OrbitEngine({
level,
seed: seed !== undefined ? seed + runRef.current : Math.floor(Math.random() * 2 ** 31),
reduced: reduce,
onSound: sound,
onGameOver: (score) => {
const key = cbRef.current.storageKey;
if (score > readBest(key)) {
writeBest(key, score);
setBest(score);
setNewBest(true);
} else setNewBest(false);
cbRef.current.onGameOver?.(score);
},
});
},
[reduce, seed, sound],
);
// main loop
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 prevStatus: Status = "ready";
let clearTimer = 0;
const frame = (now: number) => {
const dt = Math.min(50, now - last);
last = now;
if (!engineRef.current) makeEngine(startLevelRef.current);
const e = engineRef.current!;
const { w, h, dpr } = sizeRef.current;
e.update(dt);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
e.render(ctx, w, h, paletteRef.current);
if (e.status === "cleared" && prevStatus !== "cleared") cbRef.current.onLevelClear?.(e.level, e.score);
if (e.status === "cleared") {
clearTimer += dt;
if (clearTimer > 2600) e.nextLevel();
} else clearTimer = 0;
prevStatus = e.status;
const next: Hud = {
status: e.status,
score: e.score,
lives: e.lives,
level: e.level,
levelName: e.levelName,
left: e.bricksLeft,
total: e.bricksTotal,
balls: e.balls.length,
active: { laser: Math.ceil(e.active.laser / 100), wide: Math.ceil(e.active.wide / 100), slow: Math.ceil(e.active.slow / 100) },
};
const key = JSON.stringify(next);
if (key !== lastHud) {
lastHud = key;
setHud(next);
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [makeEngine, sizeRef]);
const pickLevel = (lvl: number) => {
setStartLevel(lvl);
startLevelRef.current = lvl;
makeEngine(lvl);
};
const primary = React.useCallback(() => {
const e = engineRef.current;
if (!e) return;
if (e.status === "over") {
makeEngine(startLevelRef.current);
engineRef.current!.start();
setNewBest(false);
} else if (e.status === "ready") e.start();
else if (e.status === "serve") e.launch();
else if (e.status === "paused") e.status = e.balls.length ? "playing" : "serve";
else if (e.status === "cleared") e.nextLevel();
rootRef.current?.focus({ preventScroll: true });
}, [makeEngine]);
const restart = React.useCallback(() => {
makeEngine(startLevelRef.current);
engineRef.current!.start();
setNewBest(false);
rootRef.current?.focus({ preventScroll: true });
}, [makeEngine]);
const togglePause = React.useCallback(() => {
const e = engineRef.current;
if (!e) return;
if (e.status === "playing" || e.status === "serve") e.status = "paused";
else if (e.status === "paused") e.status = e.balls.length ? "playing" : "serve";
}, []);
usePauseOnBlur(
React.useCallback(() => {
const e = engineRef.current;
if (e && (e.status === "playing" || e.status === "serve")) {
e.input = 0;
e.status = "paused";
}
}, []),
);
// keyboard: hold arrows to orbit
React.useEffect(() => {
const held = new Set<string>();
const sync = () => {
const e = engineRef.current;
if (!e) return;
const l = held.has("ArrowLeft") || held.has("KeyA");
const r = held.has("ArrowRight") || held.has("KeyD");
e.input = l === r ? 0 : l ? 1 : -1;
if (l || r) e.target = null;
};
const down = (ev: KeyboardEvent) => {
if (!keyIsForGame(ev, rootRef.current)) return;
if (["ArrowLeft", "ArrowRight", "KeyA", "KeyD"].includes(ev.code)) {
ev.preventDefault();
held.add(ev.code);
sync();
return;
}
const onButton = ev.target instanceof HTMLButtonElement;
if (ev.code === "Space" || ev.code === "Enter" || ev.code === "ArrowUp") {
if (onButton && ev.code !== "ArrowUp") return;
ev.preventDefault();
if (engineRef.current?.status === "playing") return;
primary();
} else if (ev.code === "KeyP" || ev.code === "Escape") {
ev.preventDefault();
togglePause();
} else if (ev.code === "KeyM") setMuted((m) => !m);
else if (ev.code === "KeyR") restart();
};
const up = (ev: KeyboardEvent) => {
held.delete(ev.code);
sync();
};
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [primary, restart, togglePause]);
// pointer: aim the arc at the pointer; tap to launch
const downAt = React.useRef<{ x: number; y: number } | null>(null);
const aim = (e: React.PointerEvent<HTMLDivElement>) => {
const eng = engineRef.current;
if (!eng) return;
const rect = e.currentTarget.getBoundingClientRect();
eng.target = eng.angleAt(e.clientX - rect.left, e.clientY - rect.top, rect.width, rect.height);
};
const fromControl = (e: React.PointerEvent) => e.target instanceof Element && !!e.target.closest("button");
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (fromControl(e)) return;
downAt.current = { x: e.clientX, y: e.clientY };
const st = engineRef.current?.status;
if (st === "playing" || st === "serve") aim(e);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const st = engineRef.current?.status;
if (st !== "playing" && st !== "serve") return;
if (e.pointerType === "mouse" || downAt.current) aim(e);
};
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const d = downAt.current;
downAt.current = null;
if (!d) return;
const st = engineRef.current?.status;
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 12 && (st === "serve" || st === "ready")) primary();
};
const hold = (dir: -1 | 0 | 1) => {
const e = engineRef.current;
if (!e) return;
e.target = null;
e.input = dir;
};
const { status } = hud;
const running = status === "playing" || status === "serve";
const endless = hud.level > LEVELS.length;
const cleared = hud.total - hud.left;
return (
<div
ref={rootRef}
tabIndex={0}
data-status={status}
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/80 px-3 backdrop-blur sm:px-5">
<OrbitMark className="size-8 shrink-0" />
<div className="min-w-0 leading-none">
<p className="truncate bg-gradient-to-r from-amber-400 via-orange-500 to-pink-500 bg-clip-text text-[15px] font-black tracking-tight text-transparent italic">{title}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">
{endless ? "Endless" : `Orbit ${hud.level}/${LEVELS.length}`} · {hud.levelName}
</p>
</div>
<div className="ml-auto flex items-center gap-2 sm:gap-5">
<div className="hidden items-center gap-1 sm:flex" aria-label={`${hud.lives} lives`}>
{Array.from({ length: Math.max(3, hud.lives) }, (_, i) => (
<span key={i} className={cn("size-2.5 rounded-full transition", i < hud.lives ? "bg-gradient-to-br from-amber-300 to-pink-500 shadow-[0_0_8px] shadow-pink-500/60" : "bg-muted")} />
))}
</div>
<Stat label="Score" value={hud.score} />
<Stat label="Best" value={Math.max(best, hud.score)} className="hidden sm:flex" />
<div className="flex items-center gap-1">
<IconButton label={running ? "Pause (P)" : "Resume (P)"} onClick={running ? togglePause : primary} disabled={status === "cleared"}>
{running ? <Pause className="size-4" /> : <Play 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="Restart (R)" onClick={restart}>
<RotateCcw className="size-4" />
</IconButton>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1">
{/* left: level track */}
<aside className="hidden w-60 shrink-0 flex-col gap-5 border-r bg-muted/30 p-5 lg:flex">
<section>
<h3 className="mb-3 text-[11px] font-bold tracking-[0.16em] text-muted-foreground uppercase">Flight path</h3>
<ol className="space-y-1.5">
{LEVELS.map((l, i) => {
const n = i + 1;
const done = hud.level > n;
const current = hud.level === n;
return (
<li key={l.name} className={cn("flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm", current ? "bg-card font-semibold shadow-sm ring-1 ring-border" : "text-muted-foreground")}>
<span className={cn("grid size-6 shrink-0 place-items-center rounded-full text-[11px] font-bold", done ? "bg-gradient-to-br from-amber-400 to-pink-500 text-white" : current ? "bg-foreground text-background" : "bg-muted")}>
{done ? <Check className="size-3.5" /> : n}
</span>
<span className="truncate">{l.name}</span>
</li>
);
})}
<li className={cn("flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm", endless ? "bg-card font-semibold shadow-sm ring-1 ring-border" : "text-muted-foreground")}>
<span className={cn("grid size-6 shrink-0 place-items-center rounded-full", endless ? "bg-foreground text-background" : "bg-muted")}>
<InfinityIcon className="size-3.5" />
</span>
<span className="truncate">Endless{endless ? ` · ${hud.level - LEVELS.length}` : ""}</span>
</li>
</ol>
</section>
<section>
<div className="mb-2 flex items-baseline justify-between text-[11px] font-bold tracking-[0.16em] text-muted-foreground uppercase">
<span>Bricks</span>
<span className="font-mono tracking-normal text-foreground tabular-nums">
{cleared}/{hud.total}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<motion.div className="h-full rounded-full bg-gradient-to-r from-amber-400 to-pink-500" animate={{ width: `${(cleared / Math.max(1, hud.total)) * 100}%` }} transition={{ duration: reduce ? 0 : 0.3 }} />
</div>
<p className="mt-2 text-xs text-muted-foreground">Steel plates can't break. Thread the gaps.</p>
</section>
<section className="mt-auto rounded-xl border bg-card p-3 text-xs text-muted-foreground">
<p className="font-semibold text-foreground">Chain bonus</p>
<p className="mt-1">Every 3 hits without touching the arc adds ×1 to brick points.</p>
</section>
</aside>
{/* arena */}
<div className="relative min-h-0 min-w-0 flex-1 touch-none select-none" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={() => (downAt.current = null)}>
<canvas ref={canvasRef} className="absolute inset-0 block" role="img" aria-label={`Arena. ${hud.left} bricks left, ${hud.lives} lives, score ${hud.score}.`} />
{status === "serve" && (
<p className="pointer-events-none absolute inset-x-0 bottom-4 text-center text-xs font-medium text-muted-foreground">
<span className="hidden sm:inline">Aim with ← → or the mouse · Space to launch</span>
<span className="sm:hidden">Drag to aim · tap to launch</span>
</p>
)}
<div className="pointer-events-none absolute top-3 left-3 flex flex-col gap-1.5 xl:hidden">
{(["laser", "wide", "slow"] as const)
.filter((k) => hud.active[k] > 0)
.map((k) => (
<span key={k} className="rounded-full border bg-background/80 px-2 py-1 text-[10px] font-bold tracking-wide uppercase backdrop-blur">
{k} {(hud.active[k] / 10).toFixed(1)}s
</span>
))}
</div>
<AnimatePresence>
{(status === "ready" || status === "paused" || status === "over" || status === "cleared") && (
<motion.div
key={status}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.2 }}
className={cn("absolute inset-0 z-10 grid place-items-center p-6", status === "cleared" ? "bg-transparent" : "bg-background/60 backdrop-blur-[3px]")}
>
<motion.div initial={reduce ? false : { y: 14, scale: 0.96 }} animate={{ y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 300, damping: 24 }} className="w-full max-w-md text-center">
{status === "ready" && (
<>
<OrbitMark className="mx-auto mb-3 size-14" />
<h2 className="bg-gradient-to-r from-amber-400 via-orange-500 to-pink-500 bg-clip-text text-4xl font-black tracking-tight text-transparent italic sm:text-5xl">{title}</h2>
<p className="mx-auto mt-2 max-w-sm text-sm text-muted-foreground">Your paddle orbits the planet. Keep every ball inside the ring and crack the belts around the core.</p>
<div className="mt-5 flex flex-wrap justify-center gap-1.5" role="radiogroup" aria-label="Starting orbit">
{[...LEVELS.map((l, i) => ({ n: i + 1, label: String(i + 1), name: l.name })), { n: LEVELS.length + 1, label: "∞", name: "Endless" }].map((l) => (
<button
key={l.n}
type="button"
role="radio"
aria-checked={startLevel === l.n}
aria-label={`Start at ${l.name}`}
title={l.name}
onClick={() => pickLevel(l.n)}
className={cn(
"grid size-9 place-items-center rounded-full border text-sm font-bold transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
startLevel === l.n ? "border-transparent bg-foreground text-background" : "bg-background hover:bg-accent",
)}
>
{l.label}
</button>
))}
</div>
<PrimaryButton onClick={primary}>
<Rocket className="size-4" /> Launch
</PrimaryButton>
<p className="mt-4 text-[11px] text-muted-foreground">
<span className="hidden sm:inline">← → or mouse to orbit · Space launch · P pause · M sound</span>
<span className="sm:hidden">Drag to aim · tap to launch</span>
</p>
</>
)}
{status === "paused" && (
<>
<h2 className="text-4xl font-black tracking-tight italic">Paused</h2>
<p className="mt-1 text-sm text-muted-foreground">
Orbit {hud.level} · {hud.levelName}
</p>
<div className="mt-6 flex justify-center gap-2">
<PrimaryButton onClick={primary} 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 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
<RotateCcw className="size-4" /> Restart
</button>
</div>
</>
)}
{status === "cleared" && (
<motion.div initial={reduce ? false : { scale: 0.8 }} animate={{ scale: 1 }} className="mx-auto mt-40 inline-block rounded-2xl border bg-background/85 px-6 py-4 shadow-xl backdrop-blur sm:mt-48">
<p className="text-xs font-bold tracking-[0.2em] text-muted-foreground uppercase">Next up</p>
<p className="mt-1 text-lg font-black italic">{hud.level + 1 > LEVELS.length ? "Endless mode" : LEVELS[hud.level].name}</p>
<button type="button" onClick={primary} className="mt-2 text-xs font-semibold text-pink-600 underline-offset-4 hover:underline dark:text-pink-400">
Skip wait →
</button>
</motion.div>
)}
{status === "over" && (
<>
<p className="text-xs font-bold tracking-[0.2em] text-pink-500 uppercase">Out of orbit</p>
<h2 className="mt-2 text-6xl font-black tabular-nums">{hud.score}</h2>
{newBest ? (
<p className="mx-auto mt-3 inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-amber-400 to-pink-500 px-3 py-1 text-xs font-bold text-white">
<Trophy className="size-3.5" /> New best!
</p>
) : (
<p className="mt-3 text-xs text-muted-foreground">Best {best}</p>
)}
<p className="mt-4 text-sm text-muted-foreground">
Reached {endless ? `endless ${hud.level - LEVELS.length}` : `orbit ${hud.level}`} · {cleared}/{hud.total} bricks
</p>
<PrimaryButton onClick={primary}>
<RotateCcw className="size-4" /> Fly again
</PrimaryButton>
</>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* right: power-ups */}
<aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto border-l bg-muted/30 p-5 xl:flex">
<h3 className="text-[11px] font-bold tracking-[0.16em] text-muted-foreground uppercase">Capsules</h3>
<ul className="space-y-2">
{POWERS.map((p) => {
const timed = p.kind === "laser" || p.kind === "wide" || p.kind === "slow";
const left = timed ? hud.active[p.kind as "laser"] * 100 : 0;
return (
<li key={p.kind} className={cn("rounded-xl border bg-card p-2.5", left > 0 && "ring-2 ring-pink-500/40")}>
<div className="flex items-center gap-2.5">
<span className={cn("grid size-8 shrink-0 place-items-center rounded-lg", p.tone)}>
<p.Icon className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{p.name}</p>
<p className="truncate text-xs text-muted-foreground">{p.blurb}</p>
</div>
</div>
{left > 0 && (
<div className="mt-2 h-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-gradient-to-r from-amber-400 to-pink-500" style={{ width: `${(left / POWER_DURATION[p.kind]) * 100}%` }} />
</div>
)}
</li>
);
})}
</ul>
<div className="mt-auto flex items-center gap-2 rounded-xl border bg-card p-3 text-xs text-muted-foreground">
<Gauge className="size-4 shrink-0 text-amber-500" aria-hidden />
Balls speed up every orbit. Endless mode never ends.
</div>
</aside>
</div>
{/* mobile controls */}
<div className="flex shrink-0 items-center justify-between gap-3 border-t bg-muted/30 px-4 py-3 lg:hidden">
<HoldButton label="Orbit left" onHold={() => hold(1)} onRelease={() => hold(0)}>
<ChevronLeft className="size-6" />
</HoldButton>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1" aria-label={`${hud.lives} lives`}>
{Array.from({ length: Math.max(3, hud.lives) }, (_, i) => (
<span key={i} className={cn("size-2 rounded-full", i < hud.lives ? "bg-gradient-to-br from-amber-300 to-pink-500" : "bg-muted-foreground/30")} />
))}
</div>
<button
type="button"
onClick={status === "playing" ? togglePause : primary}
className="h-10 min-w-24 rounded-full bg-foreground px-5 text-sm font-bold text-background transition active:scale-95 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{status === "serve" ? "Launch" : status === "over" ? "Again" : status === "playing" ? "Pause" : status === "paused" ? "Resume" : "Go"}
</button>
</div>
<HoldButton label="Orbit right" onHold={() => hold(-1)} onRelease={() => hold(0)}>
<ChevronRight className="size-6" />
</HoldButton>
</div>
<p className="sr-only" aria-live="polite">
{status === "over" ? `Game over. Score ${hud.score}.` : status === "cleared" ? `Orbit ${hud.level} cleared.` : status === "paused" ? "Paused" : ""}
</p>
</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-amber-400 via-orange-500 to-pink-500 px-6 text-sm font-bold text-white shadow-lg shadow-orange-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 }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean; pressed?: boolean }) {
return (
<button
type="button"
aria-label={label}
title={label}
aria-pressed={pressed}
disabled={disabled}
onClick={onClick}
className="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"
>
{children}
</button>
);
}
function HoldButton({ label, onHold, onRelease, children }: { label: string; onHold: () => void; onRelease: () => void; children: React.ReactNode }) {
return (
<button
type="button"
aria-label={label}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
onHold();
}}
onPointerUp={onRelease}
onPointerCancel={onRelease}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onHold();
}
}}
onKeyUp={onRelease}
onBlur={onRelease}
className="grid h-14 w-20 touch-none place-items-center rounded-2xl border bg-card shadow-sm transition active:scale-95 active:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{children}
</button>
);
}
function Stat({ label, value, className }: { label: string; value: number; className?: string }) {
return (
<div className={cn("flex flex-col items-end leading-none", className)}>
<span className="text-[9px] font-bold tracking-[0.16em] text-muted-foreground uppercase">{label}</span>
<span className="mt-1 font-mono text-lg font-black tabular-nums">{value}</span>
</div>
);
}
function OrbitMark({ className }: { className?: string }) {
const id = React.useId();
return (
<svg viewBox="0 0 40 40" className={className} aria-hidden>
<defs>
<radialGradient id={`${id}p`} cx="0.35" cy="0.35" r="0.8">
<stop offset="0" stopColor="#5eead4" />
<stop offset="1" stopColor="#4338ca" />
</radialGradient>
<linearGradient id={`${id}a`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#fbbf24" />
<stop offset="1" stopColor="#ec4899" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="7" fill={`url(#${id}p)`} />
<circle cx="20" cy="20" r="12" fill="none" stroke="#a78bfa" strokeOpacity="0.55" strokeWidth="2.5" strokeDasharray="5 2.5" />
<path d="M7.5 27 A15 15 0 0 0 32.5 27" fill="none" stroke={`url(#${id}a)`} strokeWidth="3.5" strokeLinecap="round" />
<circle cx="28" cy="12" r="2.4" fill="#fde68a" />
</svg>
);
}