Files
wizwar6e/packages/web/src/Board.svelte
T
Eric WagonerandClaude Opus 5.5 c64ff70ed4 The table speaks plainer, and keeps its whimsy
Wording throughout the app, the server's messages and the engine's
refusals trades its stacked dashes for sentences, while the maze still
trusts you to honor its curses, the gallery still listens, and the
clockwork still learns its best tricks from the humans at the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:29:07 -04:00

966 lines
39 KiB
Svelte

<script lang="ts">
import type { Component } from "svelte";
import type { GameView, SightTrace } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import { edgeKey, fearCells, neighbor } 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 { edgeScar, edgeWisps, webThreads, warpCurls } from "./boardArt";
import EdgeGlyph, { type LockState } 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,
punchFrom = null,
selectedCreatureId = null,
onCellClick,
onEdgeClick,
onPlayerClick,
povFacing = null,
povCreatureId = null,
onCreatureClick,
onWarpClick,
onCellPeek,
onIllusionClick,
onEdgePeek,
markedCell = null,
markedCells = null,
litCells = null,
spotCells = [],
markedSector = null,
ghostSlots = null,
onGhostClick,
effects = null,
sightTrace = null,
}: {
view: GameView;
edgeSelectMode?: boolean;
/** A puncher's square, wizard or troll: only the walls and doors on its
* four sides can be struck, so only those lines light. */
punchFrom?: { x: number; y: number } | null;
selectedCreatureId?: string | null;
onCellClick?: (cell: { x: number; y: number }) => void;
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
onPlayerClick?: (playerId: string) => void;
/** Which way YOUR first-person camera faces: a wedge on your token
* keeps the keymap and the pane pointing the same way. */
povFacing?: "N" | "E" | "S" | "W" | null;
/** While the pane rides a creature, the ribbon moves to ITS token. */
povCreatureId?: string | null;
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;
/** The step light's squares: a pulsing rim on each. */
spotCells?: string[];
/** 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; bend?: { mid: { x: number; y: number }; trace: SightTrace }; pierced?: string } | null;
} = $props();
/** The wall segment a VISIONSTONE shot pierced, from its edge key. */
function piercedSegment(key: string): { x1: number; y1: number; x2: number; y2: number } {
const [kind, xy] = key.split(":") as [string, string];
const [x, y] = xy.split(",").map(Number) as [number, number];
return kind === "V"
? { x1: (x + 1) * CELL, y1: y * CELL, x2: (x + 1) * CELL, y2: (y + 1) * CELL }
: { x1: x * CELL, y1: (y + 1) * CELL, x2: (x + 1) * CELL, y2: (y + 1) * CELL };
}
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.
/** The facing ribbon: a shallow golden triangle hugging a token's
* leading edge — base the token's width, rising a quarter of it. */
function facingWedge(side: "N" | "E" | "S" | "W", half: number): string {
const a = side === "E" ? 0 : side === "S" ? Math.PI / 2 : side === "W" ? Math.PI : -Math.PI / 2;
const fx = Math.cos(a), fy = Math.sin(a);
const px = -fy, py = fx;
const rise = half * 0.5;
return `${fx * half + px * half},${fy * half + py * half} ` +
`${fx * half - px * half},${fy * half - py * half} ` +
`${fx * (half + rise)},${fy * (half + rise)}`;
}
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 });
}
if (!punchFrom) return boxes;
const beside = (b: { cell: { x: number; y: number }; side: Side }) => {
const far = neighbor(b.cell, b.side);
const at = (c: { x: number; y: number }) => c.x === punchFrom!.x && c.y === punchFrom!.y;
return at(b.cell) || at(far);
};
const solid = (b: { cell: { x: number; y: number }; side: Side }) => {
const state = view.board.edges[edgeKey(b.cell, b.side)];
return state === "wall" || state === "door";
};
return boxes.filter((b) => beside(b) && solid(b));
});
// FEAR's dread: three spaces in every direction, straight through walls
// ("even if walls separate you"), never diagonally — and the maze wraps,
// so the diamond continues from the opposite rim. The engine's own
// yardstick, so the aura and the refusal can never disagree.
const fearAura = $derived(fearCells(view));
// 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}
{#if content.kind === "slime" && (view.slimeTraps?.[key]?.length ?? 0) > 0}
<!-- spells waiting in the gel: everyone saw them cast -->
<g class="slime-charge" transform={`translate(${sx * CELL + CELL - 9}, ${sy * CELL + 9})`}>
<circle r="7" />
<text y="3.5">{view.slimeTraps[key]!.length}</text>
</g>
{/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 peekable"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
onkeydown={() => {}}
>
<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 peekable"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
onkeydown={() => {}}
>
<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" ? "a door with its lock removed, swinging free"
: e.lock === "jammed" ? "a door with its lock jammed, sealed for good"
: e.lock === "held" ? "a door held open by a standing wizard"
: e.lock === "ajar" ? "a door unlocked until the end of the 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 LockState | 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)}
{@const scar = kind === "V"
? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
: edgeScar(wx * CELL + 3, (wy + 1) * CELL, (wx + 1) * CELL - 3, (wy + 1) * CELL, frac)}
<path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred, {dmg} damage taken</title></path>
{/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"}
<path d={edgeWisps((ix+1)*CELL, iy*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion your eyes see through</title></path>
{:else}
<path d={edgeWisps(ix*CELL, (iy+1)*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion your eyes see through</title></path>
{/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 -->
<path d={warpCurls(w.to.cell.x*CELL+CELL/2, w.to.cell.y*CELL+CELL/2, CELL*.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" : ""}${p.inPit ? " in-pit" : ""}`}
/>
<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">
<path d={webThreads(half)} />
</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>
{#if povFacing && !povCreatureId && p.id === view.you}
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
{/if}
</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>
{#if povFacing && povCreatureId === c.id}
<!-- The rider's facing ribbon, worn by the mount. -->
<polygon points={facingWedge(povFacing, CELL * 0.26)} class="pov-wedge" />
{/if}
</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 step light: the squares a stride reaches, rimmed and pulsing -->
{#each spotCells as key (key)}
{@const sx = Number(key.split(",")[0])}
{@const sy = Number(key.split(",")[1])}
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
{/each}
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
{#each fearAura as key (key)}
{@const bx = Number(key.split(",")[0])}
{@const by = Number(key.split(",")[1])}
<rect x={bx * CELL + 1.5} y={by * CELL + 1.5} width={CELL - 3} height={CELL - 3}
class="fear-aura" rx="4" />
{/each}
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#if sightTrace}
{#if sightTrace.bend}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.bend.mid} trace={sightTrace.trace} />
<SightTraceOverlay from={sightTrace.bend.mid} to={sightTrace.to} trace={sightTrace.bend.trace} />
<rect x={sightTrace.bend.mid.x * CELL + CELL / 2 - 6} y={sightTrace.bend.mid.y * CELL + CELL * 0.36 - 6}
width={12} height={12} class="sight-corner" transform="rotate(45 {sightTrace.bend.mid.x * CELL + CELL / 2} {sightTrace.bend.mid.y * CELL + CELL * 0.36})" />
{:else}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
{/if}
{#if sightTrace.pierced}
<!-- the one wall VISIONSTONE dissolved for this shot -->
{@const seg = piercedSegment(sightTrace.pierced)}
<line x1={seg.x1} y1={seg.y1} x2={seg.x2} y2={seg.y2} class="sight-pierced" />
{/if}
{/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; }
.slime-charge { pointer-events: none; }
.slime-charge circle { fill: #3b2a6b; stroke: #f2cf5b; stroke-width: 1.5; }
.slime-charge text { fill: #f7ecc8; font-size: 10px; font-weight: 700; text-anchor: middle; font-family: "Oswald", sans-serif; }
.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; }
/* Down a pit: the standee sits in shadow, smaller, as if seen from above. */
:global(.token-art.in-pit) { filter: brightness(0.5) saturate(0.7); transform: scale(0.8); transform-box: fill-box; transform-origin: center; }
.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 { fill: #7a6f9a; stroke: none; opacity: 0.7; pointer-events: none; }
.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;
}
.pov-wedge {
fill: #ffd23e;
stroke: #43331f;
stroke-width: 1.2;
pointer-events: none;
}
.peekable { cursor: pointer; pointer-events: all; }
.sight-corner {
fill: none;
stroke: #c9a72a;
stroke-width: 2;
animation: sight-pulse 1.6s ease-in-out infinite;
}
.sight-pierced {
stroke: #c9a72a;
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 2 5;
animation: sight-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-pulse {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.35; }
}
.dim-cell {
fill: rgba(12, 9, 5, 0.55);
pointer-events: none;
}
.spot-cell {
fill: none;
stroke: #f2cf5b;
stroke-width: 3;
pointer-events: none;
animation: spot-pulse 1.3s ease-in-out infinite;
}
@keyframes spot-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.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; }
.fear-aura {
fill: rgba(90, 44, 110, 0.05);
stroke: #7a4a8a;
stroke-width: 2;
stroke-dasharray: 15 5 3 5;
pointer-events: none;
animation: fear-breathe 2.4s ease-in-out infinite;
}
@keyframes fear-breathe {
0%, 100% { opacity: 0.65; }
50% { opacity: 0.3; }
}
.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 { pointer-events: none; }
.webbing path {
fill: none;
stroke: rgba(240, 231, 209, 0.9);
stroke-width: 1.25;
stroke-linecap: round;
}
.warp-dest {
fill: none;
stroke: #2e7d32;
stroke-width: 2.5;
stroke-linecap: round;
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, .fear-aura,
.spot-cell, .sight-corner, .sight-pierced { 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-shadow { fill: none; stroke: #6e5a3d; stroke-width: 2.8; stroke-linejoin: bevel; stroke-linecap: round; pointer-events: none; }
.crack {
fill: none;
stroke: #efe8d4;
stroke-width: 1.4;
stroke-linejoin: bevel;
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>