Phase 3: hotseat play — the whole game in one browser
Hotseat runs the engine entirely client-side; no server is involved. The lobby takes comma-separated names (2-6 wizards), and the game plays through the same table UI, with one addition: a full-screen hand-off card between actors — "pass the device to Morgana, tap when only they can see the screen" — shown whenever the needed input moves to another wizard (turns, counteractions, forced discards, interrupts). Each player sees only their own hand while seated. The game saves itself to localStorage after every command (config + command log, replayed on resume — the server's own determinism trick), so "set the game aside" keeps it and "abandon game" forgets it; a Resume Hotseat button appears whenever a save exists. Fixed en route: the engine's structuredClone cannot digest Svelte's reactive proxies, so hotseat snapshots state before every engine call. Verified live: 3-player game started, reloaded, resumed, turn ended, device handed to the next wizard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
85b40be113
commit
9323399b60
@@ -0,0 +1,168 @@
|
||||
// Hotseat: the whole game runs in this browser — the engine is pure
|
||||
// TypeScript, so no server is involved at all. The device is passed between
|
||||
// players; a hand-off screen keeps hands private between seats. The game
|
||||
// saves itself after every command (config + command log, replayed on
|
||||
// resume — the same determinism trick the server uses).
|
||||
|
||||
import {
|
||||
applyCommand,
|
||||
createGame,
|
||||
viewFor,
|
||||
type Command,
|
||||
type GameConfig,
|
||||
type GameState,
|
||||
type GameView,
|
||||
type PlayerId,
|
||||
} from "@wizwar/engine";
|
||||
import { humanize } from "./net.svelte";
|
||||
|
||||
const SAVE_KEY = "wizwar-hotseat";
|
||||
|
||||
interface SavedHotseat {
|
||||
config: GameConfig;
|
||||
commands: { playerId: PlayerId; command: Command }[];
|
||||
}
|
||||
|
||||
/** Whose input does the game need right now? */
|
||||
function actorId(state: GameState): PlayerId {
|
||||
return (
|
||||
state.pendingDiscard ??
|
||||
state.outOfTurnWindow?.playerId ??
|
||||
state.stack?.waitingOn ??
|
||||
state.players[state.turn.activeIndex]!.id
|
||||
);
|
||||
}
|
||||
|
||||
class LocalGame {
|
||||
active = $state(false);
|
||||
gameState = $state<GameState | null>(null);
|
||||
viewerId = $state<PlayerId | null>(null);
|
||||
/** Set while the device should be handed to the named player. */
|
||||
handoffTo = $state<PlayerId | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
view = $derived(
|
||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||
);
|
||||
|
||||
private config: GameConfig | null = null;
|
||||
private commands: { playerId: PlayerId; command: Command }[] = [];
|
||||
|
||||
hasSave(): boolean {
|
||||
return localStorage.getItem(SAVE_KEY) !== null;
|
||||
}
|
||||
|
||||
start(names: PlayerId[], expansion: boolean): string | null {
|
||||
const cleaned = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
|
||||
if (cleaned.length < 2 || cleaned.length > 6) return "two to six wizards, each with a name";
|
||||
const seed = crypto.getRandomValues(new Uint32Array(1))[0]!;
|
||||
const config: GameConfig = {
|
||||
playerIds: cleaned,
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
this.config = config;
|
||||
this.commands = [];
|
||||
this.gameState = state;
|
||||
this.log = [];
|
||||
for (const e of events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
}
|
||||
this.active = true;
|
||||
this.viewerId = null;
|
||||
this.handoffTo = actorId(state);
|
||||
this.persist();
|
||||
return null;
|
||||
}
|
||||
|
||||
resume(): boolean {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const saved = JSON.parse(raw) as SavedHotseat;
|
||||
const { state, events } = createGame(saved.config);
|
||||
let current = state;
|
||||
this.log = [];
|
||||
for (const e of events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
}
|
||||
for (const c of saved.commands) {
|
||||
const result = applyCommand(current, c.playerId, c.command);
|
||||
if (!result.ok) throw new Error(`replay failed: ${result.error}`);
|
||||
current = result.state;
|
||||
for (const e of result.events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
}
|
||||
}
|
||||
this.config = saved.config;
|
||||
this.commands = saved.commands;
|
||||
this.gameState = current;
|
||||
this.active = true;
|
||||
this.viewerId = null;
|
||||
this.handoffTo = actorId(current);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("hotseat resume failed:", e);
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The named player takes the device: their hand becomes visible. */
|
||||
takeSeat(): void {
|
||||
if (!this.handoffTo) return;
|
||||
this.viewerId = this.handoffTo;
|
||||
this.handoffTo = null;
|
||||
}
|
||||
|
||||
command(command: Command): void {
|
||||
if (!this.gameState || !this.viewerId) return;
|
||||
// The engine deep-clones with structuredClone, which cannot handle
|
||||
// Svelte's reactive proxies — hand it a plain snapshot.
|
||||
const plain = $state.snapshot(this.gameState) as GameState;
|
||||
const result = applyCommand(plain, this.viewerId, command);
|
||||
if (!result.ok) {
|
||||
this.log = [...this.log, `— ${result.error} —`];
|
||||
return;
|
||||
}
|
||||
this.gameState = result.state;
|
||||
this.commands = [...this.commands, { playerId: this.viewerId, command }];
|
||||
for (const e of result.events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
}
|
||||
this.persist();
|
||||
if (this.gameState.phase === "playing") {
|
||||
const next = actorId(this.gameState);
|
||||
if (next !== this.viewerId) this.handoffTo = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** End the hotseat session (the save survives unless the game is over). */
|
||||
leave(): void {
|
||||
if (this.gameState?.phase === "finished") localStorage.removeItem(SAVE_KEY);
|
||||
this.active = false;
|
||||
this.gameState = null;
|
||||
this.viewerId = null;
|
||||
this.handoffTo = null;
|
||||
this.log = [];
|
||||
}
|
||||
|
||||
abandon(): void {
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
this.leave();
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (!this.config) return;
|
||||
localStorage.setItem(
|
||||
SAVE_KEY,
|
||||
JSON.stringify({ config: this.config, commands: this.commands } satisfies SavedHotseat),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const local = new LocalGame();
|
||||
Reference in New Issue
Block a user