Files
wizwar6e/packages/web/src/Board.svelte
T
Eric WagonerandClaude Fable 5 87a5067c77 Make wraparound warps usable: click the arrow to step through
The engine's warps worked all along (verified in all six directions),
but the client offered no way to trigger one short of clicking the
destination cell across the map. The warp arrows are now buttons: when
you stand on an opening its arrow lights green — click it to step
through. Clicking a far arrow behaves as a click on its cell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:28:51 -04:00

355 lines
13 KiB
Svelte

<script lang="ts">
import type { GameView } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
const CELL = 48;
const WALL = 7;
let {
view,
edgeSelectMode = false,
selectedCreatureId = null,
onCellClick,
onEdgeClick,
onPlayerClick,
onCreatureClick,
onWarpClick,
}: {
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;
} = $props();
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
function playerColor(id: string): string {
const idx = view.players.findIndex((p) => p.id === id);
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
}
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;
});
// 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={() => onCellClick?.(c)}
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 as t (t.id)}
{#if t.position}
{@const tx = t.position.x * CELL + CELL / 2}
{@const ty = t.position.y * CELL + CELL * 0.72}
<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}
{/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 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)}
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
class="dimwarp" />
{/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.26} cy={tc.y * CELL + CELL * 0.26} 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)}
<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>
{/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}
<!-- 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)}
<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>
</g>
{/each}
<!-- 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(); onPlayerClick?.(p.id); }}
onkeydown={() => {}}
class="wizard"
>
<circle {cx} {cy} r={12} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
{#if p.carriedTreasureId}
<circle cx={cx + 9} cy={cy + 9} r={5} class="carried" />
{/if}
</g>
{/each}
{/each}
<!-- creatures -->
{#each view.creatures as c (c.id)}
{@const ccx = c.position.x * CELL + CELL * 0.72}
{@const ccy = c.position.y * CELL + CELL * 0.7}
<g
role="button" tabindex="-1" class="creature"
onclick={(ev) => { ev.stopPropagation(); onCreatureClick?.(c.id); }}
onkeydown={() => {}}
>
<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>
</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}
</svg>
<style>
.board {
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; }
.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; }
.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; }
.edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; }
.edge-hit:hover { fill: rgba(30, 120, 240, 0.5); }
</style>