diff --git a/frontend/index.html b/frontend/index.html index b83dda0..ef4ee5b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,7 +33,10 @@ 0 / 12 TOKEN KINDS THEMED - +
+ + +
diff --git a/frontend/src/game/multiplayerMatch.ts b/frontend/src/game/multiplayerMatch.ts index a9c2b07..157f3a6 100644 --- a/frontend/src/game/multiplayerMatch.ts +++ b/frontend/src/game/multiplayerMatch.ts @@ -2,7 +2,10 @@ // `ThemeGuessGame` + `OpponentView` + `timerHud` to a `GameClient`'s // round:start/round:progress/round:timeWarning/round:reveal messages // (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 // supplies its `onCategoryAssigned` constructor callback (see @@ -12,6 +15,12 @@ // with `(id, hex) => match.handleLocalAssignment(id, hex)`, then call // `match.bindGame(game)` — so the callback closure can reference a // `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 { rgbToHex } from '../engine/colorUtils'; @@ -37,6 +46,11 @@ function requireEl(id: string): T { const FALLBACK_BG_HEX = rgbToHex(UNSET_BG_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 * 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. */ @@ -95,6 +109,7 @@ export class MultiplayerMatch { private readonly timerEl = requireEl('match-timer-hud'); private readonly timerWarningEl = requireEl('match-timer-warning'); + private readonly quitBtn = requireEl('match-quit-btn'); private readonly resultModal = requireEl('result-modal'); private readonly mpScoreboard = requireEl('mp-scoreboard'); private readonly soloResultEls = [ @@ -127,7 +142,23 @@ export class MultiplayerMatch { private unsubWarning: (() => 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`/ * `setTheme`. Must be constructed with its `onCategoryAssigned` @@ -181,6 +212,8 @@ export class MultiplayerMatch { endsAt: payload.endsAt, onExpire: () => this.submitFinalColors(), }); + + this.quitBtn.classList.remove('hidden'); } /** Pads the tracked local color map with placeholders for any @@ -225,5 +258,52 @@ export class MultiplayerMatch { this.unsubWarning = null; this.unsubReveal?.(); 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?.(); } } diff --git a/frontend/src/style.css b/frontend/src/style.css index fba17dd..738a1d9 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -73,6 +73,8 @@ body { .btn.reveal { background: linear-gradient(90deg, var(--yellow), var(--orange)); } .btn.reveal:not(:disabled) { animation: revealPulse 1.6s steps(2) infinite; } .btn.big { padding: 16px 22px; font-size: 13px; } +.btn.danger { background: var(--red); color: var(--ink); } +.btn.danger.armed { background: var(--yellow); } @keyframes revealPulse { 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); } @@ -172,9 +174,11 @@ body { /* ---------- match timer HUD (#11: countdown + time warning) ---------- */ /* A second, visually distinct progress bar from .progress-wrap above — * that one tracks "N/12 token kinds themed"; this one tracks time - * remaining in the round. Built by game/timerHud.ts into the empty - * #match-timer-hud / #match-timer-warning mounts in index.html. Hidden - * by default: not mounted into any live view until a later ticket. */ + * remaining in the round, alongside the quit button. Built by + * game/timerHud.ts / game/multiplayerMatch.ts into the empty + * #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 { display: flex; align-items: center; @@ -188,6 +192,8 @@ body { box-shadow: 3px 3px 0 var(--ink); } #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-track { flex: 1; @@ -520,6 +526,23 @@ body { .menu-section-actions { display: flex; flex-direction: column; gap: 8px; margin-top: auto; } .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; } .solo-config-group { display: flex; flex-direction: column; gap: 6px; } diff --git a/frontend/src/ui/banBanner.ts b/frontend/src/ui/banBanner.ts new file mode 100644 index 0000000..fa06c28 --- /dev/null +++ b/frontend/src/ui/banBanner.ts @@ -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 ` +
+ ⏳ + +
`; +} + +/** 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('.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; +}