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:
parent
74a6ab471f
commit
12423cec1d
4 changed files with 186 additions and 6 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
74
frontend/src/ui/banBanner.ts
Normal file
74
frontend/src/ui/banBanner.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue