diff --git a/frontend/index.html b/frontend/index.html index 04d059c..3e3355a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -209,6 +209,8 @@ + + diff --git a/frontend/src/game/multiplayerMatch.ts b/frontend/src/game/multiplayerMatch.ts index 157f3a6..8f79679 100644 --- a/frontend/src/game/multiplayerMatch.ts +++ b/frontend/src/game/multiplayerMatch.ts @@ -110,6 +110,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 splitView = requireEl('split-view'); private readonly resultModal = requireEl('result-modal'); private readonly mpScoreboard = requireEl('mp-scoreboard'); private readonly soloResultEls = [ @@ -214,6 +215,7 @@ export class MultiplayerMatch { }); this.quitBtn.classList.remove('hidden'); + this.splitView.classList.remove('hidden'); } /** Pads the tracked local color map with placeholders for any @@ -261,6 +263,7 @@ export class MultiplayerMatch { this.disarmQuit(); this.quitBtn.classList.add('hidden'); + this.splitView.classList.add('hidden'); } /** First click on the Quit button arms it (shows "Confirm Quit?" and diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 48d9f89..d0c827e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,6 +6,7 @@ import { createThemeGrid } from './ui/themeGrid'; import { runThemePreview } from './ui/previewFlow'; import { createSoloConfigFlow } from './ui/soloConfigFlow'; import { createMatchmakingFlow } from './ui/matchmakingFlow'; +import { createPrivateRoomFlow } from './ui/privateRoomFlow'; import { client } from './net/client'; function requireEl(id: string): T { @@ -30,6 +31,7 @@ const helpBtn = requireEl('help-btn'); const muteBtn = requireEl('mute-btn'); const themeNameBadge = requireEl('theme-name-badge'); const changeThemeBtn = requireEl('change-theme-btn'); +const privateRoomMount = requireEl('private-room-mount'); const previewElements = { overlay: requireEl('preview-overlay'), @@ -87,10 +89,15 @@ const soloConfigFlow = createSoloConfigFlow( }, ); -// TODO(#17/#15/#14): wire real flow -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)')); +const privateRoomFlow = createPrivateRoomFlow( + client, + { mount: privateRoomMount, themeNameBadge }, + { hideMenu: () => showView(null), showMenu: () => showView('menu') }, +); + +enterCodeBtn.addEventListener('click', () => privateRoomFlow.openJoin()); +createRoomBtn.addEventListener('click', () => privateRoomFlow.openCreate()); + createMatchmakingFlow( { findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge }, client, diff --git a/frontend/src/style.css b/frontend/src/style.css index 9ace3e7..952cd10 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -761,3 +761,57 @@ body { .panel { grid-template-columns: repeat(3, 1fr); max-height: 40vh; } .theme-grid { grid-template-columns: repeat(2, 1fr); } } + +/* ---------- private room flow (#15: create/join, code share, match-N-of-5 transition) ---------- */ + +.private-room-modal { text-align: center; } +.private-room-modal .solo-config-group { text-align: left; } +.private-room-modal .private-room-join-row { text-align: left; } + +.private-room-code-wrap { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin: 18px 0; +} +.private-room-code { + font-family: var(--pixel); + font-size: 32px; + letter-spacing: 4px; + color: var(--yellow); + text-shadow: 3px 3px 0 var(--ink); +} + +.private-room-status { + font-size: 12px; + color: var(--fg-dim); + margin: 10px 0; +} + +.private-room-join-row { + display: flex; + gap: 8px; + margin: 14px 0; +} +.private-room-code-input { + flex: 1 1 auto; + min-width: 0; + font-family: var(--pixel); + font-size: 14px; + letter-spacing: 2px; + padding: 10px 12px; + background: var(--panel-2); + color: var(--fg); + border: 2px solid var(--ink); + border-radius: var(--radius); +} + +.private-room-error { + color: var(--red); + font-size: 12px; + margin: 0 0 10px; +} +.private-room-error.hidden { display: none; } + +.private-room-transition h2 { color: var(--green); } diff --git a/frontend/src/ui/privateRoomFlow.ts b/frontend/src/ui/privateRoomFlow.ts new file mode 100644 index 0000000..daaee87 --- /dev/null +++ b/frontend/src/ui/privateRoomFlow.ts @@ -0,0 +1,395 @@ +// Private-room UI flow (ticket #15): "Create Room" (pick time/theme mode, +// get a shareable numeric code, wait for an opponent) and "Enter Code" +// (join an existing room by code, with an inline retry on a bad/full +// code) — see backend/src/PROTOCOL.md "Private rooms" for the wire +// contract this drives. Same composable-widget pattern as +// `ui/banBanner.ts`: owns a single mount element, fully re-renders its +// markup into it per state, and wires this render's own buttons fresh +// each time (nothing to leak — a replaced `innerHTML` drops the old +// listeners along with the old nodes). +// +// One `GameClient` is shared for the whole page (see `net/client.ts`), +// so `room:create`/`room:join`/`round:*` listeners here are registered +// once, for the module's lifetime, and gate their reaction on this +// flow's own `roomActive` flag — `round:start`/`round:nextMatch` are +// shared message types with matchmaking (ticket #14), which registers +// its own listeners on the same client, so this flow must never react +// to a round that belongs to a matchmaking match. +// +// Session component lifetime: exactly one `ThemeGuessGame` + one +// `OpponentView` + one `MultiplayerMatch` are constructed, lazily, the +// first time a room's first `round:start` arrives, then reused for +// every subsequent match in that room's up-to-5-match series by calling +// `match.startRound()` again — `MultiplayerMatch.startRound()` already +// tears down and re-subscribes its own per-round listeners/timer, so +// it's safe to call repeatedly on the same instance (confirmed by +// reading its source; no fix needed there for this). Constructing a +// *second* `ThemeGuessGame` for a later room, or for matchmaking, would +// double-bind its canvas/document event listeners onto the single +// shared `#code-canvas` — so this module's `game`/`opponentView`/`match` +// are deliberately never rebuilt once created, only reconfigured via +// `startRound`. +// +// Known cross-flow gap (see this ticket's final report): main.ts's own +// solo-mode `game` singleton is *separately* constructed by +// `soloConfigFlow`, hardcoding its `onCategoryAssigned` callback to +// `soloConfigFlow.handleLocalAssignment` forever. If a player plays Solo +// and then enters a private room in the same page load, this module +// necessarily constructs its *own* `ThemeGuessGame`, which double-binds +// listeners on the same `#code-canvas`/`#category-panel`/`document` +// solo's instance already bound. Fixing this needs a shared, swappable +// dispatch for `game`'s callback at the main.ts composition-root level +// (mirroring how `soloConfigFlow` already multiplexes "alone" vs "vs +// bot" internally) — out of this ticket's edit scope (main.ts's +// `game`/`soloConfigFlow` construction block is off-limits here, see +// this ticket's report). + +import { THEMES } from '../data/themes'; +import type { GameClient } from '../net/client'; +import type { + RoomClosedReason, + RoomErrorReason, + RoundNextMatchMessage, + RoundStartMessage, + ThemeMode, + TimeMode, +} from '../net/messages'; +import { MultiplayerMatch } from '../game/multiplayerMatch'; +import { OpponentView } from '../game/opponentView'; +import { ThemeGuessGame } from '../game/ThemeGuessGame'; + +export interface PrivateRoomFlowElements { + /** Empty `.modal-overlay` mount (`#private-room-mount` in index.html) + * this flow owns entirely — every state below is a full `innerHTML` + * replacement into it. */ + mount: HTMLElement; + /** Same theme-name badge the solo flow updates, kept in sync with the + * server-assigned `themeId` once a round actually starts. */ + themeNameBadge: HTMLElement; +} + +export interface PrivateRoomFlowCallbacks { + /** Hides the main menu overlay — called when opening the create/join + * sub-form (this flow's own overlay takes over) and again once a + * match is actually live (both overlays clear, board underneath + * shows). */ + hideMenu: () => void; + /** Returns to the main menu overlay — cancel from any pre-match state, + * or after dismissing a `room:closed` reason. */ + showMenu: () => void; +} + +export interface PrivateRoomFlow { + openCreate(): void; + openJoin(): void; +} + +type ViewState = + | { kind: 'hidden' } + | { kind: 'create-form' } + | { kind: 'create-waiting'; code: number } + | { kind: 'join-form'; error: RoomErrorReason | null } + | { kind: 'vote-wait' } + | { kind: 'match-transition'; match: number } + | { kind: 'closed'; reason: RoomClosedReason }; + +const MATCH_TRANSITION_MS = 1400; + +const ROOM_ERROR_TEXT: Record = { + not_found: 'No room with that code — double check and try again.', + full: 'That room already has two players.', +}; + +const ROOM_CLOSED_TEXT: Record = { + quit: 'Your opponent left the room.', + matchLimit: "You've played all 5 matches!", + finished: 'The room has been closed.', +}; + +function radioValue(mount: HTMLElement, name: string, fallback: string): string { + const checked = mount.querySelector(`input[name="${name}"]:checked`); + return checked?.value ?? fallback; +} + +function requireEl(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing required element #${id}`); + return el as T; +} + +/** Wires the Private menu section's two sub-flows into `elements.mount`. + * `openCreate`/`openJoin` are this module's only entry points — call + * them from the `#create-room-btn`/`#enter-code-btn` click handlers. */ +export function createPrivateRoomFlow( + client: GameClient, + elements: PrivateRoomFlowElements, + callbacks: PrivateRoomFlowCallbacks, +): PrivateRoomFlow { + const { mount, themeNameBadge } = elements; + + let roomActive = false; + let lastJoinCode = ''; + let transitionTimeoutId: number | null = null; + + let opponentView: OpponentView | null = null; + let game: ThemeGuessGame | null = null; + let match: MultiplayerMatch | null = null; + const splitView = requireEl('split-view'); + + function clearTransitionTimeout(): void { + if (transitionTimeoutId !== null) { + window.clearTimeout(transitionTimeoutId); + transitionTimeoutId = null; + } + } + + /** Force-abandons a room the local player has no active round session + * in yet (still waiting alone for an opponent, or theme voting is in + * progress) — `player:quit` is a no-op server-side with no session to + * attach it to (see backend/src/game/session.js), so the only way to + * make the server actually free the room/notify an opponent already + * in it is a real socket disconnect. `GameClient.on` subscriptions + * live on the client instance, not the socket, so they survive this + * reconnect untouched. */ + function abandonIdleRoom(): void { + client.close(); + client.connect(); + } + + function render(state: ViewState): void { + clearTransitionTimeout(); + + if (state.kind === 'hidden') { + mount.classList.add('hidden'); + mount.innerHTML = ''; + return; + } + + mount.classList.remove('hidden'); + + switch (state.kind) { + case 'create-form': + mount.innerHTML = ` + `; + mount.querySelector('[data-action="cancel"]')!.addEventListener('click', () => { + callbacks.showMenu(); + render({ kind: 'hidden' }); + }); + mount.querySelector('[data-action="submit"]')!.addEventListener('click', () => { + const timeMode = Number(radioValue(mount, 'pr-time-mode', '1')) as TimeMode; + const themeMode = radioValue(mount, 'pr-theme-mode', 'random') as ThemeMode; + client.send({ type: 'room:create', timeMode, themeMode }); + }); + break; + + case 'create-waiting': { + const code = state.code; + mount.innerHTML = ` + `; + const copyBtn = mount.querySelector('[data-action="copy"]')!; + copyBtn.addEventListener('click', () => { + if (!navigator.clipboard?.writeText) return; + navigator.clipboard.writeText(String(code)).then( + () => { + const original = copyBtn.textContent; + copyBtn.textContent = '✅ Copied!'; + window.setTimeout(() => { copyBtn.textContent = original; }, 1500); + }, + () => {}, + ); + }); + mount.querySelector('[data-action="cancel"]')!.addEventListener('click', () => { + abandonIdleRoom(); + callbacks.showMenu(); + render({ kind: 'hidden' }); + }); + break; + } + + case 'join-form': + mount.innerHTML = ` + `; + { + const input = mount.querySelector('.private-room-code-input')!; + input.addEventListener('input', () => { lastJoinCode = input.value.replace(/\D/g, ''); input.value = lastJoinCode; }); + input.focus(); + const submit = (): void => { + const code = Number(lastJoinCode); + if (!lastJoinCode || Number.isNaN(code)) return; + client.send({ type: 'room:join', code }); + }; + mount.querySelector('[data-action="submit"]')!.addEventListener('click', submit); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); + } + mount.querySelector('[data-action="cancel"]')!.addEventListener('click', () => { + lastJoinCode = ''; + callbacks.showMenu(); + render({ kind: 'hidden' }); + }); + break; + + case 'vote-wait': + // TODO(#16): replace with real theme-vote modal (pick from + // `vote:start.themeIds`, live opponent-choice indicator via + // `vote:opponentChoice`). This ticket only needs to not hang — + // `round:start` (below) always follows `vote:settled`. + mount.innerHTML = ` + `; + mount.querySelector('[data-action="cancel"]')!.addEventListener('click', () => { + abandonIdleRoom(); + callbacks.showMenu(); + render({ kind: 'hidden' }); + }); + break; + + case 'match-transition': + mount.innerHTML = ` + `; + transitionTimeoutId = window.setTimeout(() => { + transitionTimeoutId = null; + render({ kind: 'hidden' }); + }, MATCH_TRANSITION_MS); + break; + + case 'closed': + mount.innerHTML = ` + `; + mount.querySelector('[data-action="menu"]')!.addEventListener('click', () => { + callbacks.showMenu(); + render({ kind: 'hidden' }); + }); + break; + } + } + + /** Lazily builds (once, ever, for this flow) the `OpponentView` + + * `MultiplayerMatch` + `ThemeGuessGame` trio, following + * `multiplayerMatch.ts`'s documented two-phase-init order, then reuses + * them for every later `round:start` this room sends. */ + function ensureMatch(payload: RoundStartMessage): MultiplayerMatch { + if (match && game) return match; + + opponentView = new OpponentView('opponent-canvas', payload.snippetIndex); + match = new MultiplayerMatch(client, opponentView, () => { + // Local player quit mid-round: per PROTOCOL.md "Quit", the + // quitter gets no room:closed of their own (only the remaining + // player does) — so this is the definitive "I've left" signal, + // not something to wait on a server reply for. + roomActive = false; + callbacks.showMenu(); + render({ kind: 'hidden' }); + }); + game = new ThemeGuessGame(payload.themeId, payload.snippetIndex, (id, hex) => match!.handleLocalAssignment(id, hex)); + match.bindGame(game); + return match; + } + + client.on('room:created', (msg) => { + render({ kind: 'create-waiting', code: msg.code }); + }); + + client.on('room:error', (msg) => { + render({ kind: 'join-form', error: msg.reason }); + }); + + client.on('room:joined', (msg) => { + roomActive = true; + if (msg.themeMode === 'chosen') { + render({ kind: 'vote-wait' }); + } else { + render({ kind: 'hidden' }); + callbacks.hideMenu(); + } + }); + + client.on('round:nextMatch', (msg: RoundNextMatchMessage) => { + if (!roomActive) return; + render({ kind: 'match-transition', match: msg.match }); + }); + + client.on('round:start', (payload) => { + if (!roomActive) return; + render({ kind: 'hidden' }); + callbacks.hideMenu(); + themeNameBadge.textContent = THEMES[payload.themeId].name; + ensureMatch(payload).startRound(payload); + }); + + client.on('room:closed', (msg) => { + roomActive = false; + // The opponent's own `MultiplayerMatch.teardownRound()` never ran + // for this abandoned round (only the quitter's local instance tore + // its round down) — hide the stale split-view pane here so it + // doesn't linger behind the closed-room banner / re-shown menu. + splitView.classList.add('hidden'); + render({ kind: 'closed', reason: msg.reason }); + }); + + return { + openCreate(): void { + callbacks.hideMenu(); + render({ kind: 'create-form' }); + }, + openJoin(): void { + lastJoinCode = ''; + callbacks.hideMenu(); + render({ kind: 'join-form', error: null }); + }, + }; +}