"It is possible, though time-consuming, to punch a wall down. A wall takes 20 points of damage to destroy; a door takes 15. Any attack against an inanimate object counts as your one attack for the turn." Damage accumulates per edge in wallDamage (remapped through sector rotations, public in the view), fed two ways: a punchWall command for the bare-fisted (1 point, from a square touching the edge) and attack spells cast at an edge target — LOS to the wall for L.O.S. cards, touching it for same-square cards, amplify and power-attack honored, wand charges spent, no counteractions since stonework plays none. Thrown daggers and rocks clatter to the floor at the foot of the wall. At the threshold the edge opens through the same override path destroy-wall uses. On the table: damaged walls wear spreading cracks, an attack card's hint offers "or a wall line to batter it" with the edge layer live, and a "Punch a wall…" stamp arms a click-the-wall mode. The chronicle counts the blows: "alice batters the wall with bare fists — 3/20." Not deployed — a live game is in progress. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
632 lines
23 KiB
Svelte
632 lines
23 KiB
Svelte
<script lang="ts">
|
|
import type { GameView } from "@wizwar/engine";
|
|
import type { Side } from "@wizwar/engine";
|
|
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
|
|
|
const CELL = 48;
|
|
const WALL = 7;
|
|
|
|
let {
|
|
view,
|
|
edgeSelectMode = false,
|
|
selectedCreatureId = null,
|
|
onCellClick,
|
|
onEdgeClick,
|
|
onPlayerClick,
|
|
onCreatureClick,
|
|
onWarpClick,
|
|
onCellPeek,
|
|
markedCell = null,
|
|
litCells = 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;
|
|
/** When set, squares NOT in this set dim — the targeting aid. */
|
|
litCells?: Set<string> | null;
|
|
} = $props();
|
|
|
|
|
|
// 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;
|
|
}
|
|
// 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 `/tokens/wizard-${sharedColorIndex(view, id) % 6}.png`;
|
|
}
|
|
function treasureArt(owner: string): string {
|
|
return `/tokens/treasure-${sharedColorIndex(view, owner) % 6}.png`;
|
|
}
|
|
|
|
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];
|
|
return { kind, x, y, state };
|
|
}),
|
|
);
|
|
|
|
// 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;
|
|
});
|
|
|
|
// 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>
|
|
|
|
<svg
|
|
viewBox={`-8 -8 ${view.board.width * CELL + 16} ${view.board.height * CELL + 16}`}
|
|
class="board"
|
|
>
|
|
<!-- 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={`/tokens/${TERRAIN_ART[content.kind]}.png`}
|
|
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="/tokens/dimensional-warp.png"
|
|
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={`/tokens/${art}.png`}
|
|
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.state === "firewall" ? "firewall" : "wall"}
|
|
{#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}
|
|
/>
|
|
{:else}
|
|
<rect
|
|
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
|
width={CELL + WALL} height={WALL}
|
|
class={cls}
|
|
/>
|
|
{/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}
|
|
|
|
<!-- wizards -->
|
|
{#each [...wizardsByCell.entries()] as [key, group] (key)}
|
|
{#each group as p, i (p.id)}
|
|
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0)}
|
|
{@const cy = p.position.y * CELL + CELL * 0.36}
|
|
<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={cx - half} y={cy - half}
|
|
width={half * 2} height={half * 2}
|
|
preserveAspectRatio="xMidYMid slice"
|
|
class="token-art hit"
|
|
/>
|
|
<rect
|
|
x={cx - half} y={cy - half}
|
|
width={half * 2} height={half * 2}
|
|
class="wizard-ring" stroke={playerColor(p.id)}
|
|
/>
|
|
{:else}
|
|
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
|
|
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
|
|
{/if}
|
|
{#if p.carriedTreasureId}
|
|
<circle cx={cx + CELL * 0.26} cy={cy + CELL * 0.26} r={5} class="carried" />
|
|
{/if}
|
|
</g>
|
|
{/each}
|
|
{/each}
|
|
|
|
<!-- creatures (fanned when several share a square) -->
|
|
{#each [...view.creatures.reduce((m, c) => {
|
|
const k = `${c.position.x},${c.position.y}`;
|
|
m.set(k, [...(m.get(k) ?? []), c]);
|
|
return m;
|
|
}, new Map()).entries()] as [cKey, cGroup] (cKey)}
|
|
{#each cGroup as c, ci (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
|
|
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={`/tokens/${CREATURE_ART[c.kind]}.png`}
|
|
x={ccx - CELL * 0.26} y={ccy - 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={ccx - CELL * 0.26} y={ccy - 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={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
|
|
transform={`rotate(45 ${ccx} ${ccy})`}
|
|
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={ccx} y={ccy + 4} class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
|
|
{/if}
|
|
</g>
|
|
{/each}
|
|
{/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}
|
|
</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;
|
|
}
|
|
.firewall {
|
|
fill: #d0342c;
|
|
stroke: #7c1a14;
|
|
stroke-width: 1.2;
|
|
}
|
|
.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; }
|
|
.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;
|
|
}
|
|
.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, .warp-dest { animation: 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>
|