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,124 @@
|
||||
// Websocket client + reactive session state (Svelte 5 runes).
|
||||
|
||||
import type { Command, GameEvent, GameView } from "@wizwar/engine";
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
|
||||
const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787";
|
||||
|
||||
function humanize(e: GameEvent): string | null {
|
||||
switch (e.type) {
|
||||
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
|
||||
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
|
||||
case "turnSkipped": return `${e.player} loses a turn.`;
|
||||
case "extraTurnStarted": return `${e.player} takes an extra turn!`;
|
||||
case "moved": return null; // too chatty for the log
|
||||
case "numberPlayedForMovement": return `${e.player} plays a ${e.value} for movement (${e.newAllowance} total).`;
|
||||
case "punched": return `${e.attacker} punches ${e.target}!`;
|
||||
case "spellCast": {
|
||||
const num = e.numberValue ? ` with a ${e.numberValue}` : "";
|
||||
const at = e.target ? ` at ${e.target}` : "";
|
||||
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${at}.`;
|
||||
}
|
||||
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
|
||||
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
|
||||
case "attackAbsorbedIntoHand": return `${e.player} absorbs the spell into their hand!`;
|
||||
case "attackResolved":
|
||||
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
|
||||
if (e.fullyStopped) return `The attack is completely stopped.`;
|
||||
return null; // the damaged event tells the story
|
||||
case "damaged": return `${e.player} takes ${e.amount} damage (${e.source}) — ${e.lifeAfter} life left.`;
|
||||
case "stunned": return `${e.player} is stunned and loses a turn!`;
|
||||
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
|
||||
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
|
||||
case "wallCreated": return `A wall appears!`;
|
||||
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
|
||||
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
||||
case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`;
|
||||
case "trapRedrawnDuringDeal": return null;
|
||||
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
|
||||
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
|
||||
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
|
||||
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
|
||||
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
|
||||
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`;
|
||||
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
|
||||
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
|
||||
case "turnEnded": return null;
|
||||
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "— last wizard standing!"}`;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
class Net {
|
||||
status = $state<"disconnected" | "connected">("disconnected");
|
||||
roomId = $state<string | null>(null);
|
||||
players = $state<string[]>([]);
|
||||
hostId = $state<string | null>(null);
|
||||
started = $state(false);
|
||||
you = $state<string | null>(null);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
|
||||
connect(): void {
|
||||
if (this.ws) return;
|
||||
const ws = new WebSocket(SERVER_URL);
|
||||
this.ws = ws;
|
||||
ws.onopen = () => (this.status = "connected");
|
||||
ws.onclose = () => {
|
||||
this.status = "disconnected";
|
||||
this.ws = null;
|
||||
setTimeout(() => this.connect(), 1500);
|
||||
};
|
||||
ws.onmessage = (raw) => {
|
||||
const msg = JSON.parse(raw.data as string);
|
||||
switch (msg.type) {
|
||||
case "room":
|
||||
this.roomId = msg.roomId;
|
||||
this.players = msg.players;
|
||||
this.hostId = msg.hostId;
|
||||
this.started = msg.started;
|
||||
break;
|
||||
case "state":
|
||||
this.view = msg.view;
|
||||
break;
|
||||
case "events":
|
||||
for (const e of msg.events as GameEvent[]) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
this.error = msg.message;
|
||||
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private send(message: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
create(name: string): void {
|
||||
this.you = name;
|
||||
this.send({ type: "create", name });
|
||||
}
|
||||
|
||||
join(roomId: string, name: string): void {
|
||||
this.you = name;
|
||||
this.send({ type: "join", roomId, name });
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.send({ type: "start" });
|
||||
}
|
||||
|
||||
command(command: Command): void {
|
||||
this.send({ type: "command", command });
|
||||
}
|
||||
}
|
||||
|
||||
export const net = new Net();
|
||||
Reference in New Issue
Block a user