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-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); }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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