"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bot, CloudRain, Crown, Flag, Hammer, Heart, Layers, Pause, Play, RotateCcw, ScrollText, Swords, Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { CARD_BY_ID, ELEMENT_INFO, STARTERS, type CardDef } from "./cards";
import { CardBack, CardFace, ColorsContext, UnitView, cardSummary, type ElementColors } from "./card-view";
import { DeckBuilder } from "./deck-builder";
import { LANES, MANA_MAX, START_HP, apply, attackTarget, canPlay, chooseAction, newDuel, validTargets, type Action, type Difficulty, type Duel, type DuelEvent, type Target } from "./engine";
import { loadJSON, saveJSON, seeded, useAutoPause, useTones } from "./kit";
export type { ElementColors } from "./card-view";
export interface CardDuelProps {
/** AI difficulty 1 (Apprentice) – 3 (Archmage). */
initialLevel?: number;
/** Seed for deck shuffles (tests, daily challenges). Omit for a fresh shuffle each duel. */
seed?: number;
/** Called when a duel ends. Wins score 100 + 5 × remaining health; losses score damage dealt. */
onGameOver?: (score: number) => void;
/** Override element accent gradients, e.g. `{ ember: { from: "#f59e0b", to: "#b91c1c" } }`. */
theme?: ElementColors;
/** localStorage key for record, custom deck and preferences. */
storageKey?: string;
title?: string;
className?: string;
}
interface Saved {
wins: number;
losses: number;
streak: number;
custom: string[] | null;
deck: string;
level: Difficulty;
}
type Screen = "menu" | "builder" | "duel";
interface Popup {
id: number;
x: number;
y: number;
text: string;
tone: "dmg" | "heal" | "info";
}
interface Bolt {
id: number;
from: { x: number; y: number };
to: { x: number; y: number };
color: string;
}
const LEVELS: { level: Difficulty; name: string; blurb: string }[] = [
{ level: 1, name: "Apprentice", blurb: "Plays on instinct" },
{ level: 2, name: "Adept", blurb: "Weighs every move" },
{ level: 3, name: "Archmage", blurb: "Thinks two moves ahead" },
];
const DEFAULT_SAVE: Saved = { wins: 0, losses: 0, streak: 0, custom: null, deck: "wildfire", level: 2 };
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function CardDuel({ initialLevel, seed, onGameOver, theme, storageKey = "card-duel:v1", title = "Glyphbound", className }: CardDuelProps) {
const reduce = useReducedMotion() ?? false;
const rootRef = React.useRef<HTMLDivElement>(null);
const boardRef = React.useRef<HTMLDivElement>(null);
const [screen, setScreen] = React.useState<Screen>("menu");
const [saved, setSaved] = React.useState<Saved>({ ...DEFAULT_SAVE, level: (initialLevel as Difficulty) ?? 2 });
const [duel, setDuel] = React.useState<Duel | null>(null);
const duelRef = React.useRef<Duel | null>(null);
const [busy, setBusy] = React.useState(false);
const busyRef = React.useRef(false);
const [paused, setPaused] = React.useState(false);
const pausedRef = React.useRef(false);
const [selected, setSelected] = React.useState<number | null>(null);
const [lunge, setLunge] = React.useState<number | null>(null);
const [heroHit, setHeroHit] = React.useState<[number, number]>([0, 0]);
const [popups, setPopups] = React.useState<Popup[]>([]);
const [bolts, setBolts] = React.useState<Bolt[]>([]);
const [banner, setBanner] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const [muted, setMuted] = React.useState(true);
const [showLog, setShowLog] = React.useState(false);
const [aiDeck, setAiDeck] = React.useState(STARTERS[1]);
const fxId = React.useRef(1);
const duelNo = React.useRef(0);
const tone = useTones(muted);
const cb = React.useRef({ onGameOver, storageKey });
React.useEffect(() => {
cb.current = { onGameOver, storageKey };
}, [onGameOver, storageKey]);
React.useEffect(() => {
const s = loadJSON<Saved>(storageKey, DEFAULT_SAVE);
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate record + custom deck after mount
setSaved(initialLevel ? { ...s, level: Math.max(1, Math.min(3, initialLevel)) as Difficulty } : s);
}, [storageKey, initialLevel]);
const persist = React.useCallback((patch: Partial<Saved>) => {
setSaved((prev) => {
const next = { ...prev, ...patch };
saveJSON(cb.current.storageKey, next);
return next;
});
}, []);
const deckList = React.useMemo(() => {
const list = STARTERS.map((s) => ({ id: s.id, name: s.name, blurb: s.blurb, element: s.element, cards: s.cards }));
if (saved.custom) list.push({ id: "custom", name: "Custom", blurb: "The deck you built.", element: "aether", cards: saved.custom });
return list;
}, [saved.custom]);
const chosenDeck = deckList.find((d) => d.id === saved.deck) ?? deckList[0];
const commit = (d: Duel) => {
duelRef.current = d;
setDuel(d);
};
const center = React.useCallback((slot: string) => {
const board = boardRef.current;
const el = board?.querySelector<HTMLElement>(`[data-slot="${slot}"]`);
if (!board || !el) return { x: 0, y: 0 };
const b = board.getBoundingClientRect();
const r = el.getBoundingClientRect();
return { x: r.left - b.left + r.width / 2, y: r.top - b.top + r.height / 2 };
}, []);
const slotOf = (t: Target) => (t.kind === "hero" ? `h-${t.side}` : `u-${t.side}-${t.lane}`);
const pop = React.useCallback(
(t: Target, text: string, toneKind: Popup["tone"]) => {
const p = center(slotOf(t));
const id = fxId.current++;
setPopups((ps) => [...ps, { id, x: p.x, y: p.y, text, tone: toneKind }]);
setTimeout(() => setPopups((ps) => ps.filter((q) => q.id !== id)), 1000);
},
[center],
);
const showEvents = React.useCallback(
(events: DuelEvent[]) => {
for (const e of events) {
if (e.kind === "damage") {
pop(e.target, `-${e.n}`, "dmg");
if (e.target.kind === "hero") {
const side = e.target.side;
setHeroHit((h) => (side === 0 ? [h[0] + 1, h[1]] : [h[0], h[1] + 1]));
}
} else if (e.kind === "heal") pop({ kind: "hero", side: e.side }, `+${e.n}`, "heal");
else if (e.kind === "ward") pop(e.target, "Ward!", "info");
else if (e.kind === "freeze") pop(e.target, "Frozen", "info");
else if (e.kind === "buff") pop(e.target, "Empowered", "heal");
else if (e.kind === "burn") setNotice(`${e.side === 0 ? "Your" : "Rival's"} hand is full — ${CARD_BY_ID[e.card].name} burned`);
}
},
[pop],
);
const finish = React.useCallback(
(d: Duel) => {
if (d.winner === null) return;
const won = d.winner === 0;
tone(won ? 660 : 180, 0.6, "triangle", 0.06, won ? 440 : -100);
const prev = loadJSON<Saved>(cb.current.storageKey, DEFAULT_SAVE);
persist({ wins: prev.wins + (won ? 1 : 0), losses: prev.losses + (won ? 0 : 1), streak: won ? prev.streak + 1 : 0 });
cb.current.onGameOver?.(won ? 100 + Math.max(0, d.sides[0].hp) * 5 : d.sides[0].stats.dealt);
},
[persist, tone],
);
/** Runs one action with its animation beats. */
const perform = React.useCallback(
async (a: Action) => {
const cur = duelRef.current;
if (!cur || busyRef.current) return;
const { duel: next, events } = apply(cur, a);
if (next === cur) return;
busyRef.current = true;
setBusy(true);
setSelected(null);
const s = cur.active;
if (a.type === "attack") {
const u = cur.sides[s].lanes[a.lane];
if (u) setLunge(u.uid);
tone(300, 0.08, "square", 0.04, -120);
await wait(reduce ? 60 : 190);
} else if (a.type === "play") {
const card = cur.sides[s].hand.find((c) => c.uid === a.uid);
const def = card ? CARD_BY_ID[card.id] : null;
if (def?.type === "spell") {
tone(520, 0.2, "sine", 0.05, 300);
const targets: Target[] = a.target ? [a.target] : def.effect?.kind === "dmgAll" ? cur.sides[s === 0 ? 1 : 0].lanes.flatMap((u, lane) => (u ? [{ kind: "unit" as const, side: (s === 0 ? 1 : 0) as 0 | 1, lane }] : [])) : [];
if (targets.length && !reduce) {
const from = center(`h-${s}`);
const color = ELEMENT_INFO[def.element].from;
const made = targets.map((t) => ({ id: fxId.current++, from, to: center(slotOf(t)), color }));
setBolts((b) => [...b, ...made]);
await wait(360);
setBolts((b) => b.filter((x) => !made.some((m) => m.id === x.id)));
} else {
setBanner(def.name);
await wait(reduce ? 200 : 450);
}
} else tone(360, 0.1, "triangle", 0.05, 120);
} else if (a.type === "end") tone(240, 0.12, "sine", 0.03, 60);
commit(next);
showEvents(events);
if (events.some((e) => e.kind === "damage")) tone(160, 0.1, "sawtooth", 0.035, -60);
if (a.type === "end") {
setBanner(next.active === 0 ? "Your turn" : "Rival's turn");
await wait(reduce ? 250 : 700);
} else await wait(reduce ? 120 : 320);
setLunge(null);
setBanner(null);
busyRef.current = false;
setBusy(false);
if (next.winner !== null) finish(next);
},
[center, finish, reduce, showEvents, tone],
);
// AI turn driver
React.useEffect(() => {
if (screen !== "duel" || !duel || duel.winner !== null || duel.active !== 1 || busy || paused) return;
const t = setTimeout(
() => {
const d = duelRef.current;
if (!d || pausedRef.current || d.active !== 1) return;
const rnd = seeded(d.turn * 977 + d.sides[1].hand.length * 13 + d.sides[1].mana);
void perform(chooseAction(d, saved.level, rnd));
},
reduce ? 250 : 650,
);
return () => clearTimeout(t);
}, [screen, duel, busy, paused, perform, saved.level, reduce]);
React.useEffect(() => {
if (!notice) return;
const t = setTimeout(() => setNotice(null), 1800);
return () => clearTimeout(t);
}, [notice]);
const startDuel = React.useCallback(() => {
duelNo.current += 1;
const pool = STARTERS.filter((s) => s.id !== chosenDeck.id);
const s0 = seed ?? Math.floor(Math.random() * 1e9);
const opp = pool[(s0 + duelNo.current) % pool.length];
setAiDeck(opp);
const d = newDuel(chosenDeck.cards, opp.cards, s0 + duelNo.current * 101);
commit(d);
setSelected(null);
setPaused(false);
pausedRef.current = false;
setScreen("duel");
setBanner("Your turn");
setTimeout(() => setBanner(null), reduce ? 300 : 900);
rootRef.current?.focus({ preventScroll: true });
}, [chosenDeck, reduce, seed]);
const setPause = React.useCallback((p: boolean) => {
pausedRef.current = p;
setPaused(p);
}, []);
useAutoPause(
React.useCallback(() => {
const d = duelRef.current;
if (d && d.winner === null) setPause(true);
}, [setPause]),
);
const concede = React.useCallback(() => {
const d = duelRef.current;
if (!d || d.winner !== null) return;
const next: Duel = { ...d, winner: 1, sides: [{ ...d.sides[0], hp: 0 }, d.sides[1]] };
commit(next);
setPause(false);
finish(next);
}, [finish, setPause]);
const myTurn = !!duel && duel.active === 0 && duel.winner === null && !busy && !paused;
const me = duel?.sides[0];
const foe = duel?.sides[1];
const selCard = selected !== null && me ? me.hand.find((c) => c.uid === selected) : undefined;
const selDef: CardDef | undefined = selCard ? CARD_BY_ID[selCard.id] : undefined;
const targets = duel && selDef?.type === "spell" ? validTargets(duel, 0, selDef) : null;
const isTarget = (t: Target) => !!targets?.some((x) => x.kind === t.kind && x.side === t.side && (x.kind === "hero" || (t.kind === "unit" && x.lane === t.lane)));
const clickHand = (uid: number) => {
if (!duel || !myTurn) return;
const c = duel.sides[0].hand.find((h) => h.uid === uid);
if (!c) return;
const def = CARD_BY_ID[c.id];
if (!canPlay(duel, 0, c)) {
setNotice(def.cost > duel.sides[0].mana ? `Needs ${def.cost} mana` : def.type === "creature" ? "No empty lane" : "No valid target");
return;
}
if (selected === uid) {
if (def.type === "spell" && !validTargets(duel, 0, def)) void perform({ type: "play", uid });
else setSelected(null);
return;
}
setSelected(uid);
};
const clickLane = (side: 0 | 1, lane: number) => {
if (!duel || !myTurn) return;
const unit = duel.sides[side].lanes[lane];
if (selDef && selCard) {
if (selDef.type === "creature" && side === 0 && !unit) void perform({ type: "play", uid: selCard.uid, lane });
else if (selDef.type === "spell" && isTarget({ kind: "unit", side, lane })) void perform({ type: "play", uid: selCard.uid, target: { kind: "unit", side, lane } });
return;
}
if (side === 0 && unit?.ready && unit.atk > 0) void perform({ type: "attack", lane });
};
const clickHero = (side: 0 | 1) => {
if (!myTurn || !selCard || !isTarget({ kind: "hero", side })) return;
void perform({ type: "play", uid: selCard.uid, target: { kind: "hero", side } });
};
const endTurn = React.useCallback(() => {
if (duelRef.current?.active === 0 && !busyRef.current && !pausedRef.current) void perform({ type: "end" });
}, [perform]);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const root = rootRef.current;
if (!root || e.metaKey || e.ctrlKey || e.altKey) return;
const active = document.activeElement;
if (active && active !== document.body && !root.contains(active)) return;
if (screen !== "duel") return;
if (e.key === "Escape") {
if (selected !== null) setSelected(null);
else if (duelRef.current?.winner === null) setPause(!pausedRef.current);
} else if (e.key === "e" || e.key === "E") endTurn();
else if (e.key === "p" || e.key === "P") {
if (duelRef.current?.winner === null) setPause(!pausedRef.current);
} else if (e.key === "m" || e.key === "M") setMuted((m) => !m);
else if (/^[1-7]$/.test(e.key)) {
const c = duelRef.current?.sides[0].hand[Number(e.key) - 1];
if (c) clickHand(c.uid);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
const header = (
<header className="relative z-20 flex h-12 shrink-0 items-center gap-2 border-b bg-background/85 px-3 backdrop-blur sm:px-5">
<GlyphMark className="size-7 shrink-0" />
<p className="truncate text-sm font-black tracking-tight">{title}</p>
{screen === "duel" && duel && (
<span className={cn("ml-1 hidden rounded-full px-2 py-0.5 text-[11px] font-bold sm:inline", duel.active === 0 ? "bg-amber-400/20 text-amber-700 dark:text-amber-300" : "bg-violet-500/15 text-violet-700 dark:text-violet-300")}>
Turn {Math.ceil(duel.turn / 2)} · {duel.winner !== null ? "Duel over" : duel.active === 0 ? "Your move" : "Rival thinking…"}
</span>
)}
<div className="ml-auto flex items-center gap-0.5">
{screen === "duel" && (
<>
<IconButton label="Battle log" onClick={() => setShowLog((v) => !v)} pressed={showLog} className="lg:hidden">
<ScrollText className="size-4" />
</IconButton>
<IconButton label={paused ? "Resume (P)" : "Pause (P)"} onClick={() => setPause(!paused)} disabled={!duel || duel.winner !== null}>
{paused ? <Play className="size-4" /> : <Pause 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>
{screen === "duel" && (
<IconButton label="Restart duel" onClick={startDuel}>
<RotateCcw className="size-4" />
</IconButton>
)}
</div>
</header>
);
return (
<ColorsContext.Provider value={theme ?? {}}>
<div
ref={rootRef}
tabIndex={-1}
data-screen={screen}
data-active={duel?.active ?? ""}
data-winner={duel?.winner ?? ""}
data-my-hp={me?.hp ?? ""}
data-foe-hp={foe?.hp ?? ""}
data-busy={busy}
data-paused={paused}
aria-label={`${title} card duel`}
className={cn("relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground outline-none", className)}
>
{header}
{screen === "menu" && (
<Menu
title={title}
decks={deckList}
deckId={chosenDeck.id}
onDeck={(id) => persist({ deck: id })}
level={saved.level}
onLevel={(l) => persist({ level: l })}
record={saved}
onStart={startDuel}
onBuild={() => setScreen("builder")}
reduce={reduce}
/>
)}
{screen === "builder" && (
<DeckBuilder
initial={saved.custom ?? chosenDeck.cards}
onBack={() => setScreen("menu")}
onSave={(cards) => persist({ custom: cards, deck: "custom" })}
/>
)}
{screen === "duel" && duel && me && foe && (
<div className="flex min-h-0 flex-1">
<div ref={boardRef} className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-[radial-gradient(ellipse_at_center,rgba(139,92,246,0.09),transparent_70%)]">
{/* rival */}
<HeroStrip side={1} name={`Rival · ${aiDeck.name}`} hp={foe.hp} mana={foe.mana} maxMana={foe.maxMana} deck={foe.deck.length} hand={foe.hand.length} hit={heroHit[1]} targetable={myTurn && isTarget({ kind: "hero", side: 1 })} onClick={() => clickHero(1)} reduce={reduce} active={duel.active === 1} />
<Lanes side={1} duel={duel} myTurn={myTurn} lunge={lunge} selDef={selDef} isTarget={isTarget} onLane={clickLane} />
{/* centre line */}
<div className="relative flex h-10 shrink-0 items-center gap-3 px-3 sm:px-5">
<span className="h-px flex-1 bg-gradient-to-r from-transparent via-border to-transparent" />
<p className={cn("truncate pr-24 text-[11px] text-muted-foreground sm:pr-0", !selDef && "hidden sm:block")}>
{selDef ? (selDef.type === "creature" ? "Choose an empty lane" : targets ? "Choose a target" : "Tap the card again to cast") : myTurn ? "Play cards or tap a glowing ally to attack" : duel.winner === null ? "Rival is thinking…" : ""}
</p>
<span className="h-px flex-1 bg-gradient-to-r from-transparent via-border to-transparent" />
<button
type="button"
onClick={endTurn}
disabled={!myTurn}
className="absolute right-3 inline-flex h-8 items-center gap-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-3.5 text-xs font-black text-amber-950 shadow-md 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 disabled:from-muted disabled:to-muted disabled:text-muted-foreground disabled:shadow-none sm:right-5"
>
End turn <kbd className="hidden rounded bg-amber-950/15 px-1 text-[10px] sm:inline">E</kbd>
</button>
</div>
<Lanes side={0} duel={duel} myTurn={myTurn} lunge={lunge} selDef={selDef} isTarget={isTarget} onLane={clickLane} />
<HeroStrip side={0} name={`You · ${chosenDeck.name}`} hp={me.hp} mana={me.mana} maxMana={me.maxMana} deck={me.deck.length} hit={heroHit[0]} targetable={false} onClick={() => undefined} reduce={reduce} active={duel.active === 0} />
{/* hand */}
<div className="relative flex min-h-0 flex-1 flex-col justify-center border-t bg-muted/30">
<ul className="flex gap-1.5 overflow-x-auto px-3 pt-4 pb-2 sm:justify-center sm:gap-2 sm:px-5" aria-label="Your hand">
<AnimatePresence initial={false}>
{me.hand.map((c, i) => {
const def = CARD_BY_ID[c.id];
const playable = myTurn && canPlay(duel, 0, c);
const on = selected === c.uid;
return (
<motion.li key={c.uid} layout={!reduce} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 60, rotate: 6 }} animate={{ opacity: 1, y: on ? -10 : 0, rotate: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: -80, scale: 0.8 }} transition={{ type: "spring", stiffness: 380, damping: 28 }} className="shrink-0">
<button
type="button"
onClick={() => clickHand(c.uid)}
aria-pressed={on}
aria-label={`${i + 1}: ${cardSummary(def)}${playable ? "" : " (can't play now)"}`}
title={cardSummary(def)}
className={cn("block rounded-xl transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none", playable && !on && "hover:-translate-y-1.5", on && "ring-2 ring-amber-400 ring-offset-2 ring-offset-background")}
>
<CardFace def={def} dim={!playable} className={cn(playable && "shadow-[0_0_18px_-4px] shadow-amber-400/60")} />
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
</div>
{/* fx layer */}
<div className="pointer-events-none absolute inset-0 z-30 overflow-hidden" aria-hidden>
{bolts.map((b) => (
<motion.span key={b.id} className="absolute size-5 -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ background: `radial-gradient(circle, #fff, ${b.color} 45%, transparent 70%)`, boxShadow: `0 0 24px 8px ${b.color}` }} initial={{ left: b.from.x, top: b.from.y, scale: 0.4 }} animate={{ left: b.to.x, top: b.to.y, scale: 1.3 }} transition={{ duration: 0.34, ease: "easeIn" }} />
))}
<AnimatePresence>
{popups.map((p) => (
<motion.span
key={p.id}
className={cn("absolute -translate-x-1/2 -translate-y-1/2 text-2xl font-black [text-shadow:0_2px_0_rgba(0,0,0,0.35)]", p.tone === "dmg" ? "text-rose-500" : p.tone === "heal" ? "text-emerald-500" : "text-sm text-sky-500")}
style={{ left: p.x, top: p.y }}
initial={{ opacity: 0, y: 0, scale: 0.5 }}
animate={{ opacity: 1, y: reduce ? 0 : -30, scale: 1.15 }}
exit={{ opacity: 0, y: reduce ? 0 : -50 }}
transition={{ duration: 0.35 }}
>
{p.text}
</motion.span>
))}
</AnimatePresence>
<AnimatePresence>
{banner && (
<motion.div key={banner} className="absolute inset-x-0 top-[42%] flex justify-center" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 1.1 }} transition={{ duration: 0.22 }}>
<span className="rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-600 px-6 py-2 text-lg font-black text-white shadow-2xl shadow-fuchsia-600/40">{banner}</span>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{notice && (
<motion.p key={notice} className="absolute bottom-40 left-1/2 -translate-x-1/2 rounded-full bg-foreground px-3 py-1 text-xs font-semibold whitespace-nowrap text-background shadow-lg sm:bottom-48" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} role="status">
{notice}
</motion.p>
)}
</AnimatePresence>
</div>
{/* overlays */}
<AnimatePresence>
{(paused || duel.winner !== null) && (
<motion.div key={duel.winner !== null ? "end" : "pause"} className="absolute inset-0 z-40 grid place-items-center bg-background/70 p-6 backdrop-blur-[3px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<motion.div className="w-full max-w-sm text-center" initial={reduce ? false : { y: 16, scale: 0.96 }} animate={{ y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 300, damping: 24 }}>
{duel.winner === null ? (
<>
<p className="text-xs font-semibold tracking-[0.3em] text-muted-foreground uppercase">The glyphs wait</p>
<h2 className="mt-2 text-4xl font-black">Paused</h2>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<PrimaryButton onClick={() => setPause(false)}>
<Play className="size-4" /> Resume
</PrimaryButton>
<SecondaryButton onClick={concede}>
<Flag className="size-4" /> Concede
</SecondaryButton>
</div>
</>
) : (
<>
<div className={cn("mx-auto mb-3 grid size-14 place-items-center rounded-2xl", duel.winner === 0 ? "bg-amber-400/20 text-amber-500" : "bg-rose-500/15 text-rose-500")}>{duel.winner === 0 ? <Crown className="size-7" /> : <CloudRain className="size-7" />}</div>
<h2 className="text-4xl font-black">{duel.winner === 0 ? "Victory" : "Defeat"}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{duel.winner === 0 ? `You won with ${Math.max(0, me.hp)} health to spare.` : "The rival's glyphs burned brighter this time."}
</p>
<dl className="mx-auto mt-5 grid max-w-xs grid-cols-3 gap-2">
<MiniStat label="Turns" value={Math.ceil(duel.turn / 2)} />
<MiniStat label="Dealt" value={me.stats.dealt} />
<MiniStat label="Record" value={`${saved.wins}–${saved.losses}`} />
</dl>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<PrimaryButton onClick={startDuel}>
<RotateCcw className="size-4" /> Rematch
</PrimaryButton>
<SecondaryButton onClick={() => setScreen("menu")}>
<Layers className="size-4" /> Change deck
</SecondaryButton>
</div>
</>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
<aside className={cn("w-64 shrink-0 flex-col border-l bg-muted/30", showLog ? "absolute inset-y-12 right-0 z-30 flex shadow-2xl lg:static lg:shadow-none" : "hidden lg:flex")} aria-label="Battle log">
<p className="shrink-0 border-b px-4 py-3 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Battle log</p>
<ol className="flex min-h-0 flex-1 flex-col-reverse gap-1 overflow-y-auto p-3 text-xs">
{[...duel.log].reverse().slice(0, 60).map((l, i) => (
<li key={duel.log.length - i} className={cn("rounded-md px-2 py-1", l.side === 0 ? "bg-amber-400/10" : l.side === 1 ? "bg-violet-500/10" : "")}>
<b className={l.side === 0 ? "text-amber-700 dark:text-amber-300" : "text-violet-700 dark:text-violet-300"}>{l.side === 0 ? "You" : "Rival"}</b> {l.text}
</li>
))}
</ol>
<div className="shrink-0 space-y-1 border-t p-3 text-[11px] text-muted-foreground">
<p>
<b className="text-foreground">Lanes:</b> attacks strike the creature opposite, or the hero if the lane is open.
</p>
<p>
<b className="text-foreground">Guard</b> covers neighbouring open lanes. <b className="text-foreground">Ward</b> blocks one hit.
</p>
<p className="pt-1">1–7 select card · E end turn · Esc cancel/pause</p>
</div>
</aside>
</div>
)}
<p className="sr-only" aria-live="polite">
{duel?.winner === 0 ? "Victory!" : duel?.winner === 1 ? "Defeat." : duel ? (duel.active === 0 ? `Your turn. ${me?.mana} mana.` : "Rival's turn.") : ""}
</p>
</div>
</ColorsContext.Provider>
);
}
/* ───────────────────────── board pieces ───────────────────────── */
function Lanes({ side, duel, myTurn, lunge, selDef, isTarget, onLane }: { side: 0 | 1; duel: Duel; myTurn: boolean; lunge: number | null; selDef?: CardDef; isTarget: (t: Target) => boolean; onLane: (side: 0 | 1, lane: number) => void }) {
return (
<div className="grid shrink-0 grid-cols-4 gap-2 px-3 sm:gap-3 sm:px-5" aria-label={side === 0 ? "Your lanes" : "Rival lanes"}>
{Array.from({ length: LANES }, (_, lane) => {
const u = duel.sides[side].lanes[lane];
const canAttack = side === 0 && myTurn && !selDef && !!u?.ready && (u?.atk ?? 0) > 0;
const dropHere = side === 0 && myTurn && selDef?.type === "creature" && !u;
const targetable = myTurn && !!u && isTarget({ kind: "unit", side, lane });
const t = canAttack ? attackTarget(duel, 0, lane) : null;
const label = u
? `${side === 0 ? "Your" : "Rival"} ${CARD_BY_ID[u.id].name}, ${u.atk} attack ${u.hp} health${canAttack ? `. Attack ${t?.kind === "hero" ? "the rival hero" : "the creature opposite"}` : ""}${targetable ? ". Target" : ""}`
: `${side === 0 ? "Your" : "Rival"} lane ${lane + 1}, empty${dropHere ? ". Play here" : ""}`;
const interactive = canAttack || dropHere || targetable;
return (
<div key={lane} data-slot={`u-${side}-${lane}`} className="relative h-[104px] sm:h-[124px] lg:h-[136px]">
<button type="button" disabled={!interactive} onClick={() => onLane(side, lane)} aria-label={label} className={cn("absolute inset-0 rounded-xl border-2 border-dashed transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", u ? "border-transparent" : "border-border/70 bg-muted/20", dropHere && "border-amber-400 bg-amber-400/10 hover:bg-amber-400/20", interactive ? "cursor-pointer" : "cursor-default")}>
{!u && <span className="text-[10px] font-semibold text-muted-foreground/60">{dropHere ? "Play here" : ""}</span>}
</button>
<div className="pointer-events-none absolute inset-0">
<AnimatePresence>{u && <UnitView key={u.uid} unit={u} mine={side === 0} canAct={canAttack} lunge={lunge === u.uid} targetable={targetable} />}</AnimatePresence>
</div>
{canAttack && t && <span className="pointer-events-none absolute -top-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-amber-400 px-1.5 text-[9px] font-black whitespace-nowrap text-amber-950 shadow">{t.kind === "hero" ? "→ hero" : "→ clash"}</span>}
</div>
);
})}
</div>
);
}
function HeroStrip({ side, name, hp, mana, maxMana, deck, hand, hit, targetable, onClick, reduce, active }: { side: 0 | 1; name: string; hp: number; mana: number; maxMana: number; deck: number; hand?: number; hit: number; targetable: boolean; onClick: () => void; reduce: boolean; active: boolean }) {
const pct = Math.max(0, hp) / START_HP;
return (
<div className="flex h-14 shrink-0 items-center gap-3 px-3 sm:px-5">
<motion.button
key={hit}
type="button"
data-slot={`h-${side}`}
onClick={onClick}
disabled={!targetable}
aria-label={`${name}: ${Math.max(0, hp)} health${targetable ? ". Target" : ""}`}
animate={hit && !reduce ? { x: [0, -6, 6, -3, 0] } : undefined}
transition={{ duration: 0.35 }}
className={cn("relative grid size-11 shrink-0 place-items-center rounded-full text-white shadow-md transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", side === 0 ? "bg-gradient-to-br from-amber-400 to-orange-600" : "bg-gradient-to-br from-violet-500 to-indigo-700", targetable && "ring-4 ring-rose-500 ring-offset-2 ring-offset-background", active && "shadow-[0_0_22px_-2px] shadow-amber-400/70")}
>
{side === 0 ? <Swords className="size-5" aria-hidden /> : <Bot className="size-5" aria-hidden />}
<span className="absolute -right-1 -bottom-1 grid h-5 min-w-5 place-items-center rounded-full bg-rose-600 px-1 text-[11px] font-black ring-2 ring-background tabular-nums">{Math.max(0, hp)}</span>
</motion.button>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-bold">{name}</p>
<div className="mt-1 flex items-center gap-2">
<span className="h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:w-28">
<motion.span className="block h-full rounded-full bg-gradient-to-r from-rose-500 to-orange-400" animate={{ width: `${pct * 100}%` }} transition={{ duration: reduce ? 0 : 0.4 }} />
</span>
<span className="flex gap-0.5" aria-label={`${mana} of ${maxMana} mana`}>
{Array.from({ length: MANA_MAX }, (_, i) => (
<span key={i} className={cn("size-2 rotate-45 rounded-[2px] sm:size-2.5", i < mana ? "bg-sky-500 shadow-[0_0_6px] shadow-sky-400" : i < maxMana ? "bg-sky-500/25" : "bg-muted", i >= 7 && "hidden sm:block")} />
))}
</span>
<span className="text-[10px] font-bold text-sky-600 tabular-nums dark:text-sky-400">
{mana}/{maxMana}
</span>
</div>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
{hand !== undefined && (
<span className="flex items-center gap-1" title={`${hand} cards in hand`}>
<span className="flex -space-x-2">
{Array.from({ length: Math.min(hand, 5) }, (_, i) => (
<CardBack key={i} className="h-5 w-3.5 sm:h-6 sm:w-4" />
))}
</span>
<span className="font-bold tabular-nums">{hand}</span>
</span>
)}
<span className="flex items-center gap-1" title={`${deck} cards in deck`}>
<Layers className="size-3.5" aria-hidden />
<span className="font-bold tabular-nums">{deck}</span>
</span>
</div>
</div>
);
}
/* ───────────────────────── menu ───────────────────────── */
function Menu({ title, decks, deckId, onDeck, level, onLevel, record, onStart, onBuild, reduce }: { title: string; decks: { id: string; name: string; blurb: string; element: keyof typeof ELEMENT_INFO; cards: string[] }[]; deckId: string; onDeck: (id: string) => void; level: Difficulty; onLevel: (l: Difficulty) => void; record: Saved; onStart: () => void; onBuild: () => void; reduce: boolean }) {
const hero = CARD_BY_ID["pyre-drake"];
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto grid max-w-5xl gap-8 p-5 sm:p-8 lg:grid-cols-[1fr_auto] lg:items-center">
<div>
<p className="text-xs font-bold tracking-[0.3em] text-violet-600 uppercase dark:text-violet-400">Lane-based card duels</p>
<h1 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">{title}</h1>
<p className="mt-3 max-w-md text-sm text-muted-foreground">Four lanes, four elements, thirty cards. Summon creatures into lanes, cast glyph spells, and break through to your rival's heart before they break yours.</p>
<h2 className="mt-6 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Your deck</h2>
<div className="mt-2 grid gap-2 sm:grid-cols-2" role="radiogroup" aria-label="Deck">
{decks.map((d) => {
const c = ELEMENT_INFO[d.element];
const on = d.id === deckId;
return (
<button key={d.id} type="button" role="radio" aria-checked={on} onClick={() => onDeck(d.id)} className={cn("flex items-center gap-3 rounded-xl border bg-card p-3 text-left transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", on ? "border-violet-500 ring-2 ring-violet-500/30" : "hover:border-foreground/20")}>
<span className="size-9 shrink-0 rounded-lg" style={{ background: `linear-gradient(135deg, ${c.from}, ${c.to})` }} />
<span className="min-w-0">
<span className="block text-sm font-bold">{d.name}</span>
<span className="block truncate text-xs text-muted-foreground">{d.blurb}</span>
</span>
</button>
);
})}
</div>
<h2 className="mt-5 text-[11px] font-bold tracking-[0.2em] text-muted-foreground uppercase">Rival</h2>
<div className="mt-2 grid grid-cols-3 gap-2" role="radiogroup" aria-label="Difficulty">
{LEVELS.map((l) => (
<button key={l.level} type="button" role="radio" aria-checked={level === l.level} onClick={() => onLevel(l.level)} className={cn("rounded-xl border bg-card px-2 py-2.5 text-center transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", level === l.level ? "border-violet-500 ring-2 ring-violet-500/30" : "hover:border-foreground/20")}>
<span className="block text-sm font-bold">{l.name}</span>
<span className="block text-[11px] text-muted-foreground">{l.blurb}</span>
</button>
))}
</div>
<div className="mt-6 flex flex-wrap items-center gap-2">
<PrimaryButton onClick={onStart}>
<Swords className="size-4" /> Start duel
</PrimaryButton>
<SecondaryButton onClick={onBuild}>
<Hammer className="size-4" /> Deck builder
</SecondaryButton>
<span className="ml-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Heart className="size-3.5 text-rose-500" aria-hidden /> Record {record.wins}–{record.losses}
{record.streak > 1 && <b className="text-amber-600 dark:text-amber-400"> · {record.streak} streak</b>}
</span>
</div>
</div>
<motion.div className="relative mx-auto hidden h-[320px] w-[260px] sm:block" initial={reduce ? false : { opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
<div className="absolute top-6 left-0 -rotate-12 opacity-80">
<CardFace def={CARD_BY_ID["leviathan"]} />
</div>
<div className="absolute top-6 right-0 rotate-12 opacity-80">
<CardFace def={CARD_BY_ID["elder-oak"]} />
</div>
<motion.div className="absolute top-0 left-1/2 -translate-x-1/2" animate={reduce ? undefined : { y: [0, -8, 0] }} transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}>
<CardFace def={hero} size="lg" className="shadow-2xl shadow-orange-500/30" />
</motion.div>
</motion.div>
</div>
</div>
);
}
/* ───────────────────────── bits ───────────────────────── */
function PrimaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="inline-flex h-11 items-center gap-2 rounded-full bg-gradient-to-r from-violet-600 to-fuchsia-600 px-6 text-sm font-bold text-white shadow-lg shadow-fuchsia-600/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">
{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, className }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean; pressed?: boolean; className?: string }) {
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", className)}>
{children}
</button>
);
}
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-[9px] tracking-wider text-muted-foreground uppercase">{label}</dt>
<dd className="mt-0.5 text-base font-bold tabular-nums">{value}</dd>
</div>
);
}
function GlyphMark({ className }: { className?: string }) {
const id = React.useId();
return (
<svg viewBox="0 0 40 40" className={className} aria-hidden>
<defs>
<linearGradient id={`${id}g`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8b5cf6" />
<stop offset="1" stopColor="#db2777" />
</linearGradient>
</defs>
<rect width="40" height="40" rx="11" fill={`url(#${id}g)`} />
<rect x="11" y="8" width="14" height="20" rx="3" fill="#fff" opacity="0.35" transform="rotate(-12 18 18)" />
<rect x="15" y="11" width="14" height="20" rx="3" fill="#fff" />
<path d="M22 15 L25.5 21 L22 27 L18.5 21Z" fill="#8b5cf6" />
</svg>
);
}