Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpCircle, Coins, FastForward, Heart, Pause, Play, RotateCcw, MoonStar, Sunrise, Trophy, Volume2, VolumeX, Waves, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { CAMPAIGN_WAVES, DUSK, LanternEngine, NIGHT, TOWER_ORDER, TOWERS, type LanternPalette, type LanternSound, type Layout, type Status, type TowerKind } from "./engine";
import { keyBelongsTo, loadJSON, saveJSON, useAutoPause, useFitCanvas, useIsDark, useTones } from "./kit";

export type { LanternPalette, TowerKind } from "./engine";

export interface LanternDefenseProps {
  /** Wave to begin on (1–10). Later waves start with extra gold. */
  initialLevel?: number;
  /** Fixed seed for deterministic scenery (tests, daily challenges). */
  seed?: number;
  /** Called when the run ends — on defeat, or on clearing the campaign. */
  onGameOver?: (score: number) => void;
  /** Override canvas colours (hex) on top of the night / dusk palette. */
  theme?: Partial<LanternPalette>;
  /** localStorage key for best score, best wave and speed preference. */
  storageKey?: string;
  title?: string;
  className?: string;
}

interface Hud {
  status: Status;
  gold: number;
  lives: number;
  wave: number;
  score: number;
  kills: number;
  endless: boolean;
  speed: 1 | 2;
  canCall: boolean;
  countdown: number | null;
  bonus: number;
  spawning: boolean;
  sel: { id: number; kind: TowerKind; level: number; kills: number; up: number | null; refund: number } | null;
}
interface Saved {
  best: number;
  bestWave: number;
  speed: 1 | 2;
}

const HOTKEY: Record<TowerKind, string> = { lantern: "1", prism: "2", brazier: "3" };

export function LanternDefense({ initialLevel = 1, seed = 7, onGameOver, theme, storageKey = "lantern-defense:v1", title = "Lantern Defense", className }: LanternDefenseProps) {
  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<LanternEngine | null>(null);
  const [muted, setMuted] = React.useState(true);
  const tone = useTones(muted);
  const [saved, setSaved] = React.useState<Saved>({ best: 0, bestWave: 0, speed: 1 });
  const [newBest, setNewBest] = React.useState(false);
  const [armed, setArmedState] = React.useState<TowerKind | null>(null);
  const [, setSelectedState] = React.useState<number | null>(null);
  const uiRef = React.useRef<{ hover: { x: number; y: number } | null; cursor: { x: number; y: number } | null; armed: TowerKind | null; selected: number | null }>({ hover: null, cursor: null, armed: null, selected: null });
  const [hud, setHud] = React.useState<Hud>({ status: "ready", gold: 160, lives: 20, wave: 0, score: 0, kills: 0, endless: false, speed: 1, canCall: true, countdown: null, bonus: 0, spawning: false, sel: null });

  const setArmed = React.useCallback((k: TowerKind | null) => {
    uiRef.current.armed = k;
    setArmedState(k);
    if (k) {
      uiRef.current.selected = null;
      setSelectedState(null);
    }
  }, []);
  const setSelected = React.useCallback((id: number | null) => {
    uiRef.current.selected = id;
    setSelectedState(id);
  }, []);

  const palette = React.useMemo(() => ({ ...(dark ? NIGHT : DUSK), ...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(() => {
    const s = loadJSON<Saved>(storageKey, { best: 0, bestWave: 0, speed: 1 });
    // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate persisted progress after mount
    setSaved(s);
    if (engineRef.current) engineRef.current.speed = s.speed === 2 ? 2 : 1;
  }, [storageKey]);

  const sound = React.useCallback((s: LanternSound) => {
    const t = cb.current.tone;
    if (s === "build") t(420, 0.12, "triangle", 0.05, 260);
    else if (s === "shoot") t(900, 0.04, "sine", 0.015, 300);
    else if (s === "beam") t(1200, 0.12, "sawtooth", 0.015, -600);
    else if (s === "pulse") t(160, 0.16, "triangle", 0.03, -60);
    else if (s === "kill") t(640, 0.06, "sine", 0.025, 400);
    else if (s === "leak") t(180, 0.3, "sawtooth", 0.05, -100);
    else if (s === "wave") t(330, 0.35, "triangle", 0.05, 110);
    else if (s === "upgrade") t(520, 0.2, "triangle", 0.05, 520);
    else if (s === "sell") t(300, 0.12, "square", 0.03, -120);
    else if (s === "win") t(523, 0.6, "triangle", 0.06, 523);
    else if (s === "lose") t(200, 0.7, "sawtooth", 0.05, -140);
  }, []);

  const makeEngine = React.useCallback(
    (layout: Layout) => {
      const eng = new LanternEngine({
        seed,
        layout,
        startWave: Math.max(1, Math.min(CAMPAIGN_WAVES, initialLevel)),
        reduced: reduce,
        onSound: sound,
        onEnd: ({ score, wave }) => {
          const key = cb.current.storageKey;
          const prev = loadJSON<Saved>(key, { best: 0, bestWave: 0, speed: 1 });
          const next = { ...prev, best: Math.max(prev.best, score), bestWave: Math.max(prev.bestWave, wave) };
          saveJSON(key, next);
          setSaved(next);
          setNewBest(score > prev.best);
          cb.current.onGameOver?.(score);
        },
      });
      eng.speed = loadJSON<Saved>(cb.current.storageKey, { best: 0, bestWave: 0, speed: 1 }).speed === 2 ? 2 : 1;
      engineRef.current = eng;
      uiRef.current.cursor = null;
      uiRef.current.hover = null;
      setSelected(null);
      return eng;
    },
    [initialLevel, reduce, seed, sound, setSelected],
  );

  const layoutFor = (w: number, h: number): Layout => (w >= h * 0.95 ? "wide" : "tall");

  // 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 = "";
    const frame = (now: number) => {
      const dt = now - last;
      last = now;
      const { w, h, dpr } = sizeRef.current;
      let eng = engineRef.current;
      if (!eng || (eng.status === "ready" && eng.towers.length === 0 && eng.layout !== layoutFor(w, h))) eng = makeEngine(layoutFor(w, h));
      eng.update(dt);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      eng.render(ctx, w, h, dpr, paletteRef.current, uiRef.current);
      const g = eng.geometry(w, h);
      canvas.dataset.tile = g.tile.toFixed(3);
      canvas.dataset.ox = g.ox.toFixed(2);
      canvas.dataset.oy = g.oy.toFixed(2);
      const st = uiRef.current.selected !== null ? eng.towers.find((t) => t.id === uiRef.current.selected) : null;
      const next: Hud = {
        status: eng.status,
        gold: eng.gold,
        lives: eng.lives,
        wave: eng.wave,
        score: eng.score,
        kills: eng.kills,
        endless: eng.endless,
        speed: eng.speed,
        canCall: eng.canCallWave,
        countdown: eng.countdown === null ? null : Math.ceil(eng.countdown),
        bonus: eng.callBonus,
        spawning: eng.waveSpawning,
        sel: st ? { id: st.id, kind: st.kind, level: st.level, kills: st.kills, up: eng.upgradeCost(st), refund: eng.refund(st) } : null,
      };
      const key = JSON.stringify(next);
      if (key !== lastHud) {
        lastHud = key;
        setHud(next);
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [makeEngine, sizeRef]);

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

  const begin = React.useCallback(() => {
    const e = engineRef.current;
    if (!e) return;
    if (e.status === "ready") e.start();
    else if (e.status === "paused") e.togglePause();
    setNewBest(false);
    focusRoot();
  }, []);

  const restart = React.useCallback(() => {
    const { w, h } = sizeRef.current;
    const e = makeEngine(layoutFor(w, h));
    e.start();
    setArmed(null);
    setNewBest(false);
    focusRoot();
  }, [makeEngine, setArmed, sizeRef]);

  const togglePause = React.useCallback(() => engineRef.current?.togglePause(), []);
  const callWave = React.useCallback(() => {
    engineRef.current?.callWave();
  }, []);
  const toggleSpeed = React.useCallback(() => {
    const e = engineRef.current;
    if (!e) return;
    e.speed = e.speed === 1 ? 2 : 1;
    setSaved((s) => {
      const n = { ...s, speed: e.speed };
      saveJSON(cb.current.storageKey, n);
      return n;
    });
  }, []);

  const upgrade = React.useCallback(() => {
    const id = uiRef.current.selected;
    if (id !== null) engineRef.current?.upgrade(id);
  }, []);
  const sell = React.useCallback(() => {
    const id = uiRef.current.selected;
    if (id !== null && engineRef.current?.sell(id)) setSelected(null);
  }, [setSelected]);

  const tapCell = React.useCallback(
    (cell: { x: number; y: number }, confirmNow: boolean) => {
      const e = engineRef.current;
      if (!e || e.status === "won" || e.status === "lost") return;
      if (e.status === "paused") e.togglePause();
      const ui = uiRef.current;
      const tower = e.towerAt(cell.x, cell.y);
      if (ui.armed) {
        if (e.canBuild(cell.x, cell.y)) {
          const same = ui.cursor && ui.cursor.x === cell.x && ui.cursor.y === cell.y;
          if (confirmNow || same) {
            const kind = ui.armed;
            if (e.build(kind, cell.x, cell.y)) {
              ui.cursor = null;
              if (e.gold < TOWERS[kind].cost) setArmed(null);
            }
          } else ui.cursor = cell;
          return;
        }
        if (tower) {
          setArmed(null);
          setSelected(tower.id);
        }
        ui.cursor = cell;
        return;
      }
      ui.cursor = confirmNow ? ui.cursor : null;
      if (tower) setSelected(ui.selected === tower.id ? null : tower.id);
      else setSelected(null);
    },
    [setArmed, setSelected],
  );

  useAutoPause(
    React.useCallback(() => {
      const e = engineRef.current;
      if (e?.status === "playing") e.togglePause();
    }, []),
  );

  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 ui = uiRef.current;
      const moves: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
      const mv = moves[ev.key];
      if (mv && !onButton) {
        ev.preventDefault();
        const c = ui.cursor ?? { x: Math.floor(e.cols / 2), y: Math.floor(e.rows / 2) };
        ui.cursor = { x: Math.max(0, Math.min(e.cols - 1, c.x + mv[0])), y: Math.max(0, Math.min(e.rows - 1, c.y + mv[1])) };
        return;
      }
      const k = ev.key.toLowerCase();
      if (k === "1" || k === "2" || k === "3") {
        const kind = TOWER_ORDER[Number(k) - 1];
        setArmed(ui.armed === kind ? null : kind);
      } else if (k === "enter" && !onButton) {
        ev.preventDefault();
        if (e.status === "ready" || e.status === "paused") begin();
        else if (ui.cursor) tapCell(ui.cursor, true);
      } else if (k === " " && !onButton) {
        ev.preventDefault();
        if (e.status === "ready") begin();
        else if (e.status === "paused") e.togglePause();
        else e.callWave();
      } else if (k === "escape") {
        if (ui.armed || ui.selected !== null) {
          setArmed(null);
          setSelected(null);
        } else togglePause();
      } else if (k === "p") togglePause();
      else if (k === "f") toggleSpeed();
      else if (k === "m") setMuted((m) => !m);
      else if (k === "r") restart();
      else if (k === "u") upgrade();
      else if (k === "s" || k === "backspace" || k === "delete") sell();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [begin, restart, sell, setArmed, setSelected, tapCell, togglePause, toggleSpeed, upgrade]);

  const cellFromEvent = (ev: React.PointerEvent) => {
    const e = engineRef.current;
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!e || !rect) return null;
    return e.cellAt(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
  };

  const status = hud.status;
  const playing = status === "playing";
  const sel = hud.sel;
  const waveLabel = hud.endless ? `${hud.wave}` : `${hud.wave}/${CAMPAIGN_WAVES}`;

  return (
    <div
      ref={rootRef}
      tabIndex={0}
      data-status={status}
      data-wave={hud.wave}
      data-gold={hud.gold}
      data-lives={hud.lives}
      data-score={hud.score}
      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 shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b bg-background/85 px-3 py-2 backdrop-blur sm:h-14 sm:flex-nowrap sm:px-5 sm:py-0">
        <div className="flex min-w-0 items-center gap-2.5">
          <LanternMark className="size-8 shrink-0" />
          <div className="min-w-0 leading-none">
            <p className="truncate text-sm font-black tracking-tight">{title}</p>
            <p className="mt-1 hidden text-[11px] text-muted-foreground sm:block">Best {saved.best.toLocaleString()} · wave {saved.bestWave}</p>
          </div>
        </div>
        <div className="order-3 grid w-full grid-cols-4 gap-1.5 sm:order-none sm:ml-auto sm:flex sm:w-auto sm:items-center sm:gap-2">
          <Chip icon={<Coins className="size-3.5 text-amber-500" />} label="Gold" value={hud.gold} />
          <Chip icon={<Heart className="size-3.5 text-rose-500" />} label="Lives" value={hud.lives} warn={hud.lives <= 5} />
          <Chip icon={<Waves className="size-3.5 text-violet-500" />} label="Wave" value={waveLabel} />
          <Chip icon={<Trophy className="size-3.5 text-sky-500" />} label="Score" value={hud.score.toLocaleString()} />
        </div>
        <div className="ml-auto flex items-center gap-1 sm:ml-0">
          <IconButton label={`Speed ×${hud.speed === 1 ? 2 : 1} (F)`} onClick={toggleSpeed} pressed={hud.speed === 2}>
            <FastForward className="size-4" />
            <span className="sr-only">×2</span>
          </IconButton>
          <IconButton label={playing ? "Pause (P)" : "Resume (P)"} onClick={playing ? togglePause : begin} disabled={status === "won" || status === "lost"}>
            {playing ? <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>
      </header>

      <div
        className="relative min-h-0 flex-1 touch-none select-none"
        onPointerMove={(ev) => {
          if (ev.pointerType === "mouse") uiRef.current.hover = cellFromEvent(ev);
        }}
        onPointerLeave={() => (uiRef.current.hover = null)}
        onPointerUp={(ev) => {
          const c = cellFromEvent(ev);
          if (c) {
            uiRef.current.cursor = ev.pointerType === "mouse" ? null : uiRef.current.cursor;
            tapCell(c, ev.pointerType === "mouse");
          }
        }}
      >
        <canvas ref={canvasRef} className="absolute inset-0 block" role="img" aria-label={`Forest map. Wave ${hud.wave}, ${hud.lives} lives, ${hud.gold} gold.`} />
        {hud.speed === 2 && playing && <span className="pointer-events-none absolute top-3 left-3 rounded-full bg-amber-400/90 px-2 py-0.5 text-[10px] font-black tracking-wider text-amber-950">×2 SPEED</span>}

        {/* wave caller */}
        {(status === "playing" || status === "paused") && hud.canCall && (
          <motion.button
            type="button"
            initial={reduce ? false : { y: 12, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            onPointerUp={(ev) => ev.stopPropagation()}
            onClick={callWave}
            className="absolute right-3 bottom-3 z-10 inline-flex h-10 sm:right-auto sm:left-3 items-center gap-2 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-4 text-sm font-bold text-amber-950 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:outline-none active:scale-95"
          >
            <Waves className="size-4" />
            {hud.wave === 0 ? "Start wave 1" : `Call wave ${hud.wave + 1}`}
            {hud.countdown !== null && (
              <span className="rounded-full bg-amber-950/15 px-1.5 text-xs tabular-nums">
                {hud.countdown}s · +{hud.bonus}g
              </span>
            )}
            <kbd className="hidden rounded bg-amber-950/15 px-1 text-[10px] sm:inline">Space</kbd>
          </motion.button>
        )}
        {playing && hud.spawning && <span className="pointer-events-none absolute bottom-4 left-3 rounded-full bg-background/80 px-3 py-1 text-xs font-semibold backdrop-blur">Wave {hud.wave} incoming…</span>}

        {/* selected tower */}
        <AnimatePresence>
          {sel && (
            <motion.div
              key={sel.id}
              initial={reduce ? false : { opacity: 0, x: 16 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: 16 }}
              onPointerUp={(ev) => ev.stopPropagation()}
              className="absolute top-3 right-3 z-10 w-60 rounded-2xl border bg-card/95 p-3 text-card-foreground shadow-xl backdrop-blur"
              role="dialog"
              aria-label={`${TOWERS[sel.kind].name} details`}
            >
              <div className="flex items-start gap-2.5">
                <TowerIcon kind={sel.kind} className="size-10 shrink-0" />
                <div className="min-w-0 flex-1">
                  <p className="text-sm font-bold">{TOWERS[sel.kind].name}</p>
                  <p className="text-xs text-muted-foreground">
                    Level {sel.level + 1} · {sel.kills} banished
                  </p>
                </div>
                <button type="button" aria-label="Close" onClick={() => setSelected(null)} className="grid size-7 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>
              <dl className="mt-3 grid grid-cols-3 gap-1.5 text-center">
                <SpecStat label="Dmg" now={TOWERS[sel.kind].damage[sel.level]} next={sel.level < 2 ? TOWERS[sel.kind].damage[sel.level + 1] : undefined} />
                <SpecStat label="Range" now={TOWERS[sel.kind].range[sel.level]} next={sel.level < 2 ? TOWERS[sel.kind].range[sel.level + 1] : undefined} />
                <SpecStat label="Rate" now={TOWERS[sel.kind].rate[sel.level]} next={sel.level < 2 ? TOWERS[sel.kind].rate[sel.level + 1] : undefined} />
              </dl>
              <div className="mt-3 grid grid-cols-2 gap-2">
                <button
                  type="button"
                  onClick={upgrade}
                  disabled={sel.up === null || hud.gold < sel.up}
                  className="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-primary px-2 text-xs font-bold text-primary-foreground transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-40"
                >
                  <ArrowUpCircle className="size-3.5" />
                  {sel.up === null ? "Max level" : `Upgrade ${sel.up}g`}
                </button>
                <button type="button" onClick={sell} className="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border bg-background px-2 text-xs font-semibold transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
                  Sell +{sel.refund}g
                </button>
              </div>
              <p className="mt-2 hidden text-center text-[10px] text-muted-foreground sm:block">U upgrade · S sell · Esc close</p>
            </motion.div>
          )}
        </AnimatePresence>

        <AnimatePresence>
          {(status === "ready" || status === "paused" || status === "won" || status === "lost") && (
            <Overlay key={status} reduce={reduce}>
              {status === "ready" && (
                <>
                  <LanternMark 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">Shadows creep along the forest road toward the village. Light the way with lanterns, prisms and braziers — they fear nothing more than a bright night.</p>
                  <PrimaryButton onClick={begin}>
                    <Play className="size-4" /> Light the first lantern
                  </PrimaryButton>
                  <p className="mt-4 text-xs text-muted-foreground">
                    Best {saved.best.toLocaleString()} · furthest wave {saved.bestWave}
                  </p>
                  <p className="mt-2 hidden text-[11px] text-muted-foreground sm:block">1·2·3 pick a tower · click to build · Space calls a wave · F speed · P pause</p>
                  <p className="mt-2 text-[11px] text-muted-foreground sm:hidden">Pick a tower below, tap a tile to preview, tap again to build</p>
                </>
              )}
              {status === "paused" && (
                <>
                  <p className="text-xs font-semibold tracking-[0.3em] text-muted-foreground uppercase">The night holds its breath</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>
                    <SecondaryButton onClick={restart}>
                      <RotateCcw className="size-4" /> Restart
                    </SecondaryButton>
                  </div>
                </>
              )}
              {(status === "won" || status === "lost") && (
                <>
                  <div className={cn("mx-auto mb-3 grid size-14 place-items-center rounded-2xl", status === "won" ? "bg-amber-400/20 text-amber-500" : "bg-rose-500/15 text-rose-500")}>{status === "won" ? <Sunrise className="size-7" /> : <MoonStar className="size-7" />}</div>
                  <p className={cn("text-xs font-bold tracking-[0.3em] uppercase", status === "won" ? "text-amber-500" : "text-rose-500")}>{status === "won" ? "Dawn breaks" : "The lanterns went dark"}</p>
                  <h2 className="mt-2 text-5xl font-black tabular-nums">{hud.score.toLocaleString()}</h2>
                  {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-amber-400 to-orange-500 px-3 py-1 text-xs font-bold text-amber-950">
                      <Trophy className="size-3.5" /> New best!
                    </motion.p>
                  ) : (
                    <p className="mt-3 text-xs text-muted-foreground">Best {saved.best.toLocaleString()}</p>
                  )}
                  <dl className="mx-auto mt-5 grid max-w-xs grid-cols-3 gap-2">
                    <MiniStat label="Wave" value={hud.wave} />
                    <MiniStat label="Banished" value={hud.kills} />
                    <MiniStat label="Lives" value={hud.lives} />
                  </dl>
                  <div className="mt-6 flex flex-wrap justify-center gap-2">
                    {status === "won" && (
                      <PrimaryButton onClick={() => engineRef.current?.continueEndless()} className="mt-0">
                        <Waves className="size-4" /> Endless night
                      </PrimaryButton>
                    )}
                    {status === "won" ? (
                      <SecondaryButton onClick={restart}>
                        <RotateCcw className="size-4" /> New forest
                      </SecondaryButton>
                    ) : (
                      <PrimaryButton onClick={restart} className="mt-0">
                        <RotateCcw className="size-4" /> Try again
                      </PrimaryButton>
                    )}
                  </div>
                </>
              )}
            </Overlay>
          )}
        </AnimatePresence>
      </div>

      {/* tower shop */}
      <nav aria-label="Towers" className="grid shrink-0 grid-cols-3 gap-2 border-t bg-muted/40 p-2 sm:gap-3 sm:px-5 sm:py-3">
        {TOWER_ORDER.map((kind) => {
          const t = TOWERS[kind];
          const on = armed === kind;
          const afford = hud.gold >= t.cost;
          return (
            <button
              key={kind}
              type="button"
              aria-pressed={on}
              onClick={() => setArmed(on ? null : kind)}
              className={cn(
                "group relative flex min-w-0 items-center gap-2.5 rounded-xl border bg-card p-2 text-left transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none sm:p-2.5",
                on ? "border-amber-400 ring-2 ring-amber-400/40" : "hover:border-foreground/20",
                !afford && "opacity-55",
              )}
            >
              <TowerIcon kind={kind} className="size-9 shrink-0 transition group-hover:scale-105 sm:size-11" />
              <span className="min-w-0 flex-1">
                <span className="flex items-center gap-1.5">
                  <span className="truncate text-xs font-bold sm:text-sm">
                    <span className="sm:hidden">{t.name.split(" ")[t.kind === "lantern" ? 0 : 1]}</span>
                    <span className="hidden sm:inline">{t.name}</span>
                  </span>
                  <kbd className="hidden rounded border bg-muted px-1 font-mono text-[10px] text-muted-foreground md:inline">{HOTKEY[kind]}</kbd>
                </span>
                <span className="flex items-center gap-1 text-xs font-semibold text-amber-600 tabular-nums dark:text-amber-400">
                  <Coins className="size-3" aria-hidden /> {t.cost}
                </span>
                <span className="hidden truncate text-[11px] text-muted-foreground lg:block">{t.blurb}</span>
              </span>
            </button>
          );
        })}
      </nav>
      <p className="sr-only" aria-live="polite">
        {status === "lost" ? `Defeat on wave ${hud.wave}. Score ${hud.score}.` : status === "won" ? `Victory! Score ${hud.score}.` : status === "paused" ? "Paused" : ""}
      </p>
    </div>
  );
}

/* ───────────────────────── bits ───────────────────────── */

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.25 }} className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-background/65 p-6 backdrop-blur-[3px]" onPointerUp={(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-amber-400 to-orange-500 px-6 text-sm font-bold text-amber-950 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 SecondaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
  return (
    <button type="button" onClick={onClick} 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">
      {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={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")}
    >
      {children}
    </button>
  );
}

function Chip({ icon, label, value, warn }: { icon: React.ReactNode; label: string; value: React.ReactNode; warn?: boolean }) {
  return (
    <div className={cn("flex min-w-0 items-center gap-1.5 rounded-lg border bg-card px-2 py-1 sm:px-2.5", warn && "border-rose-500/50 bg-rose-500/10")} title={label}>
      {icon}
      <span className="sr-only">{label}</span>
      <span className="truncate text-sm font-bold tabular-nums">{value}</span>
    </div>
  );
}

function SpecStat({ label, now, next }: { label: string; now: number; next?: number }) {
  return (
    <div className="rounded-lg bg-muted px-1 py-1.5">
      <dt className="text-[10px] text-muted-foreground uppercase">{label}</dt>
      <dd className="text-sm font-bold tabular-nums">
        {now}
        {next !== undefined && <span className="ml-1 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">→{next}</span>}
      </dd>
    </div>
  );
}

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

function TowerIcon({ kind, className }: { kind: TowerKind; className?: string }) {
  const id = React.useId();
  return (
    <svg viewBox="0 0 44 44" className={className} aria-hidden>
      <defs>
        <radialGradient id={`${id}g`} cx="0.5" cy="0.4" r="0.6">
          <stop offset="0" stopColor={TOWERS[kind].color} stopOpacity="0.55" />
          <stop offset="1" stopColor={TOWERS[kind].color} stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect width="44" height="44" rx="12" fill="#101a17" />
      <circle cx="22" cy="18" r="18" fill={`url(#${id}g)`} />
      <rect x="11" y="31" width="22" height="7" rx="2" fill="#57534e" />
      {kind === "lantern" && (
        <>
          <rect x="17" y="10" width="3" height="22" fill="#6b4f3a" />
          <rect x="17" y="10" width="10" height="2.5" fill="#6b4f3a" />
          <rect x="22.5" y="14" width="8" height="9" rx="2" fill="#2b2118" />
          <rect x="24" y="15.5" width="5" height="6" rx="1.2" fill="#ffc857" />
        </>
      )}
      {kind === "prism" && (
        <>
          <path d="M16 31 L19 24 H25 L28 31Z" fill="#475569" />
          <path d="M22 6 L28 15 L22 22 L16 15Z" fill="#7ce7ff" />
          <path d="M22 6 L28 15 L22 22Z" fill="#a78bfa" opacity="0.8" />
        </>
      )}
      {kind === "brazier" && (
        <>
          <path d="M11 24 H33 L29 31 H15Z" fill="#44403c" />
          <path d="M15 24 Q18 12 21 24Z" fill="#ff7a3d" />
          <path d="M19 24 Q22 8 25 24Z" fill="#ffd166" />
          <path d="M23 24 Q26 13 29 24Z" fill="#ff7a3d" />
        </>
      )}
    </svg>
  );
}

function LanternMark({ className }: { className?: string }) {
  const id = React.useId();
  return (
    <svg viewBox="0 0 40 40" className={className} aria-hidden>
      <defs>
        <radialGradient id={`${id}r`} cx="0.5" cy="0.55" r="0.5">
          <stop offset="0" stopColor="#ffd98a" />
          <stop offset="1" stopColor="#ffd98a" stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect width="40" height="40" rx="11" fill="#0b1512" />
      <circle cx="20" cy="22" r="15" fill={`url(#${id}r)`} opacity="0.6" />
      <path d="M14 8 H26 L24 12 H16Z" fill="#3f2e22" />
      <rect x="14.5" y="12" width="11" height="15" rx="3" fill="#2b2118" />
      <rect x="16.5" y="14" width="7" height="11" rx="2" fill="#ffc857" />
      <ellipse cx="20" cy="20" rx="1.6" ry="3" fill="#fff5d6" />
      <rect x="15" y="27" width="10" height="3" rx="1" fill="#3f2e22" />
      <circle cx="9" cy="31" r="1.5" fill="#c9b6ff" />
      <circle cx="12" cy="31" r="1.5" fill="#c9b6ff" />
    </svg>
  );
}

More in Games

View all →