Lobby standee picker for online rooms

Waiting rooms now show the six wizard standees: tap to claim yours,
claimed ones gray out with the claimant named for screen readers, the
roster shows each player's chosen standee (or "choosing..."), and
conflicts are refused first-come. Choices sync live to everyone in
the room, resolve to first-free defaults for the undecided when the
host flips the boards, persist in the start line of the room log (so
restored games keep their colors), and flow through the same engine
config field the hotseat picker uses. Also fixed: the static-serving
realpath guard crashed the dev server when no client build exists —
it now 404s static requests instead. Verified end to end: claim,
conflict refusal, second pick, start, and in-game colorIndex 5/2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 01:19:42 -04:00
co-authored by Claude Fable 5
parent 77ae65b263
commit 4cbf5a013e
5 changed files with 91 additions and 6 deletions
+26 -1
View File
@@ -647,9 +647,33 @@
<div class="boxlid-tag">Share the code. Two to six wizards enter the maze.</div>
<ul class="roster">
{#each net.players as p (p)}
<li><span class="dot" style:background={playerColor(p)}></span>{p}{p === net.hostId ? " — host" : ""}</li>
{@const chosen = net.roomColors[p]}
<li>
{#if chosen !== undefined}
<img class="roster-standee" src={`/tokens/wizard-${chosen}.png`} alt="" />
{:else}
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""}
</li>
{/each}
</ul>
<div class="standee-row" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
<button
class="standee"
class:current={net.you != null && net.roomColors[net.you] === c}
class:taken={takenBy !== undefined && takenBy !== net.you}
disabled={takenBy !== undefined && takenBy !== net.you}
style:--ring={PLAYER_COLORS[c]}
onclick={() => net.pickColor(c)}
aria-label={`wizard color ${c + 1}${takenBy && takenBy !== net.you ? ` (taken by ${takenBy})` : ""}`}
>
<img src={`/tokens/wizard-${c}.png`} alt="" />
</button>
{/each}
</div>
{#if net.you === net.hostId}
<label class="check">
<input type="checkbox" bind:checked={withExpansion} />
@@ -1062,6 +1086,7 @@
.code { width: 6.5rem; padding: 0.55rem 0.4rem; text-transform: uppercase; text-align: center; letter-spacing: 0.15em; }
.roster { list-style: none; padding: 0; margin: 0 0 1rem; text-align: left; }
.roster li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; font-size: 1.05rem; }
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.waiting { color: #6b5a41; font-style: italic; }
+7
View File
@@ -166,6 +166,8 @@ class Net {
players = $state<string[]>([]);
hostId = $state<string | null>(null);
started = $state(false);
/** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({});
you = $state<string | null>(null);
view = $state<GameView | null>(null);
log = $state<string[]>([]);
@@ -225,6 +227,7 @@ class Net {
}
case "room":
this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {};
if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -369,6 +372,10 @@ class Net {
this.send({ type: "start", expansion });
}
pickColor(color: number): void {
this.send({ type: "pickColor", color });
}
command(command: Command): void {
this.send({ type: "command", command });
}