Fazekit

Code

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

More in Games

View all →