feat(frontend): add private room UI flow (create/join/repeat)

- frontend/src/ui/privateRoomFlow.ts: create-room sub-flow (time/theme
  mode form, large shareable code, copy-to-clipboard with graceful
  no-op fallback, waiting-for-opponent state) and enter-code sub-flow
  (numeric input + inline room:error retry, no dead end). Handles
  room:joined for both themeMode:'random' (straight into the live
  match) and 'chosen' (vote-wait placeholder, TODO(#16) for the real
  vote modal). Listens for round:nextMatch to show a brief 'Match N of
  5' transition, and room:closed to show the reason and return to the
  main menu. A room with no active session yet (waiting alone, or
  mid-vote) is abandoned via a socket disconnect/reconnect cycle, since
  player:quit is a no-op server-side without a session to attach to.
- frontend/src/game/multiplayerMatch.ts: MultiplayerMatch now owns
  #split-view visibility (shown in startRound, hidden in
  teardownRound), mirroring botMatch.ts's existing convention — nothing
  previously made the opponent pane visible for a real multiplayer
  match.
- frontend/src/net/client.ts: exports a shared `client` GameClient
  singleton (connects on import) so private rooms and matchmaking use
  one socket instead of each constructing their own.
- frontend/index.html: adds the #private-room-mount overlay container.
- frontend/src/style.css: private-room-* styles for the code display,
  join form, and error/status text.
- frontend/src/main.ts: replaces only the enterCodeBtn/createRoomBtn
  TODO stubs with real privateRoomFlow wiring; findMatchBtn line
  untouched.
This commit is contained in:
Gabriel Franco 2026-09-10 14:07:20 -03:00
parent 8bb0c79659
commit f0d08f3b50
5 changed files with 465 additions and 4 deletions

View file

@ -209,6 +209,8 @@
</div>
</div>
<div id="private-room-mount" class="modal-overlay hidden"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -110,6 +110,7 @@ export class MultiplayerMatch {
private readonly timerEl = requireEl<HTMLElement>('match-timer-hud');
private readonly timerWarningEl = requireEl<HTMLElement>('match-timer-warning');
private readonly quitBtn = requireEl<HTMLButtonElement>('match-quit-btn');
private readonly splitView = requireEl<HTMLElement>('split-view');
private readonly resultModal = requireEl<HTMLElement>('result-modal');
private readonly mpScoreboard = requireEl<HTMLElement>('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

View file

@ -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<T extends HTMLElement>(id: string): T {
@ -30,6 +31,7 @@ const helpBtn = requireEl<HTMLButtonElement>('help-btn');
const muteBtn = requireEl<HTMLButtonElement>('mute-btn');
const themeNameBadge = requireEl<HTMLElement>('theme-name-badge');
const changeThemeBtn = requireEl<HTMLButtonElement>('change-theme-btn');
const privateRoomMount = requireEl<HTMLElement>('private-room-mount');
const previewElements = {
overlay: requireEl<HTMLElement>('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,

View file

@ -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); }

View file

@ -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<RoomErrorReason, string> = {
not_found: 'No room with that code — double check and try again.',
full: 'That room already has two players.',
};
const ROOM_CLOSED_TEXT: Record<RoomClosedReason, string> = {
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<HTMLInputElement>(`input[name="${name}"]:checked`);
return checked?.value ?? fallback;
}
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;
}
/** 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<HTMLElement>('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 = `
<div class="modal private-room-modal">
<h2>🔒 Create Room</h2>
<div class="solo-config-group">
<p class="solo-config-label">⏱ TIME</p>
<div class="solo-radio-row">
<label class="solo-radio"><input type="radio" name="pr-time-mode" value="1" checked />1 MIN</label>
<label class="solo-radio"><input type="radio" name="pr-time-mode" value="2" />2 MIN</label>
</div>
</div>
<div class="solo-config-group">
<p class="solo-config-label">🎨 THEME</p>
<div class="solo-radio-row">
<label class="solo-radio"><input type="radio" name="pr-theme-mode" value="random" checked />RANDOM</label>
<label class="solo-radio"><input type="radio" name="pr-theme-mode" value="chosen" />VOTE TOGETHER</label>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn ghost" data-action="cancel">Cancel</button>
<button type="button" class="btn primary" data-action="submit">Create Room</button>
</div>
</div>`;
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 = `
<div class="modal private-room-modal">
<h2>Room Created</h2>
<p>Share this code with your friend:</p>
<div class="private-room-code-wrap">
<span class="private-room-code">${code}</span>
<button type="button" class="btn ghost small" data-action="copy">📋 Copy</button>
</div>
<p class="private-room-status">⏳ Waiting for opponent…</p>
<div class="modal-actions">
<button type="button" class="btn ghost" data-action="cancel">Cancel</button>
</div>
</div>`;
const copyBtn = mount.querySelector<HTMLButtonElement>('[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 = `
<div class="modal private-room-modal">
<h2>Enter Code</h2>
<div class="private-room-join-row">
<input type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6"
class="private-room-code-input" placeholder="123456" value="${lastJoinCode}" />
<button type="button" class="btn primary" data-action="submit">Join</button>
</div>
<p class="private-room-error${state.error ? '' : ' hidden'}">${state.error ? ROOM_ERROR_TEXT[state.error] : ''}</p>
<div class="modal-actions">
<button type="button" class="btn ghost" data-action="cancel">Cancel</button>
</div>
</div>`;
{
const input = mount.querySelector<HTMLInputElement>('.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 = `
<div class="modal private-room-modal private-room-vote-wait">
<h2>🗳 Theme Vote</h2>
<p class="private-room-status">Waiting for theme vote to settle…</p>
<div class="modal-actions">
<button type="button" class="btn ghost" data-action="cancel">Cancel</button>
</div>
</div>`;
mount.querySelector('[data-action="cancel"]')!.addEventListener('click', () => {
abandonIdleRoom();
callbacks.showMenu();
render({ kind: 'hidden' });
});
break;
case 'match-transition':
mount.innerHTML = `
<div class="modal private-room-modal private-room-transition">
<h2>Match ${state.match} of 5</h2>
</div>`;
transitionTimeoutId = window.setTimeout(() => {
transitionTimeoutId = null;
render({ kind: 'hidden' });
}, MATCH_TRANSITION_MS);
break;
case 'closed':
mount.innerHTML = `
<div class="modal private-room-modal">
<h2>Room Closed</h2>
<p class="private-room-status">${ROOM_CLOSED_TEXT[state.reason]}</p>
<div class="modal-actions">
<button type="button" class="btn primary" data-action="menu">Back to Menu</button>
</div>
</div>`;
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 });
},
};
}