Pick your standee: hotseat wizards choose their color while naming

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 01:15:07 -04:00
co-authored by Claude Fable 5
parent 98d7dee5c7
commit 77ae65b263
5 changed files with 75 additions and 17 deletions
+4
View File
@@ -173,6 +173,10 @@ export interface GameConfig {
playerIds: PlayerId[]; playerIds: PlayerId[];
seed: number; seed: number;
sets: CardSet[]; 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 { export interface GameState {
+4 -1
View File
@@ -27,6 +27,8 @@ export interface PlayerPublicView {
lostTurns: number; lostTurns: number;
extraTurns: number; extraTurns: number;
displayed: CardInstance[]; displayed: CardInstance[];
/** Which of the six physical wizard colors this player plays. */
colorIndex: number;
} }
export interface GameView { export interface GameView {
@@ -85,8 +87,9 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
turn: state.turn, turn: state.turn,
activePlayerId: state.players[state.turn.activeIndex]!.id, activePlayerId: state.players[state.turn.activeIndex]!.id,
board, board,
players: state.players.map((p) => ({ players: state.players.map((p, i) => ({
id: p.id, id: p.id,
colorIndex: state.config.colors?.[i] ?? i,
position: p.position, position: p.position,
home: p.home, home: p.home,
life: p.life, life: p.life,
+44 -2
View File
@@ -16,6 +16,13 @@
let showHelp = $state(false); let showHelp = $state(false);
let hotseatCount = $state(2); let hotseatCount = $state(2);
let setupName = $state(""); 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. */ /** Tap-to-inspect: hide the enlarged card for the current selection. */
let inspectorHidden = $state(false); let inspectorHidden = $state(false);
$effect(() => { $effect(() => {
@@ -456,7 +463,8 @@
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"]; const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
function playerColor(id: string): string { 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]!; return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
} }
</script> </script>
@@ -503,9 +511,23 @@
<div class="handoff-card"> <div class="handoff-card">
<div class="handoff-eyebrow">wizard {local.setup.names.length + 1} of {local.setup.count}</div> <div class="handoff-eyebrow">wizard {local.setup.names.length + 1} of {local.setup.count}</div>
<div class="handoff-name setup-title">What is your name?</div> <div class="handoff-name setup-title">What is your name?</div>
<div class="standee-row" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const taken = local.setup.colors.includes(c)}
<button
class="standee" class:current={setupColor === c} class:taken
disabled={taken}
style:--ring={PLAYER_COLORS[c]}
onclick={() => (setupColor = c)}
aria-label={`wizard color ${c + 1}${taken ? " (taken)" : ""}`}
>
<img src={`/tokens/wizard-${c}.png`} alt="" />
</button>
{/each}
</div>
<form class="setup-form" onsubmit={(e) => { <form class="setup-form" onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
const err = local.submitName(setupName); const err = local.submitName(setupName, setupColor);
if (err) net.error = err; if (err) net.error = err;
else setupName = ""; else setupName = "";
}}> }}>
@@ -925,6 +947,26 @@
} }
.handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; } .handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; }
.setup-title { font-size: 2.2rem; } .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-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; }
.setup-input { .setup-input {
background: #f4eede; background: #f4eede;
+7 -6
View File
@@ -49,17 +49,18 @@
return null; return null;
} }
function wizardArt(id: string): string { function wizardArt(id: string): string {
const idx = view.players.findIndex((p) => p.id === id); return `/tokens/wizard-${colorIndexOf(id) % 6}.png`;
return `/tokens/wizard-${((idx % 6) + 6) % 6}.png`;
} }
function treasureArt(owner: string): string { function treasureArt(owner: string): string {
const idx = view.players.findIndex((p) => p.id === owner); return `/tokens/treasure-${colorIndexOf(owner) % 6}.png`;
return `/tokens/treasure-${((idx % 6) + 6) % 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 { function playerColor(id: string): string {
const idx = view.players.findIndex((p) => p.id === id); return PLAYER_COLORS[colorIndexOf(id) % PLAYER_COLORS.length]!;
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
} }
const cells = $derived( const cells = $derived(
+16 -8
View File
@@ -36,7 +36,7 @@ function actorId(state: GameState): PlayerId {
class LocalGame { class LocalGame {
active = $state(false); active = $state(false);
/** Hotseat setup round: players name themselves one by one. */ /** 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<GameState | null>(null); gameState = $state<GameState | null>(null);
viewerId = $state<PlayerId | null>(null); viewerId = $state<PlayerId | null>(null);
/** Set while the device should be handed to the named player. */ /** Set while the device should be handed to the named player. */
@@ -54,31 +54,38 @@ class LocalGame {
} }
beginSetup(count: number, expansion: boolean): void { beginSetup(count: number, expansion: boolean): void {
this.setup = { count, expansion, names: [] }; this.setup = { count, expansion, names: [], colors: [] };
} }
cancelSetup(): void { cancelSetup(): void {
this.setup = null; this.setup = null;
} }
/** One wizard names themselves; the last name starts the game. */ /** One wizard names themselves and picks a standee; the last starts the game. */
submitName(raw: string): string | null { submitName(raw: string, colorIndex: number): string | null {
if (!this.setup) return "no setup in progress"; if (!this.setup) return "no setup in progress";
const name = raw.trim(); const name = raw.trim();
if (!name) return "every wizard needs a name"; if (!name) return "every wizard needs a name";
if (this.setup.names.some((n) => n.toLowerCase() === name.toLowerCase())) { if (this.setup.names.some((n) => n.toLowerCase() === name.toLowerCase())) {
return "that name is already taken at this table"; 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) { if (this.setup.names.length === this.setup.count) {
const { names, expansion } = this.setup; const { names, expansion, colors } = this.setup;
this.setup = null; this.setup = null;
return this.start(names, expansion); return this.start(names, expansion, colors);
} }
return null; 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))]; 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"; 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 seed = crypto.getRandomValues(new Uint32Array(1))[0]!;
@@ -86,6 +93,7 @@ class LocalGame {
playerIds: cleaned, playerIds: cleaned,
seed, seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"], sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
}; };
const { state, events } = createGame(config); const { state, events } = createGame(config);
this.config = config; this.config = config;