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