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
+107
-32
@@ -3,6 +3,7 @@
|
|||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
import Help from "./Help.svelte";
|
import Help from "./Help.svelte";
|
||||||
|
import { local } from "./local.svelte";
|
||||||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
|
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
|
||||||
import type { CardInstance, Side } from "@wizwar/engine";
|
import type { CardInstance, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
let joinCode = $state("");
|
let joinCode = $state("");
|
||||||
let claimPhrase = $state("");
|
let claimPhrase = $state("");
|
||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
|
let hotseatNames = $state("");
|
||||||
let drawCount = $state(2);
|
let drawCount = $state(2);
|
||||||
let withExpansion = $state(true);
|
let withExpansion = $state(true);
|
||||||
/** Your creature selected for movement/attacks. */
|
/** Your creature selected for movement/attacks. */
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */
|
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */
|
||||||
let discardMode = $state(false);
|
let discardMode = $state(false);
|
||||||
|
|
||||||
const view = $derived(net.view);
|
const view = $derived(local.active ? local.view : net.view);
|
||||||
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
|
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
|
||||||
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
|
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
|
||||||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||||||
@@ -75,6 +77,11 @@
|
|||||||
);
|
);
|
||||||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
||||||
|
|
||||||
|
function dispatch(command: Parameters<typeof net.command>[0]) {
|
||||||
|
if (local.active) local.command(command);
|
||||||
|
else net.command(command);
|
||||||
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
trapCells = [];
|
trapCells = [];
|
||||||
tradeFrom = null;
|
tradeFrom = null;
|
||||||
@@ -112,7 +119,7 @@
|
|||||||
function selectCard(card: CardInstance) {
|
function selectCard(card: CardInstance) {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
if (youMustRespond) {
|
if (youMustRespond) {
|
||||||
net.command({ type: "counteract", instanceId: card.instanceId });
|
dispatch({ type: "counteract", instanceId: card.instanceId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (youMustDiscard || discardMode || discardSelection.size > 0) {
|
if (youMustDiscard || discardMode || discardSelection.size > 0) {
|
||||||
@@ -163,7 +170,7 @@
|
|||||||
const add = view.yourHand.find((c) => c.cardId === "add");
|
const add = view.yourHand.find((c) => c.cardId === "add");
|
||||||
if (add) cmd.addInstanceId = add.instanceId;
|
if (add) cmd.addInstanceId = add.instanceId;
|
||||||
}
|
}
|
||||||
net.command(cmd);
|
dispatch(cmd);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,13 +187,13 @@
|
|||||||
if (!selectedCard) return;
|
if (!selectedCard) return;
|
||||||
const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
|
const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
|
||||||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||||
net.command(cmd);
|
dispatch(cmd);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function castPowerRun() {
|
function castPowerRun() {
|
||||||
if (!selectedCard) return;
|
if (!selectedCard) return;
|
||||||
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
|
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +201,7 @@
|
|||||||
if (!view || !isYourTurn) return;
|
if (!view || !isYourTurn) return;
|
||||||
if (pendingCellFor && selectedCard) {
|
if (pendingCellFor && selectedCard) {
|
||||||
// Stage 2 of teleport-opponent: destination chosen.
|
// Stage 2 of teleport-opponent: destination chosen.
|
||||||
net.command({
|
dispatch({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "player", playerId: pendingCellFor },
|
target: { kind: "player", playerId: pendingCellFor },
|
||||||
params: { cell },
|
params: { cell },
|
||||||
@@ -207,7 +214,7 @@
|
|||||||
pendingSectorFrom = cell; // first click: the sector to move
|
pendingSectorFrom = cell; // first click: the sector to move
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
net.command({
|
dispatch({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
|
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
|
||||||
});
|
});
|
||||||
@@ -217,14 +224,14 @@
|
|||||||
if (selectedCard?.cardId === "boobytrap") {
|
if (selectedCard?.cardId === "boobytrap") {
|
||||||
trapCells = [...trapCells, cell];
|
trapCells = [...trapCells, cell];
|
||||||
if (trapCells.length === 4) {
|
if (trapCells.length === 4) {
|
||||||
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
||||||
if (!tradeFrom) { tradeFrom = cell; return; }
|
if (!tradeFrom) { tradeFrom = cell; return; }
|
||||||
net.command({
|
dispatch({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
||||||
});
|
});
|
||||||
@@ -232,7 +239,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard?.cardId === "rotate-sector") {
|
if (selectedCard?.cardId === "rotate-sector") {
|
||||||
net.command({
|
dispatch({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
|
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
|
||||||
});
|
});
|
||||||
@@ -240,7 +247,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
||||||
net.command({
|
dispatch({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "cell", cell },
|
target: { kind: "cell", cell },
|
||||||
});
|
});
|
||||||
@@ -254,7 +261,7 @@
|
|||||||
const n = { x: creature.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
const n = { x: creature.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||||
y: creature.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
y: creature.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||||
if (cellKey(n) === cellKey(cell)) {
|
if (cellKey(n) === cellKey(cell)) {
|
||||||
net.command({ type: "moveCreature", creatureId: selectedCreature, direction: side });
|
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: side });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +275,7 @@
|
|||||||
for (const side of SIDES) {
|
for (const side of SIDES) {
|
||||||
const t = stepTarget(view.board, me.position, side);
|
const t = stepTarget(view.board, me.position, side);
|
||||||
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
|
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
|
||||||
net.command({ type: "move", direction: side });
|
dispatch({ type: "move", direction: side });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +285,7 @@
|
|||||||
const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||||
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||||
if (cellKey(n) === cellKey(cell)) {
|
if (cellKey(n) === cellKey(cell)) {
|
||||||
net.command({ type: "move", direction: side });
|
dispatch({ type: "move", direction: side });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +296,7 @@
|
|||||||
// Standing on the opening: step through. Otherwise treat it as a cell
|
// Standing on the opening: step through. Otherwise treat it as a cell
|
||||||
// click on the opening square (e.g. to walk toward it).
|
// click on the opening square (e.g. to walk toward it).
|
||||||
if (me.position.x === cell.x && me.position.y === cell.y) {
|
if (me.position.x === cell.x && me.position.y === cell.y) {
|
||||||
net.command({ type: "move", direction: side });
|
dispatch({ type: "move", direction: side });
|
||||||
} else {
|
} else {
|
||||||
clickCell(cell);
|
clickCell(cell);
|
||||||
}
|
}
|
||||||
@@ -297,7 +304,7 @@
|
|||||||
|
|
||||||
function clickEdge(cell: { x: number; y: number }, side: Side) {
|
function clickEdge(cell: { x: number; y: number }, side: Side) {
|
||||||
if (!selectedCard || !edgeSelectMode) return;
|
if (!selectedCard || !edgeSelectMode) return;
|
||||||
net.command({
|
dispatch({
|
||||||
type: "cast",
|
type: "cast",
|
||||||
instanceId: selectedCard.instanceId,
|
instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "edge", cell, side },
|
target: { kind: "edge", cell, side },
|
||||||
@@ -310,7 +317,7 @@
|
|||||||
const creature = view.creatures.find((c) => c.id === creatureId);
|
const creature = view.creatures.find((c) => c.id === creatureId);
|
||||||
if (!creature) return;
|
if (!creature) return;
|
||||||
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
||||||
net.command({
|
dispatch({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
target: { kind: "creature", creatureId },
|
target: { kind: "creature", creatureId },
|
||||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||||
@@ -320,7 +327,7 @@
|
|||||||
}
|
}
|
||||||
if (selectedCreature && selectedCreature !== creatureId) {
|
if (selectedCreature && selectedCreature !== creatureId) {
|
||||||
// Your selected creature attacks another creature in its square.
|
// Your selected creature attacks another creature in its square.
|
||||||
net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
|
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
|
||||||
selectedCreature = null;
|
selectedCreature = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -333,7 +340,7 @@
|
|||||||
function clickPlayer(playerId: string) {
|
function clickPlayer(playerId: string) {
|
||||||
if (!view || !isYourTurn) return;
|
if (!view || !isYourTurn) return;
|
||||||
if (selectedCreature) {
|
if (selectedCreature) {
|
||||||
net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
|
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
|
||||||
selectedCreature = null;
|
selectedCreature = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -342,7 +349,7 @@
|
|||||||
const me = view.players.find((p) => p.id === view.you)!;
|
const me = view.players.find((p) => p.id === view.you)!;
|
||||||
const them = view.players.find((p) => p.id === playerId)!;
|
const them = view.players.find((p) => p.id === playerId)!;
|
||||||
if (playerId !== view.you && cellKey(me.position) === cellKey(them.position)) {
|
if (playerId !== view.you && cellKey(me.position) === cellKey(them.position)) {
|
||||||
net.command({ type: "punch", targetId: playerId });
|
dispatch({ type: "punch", targetId: playerId });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -365,24 +372,24 @@
|
|||||||
if (!id) return; // needs a card name typed first
|
if (!id) return; // needs a card name typed first
|
||||||
cmd.params = { cardId: id };
|
cmd.params = { cardId: id };
|
||||||
}
|
}
|
||||||
net.command(cmd);
|
dispatch(cmd);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function doDiscard() {
|
function doDiscard() {
|
||||||
net.command({ type: "discard", instanceIds: [...discardSelection] });
|
dispatch({ type: "discard", instanceIds: [...discardSelection] });
|
||||||
discardSelection = new Set();
|
discardSelection = new Set();
|
||||||
discardMode = false;
|
discardMode = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function endTurn() {
|
function endTurn() {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
net.command({ type: "endTurn", draw: drawCount });
|
dispatch({ type: "endTurn", draw: drawCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickUp() { net.command({ type: "pickUpTreasure" }); }
|
function pickUp() { dispatch({ type: "pickUpTreasure" }); }
|
||||||
function drop() { net.command({ type: "dropTreasure" }); }
|
function drop() { dispatch({ type: "dropTreasure" }); }
|
||||||
function pass() { net.command({ type: "pass" }); }
|
function pass() { dispatch({ type: "pass" }); }
|
||||||
|
|
||||||
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
|
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
|
||||||
const carryingTreasure = $derived(me?.carriedTreasureId != null);
|
const carryingTreasure = $derived(me?.carriedTreasureId != null);
|
||||||
@@ -401,6 +408,7 @@
|
|||||||
let chronicleEl = $state<HTMLElement | null>(null);
|
let chronicleEl = $state<HTMLElement | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void net.log.length;
|
void net.log.length;
|
||||||
|
void local.log.length;
|
||||||
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,7 +458,11 @@
|
|||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<span class="mast-title">Wiz-War</span>
|
<span class="mast-title">Wiz-War</span>
|
||||||
<span class="mast-sub">sixth edition</span>
|
<span class="mast-sub">sixth edition</span>
|
||||||
{#if net.roomId}
|
{#if local.active}
|
||||||
|
<span class="mast-room">hotseat</span>
|
||||||
|
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
|
||||||
|
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
|
||||||
|
{:else if net.roomId}
|
||||||
<span class="mast-room">room <b>{net.roomId}</b></span>
|
<span class="mast-room">room <b>{net.roomId}</b></span>
|
||||||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||||||
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
|
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
|
||||||
@@ -479,7 +491,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !net.roomId}
|
{#if local.handoffTo}
|
||||||
|
<div class="scrim-handoff" role="button" tabindex="0"
|
||||||
|
onclick={() => local.takeSeat()} onkeydown={(e) => e.key === "Enter" && local.takeSeat()}>
|
||||||
|
<div class="handoff-card">
|
||||||
|
<div class="handoff-eyebrow">pass the device to</div>
|
||||||
|
<div class="handoff-name">{local.handoffTo}</div>
|
||||||
|
<div class="handoff-hint">tap when only they can see the screen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !net.roomId && !local.active}
|
||||||
<section class="boxlid">
|
<section class="boxlid">
|
||||||
<div class="boxlid-inner">
|
<div class="boxlid-inner">
|
||||||
<div class="boxlid-title">Wiz-War</div>
|
<div class="boxlid-title">Wiz-War</div>
|
||||||
@@ -499,6 +522,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hotseat-row">
|
||||||
|
<input class="claim-input wide" bind:value={hotseatNames}
|
||||||
|
placeholder="hotseat: names, comma-separated" aria-label="hotseat player names" />
|
||||||
|
<button class="stamp tiny" disabled={hotseatNames.split(",").filter((n) => n.trim()).length < 2}
|
||||||
|
onclick={() => {
|
||||||
|
const err = local.start(hotseatNames.split(","), withExpansion);
|
||||||
|
if (err) net.error = err;
|
||||||
|
}}>
|
||||||
|
Play here
|
||||||
|
</button>
|
||||||
|
{#if local.hasSave()}
|
||||||
|
<button class="stamp tiny" onclick={() => local.resume()}>Resume hotseat</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<label class="check hotseat-exp">
|
||||||
|
<input type="checkbox" bind:checked={withExpansion} /> with Expansion Set #1
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="claim-row">
|
<div class="claim-row">
|
||||||
<input class="claim-input" bind:value={claimPhrase}
|
<input class="claim-input" bind:value={claimPhrase}
|
||||||
placeholder="ember-troll-dagger" aria-label="seat transfer phrase" />
|
placeholder="ember-troll-dagger" aria-label="seat transfer phrase" />
|
||||||
@@ -545,7 +586,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if !net.started}
|
{:else if !local.active && !net.started}
|
||||||
<section class="boxlid">
|
<section class="boxlid">
|
||||||
<div class="boxlid-inner">
|
<div class="boxlid-inner">
|
||||||
<div class="boxlid-title small">Room {net.roomId}</div>
|
<div class="boxlid-title small">Room {net.roomId}</div>
|
||||||
@@ -630,7 +671,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
|
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
|
||||||
{#each net.log.slice(-60) as line, i (i)}
|
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
|
||||||
<div>{line}</div>
|
<div>{line}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -679,7 +720,7 @@
|
|||||||
<button class="stamp tiny" onclick={() => {
|
<button class="stamp tiny" onclick={() => {
|
||||||
const id = nameToCardId(nameInput);
|
const id = nameToCardId(nameInput);
|
||||||
if (id && selectedCard) {
|
if (id && selectedCard) {
|
||||||
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
|
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
}}>Retrieve</button>
|
}}>Retrieve</button>
|
||||||
@@ -712,7 +753,7 @@
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if isYourTurn && onWarpToken}
|
{#if isYourTurn && onWarpToken}
|
||||||
<button class="stamp" onclick={() => net.command({ type: "warpStep" })}>Step through the warp</button>
|
<button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isYourTurn}
|
{#if isYourTurn}
|
||||||
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
|
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
|
||||||
@@ -806,6 +847,40 @@
|
|||||||
}
|
}
|
||||||
.mast-leave:hover { color: #d8d2c0; }
|
.mast-leave:hover { color: #d8d2c0; }
|
||||||
.mast-help-solo { margin-left: auto; }
|
.mast-help-solo { margin-left: auto; }
|
||||||
|
.claim-input.wide { width: 17rem; }
|
||||||
|
.hotseat-row { display: flex; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
|
||||||
|
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.scrim-handoff {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #14161b;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 60;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.handoff-card {
|
||||||
|
background: #e9e1cb;
|
||||||
|
color: #43331f;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2.4rem 3.2rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.handoff-eyebrow {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6b5a41;
|
||||||
|
}
|
||||||
|
.handoff-name {
|
||||||
|
font-family: "Caveat", cursive;
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; }
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
background: #6d2119;
|
background: #6d2119;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -5,7 +5,7 @@ import { cardDef } from "@wizwar/engine";
|
|||||||
|
|
||||||
const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787";
|
const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787";
|
||||||
|
|
||||||
function humanize(e: GameEvent): string | null {
|
export function humanize(e: GameEvent): string | null {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
|
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
|
||||||
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
|
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
|
||||||
|
|||||||
Reference in New Issue
Block a user