fix(frontend,backend): online scoring bug, mutual reveal vote, theme preview timing
Scoring bug (priority fix): MultiplayerMatch/BotMatch add a `hidden` class to the solo result panel (#solo-score-meter-wrap, #score-verdict, #score-breakdown, #compare-toggle) once a networked/bot round's real two-column scoreboard takes over, but no CSS rule ever paired `.hidden` with those selectors (every other hideable element in this codebase has its own scoped rule) -- the class was inert, so the solo panel stayed visible forever, stacked above the mp-scoreboard, making both look like they showed the same/wrong numbers. Added the missing scoped rules. Reveal button (online play): clicking Reveal Match used to run ThemeGuessGame's own instant local reveal() even in a live multiplayer round -- unrelated to the server, and the direct trigger of the above scoring bug. Added a revealOverride hook on ThemeGuessGame; while a multiplayer round is live, MultiplayerMatch routes the click through a new round:revealVote message instead. The round now only ends early once *both* players vote (server tracks votes per session and reveals immediately on a mutual vote); a single vote relays round:revealVoteUpdate to the opponent, and the voter sees "Waiting for opponent to vote for reveal..." below the button. Theme preview timing: matchmaking and private-room rounds never ran the 10s theme-preview flash solo/bot rounds already have, so a round's real colors never appeared before painting started. Added it to both flows (ui/matchmakingFlow.ts, ui/privateRoomFlow.ts, including every match in a private room's up-to-5-match series, not just the first). The backend's round:start.endsAt now bakes in a matching 10s PREVIEW_MS head start (session.js) so the preview never eats into actual painting time. Private room results view: a private room's next match used to start within milliseconds of the previous one's reveal, cutting the result modal off almost instantly. room.js now delays round:nextMatch (and the series-ending room:closed) by RESULT_VIEW_MS (10s) after round:reveal so both players actually get to look at a match's result. Theme grid / vote UI: the theme-vote modal's grid was missing the theme-grid class that actually applies the 2-column CSS grid (dead CSS already anticipated the combination), so vote theme cards rendered in whatever the browser's default block flow was. Added the class, and added a distinct (pink, dashed outline) opponent-pick highlight to ui/themeGrid.ts so the opponent's live vote pick is visually distinct from the local player's own selection, keeping the existing 'Opponent chose <theme>' text. PROTOCOL.md updated for all wire-visible changes: endsAt formula, round:revealVote/round:revealVoteUpdate, and the round:nextMatch/ room:closed timing.
This commit is contained in:
parent
42d1732816
commit
7a6df57ad0
13 changed files with 285 additions and 40 deletions
|
|
@ -68,8 +68,9 @@ stateDiagram-v2
|
||||||
Painting --> Painting: round:progress (either direction, repeatable)
|
Painting --> Painting: round:progress (either direction, repeatable)
|
||||||
Painting --> Warning: round:timeWarning (t = endsAt - 20s)
|
Painting --> Warning: round:timeWarning (t = endsAt - 20s)
|
||||||
Warning --> Warning: round:progress
|
Warning --> Warning: round:progress
|
||||||
Warning --> Submitted: round:submit (per player, 0-2 times)
|
Painting --> Submitted: round:submit / round:revealVote (per player, 0-2 times)
|
||||||
Submitted --> Reveal: endsAt reached
|
Warning --> Submitted: round:submit / round:revealVote (per player, 0-2 times)
|
||||||
|
Submitted --> Reveal: endsAt reached, or both round:revealVote
|
||||||
Reveal --> [*]: round:reveal
|
Reveal --> [*]: round:reveal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -83,9 +84,16 @@ stateDiagram-v2
|
||||||
"endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline
|
"endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`endsAt` is computed server-side (`now + timeMode * 60_000`) and is the
|
`endsAt` is computed server-side (`now + PREVIEW_MS + timeMode * 60_000`,
|
||||||
single source of truth for round timing; clients render a countdown
|
`PREVIEW_MS` = 10s, `backend/src/game/session.js`) and is the single
|
||||||
from it but never decide when the round ends themselves.
|
source of truth for round timing. The `PREVIEW_MS` head start is
|
||||||
|
deliberate, not latency slack: both clients spend it flashing
|
||||||
|
`themeId`'s real colors (`ui/previewFlow.ts`'s `runThemePreview`, same
|
||||||
|
10s window as solo/bot rounds) before the board unlocks and the
|
||||||
|
countdown is even shown, so painting time always equals the full
|
||||||
|
`timeMode` regardless of that shared preview. Clients render a
|
||||||
|
countdown from `endsAt` but never decide when the round ends
|
||||||
|
themselves.
|
||||||
|
|
||||||
- **`round:progress`** — client -> server -> opponent, fired every time a
|
- **`round:progress`** — client -> server -> opponent, fired every time a
|
||||||
player assigns a color to a category while painting.
|
player assigns a color to a category while painting.
|
||||||
|
|
@ -133,10 +141,30 @@ stateDiagram-v2
|
||||||
omitted and the receiving client treats it the same as an
|
omitted and the receiving client treats it the same as an
|
||||||
all-`null` map).
|
all-`null` map).
|
||||||
|
|
||||||
- **`round:reveal`** — server -> both players, sent only once `endsAt`
|
- **`round:revealVote`** — client -> server, the Reveal button's click
|
||||||
has passed (never early, even if both players already submitted) so
|
while a round is still live (only enabled once every category is
|
||||||
neither client can infer the opponent's picks before the round is
|
painted — see `frontend/src/game/ThemeGuessGame.ts`'s `revealBtn`
|
||||||
officially over.
|
gating). Same shape and same "last one wins" semantics as
|
||||||
|
`round:submit`, and is in fact stored exactly like one; the *only*
|
||||||
|
extra effect is that once **both** players have sent a
|
||||||
|
`round:revealVote` for the round, it ends immediately — `round:reveal`
|
||||||
|
fires right away instead of waiting for `endsAt`.
|
||||||
|
```jsonc
|
||||||
|
{ "type": "round:revealVote", "colors": { "background": "#1a1b26", ... } }
|
||||||
|
```
|
||||||
|
- **`round:revealVoteUpdate`** — server -> the *other* player, sent when
|
||||||
|
the sender is the first (not yet both) of the two to `round:revealVote`
|
||||||
|
this round, so that player's client can nudge them ("your opponent
|
||||||
|
wants to reveal") to do the same.
|
||||||
|
```jsonc
|
||||||
|
{ "type": "round:revealVoteUpdate" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`round:reveal`** — server -> both players, sent once `endsAt` has
|
||||||
|
passed, or immediately if both players `round:revealVote` first —
|
||||||
|
never early on a *single* player's submission or vote, so neither
|
||||||
|
client can infer the opponent's picks before the round is officially
|
||||||
|
over for both.
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"type": "round:reveal",
|
"type": "round:reveal",
|
||||||
|
|
@ -155,7 +183,12 @@ Private rooms additionally get:
|
||||||
|
|
||||||
- **`round:nextMatch`** — server -> both players, sent after a
|
- **`round:nextMatch`** — server -> both players, sent after a
|
||||||
`round:reveal` in a private room if the series hasn't hit its 5-match
|
`round:reveal` in a private room if the series hasn't hit its 5-match
|
||||||
cap, immediately followed by the next `round:start`.
|
cap, `RESULT_VIEW_MS` (10s, `backend/src/rooms/room.js`) after that
|
||||||
|
`round:reveal` — giving both players a fixed window to actually look
|
||||||
|
at the match's result before the room moves on — immediately followed
|
||||||
|
by the next `round:start`. The series-ending match instead delays
|
||||||
|
`room:closed` (`reason: "matchLimit"`, see below) by the same
|
||||||
|
`RESULT_VIEW_MS` for the same reason.
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "type": "round:nextMatch", "match": 3 } // 1-5, the match about to start
|
{ "type": "round:nextMatch", "match": 3 } // 1-5, the match about to start
|
||||||
```
|
```
|
||||||
|
|
@ -266,9 +299,11 @@ stateDiagram-v2
|
||||||
```
|
```
|
||||||
- `quit` — `room:playerLeft` just fired; a player quit or disconnected
|
- `quit` — `room:playerLeft` just fired; a player quit or disconnected
|
||||||
before the 5-match series completed.
|
before the 5-match series completed.
|
||||||
- `matchLimit` — match 5 of 5's `round:reveal` was just delivered and
|
- `matchLimit` — match 5 of 5's `round:reveal` was delivered and the
|
||||||
the series cap was reached; the server closes the room on its own,
|
series cap was reached; sent `RESULT_VIEW_MS` after that
|
||||||
no client action required.
|
`round:reveal` (same result-viewing window `round:nextMatch` gets —
|
||||||
|
see above) and the server closes the room on its own, no client
|
||||||
|
action required.
|
||||||
- `finished` — both players are done looking at the last result and
|
- `finished` — both players are done looking at the last result and
|
||||||
one of them explicitly leaves the room via the post-game UI (a
|
one of them explicitly leaves the room via the post-game UI (a
|
||||||
normal, non-quit exit after `matchLimit` would otherwise leave the
|
normal, non-quit exit after `matchLimit` would otherwise leave the
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ const WARNING_OFFSET_MS = 20_000;
|
||||||
// server reveals with whatever it has (empty color map for a client that
|
// server reveals with whatever it has (empty color map for a client that
|
||||||
// never submitted).
|
// never submitted).
|
||||||
const SUBMIT_GRACE_MS = 3_000;
|
const SUBMIT_GRACE_MS = 3_000;
|
||||||
|
// PROTOCOL.md "round:start": every round's countdown (`endsAt`) starts
|
||||||
|
// this many ms after `round:start` is sent, giving both clients a fixed
|
||||||
|
// window to run their local theme-preview flash before painting is even
|
||||||
|
// possible — baked into `endsAt` itself (not a separate delayed send) so
|
||||||
|
// the server stays the single source of truth for round timing that
|
||||||
|
// `PROTOCOL.md` promises.
|
||||||
|
const PREVIEW_MS = 10_000;
|
||||||
|
|
||||||
const HEX_COLOR_PATTERN = /^#?[0-9a-fA-F]{3,8}$/;
|
const HEX_COLOR_PATTERN = /^#?[0-9a-fA-F]{3,8}$/;
|
||||||
|
|
||||||
|
|
@ -120,7 +127,7 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde
|
||||||
? snippetIndex
|
? snippetIndex
|
||||||
: Math.floor(Math.random() * SNIPPET_CATALOG_LENGTH);
|
: Math.floor(Math.random() * SNIPPET_CATALOG_LENGTH);
|
||||||
|
|
||||||
const endsAt = Date.now() + timeMode * 60_000;
|
const endsAt = Date.now() + PREVIEW_MS + timeMode * 60_000;
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
playerA,
|
playerA,
|
||||||
|
|
@ -131,6 +138,10 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde
|
||||||
endsAt,
|
endsAt,
|
||||||
onEnd,
|
onEnd,
|
||||||
submissions: new Map(),
|
submissions: new Map(),
|
||||||
|
// playerIds who've clicked "Reveal Match" and asked to end the round
|
||||||
|
// early — see the `round:revealVote` handler below. Reset per
|
||||||
|
// session; a mutual vote reveals immediately, bypassing endsAt.
|
||||||
|
revealVotes: new Set(),
|
||||||
ended: false,
|
ended: false,
|
||||||
roundEnded: false,
|
roundEnded: false,
|
||||||
warningTimer: null,
|
warningTimer: null,
|
||||||
|
|
@ -187,6 +198,33 @@ registerHandler('round:submit', (socket, message) => {
|
||||||
tryReveal(session);
|
tryReveal(session);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PROTOCOL.md "round:revealVote": lets both players agree to end the
|
||||||
|
// round before endsAt. Carries the same full colors shape as
|
||||||
|
// round:submit (the Reveal button is only enabled once every category
|
||||||
|
// is painted, so this is always a real final guess, not a partial one)
|
||||||
|
// and is treated identically to a submission; the only difference is
|
||||||
|
// that a *mutual* vote reveals immediately instead of waiting for
|
||||||
|
// endsAt/tryReveal's grace window.
|
||||||
|
registerHandler('round:revealVote', (socket, message) => {
|
||||||
|
const playerId = getPlayerId(socket);
|
||||||
|
const session = sessionsByPlayerId.get(playerId);
|
||||||
|
if (!session || session.ended) return;
|
||||||
|
|
||||||
|
const colors =
|
||||||
|
message.colors && typeof message.colors === 'object' && !Array.isArray(message.colors) ? message.colors : {};
|
||||||
|
session.submissions.set(playerId, colors);
|
||||||
|
session.revealVotes.add(playerId);
|
||||||
|
|
||||||
|
if (session.revealVotes.size >= 2) {
|
||||||
|
session.roundEnded = true;
|
||||||
|
clearSessionTimers(session);
|
||||||
|
revealSession(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTo(otherPlayer(session, playerId), { type: 'round:revealVoteUpdate' });
|
||||||
|
});
|
||||||
|
|
||||||
registerHandler('player:quit', (socket) => {
|
registerHandler('player:quit', (socket) => {
|
||||||
const playerId = getPlayerId(socket);
|
const playerId = getPlayerId(socket);
|
||||||
const session = sessionsByPlayerId.get(playerId);
|
const session = sessionsByPlayerId.get(playerId);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ import { startVote } from './themeVote.js';
|
||||||
const MAX_MATCHES = 5;
|
const MAX_MATCHES = 5;
|
||||||
const CODE_MIN = 100000;
|
const CODE_MIN = 100000;
|
||||||
const CODE_MAX = 999999;
|
const CODE_MAX = 999999;
|
||||||
|
// How long a private-room result screen stays up (both `round:nextMatch`
|
||||||
|
// and, for the series-ending match, `room:closed`) before the server
|
||||||
|
// moves the room on to whatever's next — gives both players a fixed
|
||||||
|
// window to actually look at a match's result instead of being cut off
|
||||||
|
// the instant it lands. Frontend mirrors this by only tearing the result
|
||||||
|
// modal down once it actually receives that delayed message.
|
||||||
|
const RESULT_VIEW_MS = 10_000;
|
||||||
|
|
||||||
// code (number) -> room. A room's code is freed (removed from this map)
|
// code (number) -> room. A room's code is freed (removed from this map)
|
||||||
// as soon as the room closes, so a future room:create can reuse it.
|
// as soon as the room closes, so a future room:create can reuse it.
|
||||||
|
|
@ -45,6 +52,8 @@ function teardownRoom(room) {
|
||||||
for (const id of room.players) {
|
for (const id of room.players) {
|
||||||
roomCodeByPlayerId.delete(id);
|
roomCodeByPlayerId.delete(id);
|
||||||
}
|
}
|
||||||
|
clearTimeout(room.nextMatchTimer);
|
||||||
|
clearTimeout(room.closeTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -137,16 +146,26 @@ function handleSessionEnd(room, reason, quitterId) {
|
||||||
room.matchesPlayed += 1;
|
room.matchesPlayed += 1;
|
||||||
room.previousThemeId = room.currentThemeId;
|
room.previousThemeId = room.currentThemeId;
|
||||||
|
|
||||||
|
// Give both players RESULT_VIEW_MS to actually look at this match's
|
||||||
|
// result before the room moves on — see RESULT_VIEW_MS's own comment.
|
||||||
if (room.matchesPlayed >= MAX_MATCHES) {
|
if (room.matchesPlayed >= MAX_MATCHES) {
|
||||||
closeRoom(room, 'matchLimit');
|
room.closeTimer = setTimeout(() => {
|
||||||
|
room.closeTimer = null;
|
||||||
|
if (!rooms.has(room.code)) return; // torn down while waiting
|
||||||
|
closeRoom(room, 'matchLimit');
|
||||||
|
}, RESULT_VIEW_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMatch = room.matchesPlayed + 1;
|
const nextMatch = room.matchesPlayed + 1;
|
||||||
for (const id of room.players) {
|
room.nextMatchTimer = setTimeout(() => {
|
||||||
sendTo(id, { type: 'round:nextMatch', match: nextMatch });
|
room.nextMatchTimer = null;
|
||||||
}
|
if (!rooms.has(room.code)) return; // torn down while waiting
|
||||||
startMatch(room);
|
for (const id of room.players) {
|
||||||
|
sendTo(id, { type: 'round:nextMatch', match: nextMatch });
|
||||||
|
}
|
||||||
|
startMatch(room);
|
||||||
|
}, RESULT_VIEW_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -174,6 +193,8 @@ export function registerRoomHandlers() {
|
||||||
currentThemeId: null,
|
currentThemeId: null,
|
||||||
votedThemeId: null,
|
votedThemeId: null,
|
||||||
session: null,
|
session: null,
|
||||||
|
nextMatchTimer: null,
|
||||||
|
closeTimer: null,
|
||||||
};
|
};
|
||||||
rooms.set(code, room);
|
rooms.set(code, room);
|
||||||
roomCodeByPlayerId.set(ownerId, code);
|
roomCodeByPlayerId.set(ownerId, code);
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@
|
||||||
<button id="reset-btn" class="btn ghost">↺ RESET COLORS</button>
|
<button id="reset-btn" class="btn ghost">↺ RESET COLORS</button>
|
||||||
<button id="reveal-btn" class="btn reveal" disabled>REVEAL MATCH ✨</button>
|
<button id="reveal-btn" class="btn reveal" disabled>REVEAL MATCH ✨</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="reveal-vote-status" class="reveal-vote-status hidden"></p>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,15 @@ export class ThemeGuessGame {
|
||||||
* listeners onto the same DOM). */
|
* listeners onto the same DOM). */
|
||||||
onCategoryAssigned?: (id: CategoryId, hex: string) => void;
|
onCategoryAssigned?: (id: CategoryId, hex: string) => void;
|
||||||
|
|
||||||
|
/** Set by `MultiplayerMatch` while a networked round is live: routes
|
||||||
|
* the shared Reveal button's click through a server-mediated mutual
|
||||||
|
* vote instead of this class's own instant local `reveal()` — a
|
||||||
|
* multiplayer round's real result must wait for `round:reveal` (both
|
||||||
|
* players agreeing to reveal, or endsAt), never a single player's own
|
||||||
|
* click. `null` (solo/bot play, or no round active) falls back to the
|
||||||
|
* normal `reveal()`. Reset to `null` once the round tears down. */
|
||||||
|
revealOverride: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
themeId: ThemeId,
|
themeId: ThemeId,
|
||||||
snippetIndex: number = pickRandomSnippetIndex(),
|
snippetIndex: number = pickRandomSnippetIndex(),
|
||||||
|
|
@ -216,7 +225,13 @@ export class ThemeGuessGame {
|
||||||
this.closePicker(true);
|
this.closePicker(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.revealBtn.addEventListener('click', () => this.reveal());
|
this.revealBtn.addEventListener('click', () => {
|
||||||
|
if (this.revealOverride) {
|
||||||
|
this.revealOverride();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.reveal();
|
||||||
|
});
|
||||||
this.resetBtn.addEventListener('click', () => this.resetColors());
|
this.resetBtn.addEventListener('click', () => this.resetColors());
|
||||||
|
|
||||||
this.compareToggle.addEventListener('click', (e) => {
|
this.compareToggle.addEventListener('click', (e) => {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export class MultiplayerMatch {
|
||||||
private readonly splitView = requireEl<HTMLElement>('split-view');
|
private readonly splitView = requireEl<HTMLElement>('split-view');
|
||||||
private readonly resultModal = requireEl<HTMLElement>('result-modal');
|
private readonly resultModal = requireEl<HTMLElement>('result-modal');
|
||||||
private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard');
|
private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard');
|
||||||
|
private readonly revealVoteStatusEl = requireEl<HTMLElement>('reveal-vote-status');
|
||||||
private readonly soloResultEls = [
|
private readonly soloResultEls = [
|
||||||
requireEl<HTMLElement>('solo-score-meter-wrap'),
|
requireEl<HTMLElement>('solo-score-meter-wrap'),
|
||||||
requireEl<HTMLElement>('score-verdict'),
|
requireEl<HTMLElement>('score-verdict'),
|
||||||
|
|
@ -135,6 +136,12 @@ export class MultiplayerMatch {
|
||||||
private unsubProgress: (() => void) | null = null;
|
private unsubProgress: (() => void) | null = null;
|
||||||
private unsubWarning: (() => void) | null = null;
|
private unsubWarning: (() => void) | null = null;
|
||||||
private unsubReveal: (() => void) | null = null;
|
private unsubReveal: (() => void) | null = null;
|
||||||
|
private unsubRevealVoteUpdate: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** Whether the local player has clicked Reveal Match this round and
|
||||||
|
* is waiting on the opponent to do the same — see `handleRevealVoteClick`.
|
||||||
|
* Reset per round by `startRound`/`teardownRound`. */
|
||||||
|
private revealVoted = false;
|
||||||
|
|
||||||
private quitArmed = false;
|
private quitArmed = false;
|
||||||
private quitArmTimeoutId: number | null = null;
|
private quitArmTimeoutId: number | null = null;
|
||||||
|
|
@ -185,11 +192,14 @@ export class MultiplayerMatch {
|
||||||
|
|
||||||
this.game.setTheme(payload.themeId);
|
this.game.setTheme(payload.themeId);
|
||||||
this.game.setSnippet(payload.snippetIndex);
|
this.game.setSnippet(payload.snippetIndex);
|
||||||
|
this.game.revealOverride = () => this.handleRevealVoteClick();
|
||||||
this.opponentView.setSnippet(payload.snippetIndex);
|
this.opponentView.setSnippet(payload.snippetIndex);
|
||||||
this.opponentView.reset();
|
this.opponentView.reset();
|
||||||
|
|
||||||
this.resultModal.classList.add('hidden');
|
this.resultModal.classList.add('hidden');
|
||||||
this.mpScoreboard.classList.add('hidden');
|
this.mpScoreboard.classList.add('hidden');
|
||||||
|
this.revealVoted = false;
|
||||||
|
this.revealVoteStatusEl.classList.add('hidden');
|
||||||
|
|
||||||
this.unsubProgress = this.client.on('round:progress', (msg) => {
|
this.unsubProgress = this.client.on('round:progress', (msg) => {
|
||||||
this.opponentView.markAssigned(msg.categoryId);
|
this.opponentView.markAssigned(msg.categoryId);
|
||||||
|
|
@ -200,6 +210,11 @@ export class MultiplayerMatch {
|
||||||
this.unsubReveal = this.client.on('round:reveal', (msg) => {
|
this.unsubReveal = this.client.on('round:reveal', (msg) => {
|
||||||
this.handleReveal(msg);
|
this.handleReveal(msg);
|
||||||
});
|
});
|
||||||
|
this.unsubRevealVoteUpdate = this.client.on('round:revealVoteUpdate', () => {
|
||||||
|
if (this.revealVoted) return;
|
||||||
|
this.revealVoteStatusEl.textContent = 'Opponent voted to reveal — click Reveal Match to agree!';
|
||||||
|
this.revealVoteStatusEl.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
this.stopTimer = startCountdown(this.timerEl, {
|
this.stopTimer = startCountdown(this.timerEl, {
|
||||||
timeMode: payload.timeMode,
|
timeMode: payload.timeMode,
|
||||||
|
|
@ -222,6 +237,21 @@ export class MultiplayerMatch {
|
||||||
this.client.send({ type: 'round:submit', colors: this.finalizeColors() });
|
this.client.send({ type: 'round:submit', colors: this.finalizeColors() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `ThemeGuessGame.revealOverride`'s target while a multiplayer round
|
||||||
|
* is live: the Reveal button (only enabled once every category is
|
||||||
|
* painted) casts this player's vote to end the round early instead of
|
||||||
|
* revealing anything locally — the real result only ever comes from
|
||||||
|
* `handleReveal`, once the server says both players agreed (or endsAt
|
||||||
|
* arrives regardless). A second click while already waiting is a
|
||||||
|
* no-op; the vote can't be un-cast. */
|
||||||
|
private handleRevealVoteClick(): void {
|
||||||
|
if (this.revealVoted) return;
|
||||||
|
this.revealVoted = true;
|
||||||
|
this.client.send({ type: 'round:revealVote', colors: this.finalizeColors() });
|
||||||
|
this.revealVoteStatusEl.textContent = 'Waiting for opponent to vote for reveal…';
|
||||||
|
this.revealVoteStatusEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
private handleReveal(msg: RoundRevealMessage): void {
|
private handleReveal(msg: RoundRevealMessage): void {
|
||||||
if (this.snippetIndex === null || this.themeId === null) return;
|
if (this.snippetIndex === null || this.themeId === null) return;
|
||||||
|
|
||||||
|
|
@ -253,6 +283,12 @@ export class MultiplayerMatch {
|
||||||
this.unsubWarning = null;
|
this.unsubWarning = null;
|
||||||
this.unsubReveal?.();
|
this.unsubReveal?.();
|
||||||
this.unsubReveal = null;
|
this.unsubReveal = null;
|
||||||
|
this.unsubRevealVoteUpdate?.();
|
||||||
|
this.unsubRevealVoteUpdate = null;
|
||||||
|
|
||||||
|
if (this.game) this.game.revealOverride = null;
|
||||||
|
this.revealVoted = false;
|
||||||
|
this.revealVoteStatusEl.classList.add('hidden');
|
||||||
|
|
||||||
this.disarmQuit();
|
this.disarmQuit();
|
||||||
this.quitBtn.classList.add('hidden');
|
this.quitBtn.classList.add('hidden');
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ createSoloConfigFlow(
|
||||||
|
|
||||||
const privateRoomFlow = createPrivateRoomFlow(
|
const privateRoomFlow = createPrivateRoomFlow(
|
||||||
client,
|
client,
|
||||||
{ mount: privateRoomMount, themeNameBadge },
|
{ mount: privateRoomMount, themeNameBadge, preview: previewElements },
|
||||||
{ hideMenu: () => showView(null), showMenu: () => showView('menu') },
|
{ hideMenu: () => showView(null), showMenu: () => showView('menu') },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ enterCodeBtn.addEventListener('click', () => privateRoomFlow.openJoin());
|
||||||
createRoomBtn.addEventListener('click', () => privateRoomFlow.openCreate());
|
createRoomBtn.addEventListener('click', () => privateRoomFlow.openCreate());
|
||||||
|
|
||||||
createMatchmakingFlow(
|
createMatchmakingFlow(
|
||||||
{ findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge },
|
{ findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge, preview: previewElements },
|
||||||
client,
|
client,
|
||||||
{ showBoard: () => showView(null), showMenu: () => showView('menu') },
|
{ showBoard: () => showView(null), showMenu: () => showView('menu') },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,22 @@ export interface RoundRevealMessage {
|
||||||
colors: Record<string, Record<CategoryId, string>>;
|
colors: Record<string, Record<CategoryId, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Client -> server, ask to end the round early — see PROTOCOL.md
|
||||||
|
* "round:revealVote". Carries the same full-guess shape as
|
||||||
|
* `round:submit` (the Reveal button is only enabled once every category
|
||||||
|
* is painted); the round only actually ends once both players have
|
||||||
|
* sent this. */
|
||||||
|
export interface RoundRevealVoteMessage {
|
||||||
|
type: 'round:revealVote';
|
||||||
|
colors: Record<CategoryId, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> the other player, live notice that this player voted to
|
||||||
|
* reveal early — lets that client's UI nudge them to do the same. */
|
||||||
|
export interface RoundRevealVoteUpdateMessage {
|
||||||
|
type: 'round:revealVoteUpdate';
|
||||||
|
}
|
||||||
|
|
||||||
/** Server -> both players, private rooms only: next match in the series
|
/** Server -> both players, private rooms only: next match in the series
|
||||||
* is about to start. */
|
* is about to start. */
|
||||||
export interface RoundNextMatchMessage {
|
export interface RoundNextMatchMessage {
|
||||||
|
|
@ -189,6 +205,7 @@ export type ClientMessage =
|
||||||
| RoomJoinMessage
|
| RoomJoinMessage
|
||||||
| RoundProgressMessage
|
| RoundProgressMessage
|
||||||
| RoundSubmitMessage
|
| RoundSubmitMessage
|
||||||
|
| RoundRevealVoteMessage
|
||||||
| VoteCastMessage
|
| VoteCastMessage
|
||||||
| PlayerQuitMessage;
|
| PlayerQuitMessage;
|
||||||
|
|
||||||
|
|
@ -201,6 +218,7 @@ export type ServerMessage =
|
||||||
| RoundProgressMessage
|
| RoundProgressMessage
|
||||||
| RoundTimeWarningMessage
|
| RoundTimeWarningMessage
|
||||||
| RoundRevealMessage
|
| RoundRevealMessage
|
||||||
|
| RoundRevealVoteUpdateMessage
|
||||||
| RoundNextMatchMessage
|
| RoundNextMatchMessage
|
||||||
| RoomCreatedMessage
|
| RoomCreatedMessage
|
||||||
| RoomJoinedMessage
|
| RoomJoinedMessage
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,16 @@ body {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.panel-actions .btn { width: 100%; }
|
.panel-actions .btn { width: 100%; }
|
||||||
|
/* `game/multiplayerMatch.ts`'s mutual reveal-vote status line, shown
|
||||||
|
* below the Reveal button once the local player has voted to reveal
|
||||||
|
* early and is waiting on the opponent to do the same (or vice versa). */
|
||||||
|
.reveal-vote-status {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.reveal-vote-status.hidden { display: none; }
|
||||||
|
|
||||||
/* ---------- split view (in-match opponent progress pane) ---------- */
|
/* ---------- split view (in-match opponent progress pane) ---------- */
|
||||||
/* `.board` now holds up to three flex children: the "Yours" split-pane
|
/* `.board` now holds up to three flex children: the "Yours" split-pane
|
||||||
|
|
@ -503,6 +513,11 @@ body {
|
||||||
}
|
}
|
||||||
.theme-card:hover { transform: translate(-1px, -1px); }
|
.theme-card:hover { transform: translate(-1px, -1px); }
|
||||||
.theme-card.selected { border-color: var(--yellow); box-shadow: 3px 3px 0 var(--yellow); }
|
.theme-card.selected { border-color: var(--yellow); box-shadow: 3px 3px 0 var(--yellow); }
|
||||||
|
/* `ui/themeVote.ts`: the opponent's live pick during theme voting — an
|
||||||
|
* outline (not a border/box-shadow, which `.selected` already owns) so
|
||||||
|
* both can be visible at once if a player picks the same theme their
|
||||||
|
* opponent already leans towards. */
|
||||||
|
.theme-card.opponent-pick { outline: 3px dashed var(--pink); outline-offset: 2px; }
|
||||||
.theme-swatches { display: flex; gap: 3px; }
|
.theme-swatches { display: flex; gap: 3px; }
|
||||||
.theme-swatches i { width: 12px; height: 12px; border: 1px solid rgba(0,0,0,.5); display: block; }
|
.theme-swatches i { width: 12px; height: 12px; border: 1px solid rgba(0,0,0,.5); display: block; }
|
||||||
.theme-name {
|
.theme-name {
|
||||||
|
|
@ -728,6 +743,17 @@ body {
|
||||||
/* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */
|
/* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */
|
||||||
|
|
||||||
.mp-scoreboard.hidden { display: none; }
|
.mp-scoreboard.hidden { display: none; }
|
||||||
|
/* `game/multiplayerMatch.ts` / `game/botMatch.ts` add `.hidden` to these
|
||||||
|
* four solo-result elements once a networked/bot round's real
|
||||||
|
* `renderMultiplayerResult` two-column scoreboard takes over — without
|
||||||
|
* these rules the class was inert (no selector consumed it) and the
|
||||||
|
* solo score panel stayed visible, stacked above the mp scoreboard. */
|
||||||
|
#solo-score-meter-wrap.hidden,
|
||||||
|
#score-verdict.hidden,
|
||||||
|
#score-breakdown.hidden,
|
||||||
|
#compare-toggle.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mp-winner-banner {
|
.mp-winner-banner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import { THEMES } from '../data/themes';
|
||||||
import type { GameClient } from '../net/client';
|
import type { GameClient } from '../net/client';
|
||||||
import type { RoundStartMessage, TimeMode } from '../net/messages';
|
import type { RoundStartMessage, TimeMode } from '../net/messages';
|
||||||
import { renderBanBanner } from './banBanner';
|
import { renderBanBanner } from './banBanner';
|
||||||
|
import { runThemePreview, type PreviewFlowElements } from './previewFlow';
|
||||||
|
import { sound } from '../engine/sound';
|
||||||
|
|
||||||
function requireEl<T extends HTMLElement>(id: string): T {
|
function requireEl<T extends HTMLElement>(id: string): T {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
|
|
@ -37,6 +39,11 @@ export interface MatchmakingFlowElements {
|
||||||
* whenever `findMatchBtn` is showing its idle state. */
|
* whenever `findMatchBtn` is showing its idle state. */
|
||||||
statusMount: HTMLElement;
|
statusMount: HTMLElement;
|
||||||
themeNameBadge: HTMLElement;
|
themeNameBadge: HTMLElement;
|
||||||
|
/** Shared `#preview-overlay` elements (see `ui/previewFlow.ts`) — a
|
||||||
|
* match's theme flashes here for `COUNTDOWN_SECONDS` before the board
|
||||||
|
* unlocks, same as solo/bot rounds, and matching the server's own
|
||||||
|
* `endsAt` offset (`backend/src/game/session.js`'s `PREVIEW_MS`). */
|
||||||
|
preview: PreviewFlowElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchmakingFlowHooks {
|
export interface MatchmakingFlowHooks {
|
||||||
|
|
@ -71,7 +78,7 @@ export function createMatchmakingFlow(
|
||||||
client: GameClient,
|
client: GameClient,
|
||||||
hooks: MatchmakingFlowHooks,
|
hooks: MatchmakingFlowHooks,
|
||||||
): void {
|
): void {
|
||||||
const { findMatchBtn, statusMount, themeNameBadge } = elements;
|
const { findMatchBtn, statusMount, themeNameBadge, preview } = elements;
|
||||||
const closeResultBtn = requireEl<HTMLButtonElement>('close-result-btn');
|
const closeResultBtn = requireEl<HTMLButtonElement>('close-result-btn');
|
||||||
const playAgainBtn = requireEl<HTMLButtonElement>('play-again-btn');
|
const playAgainBtn = requireEl<HTMLButtonElement>('play-again-btn');
|
||||||
|
|
||||||
|
|
@ -196,7 +203,10 @@ export function createMatchmakingFlow(
|
||||||
if (isNewMatch) match.bindGame(game);
|
if (isNewMatch) match.bindGame(game);
|
||||||
|
|
||||||
hooks.showBoard();
|
hooks.showBoard();
|
||||||
match.startRound(payload);
|
// Flash the assigned theme's real colors for the same window the
|
||||||
|
// server already baked into round:start's endsAt (session.js's
|
||||||
|
// PREVIEW_MS) before actually starting the round's countdown.
|
||||||
|
runThemePreview(preview, payload.themeId, () => match!.startRound(payload), () => sound.playOpen());
|
||||||
}
|
}
|
||||||
|
|
||||||
findMatchBtn.addEventListener('click', () => {
|
findMatchBtn.addEventListener('click', () => {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import { MultiplayerMatch } from '../game/multiplayerMatch';
|
||||||
import { OpponentView } from '../game/opponentView';
|
import { OpponentView } from '../game/opponentView';
|
||||||
import { getSharedGame } from '../game/sharedGame';
|
import { getSharedGame } from '../game/sharedGame';
|
||||||
import { openThemeVote, type ThemeVoteHandle } from './themeVote';
|
import { openThemeVote, type ThemeVoteHandle } from './themeVote';
|
||||||
|
import { runThemePreview, type PreviewFlowElements } from './previewFlow';
|
||||||
|
import { sound } from '../engine/sound';
|
||||||
|
|
||||||
export interface PrivateRoomFlowElements {
|
export interface PrivateRoomFlowElements {
|
||||||
/** Empty `.modal-overlay` mount (`#private-room-mount` in index.html)
|
/** Empty `.modal-overlay` mount (`#private-room-mount` in index.html)
|
||||||
|
|
@ -51,6 +53,11 @@ export interface PrivateRoomFlowElements {
|
||||||
/** Same theme-name badge the solo flow updates, kept in sync with the
|
/** Same theme-name badge the solo flow updates, kept in sync with the
|
||||||
* server-assigned `themeId` once a round actually starts. */
|
* server-assigned `themeId` once a round actually starts. */
|
||||||
themeNameBadge: HTMLElement;
|
themeNameBadge: HTMLElement;
|
||||||
|
/** Shared `#preview-overlay` elements (see `ui/previewFlow.ts`) — every
|
||||||
|
* match in the room's up-to-5-match series flashes its theme here
|
||||||
|
* before the board unlocks, matching the server's `endsAt` offset
|
||||||
|
* (`backend/src/game/session.js`'s `PREVIEW_MS`). */
|
||||||
|
preview: PreviewFlowElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrivateRoomFlowCallbacks {
|
export interface PrivateRoomFlowCallbacks {
|
||||||
|
|
@ -110,7 +117,8 @@ export function createPrivateRoomFlow(
|
||||||
elements: PrivateRoomFlowElements,
|
elements: PrivateRoomFlowElements,
|
||||||
callbacks: PrivateRoomFlowCallbacks,
|
callbacks: PrivateRoomFlowCallbacks,
|
||||||
): PrivateRoomFlow {
|
): PrivateRoomFlow {
|
||||||
const { mount, themeNameBadge } = elements;
|
const { mount, themeNameBadge, preview } = elements;
|
||||||
|
const resultModal = requireEl<HTMLElement>('result-modal');
|
||||||
|
|
||||||
let roomActive = false;
|
let roomActive = false;
|
||||||
let lastJoinCode = '';
|
let lastJoinCode = '';
|
||||||
|
|
@ -122,12 +130,14 @@ export function createPrivateRoomFlow(
|
||||||
|
|
||||||
let activeVoteHandle: ThemeVoteHandle | null = null;
|
let activeVoteHandle: ThemeVoteHandle | null = null;
|
||||||
// Set while the local theme-vote settle animation (spin/"YOU AGREE!")
|
// Set while the local theme-vote settle animation (spin/"YOU AGREE!")
|
||||||
// is still playing. `round:start` for the settled match can arrive
|
// is still playing, or while the "Match N of 5" transition banner is
|
||||||
// from the server before that animation finishes (the backend fires
|
// showing. `round:start` for the settled/next match can arrive from
|
||||||
// it right after `vote:settled`) — in that case the payload is
|
// the server before either finishes playing out (the backend fires it
|
||||||
// buffered here and `enterRound` runs once the animation completes,
|
// right after `vote:settled`, and right after `round:nextMatch`) — in
|
||||||
// so the vote's own reveal is never cut short.
|
// that case the payload is buffered here and the round begins once
|
||||||
|
// whichever is pending completes, so neither is ever cut short.
|
||||||
let voteAnimationPending = false;
|
let voteAnimationPending = false;
|
||||||
|
let transitionPending = false;
|
||||||
let bufferedRoundStart: RoundStartMessage | null = null;
|
let bufferedRoundStart: RoundStartMessage | null = null;
|
||||||
|
|
||||||
function clearTransitionTimeout(): void {
|
function clearTransitionTimeout(): void {
|
||||||
|
|
@ -292,7 +302,13 @@ export function createPrivateRoomFlow(
|
||||||
</div>`;
|
</div>`;
|
||||||
transitionTimeoutId = window.setTimeout(() => {
|
transitionTimeoutId = window.setTimeout(() => {
|
||||||
transitionTimeoutId = null;
|
transitionTimeoutId = null;
|
||||||
|
transitionPending = false;
|
||||||
render({ kind: 'hidden' });
|
render({ kind: 'hidden' });
|
||||||
|
if (bufferedRoundStart) {
|
||||||
|
const payload = bufferedRoundStart;
|
||||||
|
bufferedRoundStart = null;
|
||||||
|
beginRoundWithPreview(payload);
|
||||||
|
}
|
||||||
}, MATCH_TRANSITION_MS);
|
}, MATCH_TRANSITION_MS);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -346,6 +362,17 @@ export function createPrivateRoomFlow(
|
||||||
ensureMatch(payload).startRound(payload);
|
ensureMatch(payload).startRound(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flashes `payload.themeId`'s real colors for the same window the
|
||||||
|
* server already baked into `round:start.endsAt` (session.js's
|
||||||
|
* `PREVIEW_MS`) before actually mounting the round via `enterRound` —
|
||||||
|
* every match in the series gets this, not just match 1, since a
|
||||||
|
* `themeMode: 'random'` room picks a fresh theme each match. */
|
||||||
|
function beginRoundWithPreview(payload: RoundStartMessage): void {
|
||||||
|
render({ kind: 'hidden' });
|
||||||
|
callbacks.hideMenu();
|
||||||
|
runThemePreview(preview, payload.themeId, () => enterRound(payload), () => sound.playOpen());
|
||||||
|
}
|
||||||
|
|
||||||
client.on('room:created', (msg) => {
|
client.on('room:created', (msg) => {
|
||||||
render({ kind: 'create-waiting', code: msg.code });
|
render({ kind: 'create-waiting', code: msg.code });
|
||||||
});
|
});
|
||||||
|
|
@ -373,26 +400,32 @@ export function createPrivateRoomFlow(
|
||||||
if (bufferedRoundStart) {
|
if (bufferedRoundStart) {
|
||||||
const payload = bufferedRoundStart;
|
const payload = bufferedRoundStart;
|
||||||
bufferedRoundStart = null;
|
bufferedRoundStart = null;
|
||||||
enterRound(payload);
|
beginRoundWithPreview(payload);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('round:nextMatch', (msg: RoundNextMatchMessage) => {
|
client.on('round:nextMatch', (msg: RoundNextMatchMessage) => {
|
||||||
if (!roomActive) return;
|
if (!roomActive) return;
|
||||||
|
// The RESULT_VIEW_MS delay the server put between round:reveal and
|
||||||
|
// this message (backend/src/rooms/room.js) is exactly the window
|
||||||
|
// the result modal (already showing via MultiplayerMatch.handleReveal)
|
||||||
|
// was up for review — hide it now, right as the series moves on.
|
||||||
|
resultModal.classList.add('hidden');
|
||||||
|
transitionPending = true;
|
||||||
render({ kind: 'match-transition', match: msg.match });
|
render({ kind: 'match-transition', match: msg.match });
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('round:start', (payload) => {
|
client.on('round:start', (payload) => {
|
||||||
if (!roomActive) return;
|
if (!roomActive) return;
|
||||||
if (voteAnimationPending) {
|
if (voteAnimationPending || transitionPending) {
|
||||||
// The settled theme's `round:start` beat the local vote-modal
|
// The settled theme's/next match's `round:start` beat the local
|
||||||
// reveal animation (see `voteAnimationPending`'s declaration) —
|
// vote-modal or "Match N of 5" animation still playing — hold it
|
||||||
// hold it until the animation's settle callback fires.
|
// until that finishes (see their respective declarations/handlers).
|
||||||
bufferedRoundStart = payload;
|
bufferedRoundStart = payload;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
enterRound(payload);
|
beginRoundWithPreview(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('room:closed', (msg) => {
|
client.on('room:closed', (msg) => {
|
||||||
|
|
@ -400,6 +433,7 @@ export function createPrivateRoomFlow(
|
||||||
activeVoteHandle?.destroy();
|
activeVoteHandle?.destroy();
|
||||||
activeVoteHandle = null;
|
activeVoteHandle = null;
|
||||||
voteAnimationPending = false;
|
voteAnimationPending = false;
|
||||||
|
transitionPending = false;
|
||||||
bufferedRoundStart = null;
|
bufferedRoundStart = null;
|
||||||
// The opponent's own `MultiplayerMatch.teardownRound()` never ran
|
// The opponent's own `MultiplayerMatch.teardownRound()` never ran
|
||||||
// for this abandoned round (only the quitter's local instance tore
|
// for this abandoned round (only the quitter's local instance tore
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'] as const;
|
||||||
export interface ThemeGrid {
|
export interface ThemeGrid {
|
||||||
readonly selected: ThemeId;
|
readonly selected: ThemeId;
|
||||||
setSelected(id: ThemeId): void;
|
setSelected(id: ThemeId): void;
|
||||||
|
/** Highlights `id` as the opponent's current live pick (theme voting
|
||||||
|
* only — see `ui/themeVote.ts`) with its own distinct border color, so
|
||||||
|
* it stays visually separate from `selected` (this player's own pick)
|
||||||
|
* even when both point at the same card. `null` clears it. */
|
||||||
|
setOpponentPick(id: ThemeId | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the selectable theme cards into `container` and keeps them in
|
/** Renders the selectable theme cards into `container` and keeps them in
|
||||||
|
|
@ -17,14 +22,18 @@ export function createThemeGrid(
|
||||||
onSelect: (id: ThemeId) => void,
|
onSelect: (id: ThemeId) => void,
|
||||||
): ThemeGrid {
|
): ThemeGrid {
|
||||||
let selected = initialId;
|
let selected = initialId;
|
||||||
|
let opponentPick: ThemeId | null = null;
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
for (const id in THEMES) {
|
for (const id in THEMES) {
|
||||||
const theme = THEMES[id];
|
const theme = THEMES[id];
|
||||||
|
const classes = ['theme-card'];
|
||||||
|
if (id === selected) classes.push('selected');
|
||||||
|
if (id === opponentPick) classes.push('opponent-pick');
|
||||||
const card = document.createElement('button');
|
const card = document.createElement('button');
|
||||||
card.type = 'button';
|
card.type = 'button';
|
||||||
card.className = `theme-card${id === selected ? ' selected' : ''}`;
|
card.className = classes.join(' ');
|
||||||
card.dataset.id = id;
|
card.dataset.id = id;
|
||||||
card.style.setProperty('--card-bg', theme.colors.background);
|
card.style.setProperty('--card-bg', theme.colors.background);
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
|
|
@ -46,5 +55,6 @@ export function createThemeGrid(
|
||||||
return {
|
return {
|
||||||
get selected() { return selected; },
|
get selected() { return selected; },
|
||||||
setSelected(id: ThemeId) { selected = id; render(); },
|
setSelected(id: ThemeId) { selected = id; render(); },
|
||||||
|
setOpponentPick(id: ThemeId | null) { opponentPick = id; render(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export function openThemeVote(
|
||||||
<div class="modal theme-vote-modal">
|
<div class="modal theme-vote-modal">
|
||||||
<h2>🗳 Pick a Theme</h2>
|
<h2>🗳 Pick a Theme</h2>
|
||||||
<p class="theme-vote-countdown" data-el="countdown">${countdownText(msg.endsAt - Date.now())}</p>
|
<p class="theme-vote-countdown" data-el="countdown">${countdownText(msg.endsAt - Date.now())}</p>
|
||||||
<div class="theme-vote-grid" data-el="grid"></div>
|
<div class="theme-vote-grid theme-grid" data-el="grid"></div>
|
||||||
<p class="theme-vote-opponent" data-el="opponent">Opponent is choosing…</p>
|
<p class="theme-vote-opponent" data-el="opponent">Opponent is choosing…</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ export function openThemeVote(
|
||||||
const gridEl = mount.querySelector<HTMLElement>('[data-el="grid"]')!;
|
const gridEl = mount.querySelector<HTMLElement>('[data-el="grid"]')!;
|
||||||
const opponentEl = mount.querySelector<HTMLElement>('[data-el="opponent"]')!;
|
const opponentEl = mount.querySelector<HTMLElement>('[data-el="opponent"]')!;
|
||||||
|
|
||||||
createThemeGrid(gridEl, msg.themeIds[0], (id) => {
|
const grid = createThemeGrid(gridEl, msg.themeIds[0], (id) => {
|
||||||
myPick = id;
|
myPick = id;
|
||||||
client.send({ type: 'vote:cast', themeId: id });
|
client.send({ type: 'vote:cast', themeId: id });
|
||||||
});
|
});
|
||||||
|
|
@ -85,7 +85,8 @@ export function openThemeVote(
|
||||||
tickCountdown();
|
tickCountdown();
|
||||||
|
|
||||||
const unsubOpponent = client.on('vote:opponentChoice', (opp) => {
|
const unsubOpponent = client.on('vote:opponentChoice', (opp) => {
|
||||||
opponentEl.textContent = `Opponent picked ${themeLabel(opp.themeId)}`;
|
opponentEl.textContent = `Opponent chose ${themeLabel(opp.themeId)}`;
|
||||||
|
grid.setOpponentPick(opp.themeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
function cleanupListeners(): void {
|
function cleanupListeners(): void {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue