From 77ae65b26332215c576facedf1a05abed30fa2ec Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 01:15:07 -0400 Subject: [PATCH] Pick your standee: hotseat wizards choose their color while naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The naming round now shows the six real wizard standees; each player taps theirs before typing their name, claimed standees gray out, and the next player defaults to the first free one. The choice travels as an optional colors array in the engine config, the view exposes each player's colorIndex, and the board, score sheet, treasure chests, and wizard art all follow the chosen color instead of seat order — persisting through hotseat saves. Verified: Alice claimed the yellow standee and marched onto the board as the yellow wizard with yellow treasure chests. (Online rooms still assign by seat order; a lobby color picker can reuse the same config field later.) Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 4 +++ packages/engine/src/view.ts | 5 +++- packages/web/src/App.svelte | 46 ++++++++++++++++++++++++++++++-- packages/web/src/Board.svelte | 13 ++++----- packages/web/src/local.svelte.ts | 24 +++++++++++------ 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 2c94189..8273600 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -173,6 +173,10 @@ export interface GameConfig { playerIds: PlayerId[]; seed: number; sets: CardSet[]; + /** Chosen wizard colors, per player (index 0-5 into the six physical + * standees: green, red, magenta, blue, light blue, yellow). Defaults to + * seat order. */ + colors?: number[]; } export interface GameState { diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 4a6db57..2a8a204 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -27,6 +27,8 @@ export interface PlayerPublicView { lostTurns: number; extraTurns: number; displayed: CardInstance[]; + /** Which of the six physical wizard colors this player plays. */ + colorIndex: number; } export interface GameView { @@ -85,8 +87,9 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { turn: state.turn, activePlayerId: state.players[state.turn.activeIndex]!.id, board, - players: state.players.map((p) => ({ + players: state.players.map((p, i) => ({ id: p.id, + colorIndex: state.config.colors?.[i] ?? i, position: p.position, home: p.home, life: p.life, diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 1e94c84..f91dd41 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -16,6 +16,13 @@ let showHelp = $state(false); let hotseatCount = $state(2); let setupName = $state(""); + let setupColor = $state(0); + // Default each new wizard to the first unclaimed standee. + $effect(() => { + if (local.setup && local.setup.colors.includes(setupColor)) { + setupColor = [0, 1, 2, 3, 4, 5].find((c) => !local.setup!.colors.includes(c)) ?? 0; + } + }); /** Tap-to-inspect: hide the enlarged card for the current selection. */ let inspectorHidden = $state(false); $effect(() => { @@ -456,7 +463,8 @@ const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"]; function playerColor(id: string): string { - const idx = view?.players.findIndex((p) => p.id === id) ?? 0; + const p = view?.players.find((p) => p.id === id); + const idx = p?.colorIndex ?? (view?.players.findIndex((q) => q.id === id) ?? 0); return PLAYER_COLORS[idx % PLAYER_COLORS.length]!; } @@ -503,9 +511,23 @@
wizard {local.setup.names.length + 1} of {local.setup.count}
What is your name?
+
+ {#each [0, 1, 2, 3, 4, 5] as c (c)} + {@const taken = local.setup.colors.includes(c)} + + {/each} +
{ e.preventDefault(); - const err = local.submitName(setupName); + const err = local.submitName(setupName, setupColor); if (err) net.error = err; else setupName = ""; }}> @@ -925,6 +947,26 @@ } .handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; } .setup-title { font-size: 2.2rem; } + .standee-row { + display: flex; + gap: 0.45rem; + justify-content: center; + margin-top: 0.9rem; + flex-wrap: wrap; + } + .standee { + width: 3.1rem; + height: 3.1rem; + padding: 0; + border: 2.5px solid transparent; + border-radius: 6px; + background: #f4eede; + cursor: pointer; + overflow: hidden; + } + .standee img { width: 100%; height: 100%; object-fit: cover; display: block; } + .standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); } + .standee.taken { opacity: 0.28; cursor: default; filter: grayscale(0.8); } .setup-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; } .setup-input { background: #f4eede; diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index d9317b7..6f1b319 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -49,17 +49,18 @@ return null; } function wizardArt(id: string): string { - const idx = view.players.findIndex((p) => p.id === id); - return `/tokens/wizard-${((idx % 6) + 6) % 6}.png`; + return `/tokens/wizard-${colorIndexOf(id) % 6}.png`; } function treasureArt(owner: string): string { - const idx = view.players.findIndex((p) => p.id === owner); - return `/tokens/treasure-${((idx % 6) + 6) % 6}.png`; + return `/tokens/treasure-${colorIndexOf(owner) % 6}.png`; } + function colorIndexOf(id: string): number { + const p = view.players.find((p) => p.id === id); + return p?.colorIndex ?? Math.max(0, view.players.findIndex((q) => q.id === id)); + } function playerColor(id: string): string { - const idx = view.players.findIndex((p) => p.id === id); - return PLAYER_COLORS[idx % PLAYER_COLORS.length]!; + return PLAYER_COLORS[colorIndexOf(id) % PLAYER_COLORS.length]!; } const cells = $derived( diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 8e33e9c..ce44ed2 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -36,7 +36,7 @@ function actorId(state: GameState): PlayerId { class LocalGame { active = $state(false); /** Hotseat setup round: players name themselves one by one. */ - setup = $state<{ count: number; expansion: boolean; names: PlayerId[] } | null>(null); + setup = $state<{ count: number; expansion: boolean; names: PlayerId[]; colors: number[] } | null>(null); gameState = $state(null); viewerId = $state(null); /** Set while the device should be handed to the named player. */ @@ -54,31 +54,38 @@ class LocalGame { } beginSetup(count: number, expansion: boolean): void { - this.setup = { count, expansion, names: [] }; + this.setup = { count, expansion, names: [], colors: [] }; } cancelSetup(): void { this.setup = null; } - /** One wizard names themselves; the last name starts the game. */ - submitName(raw: string): string | null { + /** One wizard names themselves and picks a standee; the last starts the game. */ + submitName(raw: string, colorIndex: number): string | null { if (!this.setup) return "no setup in progress"; const name = raw.trim(); if (!name) return "every wizard needs a name"; if (this.setup.names.some((n) => n.toLowerCase() === name.toLowerCase())) { return "that name is already taken at this table"; } - this.setup = { ...this.setup, names: [...this.setup.names, name] }; + if (this.setup.colors.includes(colorIndex)) { + return "that wizard has already been claimed"; + } + this.setup = { + ...this.setup, + names: [...this.setup.names, name], + colors: [...this.setup.colors, colorIndex], + }; if (this.setup.names.length === this.setup.count) { - const { names, expansion } = this.setup; + const { names, expansion, colors } = this.setup; this.setup = null; - return this.start(names, expansion); + return this.start(names, expansion, colors); } return null; } - start(names: PlayerId[], expansion: boolean): string | null { + start(names: PlayerId[], expansion: boolean, colors?: number[]): 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]!; @@ -86,6 +93,7 @@ class LocalGame { playerIds: cleaned, seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], + ...(colors ? { colors } : {}), }; const { state, events } = createGame(config); this.config = config;