Files
wizwar6e/packages/web/src/Board.svelte
T
Eric WagonerandClaude Fable 5 0f91d30a3e Press-and-hold peeks a segment — the tooltip, for hands without a mouse
The same 450ms hold that peeks a square now works on walls, doors, and
fire walls: the segment's full description (state, lock, hitpoints, the
fire's burn warning) rises in the peek scrim. Shimmering illusions keep
their richer tap modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 10:40:55 -04:00

818 lines
32 KiB
Svelte

<script lang="ts">
import type { Component } from "svelte";
import type { GameView, SightTrace } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import type { BoardFx } from "./fx";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import EdgeGlyph from "./EdgeGlyph.svelte";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
import SightTraceOverlay from "./SightTraceOverlay.svelte";
import TokenArt from "./TokenArt.svelte";
import { FX_SPRITES } from "./fx-sprites";
const CELL = 48;
const WALL = 7;
let {
view,
edgeSelectMode = false,
selectedCreatureId = null,
onCellClick,
onEdgeClick,
onPlayerClick,
onCreatureClick,
onWarpClick,
onCellPeek,
onIllusionClick,
onEdgePeek,
markedCell = null,
markedCells = null,
litCells = null,
markedSector = null,
ghostSlots = null,
onGhostClick,
effects = null,
sightTrace = 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;
/** Tap on a shimmering (untested) illusion wall: offer the belief test. */
onIllusionClick?: (cell: { x: number; y: number }, side: Side) => void;
/** Press-and-hold a wall/door/fire segment: its tooltip, for touch. */
onEdgePeek?: (tip: string) => 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?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | 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;
// 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.heldDoorEdges.includes(key) ? "held"
: 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 });
}
}
// Warp mouths sit on the perimeter, with no far cell: still wallable.
const seen = new Set(boxes.map((b) => `${b.cell.x},${b.cell.y},${b.side}`));
for (const w of view.board.warps) {
const c = w.from.cell;
const side = w.from.side;
const k = `${c.x},${c.y},${side}`;
if (seen.has(k)) continue;
seen.add(k);
if (side === "E") boxes.push({ cell: c, side, x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
if (side === "W") boxes.push({ cell: c, side, x: c.x * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
if (side === "S") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
if (side === "N") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: c.y * 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}
<TokenArt
href={treasureArt(t.owner)}
x={tx - CELL * 0.26} y={ty - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
cls="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]}
<TokenArt
href={tokenArt(TERRAIN_ART[content.kind]!, "terrain")}
x={sx * CELL + 3} y={sy * CELL + 3}
width={CELL - 6} height={CELL - 6}
cls="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}
<TokenArt
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}
cls="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}`)}
{#if e.state === "firewall"}
<FirewallEdge x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"} onpeek={onEdgePeek} />
{:else}
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
: e.lock === "jammed" ? "lock jammed — sealed for good"
: e.lock === "held" ? "held open by a standing wizard"
: e.lock === "ajar" ? "unlocked until end of turn" : null}
<EdgeGlyph x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"}
state={e.state === "door" ? "door" : "wall"}
lock={(e.lock ?? null) as "removed" | "jammed" | "held" | "ajar" | null}
title={lockTitle} damage={view.wallDamage[`${e.kind}:${e.x},${e.y}`] ?? 0}
onpeek={onEdgePeek} />
{/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}><title>battle-scarred — {dmg} damage taken</title></line>
{: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}><title>battle-scarred — {dmg} damage taken</title></line>
{/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"><title>an illusion — your eyes see through it</title></line>
{:else}
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion"><title>an illusion — your eyes see through it</title></line>
{/if}
{/each}
<!-- untested illusions: the wall stands, but it shimmers — click to doubt it -->
{#each Object.entries(view.illusionEdges) as [key, verdict] (key)}
{#if verdict === "untested"}
{@const kind = key.split(":")[0] === "V" ? "V" as const : "H" as const}
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
<IllusionShimmer x={ix} y={iy} {kind}
onclick={() => onIllusionClick?.({ x: ix, y: iy }, kind === "V" ? "E" : "S")} />
{/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)}
<TokenArt
href={wizardArt(p.id)}
x={-half} y={-half}
width={half * 2} height={half * 2}
cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}`}
/>
<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}
{@const carriedT = view.treasures.find((t) => t.id === p.carriedTreasureId)}
{#if carriedT}
<!-- Whose gold: the owner's chest rides the carrier's shoulder.
Always the drawn chest — legible at badge size, either art mode. -->
<TokenArt
href={`/tokens-svg/treasure-${sharedColorIndex(view, carriedT.owner) % 6}.svg`}
x={CELL * 0.10} y={CELL * 0.06}
width={CELL * 0.36} height={CELL * 0.36}
cls="carried-chest"
/>
{:else}
<circle cx={CELL * 0.26} cy={CELL * 0.26} r={5} class="carried" />
{/if}
{/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]}
<TokenArt
href={tokenArt(CREATURE_ART[c.kind]!, "creatures")}
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
cls={`token-art hit${c.id === selectedCreatureId ? " selected-art" : ""}`}
title={`${c.kind} (${c.controllerId}) — ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
/>
<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}
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#if sightTrace}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
{/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
<!-- One cast at the dispatch seam; the registry stays exhaustive. -->
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
<Sprite {fx} />
{/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; }
.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; }
:global(.token-art) {
filter: drop-shadow(0.5px 1.5px 1.5px rgba(10, 8, 4, 0.5));
pointer-events: none;
}
:global(.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 ------------ */
:global(.token-art.ghosted) { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
@keyframes -global-ghost-breathe { 50% { opacity: 0.22; } }
:global(.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;
}
.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; }
:global(.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; }
:global(.carried-chest) {
filter: drop-shadow(0.5px 1px 1px rgba(0, 0, 0, 0.55));
pointer-events: none;
}
.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>