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 --> 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@
|
|||
<button id="reset-btn" class="btn ghost">↺ RESET COLORS</button>
|
||||
<button id="reveal-btn" class="btn reveal" disabled>REVEAL MATCH ✨</button>
|
||||
</div>
|
||||
<p id="reveal-vote-status" class="reveal-vote-status hidden"></p>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export class MultiplayerMatch {
|
|||
private readonly splitView = requireEl<HTMLElement>('split-view');
|
||||
private readonly resultModal = requireEl<HTMLElement>('result-modal');
|
||||
private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard');
|
||||
private readonly revealVoteStatusEl = requireEl<HTMLElement>('reveal-vote-status');
|
||||
private readonly soloResultEls = [
|
||||
requireEl<HTMLElement>('solo-score-meter-wrap'),
|
||||
requireEl<HTMLElement>('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');
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,22 @@ export interface RoundRevealMessage {
|
|||
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
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<T extends HTMLElement>(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<HTMLButtonElement>('close-result-btn');
|
||||
const playAgainBtn = requireEl<HTMLButtonElement>('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', () => {
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>('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(
|
|||
</div>`;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(); },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function openThemeVote(
|
|||
<div class="modal theme-vote-modal">
|
||||
<h2>🗳 Pick a Theme</h2>
|
||||
<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>
|
||||
</div>`;
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ export function openThemeVote(
|
|||
const gridEl = mount.querySelector<HTMLElement>('[data-el="grid"]')!;
|
||||
const opponentEl = mount.querySelector<HTMLElement>('[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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue