<script lang="ts">
  import type { GameView } from "@wizwar/engine";
  import type { Side } from "@wizwar/engine";
  import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
  import { tokenArt } from "./art";
  import { FX_SPRITES } from "./fx-sprites";

  const CELL = 48;
  const WALL = 7;

  let {
    view,
    edgeSelectMode = false,
    selectedCreatureId = null,
    onCellClick,
    onEdgeClick,
    onPlayerClick,
    onCreatureClick,
    onWarpClick,
    onCellPeek,
    markedCell = null,
    markedCells = null,
    litCells = null,
    markedSector = null,
    ghostSlots = null,
    onGhostClick,
    effects = null,
  }: {
    view: GameView;
    edgeSelectMode?: boolean;
    selectedCreatureId?: string | null;
    onCellClick?: (cell: { x: number; y: number }) => void;
    onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
    onPlayerClick?: (playerId: string) => void;
    onCreatureClick?: (creatureId: string) => void;
    onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
    /** Long-press on a square: read the card behind whatever occupies it. */
    onCellPeek?: (cell: { x: number; y: number }) => void;
    /** First square of a two-square spell: marked so the click reads as taken. */
    markedCell?: { x: number; y: number } | null;
    /** A multi-square placement in progress (boobytrap tokens), in click
     * order — the first is the real trap, so each mark shows its number. */
    markedCells?: { x: number; y: number }[] | null;
    /** When set, squares NOT in this set dim — the targeting aid. */
    litCells?: Set<string> | null;
    /** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
    markedSector?: { x: number; y: number } | null;
    /** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
     * beyond the maze, since an empty slot has no floor of its own to click. */
    ghostSlots?: { x: number; y: number }[] | null;
    onGhostClick?: (origin: { x: number; y: number }) => void;
    /** Short-lived spell flourishes; purely cosmetic. */
    effects?: import("./fx").BoardFx[] | null;
  } = $props();



  const SECTOR = 5;
  /** Ghost slots can lie beyond the assembled maze — on any side, including
   * negative coordinates (the maze renormalizes after the landing). The
   * canvas grows in whichever direction holds them. */
  const minBx = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.x)) * CELL - 8);
  const minBy = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.y)) * CELL - 8);
  const boundsW = $derived(
    Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 8 - minBx,
  );
  const boundsH = $derived(
    Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 8 - minBy,
  );


  // Press-and-hold peeks the square's card; a fired hold swallows the click.
  let peekTimer: ReturnType<typeof setTimeout> | null = null;
  let peekFired = false;
  function pressCell(c: { x: number; y: number }) {
    peekFired = false;
    if (!onCellPeek) return;
    peekTimer = setTimeout(() => { peekFired = true; onCellPeek(c); }, 450);
  }
  function releasePress() {
    if (peekTimer) clearTimeout(peekTimer);
    peekTimer = null;
  }
  function tapCell(c: { x: number; y: number }) {
    if (peekFired) { peekFired = false; return; }
    onCellClick?.(c);
  }

  // Real token art, cropped from the owner's physical set (public/tokens/).
  // Anything without an entry falls back to the vector rendering below.
  const USE_TOKEN_ART = true;
  const TERRAIN_ART: Record<string, string> = {
    stone: "solid-stone", thornbush: "thorn-bush", rosebush: "rosebush",
    ooze: "killer-ooze", dust: "dustcloud", slime: "slime",
    tacks: "tacks", pit: "pit", safe: "safe",
  };
  const CREATURE_ART: Record<string, string> = {
    skeleton: "skeleton", troll: "troll", wraith: "wraith",
    "fire-imp": "fire-imp", "democratic-monster": "democratic-monster",
    shadow: "shadow", "alter-ego": "alter-ego",
  };
  function objectArt(cardId: string): string | null {
    if (cardId === "dagger") return "dagger";
    if (cardId === "large-rock") return "rock";
    if (cardId === "master-key") return "master-key";
    if (cardId.endsWith("stone")) return "magic-stone";
    if (cardId.endsWith("-wand")) return "magic-wand";
    return null;
  }
  // Ongoing spells wear their look: ghostly when unseen, webbed when caught,
  // stone-gray under Medusa's gaze.
  function hasSpell(id: string, cardId: string): boolean {
    return view.sustained.some((e) => e.cardId === cardId && e.targetId === id);
  }

  // BIG MAN towers; SHRINK dwindles. The token says so at a glance.
  function wizardScale(id: string): number {
    if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
    if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
    return 1;
  }
  function wizardArt(id: string): string {
    return tokenArt(`wizard-${sharedColorIndex(view, id) % 6}`, "players");
  }
  function treasureArt(owner: string): string {
    return tokenArt(`treasure-${sharedColorIndex(view, owner) % 6}`, "objects");
  }

  function playerColor(id: string): string {
    return wizardColor(view, id);
  }

  const cells = $derived(
    Object.keys(view.board.cells).map((k) => {
      const [x, y] = k.split(",").map(Number);
      return { x: x!, y: y! };
    }),
  );

  const edges = $derived(
    Object.entries(view.board.edges)
      .filter(([, state]) => state !== "open")
      .map(([key, state]) => {
        const [kind, coords] = key.split(":") as [string, string];
        const [x, y] = coords.split(",").map(Number) as [number, number];
        // A door's lock can be gone for good, jammed shut, or picked open
        // for the turn — each earns its own look.
        const lock = state === "door"
          ? (view.doorStates[key] ?? (view.openDoorEdges.includes(key) ? "ajar" : null))
          : null;
        return { kind, x, y, state, lock };
      }),
  );

  // Candidate edges for create/destroy wall clicks: every interior boundary.
  const edgeHitboxes = $derived.by(() => {
    if (!edgeSelectMode) return [];
    const boxes: { cell: { x: number; y: number }; side: Side; x: number; y: number; w: number; h: number }[] = [];
    for (const c of cells) {
      if (view.board.cells[`${c.x + 1},${c.y}`]) {
        boxes.push({ cell: c, side: "E", x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
      }
      if (view.board.cells[`${c.x},${c.y + 1}`]) {
        boxes.push({ cell: c, side: "S", x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
      }
    }
    return boxes;
  });

  // Warp pairs share a letter, like the printed openings on the real boards.
  const warpLetters = $derived.by(() => {
    const letters = new Map<string, string>();
    let next = 0;
    for (const w of view.board.warps) {
      const a = `${w.from.cell.x},${w.from.cell.y}`;
      const b = `${w.to.cell.x},${w.to.cell.y}`;
      const key = [a, b].sort().join("|");
      if (!letters.has(key)) letters.set(key, String.fromCharCode(65 + next++));
      letters.set(a, letters.get(key)!);
    }
    return letters;
  });

  // Tokens glide between adjacent squares; anything farther — teleports,
  // warps, drags, sector moves — snaps, because sliding a wizard across the
  // whole maze would tell a false story. Keyed by identity so the DOM node
  // survives the move.
  const lastAt = new Map<string, { x: number; y: number }>();
  function snapsTo(id: string, x: number, y: number): boolean {
    const prev = lastAt.get(id);
    lastAt.set(id, { x, y });
    if (!prev) return true;
    return Math.abs(prev.x - x) + Math.abs(prev.y - y) > CELL * 1.6;
  }

  const creaturesByCell = $derived.by(() => {
    const map = new Map<string, typeof view.creatures>();
    for (const c of view.creatures) {
      const k = `${c.position.x},${c.position.y}`;
      map.set(k, [...(map.get(k) ?? []), c]);
    }
    return map;
  });

  // Group players by cell so co-located wizards fan out.
  const wizardsByCell = $derived.by(() => {
    const map = new Map<string, typeof view.players>();
    for (const p of view.players) {
      if (!p.alive) continue;
      const k = `${p.position.x},${p.position.y}`;
      map.set(k, [...(map.get(k) ?? []), p]);
    }
    return map;
  });
</script>

<!-- Explicit width/height give the svg an intrinsic size: Safari cannot
     derive one from the viewBox alone and collapses the board to 0x0 inside
     a max-height flex column. Doubled so CSS max-* caps still govern. -->
<svg
  viewBox={`${minBx} ${minBy} ${boundsW} ${boundsH}`}
  width={boundsW * 2}
  height={boundsH * 2}
  class="board"
>
  <!-- open ground a relocated sector may claim -->
  {#each ghostSlots ?? [] as g (`${g.x},${g.y}`)}
    <rect
      x={g.x * CELL + 2} y={g.y * CELL + 2}
      width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
      class="ghost-slot"
      role="button" tabindex="-1"
      onclick={() => onGhostClick?.(g)}
      onkeydown={() => {}}
    />
  {/each}

  <!-- floor -->
  {#each cells as c (`${c.x},${c.y}`)}
    <rect
      x={c.x * CELL} y={c.y * CELL} width={CELL} height={CELL}
      class="floor"
      role="button" tabindex="-1"
      onclick={() => tapCell(c)}
      onpointerdown={() => pressCell(c)}
      onpointerup={releasePress}
      onpointerleave={releasePress}
      onkeydown={() => {}}
    />
  {/each}

  <!-- homes & treasure spaces -->
  {#each view.players as p (p.id)}
    {@const hx = p.home.x * CELL + CELL / 2}
    {@const hy = p.home.y * CELL + CELL / 2}
    {@const R = CELL * 0.36}
    {@const r = CELL * 0.14}
    <polygon
      points={Array.from({ length: 16 }, (_, i) => {
        const rad = (Math.PI / 8) * i - Math.PI / 2;
        const len = i % 2 === 0 ? R : r;
        return `${hx + Math.cos(rad) * len},${hy + Math.sin(rad) * len}`;
      }).join(" ")}
      class="home-star" fill={playerColor(p.id)}
    />
  {/each}
  {#each [...view.treasures.reduce((m, t) => {
    if (!t.position) return m;
    const k = `${t.position.x},${t.position.y}`;
    m.set(k, [...(m.get(k) ?? []), t]);
    return m;
  }, new Map()).entries()] as [tKey, group] (tKey)}
  {#each group as t, ti (t.id)}
    {#if t.position}
      {@const tx = t.position.x * CELL + CELL / 2 + (group.length > 1 ? (ti - (group.length - 1) / 2) * CELL * 0.3 : 0)}
      {@const ty = t.position.y * CELL + CELL * 0.72}
      {#if USE_TOKEN_ART}
        <image
          href={treasureArt(t.owner)}
          x={tx - CELL * 0.26} y={ty - CELL * 0.26}
          width={CELL * 0.52} height={CELL * 0.52}
          preserveAspectRatio="xMidYMid slice"
          class="token-art small"
        />
      {:else}
        <g class="treasure-g">
          <circle cx={tx} cy={ty} r={8} class="treasure" fill={playerColor(t.owner)} />
          <circle cx={tx} cy={ty} r={4.5} class="treasure-swirl" />
          <circle cx={tx} cy={ty} r={1.6} class="treasure-core" />
        </g>
      {/if}
    {/if}
  {/each}
  {/each}

  <!-- terrain: solid stone and thornbushes -->
  {#each Object.entries(view.squareContents) as [key, content] (key)}
    {@const sx = Number(key.split(",")[0])}
    {@const sy = Number(key.split(",")[1])}
    {#if USE_TOKEN_ART && TERRAIN_ART[content.kind]}
      <image
        href={tokenArt(TERRAIN_ART[content.kind]!, "terrain")}
        x={sx * CELL + 3} y={sy * CELL + 3}
        width={CELL - 6} height={CELL - 6}
        preserveAspectRatio="xMidYMid slice"
        class="token-art"
      />
    {:else if content.kind === "stone"}
      <rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
    {:else if content.kind === "thornbush" || content.kind === "rosebush"}
      <circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36}
        class={content.kind === "rosebush" ? "rose" : "bush"} />
    {:else if content.kind === "ooze" || content.kind === "slime"}
      <rect x={sx * CELL + 5} y={sy * CELL + 5} width={CELL - 10} height={CELL - 10} rx="10"
        class={content.kind === "ooze" ? "ooze" : "slime"} />
    {:else if content.kind === "dust"}
      <circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.4} class="dust" />
    {:else if content.kind === "pit"}
      <rect x={sx * CELL + 7} y={sy * CELL + 7} width={CELL - 14} height={CELL - 14} class="pit" />
    {:else if content.kind === "tacks"}
      <text x={sx * CELL + CELL / 2} y={sy * CELL + CELL / 2 + 4} class="tacks">✻✻</text>
    {:else if content.kind === "safe"}
      <rect x={sx * CELL + 10} y={sy * CELL + 12} width={CELL - 20} height={CELL - 22} rx="3" class="safe" />
    {/if}
  {/each}

  <!-- dimensional warp tokens -->
  {#each view.dimWarps as w, wi (wi)}
    {#each [w.a, w.b] as tok, i (i)}
      {#if USE_TOKEN_ART}
        <image
          href={tokenArt("dimensional-warp", "terrain")}
          x={tok.x * CELL + CELL * 0.04} y={tok.y * CELL + CELL * 0.04}
          width={CELL * 0.44} height={CELL * 0.44}
          preserveAspectRatio="xMidYMid slice" class="token-art"
        />
      {:else}
        <circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
          class="dimwarp" />
      {/if}
    {/each}
  {/each}

  <!-- boobytrap tokens: face-down for everyone (the caster knows the real one) -->
  {#each view.boobytraps as trap, ti (ti)}
    {#each trap.cells as tc, i (i)}
      <circle cx={tc.x * CELL + CELL * 0.82} cy={tc.y * CELL + CELL * 0.18} r="6"
        class="trap-token" class:trap-real={trap.realCell != null && tc.x === trap.realCell.x && tc.y === trap.realCell.y} />
    {/each}
  {/each}

  <!-- ground objects -->
  {#each Object.entries(view.groundObjects) as [key, objects] (key)}
    {@const gx = Number(key.split(",")[0])}
    {@const gy = Number(key.split(",")[1])}
    {#each objects as o, i (o.instanceId)}
      {@const art = USE_TOKEN_ART ? objectArt(o.cardId) : null}
      {#if art}
        <image
          href={tokenArt(art, "objects")}
          x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
          width={CELL * 0.4} height={CELL * 0.4}
          preserveAspectRatio="xMidYMid slice"
          class="token-art small"
        >
          <title>{o.cardId}</title>
        </image>
      {:else}
        <rect
          x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
          width={12} height={10} rx="2" class="ground-object"
        >
          <title>{o.cardId}</title>
        </rect>
      {/if}
    {/each}
  {/each}

  <!-- walls & doors & firewalls -->
  {#each edges as e (`${e.kind}:${e.x},${e.y}`)}
    {@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : e.state === "firewall" ? "firewall" : "wall"}
    {@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
      : e.lock === "jammed" ? "lock jammed — sealed for good"
      : e.lock === "ajar" ? "unlocked until end of turn" : null}
    {#if e.kind === "V"}
      <rect
        x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
        width={WALL} height={CELL + WALL}
        class={cls}
      >{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
    {:else}
      <rect
        x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
        width={CELL + WALL} height={WALL}
        class={cls}
      >{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
    {/if}
  {/each}

  <!-- battle damage: cracks spread as a wall or door takes attacks -->
  {#each Object.entries(view.wallDamage) as [key, dmg] (key)}
    {@const kind = key.split(":")[0]}
    {@const wx = Number(key.split(":")[1]?.split(",")[0])}
    {@const wy = Number(key.split(":")[1]?.split(",")[1])}
    {@const frac = Math.min(1, dmg / 20)}
    {#if kind === "V"}
      <line x1={(wx + 1) * CELL} y1={wy * CELL + 3} x2={(wx + 1) * CELL} y2={(wy + 1) * CELL - 3}
        class="crack" style:opacity={0.35 + frac * 0.65} />
    {:else}
      <line x1={wx * CELL + 3} y1={(wy + 1) * CELL} x2={(wx + 1) * CELL - 3} y2={(wy + 1) * CELL}
        class="crack" style:opacity={0.35 + frac * 0.65} />
    {/if}
  {/each}

  <!-- illusions YOU know are fake: ghostly dashed lines -->
  {#each view.knownIllusionEdges as key (key)}
    {@const kind = key.split(":")[0]}
    {@const ix = Number(key.split(":")[1]?.split(",")[0])}
    {@const iy = Number(key.split(":")[1]?.split(",")[1])}
    {#if kind === "V"}
      <line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
    {:else}
      <line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
    {/if}
  {/each}

  <!-- warp openings: click the arrow (while standing on it) to step through -->
  {#each view.board.warps as w, i (i)}
    {@const wx = w.from.cell.x * CELL + CELL / 2 +
      (w.from.side === "E" ? CELL * 0.42 : w.from.side === "W" ? -CELL * 0.42 : 0)}
    {@const wy = w.from.cell.y * CELL + CELL / 2 +
      (w.from.side === "S" ? CELL * 0.42 : w.from.side === "N" ? -CELL * 0.42 : 0)}
    {@const standing = view.players.some((p) =>
      p.id === view.you && p.position.x === w.from.cell.x && p.position.y === w.from.cell.y) ||
      view.creatures.some((c) => c.id === selectedCreatureId &&
        c.position.x === w.from.cell.x && c.position.y === w.from.cell.y)}
    {@const letter = warpLetters.get(`${w.from.cell.x},${w.from.cell.y}`) ?? ""}
    <g
      class="warp-g" class:standing
      role="button" tabindex="-1"
      onclick={(ev) => { ev.stopPropagation(); onWarpClick?.(w.from.cell, w.from.side); }}
      onkeydown={() => {}}
    >
      <circle cx={wx} cy={wy} r={11} class="warp-hit" />
      <text x={wx} y={wy + 4.5} class="warp"
      >{w.from.side === "N" ? "↑" : w.from.side === "S" ? "↓" : w.from.side === "E" ? "→" : "←"}</text>
      <text
        x={wx + (w.from.side === "E" ? -13 : w.from.side === "W" ? 13 : 12)}
        y={wy + (w.from.side === "N" ? 13 : w.from.side === "S" ? -9 : 4)}
        class="warp-letter"
      >{letter}</text>
    </g>
    {#if standing}
      <!-- show where this opening leads -->
      <circle
        cx={w.to.cell.x * CELL + CELL / 2} cy={w.to.cell.y * CELL + CELL / 2}
        r={CELL * 0.42} class="warp-dest"
      />
    {/if}
  {/each}

  <!-- first square of a two-square spell -->
  {#if markedCell}
    <rect
      x={markedCell.x * CELL + 3} y={markedCell.y * CELL + 3}
      width={CELL - 6} height={CELL - 6}
      rx="6" class="marked-cell"
    />
  {/if}

  <!-- placements so far of a multi-square spell, numbered in click order -->
  {#each markedCells ?? [] as m, i (`${m.x},${m.y}`)}
    <rect
      x={m.x * CELL + 3} y={m.y * CELL + 3}
      width={CELL - 6} height={CELL - 6}
      rx="6" class="marked-cell"
    />
    <text
      x={m.x * CELL + CELL / 2} y={m.y * CELL + CELL / 2}
      class="marked-count" text-anchor="middle" dominant-baseline="central"
    >{i + 1}</text>
  {/each}

  <!-- the sector picked up, awaiting its new ground -->
  {#if markedSector}
    <rect
      x={markedSector.x * CELL + 3} y={markedSector.y * CELL + 3}
      width={SECTOR * CELL - 6} height={SECTOR * CELL - 6}
      rx="8" class="marked-sector"
    />
  {/if}

  <!-- wizards: identity-keyed movers, so steps glide square to square -->
  {#each view.players.filter((p) => p.alive) as p (p.id)}
    {@const group = wizardsByCell.get(`${p.position.x},${p.position.y}`) ?? [p]}
    {@const gi = group.findIndex((q) => q.id === p.id)}
    {@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)}
    {@const cy = p.position.y * CELL + CELL * 0.36}
    <g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
      <g
        role="button" tabindex="-1"
        onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
        onpointerdown={() => pressCell(p.position)}
        onpointerup={releasePress}
        onpointerleave={releasePress}
        onkeydown={() => {}}
        class="wizard"
      >
        {#if USE_TOKEN_ART}
          {@const half = CELL * 0.3 * wizardScale(p.id)}
          <image
            href={wizardArt(p.id)}
            x={-half} y={-half}
            width={half * 2} height={half * 2}
            preserveAspectRatio="xMidYMid slice"
            class="token-art hit"
            class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
            class:stone-gazed={hasSpell(p.id, "medusa")}
          />
          <rect
            x={-half} y={-half}
            width={half * 2} height={half * 2}
            class="wizard-ring" stroke={playerColor(p.id)}
          />
          {#if hasSpell(p.id, "sticky-web")}
            <g class="webbing">
              <line x1={-half} y1={-half * 0.4} x2={half} y2={half * 0.5} />
              <line x1={-half * 0.6} y1={half} x2={half * 0.7} y2={-half} />
              <line x1={-half} y1={half * 0.7} x2={half} y2={-half * 0.2} />
            </g>
          {/if}
        {:else}
          <circle cx="0" cy="0" r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
          <text x="0" y="4" class="wizard-label">{p.id[0]?.toUpperCase()}</text>
        {/if}
        {#if p.carriedTreasureId}
          <circle cx={CELL * 0.26} cy={CELL * 0.26} r={5} class="carried" />
        {/if}
      </g>
    </g>
  {/each}

  <!-- creatures: identity-keyed movers, fanned when several share a square -->
  {#each view.creatures as c (c.id)}
    {@const cGroup = creaturesByCell.get(`${c.position.x},${c.position.y}`) ?? [c]}
    {@const ci = cGroup.findIndex((q) => q.id === c.id)}
    {@const ccx = c.position.x * CELL + CELL * 0.72 - (cGroup.length > 1 ? ci * CELL * 0.26 : 0)}
    {@const ccy = c.position.y * CELL + CELL * 0.7}
    <g class="mover" class:snap={snapsTo(`c:${c.id}`, ccx, ccy)} style={`transform: translate(${ccx}px, ${ccy}px)`}>
      <g
        role="button" tabindex="-1" class="creature"
        onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }}
        onpointerdown={() => pressCell(c.position)}
        onpointerup={releasePress}
        onpointerleave={releasePress}
        onkeydown={() => {}}
      >
        {#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
          <image
            href={tokenArt(CREATURE_ART[c.kind]!, "creatures")}
            x={-CELL * 0.26} y={-CELL * 0.26}
            width={CELL * 0.52} height={CELL * 0.52}
            preserveAspectRatio="xMidYMid slice"
            class="token-art hit"
            class:selected-art={c.id === selectedCreatureId}
          >
            <title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
          </image>
          <rect
            x={-CELL * 0.26} y={-CELL * 0.26}
            width={CELL * 0.52} height={CELL * 0.52}
            class="creature-ring"
            class:selected={c.id === selectedCreatureId}
            stroke={playerColor(c.controllerId)}
          />
        {:else}
          <rect
            x={-10} y={-10} width={20} height={20} rx="3"
            transform="rotate(45 0 0)"
            class="creature-body"
            class:selected={c.id === selectedCreatureId}
            stroke={playerColor(c.controllerId)}
          >
            <title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
          </rect>
          <text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
        {/if}
      </g>
    </g>
  {/each}

  <!-- edge selection hitboxes -->
  {#each edgeHitboxes as h (`${h.cell.x},${h.cell.y},${h.side}`)}
    <rect
      x={h.x} y={h.y} width={h.w} height={h.h}
      class="edge-hit"
      role="button" tabindex="-1"
      onclick={(ev) => { ev.stopPropagation(); onEdgeClick?.(h.cell, h.side); }}
      onkeydown={() => {}}
    />
  {/each}
  <!-- targeting aid: ineligible squares fall into shadow -->
  {#if litCells}
    {#each Object.keys(view.board.cells) as key (key)}
      {#if !litCells.has(key)}
        {@const dx = Number(key.split(",")[0])}
        {@const dy = Number(key.split(",")[1])}
        <rect x={dx * CELL} y={dy * CELL} width={CELL} height={CELL} class="dim-cell" />
      {/if}
    {/each}
  {/if}
  <!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
  <g class="fx-layer" aria-hidden="true">
    {#each effects ?? [] as fx (fx.id)}
      {@const Sprite = FX_SPRITES[fx.kind]}
      <Sprite fx={fx as never} />
    {/each}
  </g>
</svg>

<style>
  .board {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
    touch-action: manipulation;
    width: 100%;
    max-width: 680px;
    background: #e3dcc7;
    border: 7px solid #2b2218;
    border-radius: 3px;
    box-shadow:
      0 0 0 2px #b7ad92,
      0 12px 34px rgba(0, 0, 0, 0.6);
  }
  .floor {
    fill: #e3dcc7;
    stroke: #b7ad92;
    stroke-width: 1.1;
    stroke-dasharray: 4 2.5;
    cursor: pointer;
  }
  .floor:hover { fill: #efe8d2; }
  .wall {
    fill: #f2ecd8;
    stroke: #2b2218;
    stroke-width: 1.6;
  }
  .door {
    fill: #8b5a2b;
    stroke: #2b2218;
    stroke-width: 1.4;
    rx: 2;
  }
  /* Lock removed: pale, an open doorway forever. */
  .door.removed { fill: #d4bd8e; }
  /* Lock jammed: near-black, sealed. */
  .door.jammed { fill: #3a2a18; stroke: #14100b; }
  /* Picked or keyed open until end of turn. */
  .door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
  .firewall {
    fill: #d0342c;
    stroke: #7c1a14;
    stroke-width: 1.2;
    animation: firewall-lick 1.1s ease-in-out infinite alternate;
  }
  @keyframes firewall-lick {
    from { fill: #d0342c; filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
    to { fill: #ef6c3a; filter: drop-shadow(0 0 6px rgba(255, 140, 30, 0.9)); }
  }
  .stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
  .bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
  .rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
  .ooze { fill: #58a12b; opacity: 0.85; }
  .slime { fill: #8ec52e; opacity: 0.85; }
  .dust { fill: #9b9184; opacity: 0.75; }
  .pit { fill: #171512; }
  .tacks { font-size: 15px; text-anchor: middle; fill: #444; }
  .safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; }
  .trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
  .trap-real { stroke: #d3352b; stroke-width: 2.5; }
  .dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
  .token-art {
    filter: drop-shadow(0.5px 1.5px 1.5px rgba(10, 8, 4, 0.5));
    pointer-events: none;
  }
  .token-art.hit { pointer-events: auto; cursor: pointer; }
  .creature-ring, .wizard-ring {
    fill: none;
    stroke-width: 2.5;
    pointer-events: none;
  }
  .creature-ring.selected { stroke-width: 4; stroke-dasharray: 5 3; }
  .ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
  .illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
  .creature { cursor: pointer; }
  .creature-body { fill: #3b3428; stroke-width: 2.5; }
  .creature-body.selected { fill: #6b5a34; }
  .creature-label { font-size: 11px; font-weight: bold; fill: #f4ead2; text-anchor: middle; pointer-events: none; }
  /* Decorative shapes never eat clicks — the floor rect beneath handles them. */
  .home-star, .stone, .bush, .rose, .ooze, .slime, .dust, .pit, .tacks, .safe,
  .dimwarp, .treasure-g, .treasure, .treasure-swirl, .treasure-core {
    pointer-events: none;
  }
  .home-star { stroke: #2b2218; stroke-width: 1.4; opacity: 0.92; }
  .treasure { stroke: #2b2218; stroke-width: 1.3; }
  .treasure-swirl { fill: none; stroke: #2b2218; stroke-width: 1.2; stroke-dasharray: 5 2.2; }
  .treasure-core { fill: #2b2218; }
  .warp { font-size: 14px; text-anchor: middle; fill: #2b2218; font-weight: bold; opacity: 0.65; pointer-events: none; }
  .warp-g { cursor: pointer; }
  .warp-hit { fill: transparent; }
  .warp-g:hover .warp-hit { fill: rgba(43, 34, 24, 0.12); }
  .warp-g.standing .warp-hit {
    fill: rgba(46, 125, 50, 0.25);
    stroke: #2e7d32;
    stroke-width: 2;
  }
  .warp-g.standing .warp { opacity: 1; }
  .warp-letter {
    font-family: "Oswald", sans-serif;
    font-size: 10px;
    font-weight: 600;
    text-anchor: middle;
    fill: #6a5c44;
    pointer-events: none;
  }
  .dim-cell {
    fill: rgba(12, 9, 5, 0.55);
    pointer-events: none;
  }
  .marked-cell {
    fill: rgba(211, 133, 43, 0.18);
    stroke: #d3852b;
    stroke-width: 2.5;
    stroke-dasharray: 7 4;
    pointer-events: none;
    animation: warp-pulse 1.6s ease-in-out infinite;
  }
  .marked-count {
    font-family: "Oswald", sans-serif;
    font-size: 15px;
    font-weight: 600;
    fill: #d3852b;
    pointer-events: none;
  }
  .marked-sector {
    fill: rgba(211, 133, 43, 0.12);
    stroke: #d3852b;
    stroke-width: 3;
    stroke-dasharray: 10 6;
    pointer-events: none;
    animation: warp-pulse 1.6s ease-in-out infinite;
  }
  .ghost-slot {
    fill: rgba(122, 162, 122, 0.10);
    stroke: #7aa27a;
    stroke-width: 2.5;
    stroke-dasharray: 10 6;
    cursor: pointer;
    animation: warp-pulse 1.6s ease-in-out infinite;
  }
  .ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }

  .fx-layer { pointer-events: none; }
  .mover { transition: transform 260ms cubic-bezier(0.25, 0.8, 0.35, 1); }
  .mover.snap { transition: none; }

  /* --- persistent ambiance: ongoing spells wear their look ------------ */
  .token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
  @keyframes ghost-breathe { 50% { opacity: 0.22; } }
  .token-art.stone-gazed { filter: grayscale(0.9) brightness(0.8); }
  .webbing line {
    stroke: rgba(220, 218, 205, 0.85);
    stroke-width: 1.6;
    pointer-events: none;
  }
  .fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
  .fx-dust.late { animation-delay: 0.09s; }
  .fx-dust.later { animation-delay: 0.17s; }

  @keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
  @keyframes fx-flicker {
    0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
    60% { opacity: 1; } 100% { opacity: 0; }
  }
  @keyframes fx-ring {
    0% { opacity: 0.95; transform: scale(1); }
    100% { opacity: 0; transform: scale(5); }
  }
  @keyframes fx-ring-small {
    0% { opacity: 0.9; transform: scale(1); }
    100% { opacity: 0; transform: scale(2.6); }
  }
  @keyframes fx-shield-pulse {
    0% { opacity: 0; transform: scale(0.6); }
    30% { opacity: 1; transform: scale(1.1); }
    60% { transform: scale(0.95); }
    100% { opacity: 0; transform: scale(1.15); }
  }
  @keyframes fx-drop {
    0% { opacity: 0.9; transform: translateY(0); }
    100% { opacity: 0; transform: translateY(-14px); }
  }
  @keyframes fx-drift {
    0% { opacity: 0.8; transform: translateY(0) scale(1); }
    100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
  }
  @keyframes fx-pow-hit {
    0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
    25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
    55% { transform: scale(1) rotate(0deg); }
    100% { opacity: 0; transform: scale(1.05); }
  }
  @keyframes fx-claw-rake {
    0% { opacity: 0; transform: translateY(-6px); }
    20% { opacity: 1; }
    100% { opacity: 0; transform: translateY(6px); }
  }
  @keyframes fx-swallow {
    0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
    100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
  }
  @keyframes fx-spin {
    0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
    30% { opacity: 1; }
    100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
  }
  .warp-dest {
    fill: none;
    stroke: #2e7d32;
    stroke-width: 2.5;
    stroke-dasharray: 7 4;
    pointer-events: none;
    animation: warp-pulse 1.6s ease-in-out infinite;
  }
  @keyframes warp-pulse {
    0%, 100% { opacity: 0.9; }
    50% { opacity: 0.35; }
  }
  @media (prefers-reduced-motion: reduce) {
    .marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
    .fx-layer { display: none; }
    .firewall, .token-art.ghosted { animation: none; }
    .mover { transition: none; }
  }
  .wizard { cursor: pointer; }
  .wizard-label {
    font-family: "Oswald", sans-serif;
    font-size: 13px; font-weight: 600; fill: #f4ead2;
    text-anchor: middle; pointer-events: none;
  }
  .carried { fill: gold; stroke: #111; stroke-width: 1; }
  .crack {
    stroke: #efe8d4;
    stroke-width: 2;
    stroke-dasharray: 3 5;
    stroke-linecap: round;
    pointer-events: none;
  }
  .edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; }
  .edge-hit:hover { fill: rgba(30, 120, 240, 0.5); }
</style>
