feat(frontend): add quit button and matchmaking ban UX

- index.html/style.css: destructive-styled #match-quit-btn in the in-match
  HUD row, two-click arm/confirm (no heavyweight modal).
- multiplayerMatch.ts: wires the button (send player:quit, tear down
  timer/listeners/opponent pane immediately, no server reply awaited),
  adds an optional onQuit constructor callback for a future host
  (#14/#15) to route back to the main menu -- nothing mounts
  MultiplayerMatch yet, so this ticket's own quit-button wiring is what
  exercises it.
- ui/banBanner.ts (new): standalone renderBanBanner(container,
  bannedUntil) countdown, ready for #14 to mount into #menu-matchmaking.
  Not wired anywhere yet: per session.js/queue.js, quitting a
  matchmaking match pushes nothing to the quitter directly -- the only
  wire message carrying ban info is queue:banned, sent in reply to a
  later queue:join while banned.

Verified against a real backend + real browser tab driving
MultiplayerMatch through an actual matchmaking match (paired against a
raw ws opponent): quit sends player:quit, opponent gets
room:playerLeft + room:closed(reason:quit) with no ban message; a
follow-up queue:join from the quitter correctly receives queue:banned
(bannedUntil ~120s out) and renderBanBanner counts it down live.
Repeated via room:create/room:join (private room): identical
teardown/onQuit behavior, opponent gets zero ban-related messages, and
the quitter's earlier matchmaking ban is left completely unchanged.
This commit is contained in:
Gabriel Franco 2026-09-10 12:40:50 -03:00
parent 74a6ab471f
commit 12423cec1d
4 changed files with 186 additions and 6 deletions

View file

@ -33,7 +33,10 @@
<span id="progress-label">0 / 12 TOKEN KINDS THEMED</span> <span id="progress-label">0 / 12 TOKEN KINDS THEMED</span>
</div> </div>
<div class="match-hud-row">
<div id="match-timer-hud" class="hidden"></div> <div id="match-timer-hud" class="hidden"></div>
<button id="match-quit-btn" class="btn danger small hidden" title="Quit this match">✕ Quit</button>
</div>
<div id="match-timer-warning" class="hidden"></div> <div id="match-timer-warning" class="hidden"></div>
<main class="board"> <main class="board">

View file

@ -2,7 +2,10 @@
// `ThemeGuessGame` + `OpponentView` + `timerHud` to a `GameClient`'s // `ThemeGuessGame` + `OpponentView` + `timerHud` to a `GameClient`'s
// round:start/round:progress/round:timeWarning/round:reveal messages // round:start/round:progress/round:timeWarning/round:reveal messages
// (see backend/src/PROTOCOL.md), and renders the two-column scoreboard // (see backend/src/PROTOCOL.md), and renders the two-column scoreboard
// once the server reveals both players' submissions. // once the server reveals both players' submissions. Also owns the
// in-match Quit button (`#match-quit-btn`): a confirm-armed click sends
// `player:quit` and tears the local view down immediately, no server
// reply required (see `quit()`'s doc for why).
// //
// `ThemeGuessGame` has no network knowledge of its own: this module // `ThemeGuessGame` has no network knowledge of its own: this module
// supplies its `onCategoryAssigned` constructor callback (see // supplies its `onCategoryAssigned` constructor callback (see
@ -12,6 +15,12 @@
// with `(id, hex) => match.handleLocalAssignment(id, hex)`, then call // with `(id, hex) => match.handleLocalAssignment(id, hex)`, then call
// `match.bindGame(game)` — so the callback closure can reference a // `match.bindGame(game)` — so the callback closure can reference a
// `MultiplayerMatch` instance that already exists. // `MultiplayerMatch` instance that already exists.
//
// Nothing constructs a `MultiplayerMatch` yet (no #14/#15 host exists
// in main.ts) — the third constructor arg, `onQuit`, is this class's
// own hook for that future host to route back to the main menu once a
// quit completes; it's exercised by this ticket's own quit-button
// wiring so it's ready for #14/#15 to pass in.
import { tokenize } from '../engine/tokenizer'; import { tokenize } from '../engine/tokenizer';
import { rgbToHex } from '../engine/colorUtils'; import { rgbToHex } from '../engine/colorUtils';
@ -37,6 +46,11 @@ function requireEl<T extends HTMLElement>(id: string): T {
const FALLBACK_BG_HEX = rgbToHex(UNSET_BG_RGB); const FALLBACK_BG_HEX = rgbToHex(UNSET_BG_RGB);
const FALLBACK_FG_HEX = rgbToHex(UNSET_FG_RGB); const FALLBACK_FG_HEX = rgbToHex(UNSET_FG_RGB);
/** How long the Quit button stays "armed" (showing "Confirm Quit?")
* after a first click before reverting, so a stray double-tap can't
* quit a match by accident. */
const QUIT_CONFIRM_WINDOW_MS = 3000;
/** Every category defaulted to its "never painted" placeholder — used /** Every category defaulted to its "never painted" placeholder — used
* both to pad a local submission that's missing categories at time-up, * both to pad a local submission that's missing categories at time-up,
* and to stand in for an opponent who never submitted at all. */ * and to stand in for an opponent who never submitted at all. */
@ -95,6 +109,7 @@ export class MultiplayerMatch {
private readonly timerEl = requireEl<HTMLElement>('match-timer-hud'); private readonly timerEl = requireEl<HTMLElement>('match-timer-hud');
private readonly timerWarningEl = requireEl<HTMLElement>('match-timer-warning'); private readonly timerWarningEl = requireEl<HTMLElement>('match-timer-warning');
private readonly quitBtn = requireEl<HTMLButtonElement>('match-quit-btn');
private readonly resultModal = requireEl<HTMLElement>('result-modal'); private readonly resultModal = requireEl<HTMLElement>('result-modal');
private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard'); private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard');
private readonly soloResultEls = [ private readonly soloResultEls = [
@ -127,7 +142,23 @@ export class MultiplayerMatch {
private unsubWarning: (() => void) | null = null; private unsubWarning: (() => void) | null = null;
private unsubReveal: (() => void) | null = null; private unsubReveal: (() => void) | null = null;
constructor(private readonly client: GameClient, private readonly opponentView: OpponentView) {} private quitArmed = false;
private quitArmTimeoutId: number | null = null;
/** `onQuit`, if given, fires once the local player's quit actually
* goes through (button confirmed, `player:quit` sent, local view torn
* down) — the host of this `MultiplayerMatch` (main.ts or whatever
* mounts it; nothing does yet, see this class's header) uses it to
* route back to the main menu. Never fires for the remote end of a
* match ending (opponent quit, reveal, etc.) — this class doesn't
* listen for `room:playerLeft`/`room:closed` today. */
constructor(
private readonly client: GameClient,
private readonly opponentView: OpponentView,
private readonly onQuit?: () => void,
) {
this.quitBtn.addEventListener('click', () => this.handleQuitClick());
}
/** Binds the `ThemeGuessGame` this match drives via `setSnippet`/ /** Binds the `ThemeGuessGame` this match drives via `setSnippet`/
* `setTheme`. Must be constructed with its `onCategoryAssigned` * `setTheme`. Must be constructed with its `onCategoryAssigned`
@ -181,6 +212,8 @@ export class MultiplayerMatch {
endsAt: payload.endsAt, endsAt: payload.endsAt,
onExpire: () => this.submitFinalColors(), onExpire: () => this.submitFinalColors(),
}); });
this.quitBtn.classList.remove('hidden');
} }
/** Pads the tracked local color map with placeholders for any /** Pads the tracked local color map with placeholders for any
@ -225,5 +258,52 @@ export class MultiplayerMatch {
this.unsubWarning = null; this.unsubWarning = null;
this.unsubReveal?.(); this.unsubReveal?.();
this.unsubReveal = null; this.unsubReveal = null;
this.disarmQuit();
this.quitBtn.classList.add('hidden');
}
/** First click on the Quit button arms it (shows "Confirm Quit?" and
* reverts on its own after `QUIT_CONFIRM_WINDOW_MS`); a second click
* while armed actually quits. Requires two intentional clicks so a
* misclick can't forfeit a match by accident. */
private handleQuitClick(): void {
if (this.quitArmed) {
this.quit();
return;
}
this.armQuit();
}
private armQuit(): void {
this.quitArmed = true;
this.quitBtn.textContent = 'Confirm Quit?';
this.quitBtn.classList.add('armed');
this.quitArmTimeoutId = window.setTimeout(() => this.disarmQuit(), QUIT_CONFIRM_WINDOW_MS);
}
private disarmQuit(): void {
this.quitArmed = false;
this.quitBtn.textContent = '✕ Quit';
this.quitBtn.classList.remove('armed');
if (this.quitArmTimeoutId !== null) {
window.clearTimeout(this.quitArmTimeoutId);
this.quitArmTimeoutId = null;
}
}
/** Confirmed quit: sends `player:quit` (see PROTOCOL.md "Quit" — the
* server infers which match/room from the sending socket, no payload
* needed), then immediately tears down the local match view exactly
* as `handleReveal` does, without waiting for any server reply — a
* quit has no round:reveal to wait for. Note there is no ban-info
* reply to react to here: see `ui/banBanner.ts`'s header comment for
* why a matchmaking ban is only ever learned later, via `queue:banned`
* on a subsequent `queue:join`. */
private quit(): void {
this.client.send({ type: 'player:quit' });
this.teardownRound();
this.opponentView.reset();
this.onQuit?.();
} }
} }

View file

@ -73,6 +73,8 @@ body {
.btn.reveal { background: linear-gradient(90deg, var(--yellow), var(--orange)); } .btn.reveal { background: linear-gradient(90deg, var(--yellow), var(--orange)); }
.btn.reveal:not(:disabled) { animation: revealPulse 1.6s steps(2) infinite; } .btn.reveal:not(:disabled) { animation: revealPulse 1.6s steps(2) infinite; }
.btn.big { padding: 16px 22px; font-size: 13px; } .btn.big { padding: 16px 22px; font-size: 13px; }
.btn.danger { background: var(--red); color: var(--ink); }
.btn.danger.armed { background: var(--yellow); }
@keyframes revealPulse { @keyframes revealPulse {
0%, 100% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 0 rgba(255,210,63,.6); } 0%, 100% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 0 rgba(255,210,63,.6); }
50% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 6px rgba(255,210,63,0); } 50% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 6px rgba(255,210,63,0); }
@ -172,9 +174,11 @@ body {
/* ---------- match timer HUD (#11: countdown + time warning) ---------- */ /* ---------- match timer HUD (#11: countdown + time warning) ---------- */
/* A second, visually distinct progress bar from .progress-wrap above — /* A second, visually distinct progress bar from .progress-wrap above —
* that one tracks "N/12 token kinds themed"; this one tracks time * that one tracks "N/12 token kinds themed"; this one tracks time
* remaining in the round. Built by game/timerHud.ts into the empty * remaining in the round, alongside the quit button. Built by
* #match-timer-hud / #match-timer-warning mounts in index.html. Hidden * game/timerHud.ts / game/multiplayerMatch.ts into the empty
* by default: not mounted into any live view until a later ticket. */ * #match-timer-hud / #match-quit-btn / #match-timer-warning mounts in
* index.html. Hidden by default: not mounted into any live view until a
* later ticket. */
.match-timer-hud { .match-timer-hud {
display: flex; display: flex;
align-items: center; align-items: center;
@ -188,6 +192,8 @@ body {
box-shadow: 3px 3px 0 var(--ink); box-shadow: 3px 3px 0 var(--ink);
} }
#match-timer-hud.hidden { display: none; } #match-timer-hud.hidden { display: none; }
.match-hud-row { display: flex; align-items: center; gap: 10px; }
#match-quit-btn.hidden { display: none; }
.match-timer-badge { color: var(--yellow); flex-shrink: 0; white-space: nowrap; } .match-timer-badge { color: var(--yellow); flex-shrink: 0; white-space: nowrap; }
.match-timer-track { .match-timer-track {
flex: 1; flex: 1;
@ -520,6 +526,23 @@ body {
.menu-section-actions { display: flex; flex-direction: column; gap: 8px; margin-top: auto; } .menu-section-actions { display: flex; flex-direction: column; gap: 8px; margin-top: auto; }
.menu-section-actions .btn { width: 100%; } .menu-section-actions .btn { width: 100%; }
/* Standalone matchmaking-ban countdown banner (frontend/src/ui/banBanner.ts).
* Not mounted anywhere yet — ready for ticket #14 to mount into
* #menu-matchmaking once queue:banned is wired up. */
.ban-banner {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--pixel);
font-size: 10px;
color: var(--ink);
background: var(--orange);
border: 3px solid var(--ink);
box-shadow: 3px 3px 0 var(--ink);
padding: 8px 10px;
}
.ban-banner-icon { flex-shrink: 0; }
#menu-solo .theme-grid { margin-bottom: 0; } #menu-solo .theme-grid { margin-bottom: 0; }
.solo-config-group { display: flex; flex-direction: column; gap: 6px; } .solo-config-group { display: flex; flex-direction: column; gap: 6px; }

View file

@ -0,0 +1,74 @@
// Standalone "matchmaking ban" countdown banner.
//
// Integration point (read this before wiring it up): the ONLY wire
// message that carries ban info is `queue:banned` (see
// backend/src/PROTOCOL.md "Matchmaking" + "Quit"), which the server
// sends in reply to a `queue:join` attempt made while banned. Quitting a
// matchmaking match does NOT itself push any ban notification to the
// quitter — confirmed by reading backend/src/game/session.js's
// `quitSession` (it only `sendTo`s the *remaining* opponent,
// `room:playerLeft` then `room:closed`) and
// backend/src/matchmaking/queue.js's `tryMatch` `onEnd` callback (it
// only records `bansByPlayerId.set(...)` server-side, with no `sendTo`
// back to the quitter at all). The quitter only learns their
// `bannedUntil` the next time *any* socket for that `playerId` sends
// `queue:join` before the ban expires.
//
// So this module is not wired into `game/multiplayerMatch.ts`'s quit
// flow — there is nothing for it to react to there. Ticket #14 (the real
// "Find Match" flow) is the actual call site: it should call
// `client.on('queue:banned', (msg) => renderBanBanner(el, msg.bannedUntil))`
// and mount `el` inside `#menu-matchmaking` (see index.html), most likely
// replacing/disabling `#find-match-btn` for the ban's duration.
function formatRemaining(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function bannerMarkup(): string {
return `
<div class="ban-banner">
<span class="ban-banner-icon">⏳</span>
<span class="ban-banner-text"></span>
</div>`;
}
/** Renders a persistent "Matchmaking available in M:SS" banner into
* `container`, live-updating once a second from `bannedUntil` (the
* epoch-ms value delivered on a `queue:banned` message). Automatically
* clears itself once the ban expires. Returns a `stop()` — cancels the
* interval and clears `container` — call it on unmount, or before a
* fresh `renderBanBanner` call if a later `queue:banned` arrives with an
* updated `bannedUntil`. */
export function renderBanBanner(container: HTMLElement, bannedUntil: number): () => void {
container.innerHTML = bannerMarkup();
container.classList.remove('hidden');
const text = container.querySelector<HTMLElement>('.ban-banner-text')!;
let intervalId: number | null = null;
function stop(): void {
if (intervalId !== null) {
window.clearInterval(intervalId);
intervalId = null;
}
container.innerHTML = '';
container.classList.add('hidden');
}
function tick(): void {
const remaining = bannedUntil - Date.now();
if (remaining <= 0) {
stop();
return;
}
text.textContent = `Matchmaking available in ${formatRemaining(remaining)}`;
}
tick();
intervalId = window.setInterval(tick, 1000);
return stop;
}