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;