Wire online multiplayer: game rooms, protocol, playable Svelte client
Server: room registry with 4-letter codes, host/join/start flow, the authoritative command loop (seed + append-only command log per room — the replay/async foundation), and per-player redacted views and events broadcast after every change. Client: lobby, SVG board (floors, walls, doors, homes, color-keyed treasures and wizard tokens matching the physical set's six colors, warp arrows), click-to-move, click-to-punch, card hand with tooltips from verified card text, cast flow with number card attachment and waterbolt split, edge-click targeting for wall spells, counteract-or-pass prompt, discard flow, end-turn draw selector, and a humanized event log. Verified end-to-end over real websockets with two clients: join, start, private deals, moves, turn sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7c607ad5c7
commit
36b3ffe9a6
@@ -0,0 +1,192 @@
|
||||
<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,
|
||||
onCellClick,
|
||||
onEdgeClick,
|
||||
onPlayerClick,
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
onCellClick?: (cell: { x: number; y: number }) => void;
|
||||
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||
onPlayerClick?: (playerId: string) => 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)}
|
||||
<text
|
||||
x={p.home.x * CELL + CELL / 2} y={p.home.y * CELL + CELL / 2 + 2}
|
||||
class="home" fill={playerColor(p.id)}
|
||||
>✦</text>
|
||||
{/each}
|
||||
{#each view.treasures as t (t.id)}
|
||||
{#if t.position}
|
||||
<circle
|
||||
cx={t.position.x * CELL + CELL / 2} cy={t.position.y * CELL + CELL * 0.72}
|
||||
r={7} class="treasure" fill={playerColor(t.owner)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- walls & doors -->
|
||||
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
|
||||
{#if e.kind === "V"}
|
||||
<rect
|
||||
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
|
||||
width={WALL} height={CELL + WALL}
|
||||
class={e.state === "door" ? "door" : "wall"}
|
||||
/>
|
||||
{:else}
|
||||
<rect
|
||||
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
||||
width={CELL + WALL} height={WALL}
|
||||
class={e.state === "door" ? "door" : "wall"}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- warp openings -->
|
||||
{#each view.board.warps as w, i (i)}
|
||||
<text
|
||||
x={w.from.cell.x * CELL + CELL / 2 +
|
||||
(w.from.side === "E" ? CELL * 0.42 : w.from.side === "W" ? -CELL * 0.42 : 0)}
|
||||
y={w.from.cell.y * CELL + CELL / 2 + 3 +
|
||||
(w.from.side === "S" ? CELL * 0.42 : w.from.side === "N" ? -CELL * 0.42 : 0)}
|
||||
class="warp"
|
||||
>{w.from.side === "N" ? "↑" : w.from.side === "S" ? "↓" : w.from.side === "E" ? "→" : "←"}</text>
|
||||
{/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="#111" stroke-width="1.5" />
|
||||
<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}
|
||||
|
||||
<!-- 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: 640px;
|
||||
background: #d8d2c4;
|
||||
border: 3px solid #4a4438;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.floor {
|
||||
fill: #e8e2d4;
|
||||
stroke: #b8b0a0;
|
||||
stroke-width: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.floor:hover { fill: #f2ecda; }
|
||||
.wall { fill: #4a4438; }
|
||||
.door { fill: #8b5a2b; }
|
||||
.home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; }
|
||||
.treasure { stroke: #111; stroke-width: 1.2; }
|
||||
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }
|
||||
.wizard { cursor: pointer; }
|
||||
.wizard-label {
|
||||
font-size: 13px; font-weight: bold; fill: white;
|
||||
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>
|
||||
Reference in New Issue
Block a user