diff --git a/frontend/index.html b/frontend/index.html index ef4ee5b..04d059c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -151,6 +151,7 @@ + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 52061db..48d9f89 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,6 +5,8 @@ import { sound } from './engine/sound'; import { createThemeGrid } from './ui/themeGrid'; import { runThemePreview } from './ui/previewFlow'; import { createSoloConfigFlow } from './ui/soloConfigFlow'; +import { createMatchmakingFlow } from './ui/matchmakingFlow'; +import { client } from './net/client'; function requireEl(id: string): T { const el = document.getElementById(id); @@ -23,6 +25,7 @@ const startBtn = requireEl('start-btn'); const enterCodeBtn = requireEl('enter-code-btn'); const createRoomBtn = requireEl('create-room-btn'); const findMatchBtn = requireEl('find-match-btn'); +const matchmakingStatusEl = requireEl('matchmaking-status'); const helpBtn = requireEl('help-btn'); const muteBtn = requireEl('mute-btn'); const themeNameBadge = requireEl('theme-name-badge'); @@ -88,8 +91,11 @@ const soloConfigFlow = createSoloConfigFlow( enterCodeBtn.addEventListener('click', () => console.log('[menu] enter code clicked (not implemented yet)')); // TODO(#17/#15/#14): wire real flow createRoomBtn.addEventListener('click', () => console.log('[menu] create room clicked (not implemented yet)')); -// TODO(#17/#15/#14): wire real flow -findMatchBtn.addEventListener('click', () => console.log('[menu] find match clicked (not implemented yet)')); +createMatchmakingFlow( + { findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge }, + client, + { showBoard: () => showView(null), showMenu: () => showView('menu') }, +); helpBtn.addEventListener('click', () => showView('menu')); changeThemeBtn.addEventListener('click', () => showView('menu')); diff --git a/frontend/src/net/client.ts b/frontend/src/net/client.ts index 77e71dc..ea1ebdb 100644 --- a/frontend/src/net/client.ts +++ b/frontend/src/net/client.ts @@ -103,3 +103,11 @@ export class GameClient { for (const cb of set) cb(msg); } } + +/** Single shared connection for the whole app — every UI flow that + * needs to talk to the server (matchmaking, private rooms, ...) imports + * this instead of constructing its own `GameClient`, so there is ever + * only one socket/identify handshake per page load. Connects eagerly as + * soon as this module is first imported. */ +export const client = new GameClient(); +client.connect(); diff --git a/frontend/src/style.css b/frontend/src/style.css index 738a1d9..9ace3e7 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -527,8 +527,8 @@ body { .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. */ + * Mounted into #matchmaking-status by frontend/src/ui/matchmakingFlow.ts + * (#14) on a queue:banned reply. */ .ban-banner { display: flex; align-items: center; @@ -543,6 +543,24 @@ body { } .ban-banner-icon { flex-shrink: 0; } +/* ---------- matchmaking flow status (#14: Find Match / searching / mode reveal) ---------- */ + +#matchmaking-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +#matchmaking-status.hidden { display: none; } + +.matchmaking-status-text { margin: 0; font-size: 11px; color: var(--fg-dim); text-align: center; } +.matchmaking-mode-found { + font-family: var(--pixel); + font-size: 12px; + color: var(--green); + animation: revealPulse 1.6s steps(2) infinite; +} + #menu-solo .theme-grid { margin-bottom: 0; } .solo-config-group { display: flex; flex-direction: column; gap: 6px; } diff --git a/frontend/src/ui/matchmakingFlow.ts b/frontend/src/ui/matchmakingFlow.ts new file mode 100644 index 0000000..e2e1af6 --- /dev/null +++ b/frontend/src/ui/matchmakingFlow.ts @@ -0,0 +1,283 @@ +// Wires the Matchmaking menu section's "Find Match" flow: queue:join / +// queue:leave, the banned-state countdown (ui/banBanner.ts), a brief +// "N MINUTE MATCH" reveal once matched, and the handoff into +// game/multiplayerMatch.ts's `MultiplayerMatch` (see that module's +// header for the two-phase `MultiplayerMatch` / `ThemeGuessGame` +// construction order this mirrors). PROTOCOL.md describes `round:start` +// as following `match:found`, but the real `queue.js` implementation +// sends them in the opposite order — see the `foundAt`/`roundStartPayload` +// handling below, which treats both orderings identically. +// +// Owns a single `OpponentView` / `ThemeGuessGame` / `MultiplayerMatch` +// trio for the lifetime of the page, constructed lazily on the first +// matchmaking round and reused (`setTheme`/`setSnippet`) on every +// subsequent one — mirroring how `ui/soloConfigFlow.ts` reuses a single +// `ThemeGuessGame` across solo replays. `ThemeGuessGame`'s constructor +// binds fresh DOM listeners every time it's called, so constructing a +// second one while a first is still alive (e.g. mixing solo/bot play +// with matchmaking in the same page load) would double up event +// handling; out of scope here, same single-orchestrator assumption +// `game/multiplayerMatch.ts`'s header documents. + +import { ThemeGuessGame } from '../game/ThemeGuessGame'; +import { MultiplayerMatch } from '../game/multiplayerMatch'; +import { OpponentView } from '../game/opponentView'; +import { THEMES } from '../data/themes'; +import type { GameClient } from '../net/client'; +import type { RoundStartMessage, TimeMode } from '../net/messages'; +import { renderBanBanner } from './banBanner'; + +function requireEl(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing required element #${id}`); + return el as T; +} + +export interface MatchmakingFlowElements { + findMatchBtn: HTMLButtonElement; + /** Dedicated mount inside `#menu-matchmaking` for the dynamic + * searching / banned / mode-found states (see index.html). Hidden + * whenever `findMatchBtn` is showing its idle state. */ + statusMount: HTMLElement; + themeNameBadge: HTMLElement; +} + +export interface MatchmakingFlowHooks { + /** Hides every overlay screen so the board (and, once + * `MultiplayerMatch` shows it, the opponent split-view) becomes + * visible — mirrors `showView(null)` in main.ts. */ + showBoard: () => void; + /** Reveals the main menu overlay again — mirrors `showView('menu')` + * in main.ts. */ + showMenu: () => void; +} + +type FlowState = 'idle' | 'searching' | 'banned' | 'found' | 'in-match'; + +const TIME_MODE_LABEL: Record = { + 1: '🎯 1 MINUTE MATCH', + 2: '🎯 2 MINUTE MATCH', +}; + +/** Minimum time the assigned-mode banner stays up before handing off to + * `MultiplayerMatch` — `match:found`/`round:start` can arrive within the + * same tick (see this module's header), so without a floor here the + * mode banner could be effectively invisible. */ +const MODE_REVEAL_MIN_MS = 1500; + +/** Wires `elements.findMatchBtn` into the real matchmaking flow + * described in this module's header. Nothing needs to be exposed back + * to the caller — every transition is driven by `client` events and DOM + * clicks — so, like `ui/previewFlow.ts`, this returns nothing. */ +export function createMatchmakingFlow( + elements: MatchmakingFlowElements, + client: GameClient, + hooks: MatchmakingFlowHooks, +): void { + const { findMatchBtn, statusMount, themeNameBadge } = elements; + const closeResultBtn = requireEl('close-result-btn'); + const playAgainBtn = requireEl('play-again-btn'); + + let state: FlowState = 'idle'; + let stopBanBanner: (() => void) | null = null; + let banExpiryTimeoutId: number | null = null; + let modeRevealTimeoutId: number | null = null; + // `backend/src/matchmaking/queue.js`'s `tryMatch()` actually sends + // `round:start` (via `createSession`) *before* `match:found`, the + // reverse of PROTOCOL.md's stated "match:found, then round:start" + // ordering — so both of these are tracked independently and the + // handoff below fires once both are known, regardless of which + // arrived first. + let foundAt: number | null = null; + let roundStartPayload: RoundStartMessage | null = null; + let handoffScheduled = false; + + /** Set once a round:reveal ends an active matchmaking match, so the + * next `close-result-btn`/`play-again-btn` click (both shared, global + * result-modal buttons — see `game/ThemeGuessGame.ts`) routes back to + * the menu instead of just closing the modal. Left `false` the rest + * of the time so solo/bot result dismissals are unaffected. */ + let awaitingMenuReturn = false; + + // Lazily constructed on the first matchmaking round, then reused for + // every subsequent one this page load — see this module's header. + let opponentView: OpponentView | null = null; + let match: MultiplayerMatch | null = null; + let game: ThemeGuessGame | null = null; + + function clearBanTimers(): void { + stopBanBanner?.(); + stopBanBanner = null; + if (banExpiryTimeoutId !== null) { + window.clearTimeout(banExpiryTimeoutId); + banExpiryTimeoutId = null; + } + } + + function clearModeRevealTimer(): void { + if (modeRevealTimeoutId !== null) { + window.clearTimeout(modeRevealTimeoutId); + modeRevealTimeoutId = null; + } + } + + function hideStatus(): void { + statusMount.innerHTML = ''; + statusMount.classList.add('hidden'); + } + + function renderIdle(): void { + state = 'idle'; + clearBanTimers(); + clearModeRevealTimer(); + foundAt = null; + roundStartPayload = null; + handoffScheduled = false; + hideStatus(); + findMatchBtn.classList.remove('hidden'); + } + + function renderSearching(): void { + state = 'searching'; + findMatchBtn.classList.add('hidden'); + statusMount.innerHTML = ` +

🔎 Searching for opponent…

+ `; + statusMount.classList.remove('hidden'); + statusMount.querySelector('#matchmaking-cancel-btn')! + .addEventListener('click', handleCancelClick); + } + + function renderBanned(bannedUntil: number): void { + state = 'banned'; + clearModeRevealTimer(); + findMatchBtn.classList.add('hidden'); + clearBanTimers(); + stopBanBanner = renderBanBanner(statusMount, bannedUntil); + // renderBanBanner's own interval self-clears at expiry; this timer + // just re-enables Find Match at (essentially) the same moment, + // since renderBanBanner exposes no "expired" callback of its own. + const remaining = Math.max(0, bannedUntil - Date.now()); + banExpiryTimeoutId = window.setTimeout(() => renderIdle(), remaining + 250); + } + + function renderModeFound(timeMode: TimeMode): void { + state = 'found'; + findMatchBtn.classList.add('hidden'); + statusMount.innerHTML = `

${TIME_MODE_LABEL[timeMode]}

`; + statusMount.classList.remove('hidden'); + } + + function handleCancelClick(): void { + if (state !== 'searching') return; + client.send({ type: 'queue:leave' }); + renderIdle(); + } + + function returnToMenu(): void { + renderIdle(); + hooks.showMenu(); + } + + /** `MultiplayerMatch`'s `onQuit`: the local player's own quit already + * tore the match down with no result modal to wait for, so head + * straight back to the menu. */ + function handleLocalQuit(): void { + if (state !== 'in-match') return; + returnToMenu(); + } + + function startMatch(payload: RoundStartMessage): void { + modeRevealTimeoutId = null; + state = 'in-match'; + hideStatus(); + themeNameBadge.textContent = THEMES[payload.themeId].name; + + if (!opponentView) opponentView = new OpponentView('opponent-canvas', payload.snippetIndex); + if (!match) match = new MultiplayerMatch(client, opponentView, handleLocalQuit); + if (!game) { + game = new ThemeGuessGame(payload.themeId, payload.snippetIndex, (id, hex) => match!.handleLocalAssignment(id, hex)); + match.bindGame(game); + } else { + game.setTheme(payload.themeId); + game.setSnippet(payload.snippetIndex); + } + + hooks.showBoard(); + match.startRound(payload); + } + + findMatchBtn.addEventListener('click', () => { + if (state !== 'idle') return; + client.send({ type: 'queue:join' }); + renderSearching(); + }); + + client.on('queue:banned', (msg) => { + if (state !== 'searching' && state !== 'idle') return; + renderBanned(msg.bannedUntil); + }); + + function noteFound(timeMode: TimeMode): void { + if (foundAt !== null) return; // already noted by the other message + foundAt = Date.now(); + renderModeFound(timeMode); + } + + function maybeScheduleHandoff(): void { + if (handoffScheduled || foundAt === null || !roundStartPayload) return; + handoffScheduled = true; + const payload = roundStartPayload; + const remaining = Math.max(0, foundAt + MODE_REVEAL_MIN_MS - Date.now()); + modeRevealTimeoutId = window.setTimeout(() => startMatch(payload), remaining); + } + + client.on('match:found', (msg) => { + if (state !== 'searching' && state !== 'found') return; + noteFound(msg.timeMode); + maybeScheduleHandoff(); + }); + + // `round:start` also fires for private-room matches on this same + // shared `client`; those are ignored here because this flow is only + // ever in 'searching'/'found' state while genuinely waiting on a + // matchmaking match (see this module's header for why `round:start` + // alone — not `match:found` — is treated as authoritative for the + // handoff timing). + client.on('round:start', (payload) => { + if (state !== 'searching' && state !== 'found') return; + noteFound(payload.timeMode); + roundStartPayload = payload; + maybeScheduleHandoff(); + }); + + // Matchmaking is single-round (PROTOCOL.md "Matchmaking": both + // players return to Idle after round:reveal), so this is always the + // natural end of an active match — wait for the result modal + // (already shown by `MultiplayerMatch.handleReveal`) to be dismissed + // before actually navigating back to the menu, so the two DOM + // mutations don't race (see close/play-again handlers below). + client.on('round:reveal', () => { + if (state !== 'in-match') return; + renderIdle(); + awaitingMenuReturn = true; + }); + + // Opponent quit or disconnected mid-round: game/session.js sends + // room:playerLeft then room:closed to the remaining player, which + // `MultiplayerMatch` itself never listens for (see its header) — no + // round:reveal will ever arrive for this match, so go straight back, + // there's no result modal to wait for. + client.on('room:closed', () => { + if (state !== 'in-match') return; + returnToMenu(); + }); + + const handleResultDismiss = (): void => { + if (!awaitingMenuReturn) return; + awaitingMenuReturn = false; + hooks.showMenu(); + }; + closeResultBtn.addEventListener('click', handleResultDismiss); + playAgainBtn.addEventListener('click', handleResultDismiss); +}