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
@@ -8,3 +8,4 @@ export * from "./board";
|
|||||||
export * from "./cards";
|
export * from "./cards";
|
||||||
export * from "./setups";
|
export * from "./setups";
|
||||||
export * from "./game";
|
export * from "./game";
|
||||||
|
export * from "./view";
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Per-player projection of GameState: everything public, plus YOUR hand.
|
||||||
|
// The server sends this after every state change; clients never see the
|
||||||
|
// deck order or other players' hands.
|
||||||
|
|
||||||
|
import { type AssembledBoard } from "./board";
|
||||||
|
import { type CardInstance } from "./cards";
|
||||||
|
import {
|
||||||
|
boardView,
|
||||||
|
type CastStack,
|
||||||
|
type GameState,
|
||||||
|
type PlayerId,
|
||||||
|
type TreasureState,
|
||||||
|
type TurnState,
|
||||||
|
} from "./game";
|
||||||
|
|
||||||
|
export interface PlayerPublicView {
|
||||||
|
id: PlayerId;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
home: { x: number; y: number };
|
||||||
|
life: number;
|
||||||
|
alive: boolean;
|
||||||
|
handCount: number;
|
||||||
|
carriedTreasureId: string | null;
|
||||||
|
lostTurns: number;
|
||||||
|
extraTurns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameView {
|
||||||
|
you: PlayerId;
|
||||||
|
phase: GameState["phase"];
|
||||||
|
winner: PlayerId | null;
|
||||||
|
turn: TurnState;
|
||||||
|
activePlayerId: PlayerId;
|
||||||
|
/** Board with dynamic wall changes already merged in. */
|
||||||
|
board: AssembledBoard;
|
||||||
|
players: PlayerPublicView[];
|
||||||
|
yourHand: CardInstance[];
|
||||||
|
treasures: TreasureState[];
|
||||||
|
deckCount: number;
|
||||||
|
discardCount: number;
|
||||||
|
/** Cards on the stack are face-up: the whole exchange is public. */
|
||||||
|
stack: CastStack | null;
|
||||||
|
pendingDiscard: PlayerId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||||
|
const you = state.players.find((p) => p.id === playerId);
|
||||||
|
return {
|
||||||
|
you: playerId,
|
||||||
|
phase: state.phase,
|
||||||
|
winner: state.winner,
|
||||||
|
turn: state.turn,
|
||||||
|
activePlayerId: state.players[state.turn.activeIndex]!.id,
|
||||||
|
board: boardView(state),
|
||||||
|
players: state.players.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
position: p.position,
|
||||||
|
home: p.home,
|
||||||
|
life: p.life,
|
||||||
|
alive: p.alive,
|
||||||
|
handCount: p.hand.length,
|
||||||
|
carriedTreasureId: p.carriedTreasureId,
|
||||||
|
lostTurns: p.lostTurns,
|
||||||
|
extraTurns: p.extraTurns,
|
||||||
|
})),
|
||||||
|
yourHand: you ? [...you.hand] : [],
|
||||||
|
treasures: state.treasures.map((t) => ({ ...t })),
|
||||||
|
deckCount: state.deck.length,
|
||||||
|
discardCount: state.discard.length,
|
||||||
|
stack: state.stack,
|
||||||
|
pendingDiscard: state.pendingDiscard,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,133 @@
|
|||||||
import { WebSocketServer } from "ws";
|
// Websocket front door. Protocol (JSON messages):
|
||||||
|
// client -> server:
|
||||||
|
// {type:"create", name} create a room, become host
|
||||||
|
// {type:"join", roomId, name} join (or rejoin) a room
|
||||||
|
// {type:"start"} host starts the game
|
||||||
|
// {type:"command", command} a game Command for the engine
|
||||||
|
// server -> client:
|
||||||
|
// {type:"welcome"} on connect
|
||||||
|
// {type:"room", roomId, players, hostId, started}
|
||||||
|
// {type:"events", events} redacted for this recipient
|
||||||
|
// {type:"state", view} redacted full view (after every change)
|
||||||
|
// {type:"error", message}
|
||||||
|
|
||||||
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import type { Command, PlayerId } from "@wizwar/engine";
|
||||||
|
import {
|
||||||
|
createRoom,
|
||||||
|
getRoom,
|
||||||
|
joinRoom,
|
||||||
|
redactFor,
|
||||||
|
runCommand,
|
||||||
|
startGame,
|
||||||
|
viewForPlayer,
|
||||||
|
type Room,
|
||||||
|
} from "./rooms";
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 8787);
|
const port = Number(process.env.PORT ?? 8787);
|
||||||
const wss = new WebSocketServer({ port });
|
const wss = new WebSocketServer({ port });
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
socket: WebSocket;
|
||||||
|
playerId: PlayerId | null;
|
||||||
|
roomId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Set<Session>();
|
||||||
|
|
||||||
|
function send(socket: WebSocket, message: unknown): void {
|
||||||
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomInfo(room: Room) {
|
||||||
|
return {
|
||||||
|
type: "room",
|
||||||
|
roomId: room.id,
|
||||||
|
players: room.players,
|
||||||
|
hostId: room.hostId,
|
||||||
|
started: room.state !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
|
||||||
|
for (const s of sessions) {
|
||||||
|
if (s.roomId === room.id && s.playerId) {
|
||||||
|
send(s.socket, makeMessage(s.playerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastRoomState(room: Room): void {
|
||||||
|
broadcast(room, (playerId) => roomInfo(room));
|
||||||
|
if (room.state) {
|
||||||
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wss.on("connection", (socket) => {
|
wss.on("connection", (socket) => {
|
||||||
socket.send(JSON.stringify({ type: "hello", game: "wizwar" }));
|
const session: Session = { socket, playerId: null, roomId: null };
|
||||||
|
sessions.add(session);
|
||||||
|
send(socket, { type: "welcome", game: "wizwar" });
|
||||||
|
|
||||||
|
socket.on("close", () => sessions.delete(session));
|
||||||
|
|
||||||
socket.on("message", (data) => {
|
socket.on("message", (data) => {
|
||||||
// TODO: route client commands into game rooms once the engine exists.
|
let msg: Record<string, unknown>;
|
||||||
console.log("received:", data.toString());
|
try {
|
||||||
|
msg = JSON.parse(data.toString());
|
||||||
|
} catch {
|
||||||
|
return send(socket, { type: "error", message: "invalid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "create": {
|
||||||
|
const name = String(msg.name ?? "").trim();
|
||||||
|
if (!name) return send(socket, { type: "error", message: "name required" });
|
||||||
|
const room = createRoom(name);
|
||||||
|
session.playerId = name;
|
||||||
|
session.roomId = room.id;
|
||||||
|
broadcastRoomState(room);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "join": {
|
||||||
|
const name = String(msg.name ?? "").trim();
|
||||||
|
const roomId = String(msg.roomId ?? "").trim();
|
||||||
|
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||||
|
const problem = joinRoom(room, name);
|
||||||
|
if (problem) return send(socket, { type: "error", message: problem });
|
||||||
|
session.playerId = name;
|
||||||
|
session.roomId = room.id;
|
||||||
|
broadcastRoomState(room);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "start": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" });
|
||||||
|
const result = startGame(room);
|
||||||
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||||
|
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
||||||
|
broadcastRoomState(room);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "command": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
const result = runCommand(room, session.playerId, msg.command as Command);
|
||||||
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||||
|
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
||||||
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
send(socket, { type: "error", message: e instanceof Error ? e.message : "internal error" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Game rooms: the server is the authority. Each room holds one GameState and
|
||||||
|
// the append-only command log (the seed + log IS the game — the basis for
|
||||||
|
// replays and async play). Clients get per-player redacted views and events.
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyCommand,
|
||||||
|
createGame,
|
||||||
|
redactEvent,
|
||||||
|
viewFor,
|
||||||
|
type Command,
|
||||||
|
type GameEvent,
|
||||||
|
type GameState,
|
||||||
|
type GameView,
|
||||||
|
type PlayerId,
|
||||||
|
} from "@wizwar/engine";
|
||||||
|
|
||||||
|
export interface LoggedCommand {
|
||||||
|
seq: number;
|
||||||
|
playerId: PlayerId;
|
||||||
|
command: Command;
|
||||||
|
at: string; // ISO timestamp (server-side wall clock; not used by the engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
hostId: PlayerId;
|
||||||
|
players: PlayerId[]; // join order
|
||||||
|
seed: number;
|
||||||
|
state: GameState | null; // null until started
|
||||||
|
log: LoggedCommand[];
|
||||||
|
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rooms = new Map<string, Room>();
|
||||||
|
|
||||||
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
function makeRoomCode(): string {
|
||||||
|
let code = "";
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return rooms.has(code) ? makeRoomCode() : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoom(hostId: PlayerId): Room {
|
||||||
|
const room: Room = {
|
||||||
|
id: makeRoomCode(),
|
||||||
|
hostId,
|
||||||
|
players: [hostId],
|
||||||
|
seed: Math.floor(Math.random() * 0xffffffff),
|
||||||
|
state: null,
|
||||||
|
log: [],
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
rooms.set(room.id, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoom(id: string): Room | undefined {
|
||||||
|
return rooms.get(id.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinRoom(room: Room, playerId: PlayerId): string | null {
|
||||||
|
if (room.state) return "game already started";
|
||||||
|
if (room.players.includes(playerId)) return null; // rejoin is fine
|
||||||
|
if (room.players.length >= 4) return "room is full";
|
||||||
|
room.players.push(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGame(room: Room): { events: GameEvent[] } | { error: string } {
|
||||||
|
if (room.state) return { error: "already started" };
|
||||||
|
const n = room.players.length;
|
||||||
|
if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" };
|
||||||
|
const { state, events } = createGame({
|
||||||
|
playerIds: room.players,
|
||||||
|
seed: room.seed,
|
||||||
|
sets: ["basic"],
|
||||||
|
});
|
||||||
|
room.state = state;
|
||||||
|
room.events.push(...events);
|
||||||
|
return { events };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCommand(
|
||||||
|
room: Room,
|
||||||
|
playerId: PlayerId,
|
||||||
|
command: Command,
|
||||||
|
): { events: GameEvent[] } | { error: string } {
|
||||||
|
if (!room.state) return { error: "game not started" };
|
||||||
|
const result = applyCommand(room.state, playerId, command);
|
||||||
|
if (!result.ok) return { error: result.error };
|
||||||
|
room.state = result.state;
|
||||||
|
room.log.push({
|
||||||
|
seq: room.log.length,
|
||||||
|
playerId,
|
||||||
|
command,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
room.events.push(...result.events);
|
||||||
|
return { events: result.events };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
|
||||||
|
return room.state ? viewFor(room.state, playerId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
|
||||||
|
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
|
||||||
|
}
|
||||||
+352
-7
@@ -1,19 +1,364 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let status = $state("disconnected");
|
import { net } from "./net.svelte";
|
||||||
|
import Board from "./Board.svelte";
|
||||||
|
import { cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
|
||||||
|
import type { CardInstance, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
const ws = new WebSocket("ws://localhost:8787");
|
net.connect();
|
||||||
ws.onopen = () => (status = "connected");
|
|
||||||
ws.onclose = () => (status = "disconnected");
|
let name = $state("");
|
||||||
|
let joinCode = $state("");
|
||||||
|
let drawCount = $state(2);
|
||||||
|
|
||||||
|
/** Card selected in hand, pending a target. */
|
||||||
|
let selectedCard = $state<CardInstance | null>(null);
|
||||||
|
/** Number card attached to the pending cast. */
|
||||||
|
let attachedNumber = $state<CardInstance | null>(null);
|
||||||
|
/** Waterbolt split. */
|
||||||
|
let wbDamage = $state(0);
|
||||||
|
/** Cards marked for discard. */
|
||||||
|
let discardSelection = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const view = $derived(net.view);
|
||||||
|
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
|
||||||
|
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
|
||||||
|
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||||||
|
|
||||||
|
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
|
||||||
|
const edgeSelectMode = $derived(
|
||||||
|
selectedCard?.cardId === "create-wall" || selectedCard?.cardId === "destroy-wall",
|
||||||
|
);
|
||||||
|
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedCard = null;
|
||||||
|
attachedNumber = null;
|
||||||
|
wbDamage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCard(card: CardInstance) {
|
||||||
|
if (!view) return;
|
||||||
|
if (youMustRespond) {
|
||||||
|
net.command({ type: "counteract", instanceId: card.instanceId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (youMustDiscard || discardSelection.size > 0) {
|
||||||
|
const next = new Set(discardSelection);
|
||||||
|
next.has(card.instanceId) ? next.delete(card.instanceId) : next.add(card.instanceId);
|
||||||
|
discardSelection = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isYourTurn) return;
|
||||||
|
if (selectedCard && isNumberCard(card.cardId) && !isNumberCard(selectedCard.cardId)) {
|
||||||
|
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
|
||||||
|
wbDamage = numberTotal;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedCard?.instanceId === card.instanceId) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedCard = card;
|
||||||
|
attachedNumber = null;
|
||||||
|
if (isNumberCard(card.cardId)) {
|
||||||
|
// A bare number card: play it for movement.
|
||||||
|
net.command({ type: "playNumberForMovement", instanceId: card.instanceId });
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Self-targeting / untargeted spells cast immediately.
|
||||||
|
if (card.cardId === "speed") {
|
||||||
|
net.command({ type: "cast", instanceId: card.instanceId });
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickCell(cell: { x: number; y: number }) {
|
||||||
|
if (!view || !isYourTurn) return;
|
||||||
|
const me = view.players.find((p) => p.id === view.you)!;
|
||||||
|
// A cell click is a move if the cell is one legal step away.
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const t = stepTarget(view.board, me.position, side);
|
||||||
|
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
|
||||||
|
net.command({ type: "move", direction: side });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickEdge(cell: { x: number; y: number }, side: Side) {
|
||||||
|
if (!selectedCard || !edgeSelectMode) return;
|
||||||
|
net.command({
|
||||||
|
type: "cast",
|
||||||
|
instanceId: selectedCard.instanceId,
|
||||||
|
target: { kind: "edge", cell, side },
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickPlayer(playerId: string) {
|
||||||
|
if (!view || !isYourTurn) return;
|
||||||
|
if (!selectedCard) {
|
||||||
|
// No card selected: same-square click = punch.
|
||||||
|
const me = view.players.find((p) => p.id === view.you)!;
|
||||||
|
const them = view.players.find((p) => p.id === playerId)!;
|
||||||
|
if (playerId !== view.you && cellKey(me.position) === cellKey(them.position)) {
|
||||||
|
net.command({ type: "punch", targetId: playerId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cmd: Parameters<typeof net.command>[0] = {
|
||||||
|
type: "cast",
|
||||||
|
instanceId: selectedCard.instanceId,
|
||||||
|
target: { kind: "player", playerId },
|
||||||
|
};
|
||||||
|
if (attachedNumber) cmd.numberInstanceId = attachedNumber.instanceId;
|
||||||
|
if (selectedCard.cardId === "waterbolt") {
|
||||||
|
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
||||||
|
}
|
||||||
|
net.command(cmd);
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDiscard() {
|
||||||
|
net.command({ type: "discard", instanceIds: [...discardSelection] });
|
||||||
|
discardSelection = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endTurn() {
|
||||||
|
clearSelection();
|
||||||
|
net.command({ type: "endTurn", draw: drawCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickUp() { net.command({ type: "pickUpTreasure" }); }
|
||||||
|
function drop() { net.command({ type: "dropTreasure" }); }
|
||||||
|
function pass() { net.command({ type: "pass" }); }
|
||||||
|
|
||||||
|
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
|
||||||
|
function playerColor(id: string): string {
|
||||||
|
const idx = view?.players.findIndex((p) => p.id === id) ?? 0;
|
||||||
|
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Wiz-War</h1>
|
<h1>Wiz-War <span class="subtitle">6th edition</span></h1>
|
||||||
<p>server: {status}</p>
|
|
||||||
|
{#if net.error}
|
||||||
|
<div class="toast">{net.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !net.roomId}
|
||||||
|
<section class="lobby">
|
||||||
|
<p class="status">server: {net.status}</p>
|
||||||
|
<input placeholder="your wizard's name" bind:value={name} maxlength="20" />
|
||||||
|
<div class="lobby-actions">
|
||||||
|
<button disabled={!name.trim()} onclick={() => net.create(name)}>Create game</button>
|
||||||
|
<span>or</span>
|
||||||
|
<input placeholder="room code" bind:value={joinCode} maxlength="4" class="code" />
|
||||||
|
<button disabled={!name.trim() || !joinCode.trim()} onclick={() => net.join(joinCode, name)}>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if !net.started}
|
||||||
|
<section class="lobby">
|
||||||
|
<h2>Room <code>{net.roomId}</code></h2>
|
||||||
|
<p>Share the code with your opponents.</p>
|
||||||
|
<ul>
|
||||||
|
{#each net.players as p (p)}
|
||||||
|
<li>{p}{p === net.hostId ? " (host)" : ""}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if net.you === net.hostId}
|
||||||
|
<button
|
||||||
|
disabled={net.players.length !== 2 && net.players.length !== 4}
|
||||||
|
onclick={() => net.start()}
|
||||||
|
>
|
||||||
|
Start game ({net.players.length} wizards — need 2 or 4)
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for {net.hostId} to start…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if view}
|
||||||
|
<div class="game">
|
||||||
|
<div class="board-pane">
|
||||||
|
<Board
|
||||||
|
{view}
|
||||||
|
edgeSelectMode={edgeSelectMode && isYourTurn}
|
||||||
|
onCellClick={clickCell}
|
||||||
|
onEdgeClick={clickEdge}
|
||||||
|
onPlayerClick={clickPlayer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-pane">
|
||||||
|
{#if view.phase === "finished"}
|
||||||
|
<div class="banner winner">🏆 {view.winner} wins!</div>
|
||||||
|
{:else if youMustRespond}
|
||||||
|
<div class="banner respond">
|
||||||
|
{#if view.stack?.defenderId === view.you}
|
||||||
|
<strong>{view.stack.attackerId}</strong> attacks you
|
||||||
|
{#if view.stack.attackCard}with <strong>{cardDef(view.stack.attackCard.cardId).name}</strong>{:else}with a punch{/if}!
|
||||||
|
Click a counteraction card, or
|
||||||
|
{:else}
|
||||||
|
Respond to the counteraction, or
|
||||||
|
{/if}
|
||||||
|
<button onclick={pass}>let it resolve</button>
|
||||||
|
</div>
|
||||||
|
{:else if view.stack}
|
||||||
|
<div class="banner">Waiting for {view.stack.waitingOn}…</div>
|
||||||
|
{:else if isYourTurn}
|
||||||
|
<div class="banner your-turn">
|
||||||
|
Your turn — round {view.turn.round}.
|
||||||
|
Moves: {view.turn.movementAllowance - view.turn.movementUsed}
|
||||||
|
{view.turn.attackUsed ? "· attack used" : ""}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="banner">{view.activePlayerId} is taking their turn…</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="players">
|
||||||
|
{#each view.players as p (p.id)}
|
||||||
|
<div class="player" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
|
||||||
|
<span class="dot" style:background={playerColor(p.id)}></span>
|
||||||
|
<span class="pname">{p.id}{p.id === view.you ? " (you)" : ""}</span>
|
||||||
|
<span class="life">♥ {p.life}</span>
|
||||||
|
<span class="cards">🂠 {p.handCount}</span>
|
||||||
|
{#if p.lostTurns > 0}<span title="lost turns">💫{p.lostTurns}</span>{/if}
|
||||||
|
{#if p.carriedTreasureId}<span title="carrying treasure">💰</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="deck-info">deck {view.deckCount} · discard {view.discardCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedDef}
|
||||||
|
<div class="cast-hint">
|
||||||
|
<strong>{selectedDef.name}</strong>
|
||||||
|
{#if edgeSelectMode}
|
||||||
|
— click a wall line on the board
|
||||||
|
{:else if selectedDef.cardType === "attack"}
|
||||||
|
— click a target wizard{attachedNumber ? ` (powered by a ${numberTotal})` : " (click a number card to power it)"}
|
||||||
|
{/if}
|
||||||
|
{#if selectedCard?.cardId === "waterbolt"}
|
||||||
|
<label>damage <input type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
|
||||||
|
(knockback {numberTotal - wbDamage})
|
||||||
|
{/if}
|
||||||
|
<button class="link" onclick={clearSelection}>cancel</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="hand">
|
||||||
|
{#each view.yourHand as card (card.instanceId)}
|
||||||
|
{@const def = cardDef(card.cardId)}
|
||||||
|
<button
|
||||||
|
class="card"
|
||||||
|
class:selected={selectedCard?.instanceId === card.instanceId}
|
||||||
|
class:attached={attachedNumber?.instanceId === card.instanceId}
|
||||||
|
class:marked={discardSelection.has(card.instanceId)}
|
||||||
|
title={def.text ?? ""}
|
||||||
|
onclick={() => selectCard(card)}
|
||||||
|
>
|
||||||
|
<span class="card-type">{def.cardType}</span>
|
||||||
|
<span class="card-name">{def.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
{#if youMustDiscard}
|
||||||
|
<div class="banner respond">Hand over 7 — select cards and discard.</div>
|
||||||
|
{/if}
|
||||||
|
{#if discardSelection.size > 0}
|
||||||
|
<button onclick={doDiscard}>Discard {discardSelection.size} selected</button>
|
||||||
|
{/if}
|
||||||
|
{#if isYourTurn}
|
||||||
|
<button onclick={pickUp}>Pick up treasure</button>
|
||||||
|
<button onclick={drop}>Drop treasure</button>
|
||||||
|
<label>
|
||||||
|
draw
|
||||||
|
<select bind:value={drawCount}>
|
||||||
|
<option value={0}>0</option>
|
||||||
|
<option value={1}>1</option>
|
||||||
|
<option value={2}>2</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary" onclick={endTurn}>End turn</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log">
|
||||||
|
{#each net.log.slice(-40) as line, i (i)}
|
||||||
|
<div>{line}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
padding: 2rem;
|
padding: 1rem 1.5rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 0.75rem; }
|
||||||
|
.subtitle { font-size: 0.55em; color: #887; font-weight: normal; }
|
||||||
|
.toast {
|
||||||
|
background: #b33; color: white; padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.lobby { display: flex; flex-direction: column; gap: 0.75rem; max-width: 420px; }
|
||||||
|
.lobby input { padding: 0.5rem; font-size: 1rem; }
|
||||||
|
.lobby-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.code { width: 6ch; text-transform: uppercase; }
|
||||||
|
.status { color: #887; margin: 0; }
|
||||||
|
|
||||||
|
.game { display: grid; grid-template-columns: minmax(320px, 640px) minmax(280px, 1fr); gap: 1rem; }
|
||||||
|
@media (max-width: 800px) { .game { grid-template-columns: 1fr; } }
|
||||||
|
.side-pane { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
|
||||||
|
|
||||||
|
.banner { padding: 0.5rem 0.75rem; border-radius: 6px; background: #eee; }
|
||||||
|
.banner.your-turn { background: #d8efd8; }
|
||||||
|
.banner.respond { background: #f6dcb5; }
|
||||||
|
.banner.winner { background: gold; font-size: 1.2em; }
|
||||||
|
|
||||||
|
.players { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.player { display: flex; gap: 0.5rem; align-items: center; padding: 0.2rem 0.4rem; border-radius: 4px; }
|
||||||
|
.player.active { background: #eef4ff; }
|
||||||
|
.player.dead { opacity: 0.45; text-decoration: line-through; }
|
||||||
|
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
|
||||||
|
.pname { font-weight: 600; }
|
||||||
|
.deck-info { color: #887; font-size: 0.85em; }
|
||||||
|
|
||||||
|
.cast-hint { background: #eef; padding: 0.4rem 0.6rem; border-radius: 6px; }
|
||||||
|
.cast-hint input { width: 4ch; }
|
||||||
|
.link { background: none; border: none; color: #36c; cursor: pointer; text-decoration: underline; }
|
||||||
|
|
||||||
|
.hand { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.card {
|
||||||
|
display: flex; flex-direction: column; align-items: flex-start;
|
||||||
|
border: 1.5px solid #998; border-radius: 6px; background: #f6f2e6;
|
||||||
|
padding: 0.35rem 0.5rem; cursor: pointer; min-width: 7.5rem; text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: #333; }
|
||||||
|
.card.selected { border-color: #26c; background: #e6ecfc; }
|
||||||
|
.card.attached { border-color: #2a2; background: #e2f4e2; }
|
||||||
|
.card.marked { border-color: #c33; background: #fce6e6; }
|
||||||
|
.card-type { font-size: 0.65em; text-transform: uppercase; color: #776; }
|
||||||
|
.card-name { font-weight: 600; font-size: 0.9em; }
|
||||||
|
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||||
|
button { padding: 0.4rem 0.7rem; border-radius: 6px; border: 1px solid #998; background: #fff; cursor: pointer; }
|
||||||
|
button.primary { background: #26c; color: white; border-color: #26c; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.log {
|
||||||
|
background: #1e1c18; color: #cfc9ba; font-size: 0.8em;
|
||||||
|
border-radius: 6px; padding: 0.5rem 0.7rem; max-height: 240px;
|
||||||
|
overflow-y: auto; font-family: ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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