feat(frontend): add theme-voting modal (countdown, live picks, settle)

This commit is contained in:
Gabriel Franco 2026-09-10 14:23:44 -03:00
parent f0d08f3b50
commit bf88a340d6
3 changed files with 241 additions and 8 deletions

View file

@ -815,3 +815,45 @@ body {
.private-room-error.hidden { display: none; } .private-room-error.hidden { display: none; }
.private-room-transition h2 { color: var(--green); } .private-room-transition h2 { color: var(--green); }
/* ---------- theme-vote modal (#16: 10s countdown, live opponent pick, settle) ---------- */
.theme-vote-modal { text-align: center; }
.theme-vote-countdown {
font-family: var(--pixel);
font-size: 20px;
color: var(--yellow);
margin: 6px 0 14px;
}
.theme-vote-grid.theme-grid { margin: 0 0 14px; }
.theme-vote-opponent {
font-size: 12px;
color: var(--fg-dim);
margin: 0;
}
.theme-vote-spin-name {
font-family: var(--pixel);
font-size: 22px;
color: var(--cyan);
margin: 18px 0;
}
.theme-vote-spin-name-final { color: var(--yellow); animation: pop .4s steps(4); }
.theme-vote-agree { animation: agreeGlow 1.6s ease-out; }
.theme-vote-agree-banner {
font-size: 20px;
color: var(--green);
animation: pop .4s steps(4);
}
.theme-vote-settled-name {
font-family: var(--pixel);
font-size: 18px;
color: var(--yellow);
margin: 14px 0 0;
}
@keyframes agreeGlow {
0% { box-shadow: 8px 8px 0 var(--ink), 0 0 0 0 rgba(95,215,95,.7); }
40% { box-shadow: 8px 8px 0 var(--ink), 0 0 0 18px rgba(95,215,95,0); }
100% { box-shadow: 8px 8px 0 var(--ink), 0 0 0 0 rgba(95,215,95,0); }
}

View file

@ -57,6 +57,7 @@ import type {
import { MultiplayerMatch } from '../game/multiplayerMatch'; import { MultiplayerMatch } from '../game/multiplayerMatch';
import { OpponentView } from '../game/opponentView'; import { OpponentView } from '../game/opponentView';
import { ThemeGuessGame } from '../game/ThemeGuessGame'; import { ThemeGuessGame } from '../game/ThemeGuessGame';
import { openThemeVote, type ThemeVoteHandle } from './themeVote';
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)
@ -136,6 +137,16 @@ export function createPrivateRoomFlow(
let match: MultiplayerMatch | null = null; let match: MultiplayerMatch | null = null;
const splitView = requireEl<HTMLElement>('split-view'); const splitView = requireEl<HTMLElement>('split-view');
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.
let voteAnimationPending = false;
let bufferedRoundStart: RoundStartMessage | null = null;
function clearTransitionTimeout(): void { function clearTransitionTimeout(): void {
if (transitionTimeoutId !== null) { if (transitionTimeoutId !== null) {
window.clearTimeout(transitionTimeoutId); window.clearTimeout(transitionTimeoutId);
@ -271,10 +282,11 @@ export function createPrivateRoomFlow(
break; break;
case 'vote-wait': case 'vote-wait':
// TODO(#16): replace with real theme-vote modal (pick from // Transient placeholder for the brief gap between `room:joined`
// `vote:start.themeIds`, live opponent-choice indicator via // and the server's `vote:start` (sent synchronously right
// `vote:opponentChoice`). This ticket only needs to not hang — // after) — `client.on('vote:start', ...)` below replaces this
// `round:start` (below) always follows `vote:settled`. // mount content with the real `ui/themeVote.ts` modal as soon
// as it arrives.
mount.innerHTML = ` mount.innerHTML = `
<div class="modal private-room-modal private-room-vote-wait"> <div class="modal private-room-modal private-room-vote-wait">
<h2>🗳 Theme Vote</h2> <h2>🗳 Theme Vote</h2>
@ -340,6 +352,16 @@ export function createPrivateRoomFlow(
return match; return match;
} }
/** Common tail of both `round:start` handling paths (immediate, or
* deferred until a theme-vote settle animation finishes): hides this
* flow's own overlay, shows the board, and starts/reuses the match. */
function enterRound(payload: RoundStartMessage): void {
render({ kind: 'hidden' });
callbacks.hideMenu();
themeNameBadge.textContent = THEMES[payload.themeId].name;
ensureMatch(payload).startRound(payload);
}
client.on('room:created', (msg) => { client.on('room:created', (msg) => {
render({ kind: 'create-waiting', code: msg.code }); render({ kind: 'create-waiting', code: msg.code });
}); });
@ -358,6 +380,20 @@ export function createPrivateRoomFlow(
} }
}); });
client.on('vote:start', (msg) => {
if (!roomActive) return;
voteAnimationPending = true;
activeVoteHandle = openThemeVote(client, mount, msg, () => {
activeVoteHandle = null;
voteAnimationPending = false;
if (bufferedRoundStart) {
const payload = bufferedRoundStart;
bufferedRoundStart = null;
enterRound(payload);
}
});
});
client.on('round:nextMatch', (msg: RoundNextMatchMessage) => { client.on('round:nextMatch', (msg: RoundNextMatchMessage) => {
if (!roomActive) return; if (!roomActive) return;
render({ kind: 'match-transition', match: msg.match }); render({ kind: 'match-transition', match: msg.match });
@ -365,14 +401,22 @@ export function createPrivateRoomFlow(
client.on('round:start', (payload) => { client.on('round:start', (payload) => {
if (!roomActive) return; if (!roomActive) return;
render({ kind: 'hidden' }); if (voteAnimationPending) {
callbacks.hideMenu(); // The settled theme's `round:start` beat the local vote-modal
themeNameBadge.textContent = THEMES[payload.themeId].name; // reveal animation (see `voteAnimationPending`'s declaration) —
ensureMatch(payload).startRound(payload); // hold it until the animation's settle callback fires.
bufferedRoundStart = payload;
return;
}
enterRound(payload);
}); });
client.on('room:closed', (msg) => { client.on('room:closed', (msg) => {
roomActive = false; roomActive = false;
activeVoteHandle?.destroy();
activeVoteHandle = null;
voteAnimationPending = false;
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
// its round down) — hide the stale split-view pane here so it // its round down) — hide the stale split-view pane here so it

View file

@ -0,0 +1,147 @@
// Theme-voting modal for private rooms with `themeMode: 'chosen'` (ticket
// #16). Opened by `ui/privateRoomFlow.ts` when a `vote:start` message
// arrives (see backend/src/rooms/themeVote.js / PROTOCOL.md "Theme
// voting"): both players get 10 seconds to pick a theme via the reused
// `ui/themeGrid.ts` grid, see a live "opponent's current pick" indicator,
// and only react to the final theme once `vote:settled` arrives — never
// earlier, even if both picks already match before the countdown ends.
//
// This module owns a single mount element for its own lifetime (fully
// replaces its `innerHTML` once, on open, then patches small pieces of it
// directly — unlike `privateRoomFlow.ts`'s per-state full-`innerHTML`
// pattern — because the live countdown tick and opponent-pick updates
// need to mutate DOM without tearing down and rebuilding the theme grid
// on every tick).
import { createThemeGrid } from './themeGrid';
import type { GameClient } from '../net/client';
import type { VoteSettledMessage, VoteStartMessage } from '../net/messages';
import type { ThemeId } from '../types';
import { THEMES } from '../data/themes';
/** How long the post-settle reveal animation (spin-between-picks, or the
* "YOU AGREE!" celebration) plays before `onSettled` fires and the modal
* tears itself down. Kept well under the 10s vote window so it never
* overlaps a *second* vote's `vote:start` in the (currently impossible,
* since a room only votes once per PROTOCOL.md) case of back-to-back
* votes. */
const SETTLE_ANIMATION_MS = 1600;
export interface ThemeVoteHandle {
/** Tears down the modal and its listeners/timers immediately, without
* playing the settle animation or calling `onSettled` — for when the
* room closes (opponent quit/disconnect) mid-vote. */
destroy(): void;
}
function themeLabel(id: ThemeId): string {
return THEMES[id].name;
}
function countdownText(msRemaining: number): string {
const seconds = Math.max(0, Math.ceil(msRemaining / 1000));
return `${seconds}s`;
}
/** Opens the vote modal into `mount` (a `.modal-overlay` element the
* caller owns) for the given `vote:start` payload. Calls `onSettled`
* exactly once, after the reveal animation completes, with the final
* theme and whether both players agreed — the caller (`privateRoomFlow`)
* uses this to know when it's safe to hand off to the match view, even
* if `round:start` already arrived while the animation was still
* playing. */
export function openThemeVote(
client: GameClient,
mount: HTMLElement,
msg: VoteStartMessage,
onSettled: (themeId: ThemeId, agreed: boolean) => void,
): ThemeVoteHandle {
let settled = false;
let myPick: ThemeId | null = null;
mount.innerHTML = `
<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>
<p class="theme-vote-opponent" data-el="opponent">Opponent is choosing…</p>
</div>`;
const countdownEl = mount.querySelector<HTMLElement>('[data-el="countdown"]')!;
const gridEl = mount.querySelector<HTMLElement>('[data-el="grid"]')!;
const opponentEl = mount.querySelector<HTMLElement>('[data-el="opponent"]')!;
createThemeGrid(gridEl, msg.themeIds[0], (id) => {
myPick = id;
client.send({ type: 'vote:cast', themeId: id });
});
let countdownFrame = 0;
function tickCountdown(): void {
const remaining = msg.endsAt - Date.now();
countdownEl.textContent = countdownText(remaining);
if (remaining > 0) countdownFrame = requestAnimationFrame(tickCountdown);
}
tickCountdown();
const unsubOpponent = client.on('vote:opponentChoice', (opp) => {
opponentEl.textContent = `Opponent picked ${themeLabel(opp.themeId)}`;
});
function cleanupListeners(): void {
cancelAnimationFrame(countdownFrame);
unsubOpponent();
unsubSettled();
}
function playSettleAnimation(settledMsg: VoteSettledMessage): void {
countdownEl.textContent = '';
if (settledMsg.agreed) {
mount.innerHTML = `
<div class="modal theme-vote-modal theme-vote-agree">
<h2 class="theme-vote-agree-banner">🎉 YOU AGREE! 🎉</h2>
<p class="theme-vote-settled-name">${themeLabel(settledMsg.themeId)}</p>
</div>`;
} else {
mount.innerHTML = `
<div class="modal theme-vote-modal theme-vote-spin">
<h2>Settling…</h2>
<p class="theme-vote-spin-name" data-el="spin-name">${myPick ? themeLabel(myPick) : ''}</p>
</div>`;
// A brief "spinning between the two picks" flourish: alternate the
// displayed name a few times before landing on the real result.
const spinEl = mount.querySelector<HTMLElement>('[data-el="spin-name"]')!;
const candidates = [myPick, settledMsg.themeId].filter((id): id is ThemeId => id !== null);
let i = 0;
const spinInterval = window.setInterval(() => {
i += 1;
spinEl.textContent = themeLabel(candidates[i % candidates.length] ?? settledMsg.themeId);
}, 180);
window.setTimeout(() => {
window.clearInterval(spinInterval);
spinEl.textContent = themeLabel(settledMsg.themeId);
spinEl.classList.add('theme-vote-spin-name-final');
}, SETTLE_ANIMATION_MS - 300);
}
window.setTimeout(() => {
onSettled(settledMsg.themeId, settledMsg.agreed);
}, SETTLE_ANIMATION_MS);
}
const unsubSettled = client.on('vote:settled', (settledMsg) => {
if (settled) return;
settled = true;
cancelAnimationFrame(countdownFrame);
unsubOpponent();
unsubSettled();
playSettleAnimation(settledMsg);
});
return {
destroy(): void {
if (settled) return;
settled = true;
cleanupListeners();
},
};
}