Fazekit

Code

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

More in Games

View all →