"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Castle, ChevronLeft, Crown, Flag, Pause, Play, RotateCcw, Shuffle, Swords, Timer, Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DIFFICULTIES,
MATCH_SECONDS,
MAX_ROUNDS,
aiChooseMove,
applyMove,
canAct,
generateMap,
grow,
hexToPixel,
movesFor,
mulberry32,
passable,
preview,
territory,
visibleSet,
type Difficulty,
type MapData,
type Tile,
} from "./engine";
import { MapView, type Ripple, type TokenAnim } from "./map-view";
export interface HexConquestTheme {
player?: string;
enemy?: string;
}
export interface HexConquestProps {
/** 0 = Recruit, 1 = Captain, 2 = Warlord */
initialLevel?: number;
/** Map seed; a new one is rolled with “New map”. */
seed?: number;
playerName?: string;
enemyName?: string;
storageKey?: string | null;
onGameOver?: (score: number, won: boolean) => void;
theme?: HexConquestTheme;
className?: string;
}
type Phase = "menu" | "playing" | "over";
interface LogEntry {
id: number;
who: "player" | "ai" | "system";
text: string;
}
interface Outcome {
won: boolean | null;
reason: "capital" | "time" | "rounds" | "wiped";
score: number;
enemyScore: number;
captured: number;
rounds: number;
}
interface Records {
wins: number[];
best: number[];
}
function readRecords(key: string | null): Records {
const empty = { wins: [0, 0, 0], best: [0, 0, 0] };
if (!key) return empty;
try {
const v = JSON.parse(window.localStorage.getItem(key) ?? "null") as Records | null;
return v && Array.isArray(v.wins) ? v : empty;
} catch {
return empty;
}
}
function writeRecords(key: string | null, v: Records) {
if (!key) return;
try {
window.localStorage.setItem(key, JSON.stringify(v));
} catch {
/* ignore */
}
}
function fmtClock(s: number) {
const v = Math.max(0, Math.ceil(s));
return `${Math.floor(v / 60)}:${String(v % 60).padStart(2, "0")}`;
}
function useBlips(on: boolean) {
const ref = React.useRef<AudioContext | null>(null);
React.useEffect(() => () => void ref.current?.close().catch(() => {}), []);
return React.useCallback(
(kind: "select" | "move" | "capture" | "lose" | "win" | "turn") => {
if (!on) return;
try {
if (!ref.current) ref.current = new AudioContext();
const ctx = ref.current;
void ctx.resume();
const t = ctx.currentTime;
const tone = (f: number, at: number, d: number, type: OscillatorType = "triangle", g = 0.06) => {
const o = ctx.createOscillator();
const gn = ctx.createGain();
o.type = type;
o.frequency.value = f;
gn.gain.setValueAtTime(0.0001, t + at);
gn.gain.exponentialRampToValueAtTime(g, t + at + 0.01);
gn.gain.exponentialRampToValueAtTime(0.0001, t + at + d);
o.connect(gn).connect(ctx.destination);
o.start(t + at);
o.stop(t + at + d + 0.02);
};
if (kind === "select") tone(660, 0, 0.07);
else if (kind === "move") tone(440, 0, 0.1);
else if (kind === "capture") {
tone(523, 0, 0.12);
tone(784, 0.07, 0.2);
}
else if (kind === "lose") tone(180, 0, 0.3, "sawtooth", 0.04);
else if (kind === "turn") tone(392, 0, 0.12, "sine");
else [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.1, 0.4, "sine"));
} catch {
/* optional */
}
},
[on],
);
}
export function HexConquest({ initialLevel = 1, seed: seedProp = 20260924, playerName = "Azure League", enemyName = "Crimson Pact", storageKey = "fazekit:hex-conquest:v1", onGameOver, theme, className }: HexConquestProps) {
const reduce = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const [phase, setPhase] = React.useState<Phase>("menu");
const [difficulty, setDifficulty] = React.useState<Difficulty>(Math.max(0, Math.min(2, initialLevel)) as Difficulty);
const [seed, setSeed] = React.useState(seedProp);
const map: MapData = React.useMemo(() => generateMap(seed), [seed]);
const [tiles, setTiles] = React.useState<Tile[]>(map.tiles);
const [turn, setTurn] = React.useState<"player" | "ai">("player");
const [movesLeft, setMovesLeft] = React.useState(3);
const [round, setRound] = React.useState(1);
const [clock, setClock] = React.useState(MATCH_SECONDS);
const [selected, setSelected] = React.useState<number | null>(null);
const [hover, setHover] = React.useState<number | null>(null);
const [cursor, setCursor] = React.useState<number | null>(null);
const [anim, setAnim] = React.useState<TokenAnim | null>(null);
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const [growKey, setGrowKey] = React.useState(0);
const [log, setLog] = React.useState<LogEntry[]>([]);
const [paused, setPaused] = React.useState(false);
const [outcome, setOutcome] = React.useState<Outcome | null>(null);
const [captured, setCaptured] = React.useState(0);
const [records, setRecords] = React.useState<Records>({ wins: [0, 0, 0], best: [0, 0, 0] });
const [sound, setSound] = React.useState(false);
const [shake, setShake] = React.useState(0);
const blip = useBlips(sound);
const busy = anim !== null;
const idRef = React.useRef(0);
const capturedRef = React.useRef(0);
const roundRef = React.useRef(1);
const aiLeftRef = React.useRef(0);
React.useEffect(() => {
capturedRef.current = captured;
roundRef.current = round;
}, [captured, round]);
React.useEffect(() => setRecords(readRecords(storageKey)), [storageKey]);
const visible = React.useMemo(() => (phase === "over" ? new Set(tiles.map((t) => t.id)) : visibleSet(map, tiles, "player")), [map, tiles, phase]);
const me = territory(tiles, "player");
const them = territory(tiles, "ai");
const pushLog = (who: LogEntry["who"], text: string) => setLog((l) => [{ id: ++idRef.current, who, text }, ...l].slice(0, 7));
const newMatch = React.useCallback(
(nextSeed = seed) => {
const m = generateMap(nextSeed);
setSeed(nextSeed);
setTiles(m.tiles);
setTurn("player");
setMovesLeft(movesFor(m.tiles, "player", difficulty));
setRound(1);
setClock(MATCH_SECONDS);
setSelected(null);
setCursor(m.capitals.player);
setAnim(null);
setRipples([]);
setGrowKey(0);
setLog([{ id: ++idRef.current, who: "system", text: "Round 1 — expand from your capital." }]);
setOutcome(null);
setCaptured(0);
setPaused(false);
setPhase("playing");
blip("turn");
},
[seed, difficulty, blip],
);
const finish = React.useCallback(
(ts: Tile[], reason: Outcome["reason"], forcedWin?: boolean) => {
const p = territory(ts, "player").score;
const a = territory(ts, "ai").score;
const won = forcedWin ?? (p === a ? null : p > a);
const score = p * 10 + (won ? 250 + [0, 150, 350][difficulty] : 0);
setOutcome({ won, reason, score, enemyScore: a * 10, captured: capturedRef.current, rounds: roundRef.current });
setPhase("over");
setSelected(null);
blip(won ? "win" : "lose");
setRecords((prev) => {
const next = { wins: prev.wins.slice(), best: prev.best.slice() };
if (won) next.wins[difficulty] += 1;
next.best[difficulty] = Math.max(next.best[difficulty], score);
writeRecords(storageKey, next);
return next;
});
onGameOver?.(score, !!won);
},
[difficulty, blip, storageKey, onGameOver],
);
/** animate a token, then commit the move */
const perform = React.useCallback(
(ts: Tile[], from: number, to: number, who: "player" | "ai", done: (next: Tile[]) => void) => {
const count = ts[from].troops - 1;
setAnim({ key: ++idRef.current, from, to, owner: who, count });
window.setTimeout(
() => {
const res = applyMove(ts, from, to);
setAnim(null);
setTiles(res.tiles);
const target = ts[to];
if (res.kind === "capture") {
setRipples((r) => [...r, { key: ++idRef.current, tile: to, owner: who }]);
window.setTimeout(() => setRipples((r) => r.slice(1)), 750);
blip(who === "player" ? "capture" : "lose");
if (who === "player") setCaptured((c) => c + 1);
if (who === "ai" && target.owner === "player") setShake((s) => s + 1);
} else blip("move");
const name = target.terrain === "capital" ? "capital" : target.terrain === "town" ? "town" : "tile";
const seen = who === "player" || visibleSet(map, res.tiles, "player").has(to);
if (res.kind === "capture") pushLog(who, who === "player" ? `Captured a ${target.owner === "ai" ? "rival " : ""}${name} (${res.left} left)` : seen ? `${enemyName} took ${target.owner === "player" ? "your" : "a"} ${name}` : `${enemyName} advanced in the fog`);
else if (res.kind === "repelled") pushLog(who, who === "player" ? `Attack repelled — ${res.left} defenders remain` : seen ? `Repelled an attack on your ${name}` : `${enemyName} clashed in the fog`);
done(res.tiles);
},
reduce ? 60 : 340,
);
},
[blip, enemyName, map, reduce],
);
const checkEnd = React.useCallback(
(ts: Tile[]) => {
let end: [Outcome["reason"], boolean] | null = null;
if (ts[map.capitals.ai].owner === "player") end = ["capital", true];
else if (ts[map.capitals.player].owner === "ai") end = ["capital", false];
else if (!ts.some((t) => t.owner === "ai")) end = ["wiped", true];
else if (!ts.some((t) => t.owner === "player")) end = ["wiped", false];
if (end) finish(ts, end[0], end[1]);
return !!end;
},
[finish, map],
);
const endTurn = React.useCallback(() => {
if (phase !== "playing" || turn !== "player" || busy) return;
setSelected(null);
aiLeftRef.current = movesFor(tiles, "ai", difficulty);
setTurn("ai");
pushLog("system", `${enemyName} is moving…`);
}, [phase, turn, busy, enemyName, tiles, difficulty]);
// AI turn
const tilesRef = React.useRef(tiles);
React.useEffect(() => {
tilesRef.current = tiles;
}, [tiles]);
React.useEffect(() => {
if (phase !== "playing" || turn !== "ai" || paused) return;
let cancelled = false;
const rnd = mulberry32(seed * 31 + round * 977 + difficulty + aiLeftRef.current);
const step = () => {
if (cancelled) return;
const ts = tilesRef.current;
const m = aiLeftRef.current > 0 ? aiChooseMove(map, ts, difficulty, rnd) : null;
if (!m) {
// end of round: growth for everyone
const grown = grow(grow(ts, "player"), "ai");
setTiles(grown);
setGrowKey((k) => k + 1);
const nextRound = round + 1;
if (nextRound > MAX_ROUNDS) return finish(grown, "rounds");
setRound(nextRound);
setTurn("player");
setMovesLeft(movesFor(grown, "player", difficulty));
pushLog("system", `Round ${nextRound} — your move.`);
blip("turn");
return;
}
aiLeftRef.current -= 1;
perform(ts, m.from, m.to, "ai", (next) => {
tilesRef.current = next;
if (checkEnd(next)) return;
window.setTimeout(step, reduce ? 80 : 220);
});
};
const id = window.setTimeout(step, reduce ? 150 : 500);
return () => {
cancelled = true;
window.clearTimeout(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, turn, paused]);
// match clock
React.useEffect(() => {
if (phase !== "playing" || paused) return;
const id = window.setInterval(() => setClock((c) => c - 1), 1000);
return () => window.clearInterval(id);
}, [phase, paused]);
React.useEffect(() => {
if (phase === "playing" && clock <= 0 && !busy) finish(tilesRef.current, "time");
}, [clock, phase, busy, finish]);
// auto end turn when out of moves
const playerHasMove = tiles.some((t) => t.owner === "player" && t.troops >= 2 && map.adj[t.id].some((n) => passable(tiles[n])));
React.useEffect(() => {
if (phase !== "playing" || turn !== "player" || busy || paused) return;
if (movesLeft <= 0 || !playerHasMove) {
const id = window.setTimeout(endTurn, movesLeft <= 0 ? 450 : 1200);
return () => window.clearTimeout(id);
}
}, [movesLeft, playerHasMove, phase, turn, busy, paused, endTurn]);
// pause on blur / hide
React.useEffect(() => {
const onBlur = () => setPaused(true);
const onVis = () => document.hidden && setPaused(true);
window.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVis);
};
}, []);
const myTurn = phase === "playing" && turn === "player" && !busy && !paused;
const targets = React.useMemo(() => {
if (selected === null || !myTurn) return new Set<number>();
return new Set(map.adj[selected].filter((n) => canAct(map, tiles, "player", selected, n)));
}, [selected, myTurn, map, tiles]);
const clickTile = (id: number) => {
if (!myTurn) return;
const t = tiles[id];
if (selected !== null && targets.has(id)) {
const from = selected;
setSelected(null);
setMovesLeft((m) => m - 1);
perform(tiles, from, id, "player", (next) => {
if (checkEnd(next)) return;
if (next[id].owner === "player" && next[id].troops >= 2) setSelected(id);
});
return;
}
if (t.owner === "player" && t.troops >= 2) {
setSelected(selected === id ? null : id);
blip("select");
} else setSelected(null);
};
// keyboard
const centers = React.useMemo(() => map.tiles.map((t) => hexToPixel(t.q, t.r)), [map]);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const root = rootRef.current;
const active = document.activeElement;
if (!root || (active && active !== document.body && !root.contains(active))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key;
if (k === "Escape") {
if (selected !== null) setSelected(null);
else if (phase === "playing") setPaused((p) => !p);
return;
}
if (phase !== "playing") return;
if (k === "p" || k === "P") return setPaused((p) => !p);
if (paused) return;
if (k === "e" || k === "E") return endTurn();
const dirs: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
if (k in dirs) {
e.preventDefault();
const [dx, dy] = dirs[k];
const cur = cursor ?? map.capitals.player;
let best = cur;
let bestScore = -Infinity;
for (const n of map.adj[cur]) {
const vx = centers[n].x - centers[cur].x;
const vy = centers[n].y - centers[cur].y;
const len = Math.hypot(vx, vy);
const sc = (vx * dx + vy * dy) / len - (k === "ArrowUp" || k === "ArrowDown" ? 0.01 * Math.sign(vx) * (round % 2 ? 1 : -1) : 0);
if (sc > bestScore) {
bestScore = sc;
best = n;
}
}
if (bestScore > 0.3) setCursor(best);
return;
}
if ((k === "Enter" || k === " ") && cursor !== null && !(active instanceof HTMLButtonElement)) {
e.preventDefault();
clickTile(cursor);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
const focus = hover ?? cursor;
const pv = selected !== null && focus !== null && targets.has(focus) ? preview(tiles, selected, focus) : null;
const statusText = (() => {
if (phase !== "playing") return "";
if (turn === "ai") return `${enemyName} is moving…`;
if (busy) return "Marching…";
if (pv) return pv.kind === "move" ? `Move ${pv.sent} troops → ${pv.left} there` : pv.kind === "capture" ? `Attack with ${pv.sent}: capture, ${pv.left} survive` : `Attack with ${pv.sent}: repelled (${pv.left} defenders left)`;
if (selected !== null) return "Choose a neighbouring tile to move or attack";
if (!playerHasMove) return "No moves available — ending turn";
return "Select one of your tiles with 2+ troops";
})();
const style = { "--hc-p": theme?.player ?? "#3b82f6", "--hc-a": theme?.enemy ?? "#ef4444" } as React.CSSProperties;
const share = me.score + them.score ? me.score / (me.score + them.score) : 0.5;
const maxMoves = movesFor(tiles, "player", difficulty);
return (
<div ref={rootRef} style={style} className={cn("@container/hc relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground", "[--hc-lake:#7cb4d8] [--hc-rock:#9a948c] dark:[--hc-lake:#2d5773] dark:[--hc-rock:#57524c]", className)}>
{/* header */}
<header className="relative z-10 flex h-14 shrink-0 items-center gap-2 border-b px-3 @3xl/hc:px-5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-[linear-gradient(135deg,var(--hc-p),var(--hc-a))] text-white shadow-sm">
<Swords className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">Hex Conquest</p>
<p className="truncate text-[11px] leading-tight text-muted-foreground">
{phase === "menu" ? "Turn-based territory skirmish" : `${DIFFICULTIES[difficulty].name} · Round ${Math.min(round, MAX_ROUNDS)}/${MAX_ROUNDS}`}
</p>
</div>
{phase !== "menu" && (
<span className={cn("flex h-8 items-center gap-1.5 rounded-full border px-2.5 text-xs font-bold tabular-nums", clock <= 30 && phase === "playing" && "border-destructive/50 text-destructive")} aria-label={`Time left ${fmtClock(clock)}`}>
<Timer className="size-3.5" aria-hidden /> {fmtClock(clock)}
</span>
)}
<button type="button" onClick={() => setSound((s) => !s)} aria-label={sound ? "Mute" : "Unmute"} className="grid size-8 place-items-center rounded-full border text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
{sound ? <Volume2 className="size-4" /> : <VolumeX className="size-4" />}
</button>
{phase === "playing" && (
<button type="button" onClick={() => setPaused(true)} aria-label="Pause" className="grid size-8 place-items-center rounded-full border text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<Pause className="size-4" />
</button>
)}
</header>
<div className="relative flex min-h-0 flex-1 flex-col @4xl/hc:flex-row">
{/* map column */}
<section className="relative flex min-h-0 flex-1 flex-col" aria-label="Battlefield">
{phase !== "menu" && (
<div className="shrink-0 px-3 pt-3 @3xl/hc:px-5">
<div className="flex items-center justify-between text-[11px] font-semibold">
<span className="flex items-center gap-1.5 text-[var(--hc-p)]">
<span className="size-2 rounded-full bg-[var(--hc-p)]" />
{playerName} · {me.count} tiles
</span>
<span className="flex items-center gap-1.5 text-[var(--hc-a)]">
{them.count} tiles · {enemyName}
<span className="size-2 rounded-full bg-[var(--hc-a)]" />
</span>
</div>
<div className="relative mt-1.5 flex h-2.5 overflow-hidden rounded-full bg-muted" role="meter" aria-label="Territory share" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(share * 100)}>
<motion.div className="h-full bg-[var(--hc-p)]" animate={{ width: `${share * 100}%` }} transition={{ type: "spring", stiffness: 160, damping: 22 }} />
<div className="h-full flex-1 bg-[var(--hc-a)]" />
<span className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 bg-background/80" />
</div>
</div>
)}
<motion.div
key={shake}
className="relative min-h-0 flex-1 p-2 @3xl/hc:p-4"
animate={shake && !reduce ? { x: [0, -6, 6, -3, 0] } : undefined}
transition={{ duration: 0.3 }}
tabIndex={phase === "playing" ? 0 : -1}
aria-label={cursor !== null ? `Map. Cursor on ${describe(tiles[cursor], visible.has(cursor))}. Arrow keys move, Enter selects, E ends turn.` : "Map"}
>
<div className={cn("h-full w-full transition-[filter] duration-300", (paused || phase === "menu") && "blur-[3px]")}>
<MapView
map={map}
tiles={phase === "menu" ? map.tiles : tiles}
visible={phase === "menu" ? new Set(map.tiles.map((t) => t.id)) : visible}
selected={selected}
targets={targets}
cursor={phase === "playing" ? cursor : null}
hover={hover}
anim={anim}
ripples={ripples}
growKey={growKey}
interactive={myTurn}
onTileClick={(id) => {
setCursor(id);
clickTile(id);
}}
onHover={setHover}
/>
</div>
</motion.div>
{/* narrow bottom bar */}
{phase === "playing" && (
<div className="shrink-0 border-t bg-card/70 px-3 py-2.5 backdrop-blur @4xl/hc:hidden">
<div className="flex items-center gap-3">
<TurnBadge turn={turn} playerName={playerName} enemyName={enemyName} />
<MovePips left={movesLeft} max={maxMoves} active={turn === "player"} />
<button type="button" onClick={endTurn} disabled={!myTurn} className="ml-auto inline-flex h-10 items-center gap-1.5 rounded-xl bg-foreground px-4 text-sm font-bold text-background outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-40">
<Flag className="size-4" /> End turn
</button>
</div>
<p className="mt-1.5 truncate text-xs text-muted-foreground" aria-live="polite">
{statusText}
</p>
</div>
)}
</section>
{/* wide sidebar */}
{phase !== "menu" && (
<aside className="hidden w-[300px] shrink-0 flex-col gap-3 border-l bg-muted/20 p-4 @4xl/hc:flex" aria-label="Command">
<div className="rounded-2xl border bg-card p-4">
<div className="flex items-center justify-between">
<TurnBadge turn={turn} playerName={playerName} enemyName={enemyName} over={phase === "over"} />
<span className="text-xs text-muted-foreground">
Round {Math.min(round, MAX_ROUNDS)}/{MAX_ROUNDS}
</span>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Moves this turn</span>
<MovePips left={movesLeft} max={maxMoves} active={turn === "player"} />
</div>
<p className="mt-3 min-h-10 rounded-lg bg-muted/70 px-3 py-2 text-xs" aria-live="polite">
{statusText}
</p>
<button type="button" onClick={endTurn} disabled={!myTurn} className="mt-3 inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-xl bg-foreground text-sm font-bold text-background outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-40">
<Flag className="size-4" /> End turn <kbd className="rounded bg-background/20 px-1 font-sans text-[10px]">E</kbd>
</button>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<ScoreCard label="Your score" value={me.score} color="var(--hc-p)" />
<ScoreCard label="Rival score" value={them.score} color="var(--hc-a)" />
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl border bg-card p-3">
<p className="mb-2 text-xs font-semibold">Battle log</p>
<ul className="space-y-1.5 text-xs">
<AnimatePresence initial={false}>
{log.map((l) => (
<motion.li key={l.id} layout={!reduce} initial={reduce ? false : { opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} className="flex items-start gap-2">
<span className="mt-1 size-1.5 shrink-0 rounded-full" style={{ background: l.who === "player" ? "var(--hc-p)" : l.who === "ai" ? "var(--hc-a)" : "var(--muted-foreground)" }} />
<span className={cn(l.who === "system" && "text-muted-foreground")}>{l.text}</span>
</motion.li>
))}
</AnimatePresence>
</ul>
</div>
<Legend />
</aside>
)}
</div>
{/* menu */}
<AnimatePresence>
{phase === "menu" && (
<motion.div className="absolute inset-0 top-14 z-20 grid place-items-center overflow-y-auto bg-background/40 p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
<motion.div className="w-full max-w-md rounded-3xl border bg-card/95 p-5 shadow-2xl backdrop-blur @md/hc:p-6" initial={reduce ? false : { y: 16, scale: 0.97 }} animate={{ y: 0, scale: 1 }}>
<h2 className="text-2xl font-black tracking-tight">
Hex <span className="bg-[linear-gradient(90deg,var(--hc-p),var(--hc-a))] bg-clip-text text-transparent">Conquest</span>
</h2>
<p className="mt-1 text-sm text-muted-foreground">Spread from your capital, take towns, and out-grow the rival in a five-minute skirmish.</p>
<ul className="mt-3 grid gap-1.5 text-xs text-muted-foreground">
<li className="flex items-start gap-2">
<Swords className="mt-0.5 size-3.5 shrink-0 text-foreground" aria-hidden /> Send all but one troop to a neighbour. More attackers than defenders captures it.
</li>
<li className="flex items-start gap-2">
<Castle className="mt-0.5 size-3.5 shrink-0 text-foreground" aria-hidden /> Towns grow +2 and defend +1; capitals grow +3 and defend +2. Towns also grant extra moves.
</li>
<li className="flex items-start gap-2">
<Crown className="mt-0.5 size-3.5 shrink-0 text-foreground" aria-hidden /> Take the rival capital to win outright — or hold the bigger territory when time runs out.
</li>
</ul>
<div className="mt-4 grid gap-2" role="radiogroup" aria-label="Difficulty">
{DIFFICULTIES.map((d, i) => (
<button
key={d.name}
type="button"
role="radio"
aria-checked={difficulty === i}
onClick={() => setDifficulty(i as Difficulty)}
className={cn("relative flex items-center gap-3 rounded-xl border p-3 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-ring", difficulty === i ? "border-transparent" : "hover:bg-accent/50")}
>
{difficulty === i && <motion.span layoutId="hc-diff" className="absolute inset-0 rounded-xl ring-2 ring-[var(--hc-a)]" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
<span className="flex gap-0.5" aria-hidden>
{[0, 1, 2].map((k) => (
<span key={k} className={cn("h-4 w-1.5 rounded-sm", k <= i ? "bg-[var(--hc-a)]" : "bg-muted")} />
))}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold">{d.name}</span>
<span className="block text-xs text-muted-foreground">{d.blurb}</span>
</span>
<span className="text-right text-[11px] text-muted-foreground">
{records.wins[i]} wins
<br />
best {records.best[i]}
</span>
</button>
))}
</div>
<div className="mt-4 flex gap-2">
<button type="button" autoFocus onClick={() => newMatch()} className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-xl bg-foreground text-sm font-bold text-background outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Play className="size-4" /> Start match
</button>
<button type="button" onClick={() => setSeed((s) => (s * 1103515245 + 12345) % 2147483647)} className="inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring" aria-label="Roll a new map">
<Shuffle className="size-4" /> New map
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* pause */}
<AnimatePresence>
{paused && phase === "playing" && (
<motion.div className="absolute inset-0 z-30 grid place-items-center bg-background/40 p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
<div role="dialog" aria-label="Paused" className="w-full max-w-xs rounded-3xl border bg-card p-6 text-center shadow-2xl">
<p className="text-xl font-extrabold">Paused</p>
<p className="mt-1 text-sm text-muted-foreground">
{fmtClock(clock)} left · round {round}
</p>
<button type="button" autoFocus onClick={() => setPaused(false)} className="mt-5 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-foreground text-sm font-bold text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Play className="size-4" /> Resume
</button>
<div className="mt-2 grid grid-cols-2 gap-2">
<button type="button" onClick={() => newMatch()} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<RotateCcw className="size-3.5" /> Restart
</button>
<button type="button" onClick={() => setPhase("menu")} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<ChevronLeft className="size-3.5" /> Menu
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* game over */}
<AnimatePresence>
{phase === "over" && outcome && (
<motion.div className="absolute inset-0 top-14 z-30 grid place-items-center bg-background/35 p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
{outcome.won && !reduce && <HexConfetti />}
<motion.div role="dialog" aria-label={outcome.won ? "Victory" : "Defeat"} className="relative w-full max-w-sm rounded-3xl border bg-card/95 p-6 text-center shadow-2xl backdrop-blur" initial={reduce ? false : { scale: 0.85, y: 20 }} animate={{ scale: 1, y: 0 }} transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.2 }}>
<motion.div className="mx-auto grid size-16 place-items-center rounded-2xl text-white shadow-lg" style={{ background: outcome.won ? "var(--hc-p)" : outcome.won === null ? "var(--muted-foreground)" : "var(--hc-a)" }} initial={reduce ? false : { rotate: -25, scale: 0.4 }} animate={{ rotate: 0, scale: 1 }} transition={{ type: "spring", stiffness: 240, damping: 12, delay: 0.3 }}>
{outcome.won ? <Crown className="size-8" /> : <Flag className="size-8" />}
</motion.div>
<h2 className="mt-3 text-3xl font-black tracking-tight">{outcome.won ? "Victory" : outcome.won === null ? "Stalemate" : "Defeat"}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{outcome.reason === "capital"
? outcome.won
? `You stormed the ${enemyName} capital.`
: "Your capital has fallen."
: outcome.reason === "wiped"
? outcome.won
? "The rival has been wiped from the map."
: "Your forces were wiped out."
: `${outcome.reason === "time" ? "Time is up" : "Final round played"} — territory decides.`}
</p>
<dl className="mt-4 grid grid-cols-3 gap-2 text-xs">
{[
["Score", String(outcome.score)],
["Captured", String(outcome.captured)],
["Rounds", String(outcome.rounds)],
].map(([k, v]) => (
<div key={k} className="rounded-lg bg-muted/70 px-2 py-2">
<dt className="text-muted-foreground">{k}</dt>
<dd className="text-base font-extrabold tabular-nums">{v}</dd>
</div>
))}
</dl>
<p className="mt-3 text-xs text-muted-foreground">
Territory <b className="text-[var(--hc-p)]">{me.score}</b> vs <b className="text-[var(--hc-a)]">{them.score}</b>
</p>
<div className="mt-5 grid grid-cols-2 gap-2">
<button type="button" autoFocus onClick={() => newMatch()} className="inline-flex h-11 items-center justify-center gap-1.5 rounded-xl bg-foreground text-sm font-bold text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<RotateCcw className="size-4" /> Rematch
</button>
<button type="button" onClick={() => newMatch((seed * 1103515245 + 12345) % 2147483647)} className="inline-flex h-11 items-center justify-center gap-1.5 rounded-xl border text-sm font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<Shuffle className="size-4" /> New map
</button>
</div>
<button type="button" onClick={() => setPhase("menu")} className="mt-2 h-9 w-full rounded-lg text-xs font-semibold text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
Change difficulty
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function describe(t: Tile | undefined, seen: boolean) {
if (!t) return "nothing";
if (!passable(t)) return t.terrain;
if (!seen) return `${t.terrain === "capital" ? "town" : t.terrain} in fog`;
return `${t.terrain}, ${t.owner === "player" ? "yours" : t.owner === "ai" ? "rival" : "neutral"}, ${t.troops} troops`;
}
function TurnBadge({ turn, playerName, enemyName, over }: { turn: "player" | "ai"; playerName: string; enemyName: string; over?: boolean }) {
const reduce = useReducedMotion();
return (
<span className="flex min-w-0 items-center gap-2 text-sm font-bold">
<span className="relative flex size-2.5">
{!reduce && !over && <span className="absolute inset-0 animate-ping rounded-full opacity-60" style={{ background: turn === "player" ? "var(--hc-p)" : "var(--hc-a)" }} />}
<span className="relative size-2.5 rounded-full" style={{ background: turn === "player" ? "var(--hc-p)" : "var(--hc-a)" }} />
</span>
<span className="truncate">{over ? "Match over" : turn === "player" ? "Your turn" : "Rival’s turn"}</span>
<span className="sr-only">{turn === "player" ? playerName : enemyName}</span>
</span>
);
}
function MovePips({ left, max, active }: { left: number; max: number; active: boolean }) {
return (
<span className="flex items-center gap-1" role="img" aria-label={`${active ? left : 0} of ${max} moves left`}>
{Array.from({ length: max }, (_, i) => (
<motion.span key={i} className="h-2.5 w-5 rounded-full" animate={{ backgroundColor: active && i < left ? "var(--hc-p)" : "var(--muted)", scale: active && i < left ? 1 : 0.85 }} />
))}
</span>
);
}
function ScoreCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="rounded-xl border bg-card px-3 py-2">
<p className="text-muted-foreground">{label}</p>
<motion.p key={value} initial={{ scale: 1.25 }} animate={{ scale: 1 }} className="origin-left text-xl font-black tabular-nums" style={{ color }}>
{value}
</motion.p>
</div>
);
}
function Legend() {
const item = (swatch: React.ReactNode, label: string) => (
<span className="flex items-center gap-1.5">
{swatch}
{label}
</span>
);
return (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px] text-muted-foreground">
{item(<Crown className="size-3.5 text-[#d9a514]" aria-hidden />, "Capital +3 · def +2")}
{item(<Castle className="size-3.5" aria-hidden />, "Town +2 · def +1")}
{item(<span className="size-3 rounded-sm bg-[var(--hc-rock)]" />, "Mountain")}
{item(<span className="size-3 rounded-sm bg-[var(--hc-lake)]" />, "Lake")}
{item(<span className="size-3 rounded-sm bg-[repeating-linear-gradient(45deg,var(--muted)_0_2px,color-mix(in_oklab,var(--muted-foreground)_30%,transparent)_2px_3px)]" />, "Fog — out of sight")}
<span>Arrows · Enter · E</span>
</div>
);
}
const CONFETTI = Array.from({ length: 22 }, (_, i) => ({ x: ((i * 37) % 100) - 50, d: 0.6 + ((i * 13) % 10) / 12, r: (i * 47) % 360, c: i % 3 }));
function HexConfetti() {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
{CONFETTI.map((c, i) => (
<motion.svg
key={i}
viewBox="0 0 10 10"
className="absolute left-1/2 top-0 size-3.5"
initial={{ x: `${c.x * 8}px`, y: -20, rotate: 0, opacity: 1 }}
animate={{ y: 720, rotate: c.r + 360, opacity: [1, 1, 0] }}
transition={{ duration: 2.2 * c.d + 0.8, ease: "easeIn", delay: (i % 6) * 0.08 }}
>
<polygon points="5,0 9.3,2.5 9.3,7.5 5,10 0.7,7.5 0.7,2.5" fill={c.c === 0 ? "var(--hc-p)" : c.c === 1 ? "#fcd34d" : "var(--primary)"} />
</motion.svg>
))}
</div>
);
}