diff --git a/backend/src/PROTOCOL.md b/backend/src/PROTOCOL.md index 9753874..e99683a 100644 --- a/backend/src/PROTOCOL.md +++ b/backend/src/PROTOCOL.md @@ -68,8 +68,9 @@ stateDiagram-v2 Painting --> Painting: round:progress (either direction, repeatable) Painting --> Warning: round:timeWarning (t = endsAt - 20s) Warning --> Warning: round:progress - Warning --> Submitted: round:submit (per player, 0-2 times) - Submitted --> Reveal: endsAt reached + Painting --> Submitted: round:submit / round:revealVote (per player, 0-2 times) + Warning --> Submitted: round:submit / round:revealVote (per player, 0-2 times) + Submitted --> Reveal: endsAt reached, or both round:revealVote Reveal --> [*]: round:reveal ``` @@ -83,9 +84,16 @@ stateDiagram-v2 "endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline } ``` - `endsAt` is computed server-side (`now + timeMode * 60_000`) and is the - single source of truth for round timing; clients render a countdown - from it but never decide when the round ends themselves. + `endsAt` is computed server-side (`now + PREVIEW_MS + timeMode * 60_000`, + `PREVIEW_MS` = 10s, `backend/src/game/session.js`) and is the single + 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 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 all-`null` map). -- **`round:reveal`** — server -> both players, sent only once `endsAt` - has passed (never early, even if both players already submitted) so - neither client can infer the opponent's picks before the round is - officially over. +- **`round:revealVote`** — client -> server, the Reveal button's click + while a round is still live (only enabled once every category is + painted — see `frontend/src/game/ThemeGuessGame.ts`'s `revealBtn` + 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 { "type": "round:reveal", @@ -155,7 +183,12 @@ Private rooms additionally get: - **`round:nextMatch`** — server -> both players, sent after a `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 { "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 before the 5-match series completed. - - `matchLimit` — match 5 of 5's `round:reveal` was just delivered and - the series cap was reached; the server closes the room on its own, - no client action required. + - `matchLimit` — match 5 of 5's `round:reveal` was delivered and the + series cap was reached; sent `RESULT_VIEW_MS` after that + `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 one of them explicitly leaves the room via the post-game UI (a normal, non-quit exit after `matchLimit` would otherwise leave the diff --git a/backend/src/game/session.js b/backend/src/game/session.js index 1ac4a3c..a0c019e 100644 --- a/backend/src/game/session.js +++ b/backend/src/game/session.js @@ -22,6 +22,13 @@ const WARNING_OFFSET_MS = 20_000; // server reveals with whatever it has (empty color map for a client that // never submitted). 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}$/; @@ -120,7 +127,7 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde ? snippetIndex : 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 = { playerA, @@ -131,6 +138,10 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde endsAt, onEnd, 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, roundEnded: false, warningTimer: null, @@ -187,6 +198,33 @@ registerHandler('round:submit', (socket, message) => { 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) => { const playerId = getPlayerId(socket); const session = sessionsByPlayerId.get(playerId); diff --git a/backend/src/rooms/room.js b/backend/src/rooms/room.js index 135534e..c9418f1 100644 --- a/backend/src/rooms/room.js +++ b/backend/src/rooms/room.js @@ -20,6 +20,13 @@ import { startVote } from './themeVote.js'; const MAX_MATCHES = 5; const CODE_MIN = 100000; 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) // 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) { roomCodeByPlayerId.delete(id); } + clearTimeout(room.nextMatchTimer); + clearTimeout(room.closeTimer); } /** @@ -137,16 +146,26 @@ function handleSessionEnd(room, reason, quitterId) { room.matchesPlayed += 1; 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) { - 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; } const nextMatch = room.matchesPlayed + 1; - for (const id of room.players) { - sendTo(id, { type: 'round:nextMatch', match: nextMatch }); - } - startMatch(room); + room.nextMatchTimer = setTimeout(() => { + room.nextMatchTimer = null; + if (!rooms.has(room.code)) return; // torn down while waiting + 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, votedThemeId: null, session: null, + nextMatchTimer: null, + closeTimer: null, }; rooms.set(code, room); roomCodeByPlayerId.set(ownerId, code); diff --git a/frontend/index.html b/frontend/index.html index 785cbcd..3df0916 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -74,6 +74,7 @@ + diff --git a/frontend/src/game/ThemeGuessGame.ts b/frontend/src/game/ThemeGuessGame.ts index 2c0eaf6..ac883a4 100644 --- a/frontend/src/game/ThemeGuessGame.ts +++ b/frontend/src/game/ThemeGuessGame.ts @@ -77,6 +77,15 @@ export class ThemeGuessGame { * listeners onto the same DOM). */ 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( themeId: ThemeId, snippetIndex: number = pickRandomSnippetIndex(), @@ -216,7 +225,13 @@ export class ThemeGuessGame { 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.compareToggle.addEventListener('click', (e) => { diff --git a/frontend/src/game/multiplayerMatch.ts b/frontend/src/game/multiplayerMatch.ts index c0b7b64..cd77453 100644 --- a/frontend/src/game/multiplayerMatch.ts +++ b/frontend/src/game/multiplayerMatch.ts @@ -106,6 +106,7 @@ export class MultiplayerMatch { private readonly splitView = requireEl('split-view'); private readonly resultModal = requireEl('result-modal'); private readonly mpScoreboard = requireEl('mp-scoreboard'); + private readonly revealVoteStatusEl = requireEl('reveal-vote-status'); private readonly soloResultEls = [ requireEl('solo-score-meter-wrap'), requireEl('score-verdict'), @@ -135,6 +136,12 @@ export class MultiplayerMatch { private unsubProgress: (() => void) | null = null; private unsubWarning: (() => 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 quitArmTimeoutId: number | null = null; @@ -185,11 +192,14 @@ export class MultiplayerMatch { this.game.setTheme(payload.themeId); this.game.setSnippet(payload.snippetIndex); + this.game.revealOverride = () => this.handleRevealVoteClick(); this.opponentView.setSnippet(payload.snippetIndex); this.opponentView.reset(); this.resultModal.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.opponentView.markAssigned(msg.categoryId); @@ -200,6 +210,11 @@ export class MultiplayerMatch { this.unsubReveal = this.client.on('round:reveal', (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, { timeMode: payload.timeMode, @@ -222,6 +237,21 @@ export class MultiplayerMatch { 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 { if (this.snippetIndex === null || this.themeId === null) return; @@ -253,6 +283,12 @@ export class MultiplayerMatch { this.unsubWarning = null; this.unsubReveal?.(); 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.quitBtn.classList.add('hidden'); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 5fd2e3c..2fe379f 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -72,7 +72,7 @@ createSoloConfigFlow( const privateRoomFlow = createPrivateRoomFlow( client, - { mount: privateRoomMount, themeNameBadge }, + { mount: privateRoomMount, themeNameBadge, preview: previewElements }, { hideMenu: () => showView(null), showMenu: () => showView('menu') }, ); @@ -80,7 +80,7 @@ enterCodeBtn.addEventListener('click', () => privateRoomFlow.openJoin()); createRoomBtn.addEventListener('click', () => privateRoomFlow.openCreate()); createMatchmakingFlow( - { findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge }, + { findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge, preview: previewElements }, client, { showBoard: () => showView(null), showMenu: () => showView('menu') }, ); diff --git a/frontend/src/net/messages.ts b/frontend/src/net/messages.ts index 6a1e39b..8159dac 100644 --- a/frontend/src/net/messages.ts +++ b/frontend/src/net/messages.ts @@ -62,6 +62,22 @@ export interface RoundRevealMessage { colors: Record>; } +/** 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; +} + +/** 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 * is about to start. */ export interface RoundNextMatchMessage { @@ -189,6 +205,7 @@ export type ClientMessage = | RoomJoinMessage | RoundProgressMessage | RoundSubmitMessage + | RoundRevealVoteMessage | VoteCastMessage | PlayerQuitMessage; @@ -201,6 +218,7 @@ export type ServerMessage = | RoundProgressMessage | RoundTimeWarningMessage | RoundRevealMessage + | RoundRevealVoteUpdateMessage | RoundNextMatchMessage | RoomCreatedMessage | RoomJoinedMessage diff --git a/frontend/src/style.css b/frontend/src/style.css index 17bf340..94fbeee 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -283,6 +283,16 @@ body { gap: 8px; } .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) ---------- */ /* `.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.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 i { width: 12px; height: 12px; border: 1px solid rgba(0,0,0,.5); display: block; } .theme-name { @@ -728,6 +743,17 @@ body { /* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */ .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 { text-align: center; diff --git a/frontend/src/ui/matchmakingFlow.ts b/frontend/src/ui/matchmakingFlow.ts index 9600c37..905f3de 100644 --- a/frontend/src/ui/matchmakingFlow.ts +++ b/frontend/src/ui/matchmakingFlow.ts @@ -23,6 +23,8 @@ import { THEMES } from '../data/themes'; import type { GameClient } from '../net/client'; import type { RoundStartMessage, TimeMode } from '../net/messages'; import { renderBanBanner } from './banBanner'; +import { runThemePreview, type PreviewFlowElements } from './previewFlow'; +import { sound } from '../engine/sound'; function requireEl(id: string): T { const el = document.getElementById(id); @@ -37,6 +39,11 @@ export interface MatchmakingFlowElements { * whenever `findMatchBtn` is showing its idle state. */ statusMount: 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 { @@ -71,7 +78,7 @@ export function createMatchmakingFlow( client: GameClient, hooks: MatchmakingFlowHooks, ): void { - const { findMatchBtn, statusMount, themeNameBadge } = elements; + const { findMatchBtn, statusMount, themeNameBadge, preview } = elements; const closeResultBtn = requireEl('close-result-btn'); const playAgainBtn = requireEl('play-again-btn'); @@ -196,7 +203,10 @@ export function createMatchmakingFlow( if (isNewMatch) match.bindGame(game); 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', () => { diff --git a/frontend/src/ui/privateRoomFlow.ts b/frontend/src/ui/privateRoomFlow.ts index 55271e3..d98880c 100644 --- a/frontend/src/ui/privateRoomFlow.ts +++ b/frontend/src/ui/privateRoomFlow.ts @@ -42,6 +42,8 @@ import { MultiplayerMatch } from '../game/multiplayerMatch'; import { OpponentView } from '../game/opponentView'; import { getSharedGame } from '../game/sharedGame'; import { openThemeVote, type ThemeVoteHandle } from './themeVote'; +import { runThemePreview, type PreviewFlowElements } from './previewFlow'; +import { sound } from '../engine/sound'; export interface PrivateRoomFlowElements { /** 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 * server-assigned `themeId` once a round actually starts. */ 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 { @@ -110,7 +117,8 @@ export function createPrivateRoomFlow( elements: PrivateRoomFlowElements, callbacks: PrivateRoomFlowCallbacks, ): PrivateRoomFlow { - const { mount, themeNameBadge } = elements; + const { mount, themeNameBadge, preview } = elements; + const resultModal = requireEl('result-modal'); let roomActive = false; let lastJoinCode = ''; @@ -122,12 +130,14 @@ export function createPrivateRoomFlow( let activeVoteHandle: ThemeVoteHandle | null = null; // Set while the local theme-vote settle animation (spin/"YOU AGREE!") - // is still playing. `round:start` for the settled match can arrive - // from the server before that animation finishes (the backend fires - // it right after `vote:settled`) — in that case the payload is - // buffered here and `enterRound` runs once the animation completes, - // so the vote's own reveal is never cut short. + // is still playing, or while the "Match N of 5" transition banner is + // showing. `round:start` for the settled/next match can arrive from + // the server before either finishes playing out (the backend fires it + // right after `vote:settled`, and right after `round:nextMatch`) — in + // 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 transitionPending = false; let bufferedRoundStart: RoundStartMessage | null = null; function clearTransitionTimeout(): void { @@ -292,7 +302,13 @@ export function createPrivateRoomFlow( `; transitionTimeoutId = window.setTimeout(() => { transitionTimeoutId = null; + transitionPending = false; render({ kind: 'hidden' }); + if (bufferedRoundStart) { + const payload = bufferedRoundStart; + bufferedRoundStart = null; + beginRoundWithPreview(payload); + } }, MATCH_TRANSITION_MS); break; @@ -346,6 +362,17 @@ export function createPrivateRoomFlow( 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) => { render({ kind: 'create-waiting', code: msg.code }); }); @@ -373,26 +400,32 @@ export function createPrivateRoomFlow( if (bufferedRoundStart) { const payload = bufferedRoundStart; bufferedRoundStart = null; - enterRound(payload); + beginRoundWithPreview(payload); } }); }); client.on('round:nextMatch', (msg: RoundNextMatchMessage) => { 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 }); }); client.on('round:start', (payload) => { if (!roomActive) return; - if (voteAnimationPending) { - // The settled theme's `round:start` beat the local vote-modal - // reveal animation (see `voteAnimationPending`'s declaration) — - // hold it until the animation's settle callback fires. + if (voteAnimationPending || transitionPending) { + // The settled theme's/next match's `round:start` beat the local + // vote-modal or "Match N of 5" animation still playing — hold it + // until that finishes (see their respective declarations/handlers). bufferedRoundStart = payload; return; } - enterRound(payload); + beginRoundWithPreview(payload); }); client.on('room:closed', (msg) => { @@ -400,6 +433,7 @@ export function createPrivateRoomFlow( activeVoteHandle?.destroy(); activeVoteHandle = null; voteAnimationPending = false; + transitionPending = false; bufferedRoundStart = null; // The opponent's own `MultiplayerMatch.teardownRound()` never ran // for this abandoned round (only the quitter's local instance tore diff --git a/frontend/src/ui/themeGrid.ts b/frontend/src/ui/themeGrid.ts index cf2b2d8..fcad71c 100644 --- a/frontend/src/ui/themeGrid.ts +++ b/frontend/src/ui/themeGrid.ts @@ -6,6 +6,11 @@ const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'] as const; export interface ThemeGrid { readonly selected: ThemeId; 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 @@ -17,14 +22,18 @@ export function createThemeGrid( onSelect: (id: ThemeId) => void, ): ThemeGrid { let selected = initialId; + let opponentPick: ThemeId | null = null; function render(): void { container.innerHTML = ''; for (const id in THEMES) { 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'); card.type = 'button'; - card.className = `theme-card${id === selected ? ' selected' : ''}`; + card.className = classes.join(' '); card.dataset.id = id; card.style.setProperty('--card-bg', theme.colors.background); card.innerHTML = ` @@ -46,5 +55,6 @@ export function createThemeGrid( return { get selected() { return selected; }, setSelected(id: ThemeId) { selected = id; render(); }, + setOpponentPick(id: ThemeId | null) { opponentPick = id; render(); }, }; } diff --git a/frontend/src/ui/themeVote.ts b/frontend/src/ui/themeVote.ts index 0146bc7..7e0618b 100644 --- a/frontend/src/ui/themeVote.ts +++ b/frontend/src/ui/themeVote.ts @@ -63,7 +63,7 @@ export function openThemeVote( `; @@ -71,7 +71,7 @@ export function openThemeVote( const gridEl = mount.querySelector('[data-el="grid"]')!; const opponentEl = mount.querySelector('[data-el="opponent"]')!; - createThemeGrid(gridEl, msg.themeIds[0], (id) => { + const grid = createThemeGrid(gridEl, msg.themeIds[0], (id) => { myPick = id; client.send({ type: 'vote:cast', themeId: id }); }); @@ -85,7 +85,8 @@ export function openThemeVote( tickCountdown(); 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 {