feat(frontend): add theme-voting modal (countdown, live picks, settle)
This commit is contained in:
parent
f0d08f3b50
commit
bf88a340d6
3 changed files with 241 additions and 8 deletions
|
|
@ -815,3 +815,45 @@ body {
|
|||
.private-room-error.hidden { display: none; }
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import type {
|
|||
import { MultiplayerMatch } from '../game/multiplayerMatch';
|
||||
import { OpponentView } from '../game/opponentView';
|
||||
import { ThemeGuessGame } from '../game/ThemeGuessGame';
|
||||
import { openThemeVote, type ThemeVoteHandle } from './themeVote';
|
||||
|
||||
export interface PrivateRoomFlowElements {
|
||||
/** Empty `.modal-overlay` mount (`#private-room-mount` in index.html)
|
||||
|
|
@ -136,6 +137,16 @@ export function createPrivateRoomFlow(
|
|||
let match: MultiplayerMatch | null = null;
|
||||
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 {
|
||||
if (transitionTimeoutId !== null) {
|
||||
window.clearTimeout(transitionTimeoutId);
|
||||
|
|
@ -271,10 +282,11 @@ export function createPrivateRoomFlow(
|
|||
break;
|
||||
|
||||
case 'vote-wait':
|
||||
// TODO(#16): replace with real theme-vote modal (pick from
|
||||
// `vote:start.themeIds`, live opponent-choice indicator via
|
||||
// `vote:opponentChoice`). This ticket only needs to not hang —
|
||||
// `round:start` (below) always follows `vote:settled`.
|
||||
// Transient placeholder for the brief gap between `room:joined`
|
||||
// and the server's `vote:start` (sent synchronously right
|
||||
// after) — `client.on('vote:start', ...)` below replaces this
|
||||
// mount content with the real `ui/themeVote.ts` modal as soon
|
||||
// as it arrives.
|
||||
mount.innerHTML = `
|
||||
<div class="modal private-room-modal private-room-vote-wait">
|
||||
<h2>🗳 Theme Vote</h2>
|
||||
|
|
@ -340,6 +352,16 @@ export function createPrivateRoomFlow(
|
|||
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) => {
|
||||
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) => {
|
||||
if (!roomActive) return;
|
||||
render({ kind: 'match-transition', match: msg.match });
|
||||
|
|
@ -365,14 +401,22 @@ export function createPrivateRoomFlow(
|
|||
|
||||
client.on('round:start', (payload) => {
|
||||
if (!roomActive) return;
|
||||
render({ kind: 'hidden' });
|
||||
callbacks.hideMenu();
|
||||
themeNameBadge.textContent = THEMES[payload.themeId].name;
|
||||
ensureMatch(payload).startRound(payload);
|
||||
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.
|
||||
bufferedRoundStart = payload;
|
||||
return;
|
||||
}
|
||||
enterRound(payload);
|
||||
});
|
||||
|
||||
client.on('room:closed', (msg) => {
|
||||
roomActive = false;
|
||||
activeVoteHandle?.destroy();
|
||||
activeVoteHandle = null;
|
||||
voteAnimationPending = false;
|
||||
bufferedRoundStart = null;
|
||||
// The opponent's own `MultiplayerMatch.teardownRound()` never ran
|
||||
// for this abandoned round (only the quitter's local instance tore
|
||||
// its round down) — hide the stale split-view pane here so it
|
||||
|
|
|
|||
147
frontend/src/ui/themeVote.ts
Normal file
147
frontend/src/ui/themeVote.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue