feat(frontend): add matchmaking UI flow (Find Match)
Wires the Matchmaking menu section's Find Match button to the real queue:join/queue:leave flow: a searching state with cancel, the queue:banned countdown (banBanner.ts) with Find Match hidden until it expires, a brief assigned-mode reveal on match:found, and the handoff into MultiplayerMatch's two-phase init once round:start is known. After a match ends (round:reveal + result dismissal, local quit, or opponent quit via room:closed) the flow returns to the matchmaking menu without re-queuing. Also adds a shared GameClient singleton (net/client.ts) so every UI flow that talks to the server reuses one connection. Note: backend/src/matchmaking/queue.js actually sends round:start before match:found (the reverse of PROTOCOL.md's documented order), discovered via live two-tab testing against the real backend; matchmakingFlow.ts tracks both messages independently so either arrival order works.
This commit is contained in:
parent
0d1de48841
commit
8bb0c79659
5 changed files with 320 additions and 4 deletions
|
|
@ -151,6 +151,7 @@
|
|||
<div class="menu-section-actions">
|
||||
<button id="find-match-btn" class="btn primary">Find Match</button>
|
||||
</div>
|
||||
<div id="matchmaking-status" class="hidden"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<T extends HTMLElement>(id: string): T {
|
||||
const el = document.getElementById(id);
|
||||
|
|
@ -23,6 +25,7 @@ const startBtn = requireEl<HTMLButtonElement>('start-btn');
|
|||
const enterCodeBtn = requireEl<HTMLButtonElement>('enter-code-btn');
|
||||
const createRoomBtn = requireEl<HTMLButtonElement>('create-room-btn');
|
||||
const findMatchBtn = requireEl<HTMLButtonElement>('find-match-btn');
|
||||
const matchmakingStatusEl = requireEl<HTMLElement>('matchmaking-status');
|
||||
const helpBtn = requireEl<HTMLButtonElement>('help-btn');
|
||||
const muteBtn = requireEl<HTMLButtonElement>('mute-btn');
|
||||
const themeNameBadge = requireEl<HTMLElement>('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'));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
283
frontend/src/ui/matchmakingFlow.ts
Normal file
283
frontend/src/ui/matchmakingFlow.ts
Normal file
|
|
@ -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<T extends HTMLElement>(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<TimeMode, string> = {
|
||||
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<HTMLButtonElement>('close-result-btn');
|
||||
const playAgainBtn = requireEl<HTMLButtonElement>('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 = `
|
||||
<p class="matchmaking-status-text">🔎 Searching for opponent…</p>
|
||||
<button type="button" id="matchmaking-cancel-btn" class="btn ghost small">Cancel</button>`;
|
||||
statusMount.classList.remove('hidden');
|
||||
statusMount.querySelector<HTMLButtonElement>('#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 = `<p class="matchmaking-status-text matchmaking-mode-found">${TIME_MODE_LABEL[timeMode]}</p>`;
|
||||
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);
|
||||
}
|
||||
Loading…
Reference in a new issue