Fazekit

Code

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

More in Games

View all →