diff --git a/backend/src/PROTOCOL.md b/backend/src/PROTOCOL.md index b25f84a..53e0384 100644 --- a/backend/src/PROTOCOL.md +++ b/backend/src/PROTOCOL.md @@ -54,8 +54,8 @@ socket if anything else arrives first. ## Round lifecycle (shared by matchmaking and rooms) Both matchmaking matches and private-room matches run the same -round state machine once two players are paired. `timeMode` (`1` or -`2`, minutes) and the `themeId` for the round are decided upstream +round state machine once two players are paired. `timeMode` (`2` or +`4`, minutes) and the `themeId` for the round are decided upstream (randomly for matchmaking, per room config for rooms) before `round:start` fires. @@ -77,7 +77,7 @@ stateDiagram-v2 "type": "round:start", "snippetIndex": 2, // server-authoritative, see Snippet identity contract below "themeId": "tokyoNight", // ThemeId; server-authoritative - "timeMode": 2, // 1 | 2 (minutes); server-authoritative + "timeMode": 4, // 2 | 4 (minutes); server-authoritative "endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline } ``` @@ -172,7 +172,7 @@ stateDiagram-v2 ``` - **`queue:join`** — client -> server. No mode parameter: the server - randomly assigns 1-minute or 2-minute mode per match (each queued + randomly assigns 2-minute or 4-minute mode per match (each queued player doesn't pick, so pairing isn't constrained by mode preference). ```jsonc { "type": "queue:join" } @@ -194,7 +194,7 @@ stateDiagram-v2 of the queue. A `round:start` for match 1 follows immediately; there is no matchmaking equivalent of a room code or lobby. ```jsonc - { "type": "match:found", "opponentName": "ada", "timeMode": 1 } + { "type": "match:found", "opponentName": "ada", "timeMode": 2 } ``` Matchmaking matches are single-round (no best-of-5, no `round:nextMatch`) @@ -218,8 +218,8 @@ stateDiagram-v2 - **`room:create`** — client -> server, creates a room and puts the creator in it waiting for an opponent. ```jsonc - { "type": "room:create", "timeMode": 2, "themeMode": "chosen" } - // timeMode: 1 | 2 (minutes); themeMode: 'random' | 'chosen' + { "type": "room:create", "timeMode": 4, "themeMode": "chosen" } + // timeMode: 2 | 4 (minutes); themeMode: 'random' | 'chosen' ``` - **`room:created`** — server -> creator, with a short numeric code to share out-of-band with the intended opponent. @@ -238,7 +238,7 @@ stateDiagram-v2 "type": "room:joined", "code": 483920, "opponentName": "ada", - "timeMode": 2, + "timeMode": 4, "themeMode": "chosen" } ``` diff --git a/backend/src/game/session.js b/backend/src/game/session.js index 3ab7263..1ac4a3c 100644 --- a/backend/src/game/session.js +++ b/backend/src/game/session.js @@ -100,7 +100,7 @@ function quitSession(session, quitterId) { /** * Starts one round session between two already-identified players. - * `timeMode` is 1 or 2 (minutes, per PROTOCOL.md); `themeId` and + * `timeMode` is 2 or 4 (minutes, per PROTOCOL.md); `themeId` and * `snippetIndex` are decided upstream (or `snippetIndex` is picked * randomly here if omitted). Sends round:start to both sockets * immediately and returns the session object. diff --git a/backend/src/matchmaking/README.md b/backend/src/matchmaking/README.md index b7ad313..3b5e1cd 100644 --- a/backend/src/matchmaking/README.md +++ b/backend/src/matchmaking/README.md @@ -1,7 +1,7 @@ # matchmaking/ Pairs waiting players FIFO-style, randomly assigns per-match timeMode -(1 or 2 minutes, never player-chosen) and theme, and enforces the +(2 or 4 minutes, never player-chosen) and theme, and enforces the 2-minute matchmaking quit ban — see `queue.js` and `../PROTOCOL.md`'s "Matchmaking" and "Quit" sections. Party/lobby handling beyond a single 1v1 queue is not implemented. diff --git a/backend/src/matchmaking/queue.js b/backend/src/matchmaking/queue.js index 8f74a73..87e3d19 100644 --- a/backend/src/matchmaking/queue.js +++ b/backend/src/matchmaking/queue.js @@ -61,7 +61,7 @@ function tryMatch() { const playerAId = popOldestWaiting(); const playerBId = popOldestWaiting(); - const timeMode = Math.random() < 0.5 ? 1 : 2; + const timeMode = Math.random() < 0.5 ? 2 : 4; const themeId = pickRandomThemeId(); activeMatchmakingPlayerIds.add(playerAId); diff --git a/backend/src/rooms/room.js b/backend/src/rooms/room.js index 9776070..135534e 100644 --- a/backend/src/rooms/room.js +++ b/backend/src/rooms/room.js @@ -159,7 +159,7 @@ export function registerRoomHandlers() { if (!ownerId) return; const { timeMode, themeMode } = message; - if (timeMode !== 1 && timeMode !== 2) return; + if (timeMode !== 2 && timeMode !== 4) return; if (themeMode !== 'random' && themeMode !== 'chosen') return; const code = generateCode(); diff --git a/frontend/index.html b/frontend/index.html index 3e3355a..3d63df0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -40,19 +40,30 @@
-
- + -
- - `; } -/** Renders the mode badge + (for 1/2-minute modes) progress bar + mm:ss +/** Renders the mode badge + (for 2/4-minute modes) progress bar + mm:ss * readout into `container`, and starts driving it from `options.endsAt` * via requestAnimationFrame. Returns a `stop()` that cancels the animation * loop; call it on round end/teardown to avoid leaking the RAF handle. */ diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c6a7b88..bb71089 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,9 +1,6 @@ import './style.css'; -import { getSharedGame } from './game/sharedGame'; -import { THEMES } from './data/themes'; 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 { createPrivateRoomFlow } from './ui/privateRoomFlow'; @@ -58,24 +55,18 @@ function showView(name: ScreenName | null): void { const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen()); -const soloConfigFlow = createSoloConfigFlow( +createSoloConfigFlow( { section: menuSoloSection, themePickerWrap: soloThemePickerWrap, botDifficultyWrap: soloBotDifficultyWrap, playBtn: startBtn, timerMount: soloTimerMount, + themeNameBadge, + preview: previewElements, }, themeGrid, - (config) => { - showView(null); - runThemePreview(previewElements, config.themeId, () => { - getSharedGame(config.themeId, config.snippetIndex, (id, hex) => soloConfigFlow.handleLocalAssignment(id, hex)); - themeNameBadge.textContent = THEMES[config.themeId].name; - sound.playApply(); - soloConfigFlow.startTimer(config.timeMode); - }, () => sound.playOpen()); - }, + { hideMenu: () => showView(null), showMenu: () => showView('menu') }, ); const privateRoomFlow = createPrivateRoomFlow( diff --git a/frontend/src/net/messages.ts b/frontend/src/net/messages.ts index 247eef8..6a1e39b 100644 --- a/frontend/src/net/messages.ts +++ b/frontend/src/net/messages.ts @@ -7,7 +7,7 @@ import type { CategoryId, ThemeId } from '../types'; -export type TimeMode = 1 | 2; +export type TimeMode = 2 | 4; export type ThemeMode = 'random' | 'chosen'; export type RoomErrorReason = 'not_found' | 'full'; export type RoomClosedReason = 'quit' | 'matchLimit' | 'finished'; diff --git a/frontend/src/style.css b/frontend/src/style.css index a7314ce..302de4f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -285,14 +285,14 @@ body { .panel-actions .btn { width: 100%; } /* ---------- split view (in-match opponent progress pane) ---------- */ +/* `.board` now holds up to three flex children: the "Yours" split-pane + * (always present — it's the live interactive `#code-canvas`), the + * "Opponent" split-pane (`#split-view`, hidden outside a bot/multiplayer + * round), and the fixed-width `.panel-col`. Hiding `#split-view` lets + * `.yours-pane` fill the whole row, exactly reproducing solo-alone's + * original single-canvas layout. */ -.split-view { - flex: 1 1 auto; - min-height: 0; - display: flex; - gap: 14px; -} -.split-view.hidden { display: none; } +.split-pane.hidden { display: none; } .split-pane { flex: 1 1 0; @@ -311,12 +311,43 @@ body { flex: 0 0 auto; } +/* Only meaningful once there's something to compare against — stays + * hidden while `#split-view` (the "Opponent" pane) is hidden, so solo + * "Alone" play still shows one unlabeled canvas, same as before this + * pane was mergeable with an opponent's. */ +.yours-label { display: none; } +.board:has(> #split-view:not(.hidden)) .yours-label { display: block; } + .split-canvas-wrap { flex: 1 1 auto; } .split-canvas { display: block; } +/* Pixel-style toast over the opponent canvas once `OpponentView` has + * received every category — see `game/opponentView.ts`'s `markAssigned`. */ +.opponent-complete-banner { + position: absolute; + left: 50%; + top: 14px; + font-family: var(--pixel); + font-size: 11px; + color: var(--ink); + background: var(--green); + border: 3px solid var(--ink); + box-shadow: 3px 3px 0 var(--ink); + padding: 8px 12px; + white-space: nowrap; + z-index: 2; + animation: bannerPop .4s steps(4); +} +.opponent-complete-banner.hidden { display: none; } +@keyframes bannerPop { + 0% { transform: translateX(-50%) scale(1); } + 40% { transform: translateX(-50%) scale(1.15); } + 100% { transform: translateX(-50%) scale(1); } +} + .cat-card { background: var(--panel-2); border: 3px solid var(--ink); diff --git a/frontend/src/ui/matchmakingFlow.ts b/frontend/src/ui/matchmakingFlow.ts index 0f03163..9600c37 100644 --- a/frontend/src/ui/matchmakingFlow.ts +++ b/frontend/src/ui/matchmakingFlow.ts @@ -52,8 +52,8 @@ export interface MatchmakingFlowHooks { type FlowState = 'idle' | 'searching' | 'banned' | 'found' | 'in-match'; const TIME_MODE_LABEL: Record = { - 1: '🎯 1 MINUTE MATCH', 2: '🎯 2 MINUTE MATCH', + 4: '🎯 4 MINUTE MATCH', }; /** Minimum time the assigned-mode banner stays up before handing off to diff --git a/frontend/src/ui/privateRoomFlow.ts b/frontend/src/ui/privateRoomFlow.ts index 8892655..55271e3 100644 --- a/frontend/src/ui/privateRoomFlow.ts +++ b/frontend/src/ui/privateRoomFlow.ts @@ -169,8 +169,8 @@ export function createPrivateRoomFlow(

⏱ TIME

- - + +
@@ -190,7 +190,7 @@ export function createPrivateRoomFlow( render({ kind: 'hidden' }); }); mount.querySelector('[data-action="submit"]')!.addEventListener('click', () => { - const timeMode = Number(radioValue(mount, 'pr-time-mode', '1')) as TimeMode; + const timeMode = Number(radioValue(mount, 'pr-time-mode', '2')) as TimeMode; const themeMode = radioValue(mount, 'pr-theme-mode', 'random') as ThemeMode; client.send({ type: 'room:create', timeMode, themeMode }); }); diff --git a/frontend/src/ui/soloConfigFlow.ts b/frontend/src/ui/soloConfigFlow.ts index be41aa4..8ba81d6 100644 --- a/frontend/src/ui/soloConfigFlow.ts +++ b/frontend/src/ui/soloConfigFlow.ts @@ -1,8 +1,13 @@ import { THEMES } from '../data/themes'; import { pickRandomSnippetIndex } from '../data/snippets'; import type { BotDifficulty, BotTimeMode } from '../engine/bot'; +import { sound } from '../engine/sound'; import { BotMatch } from '../game/botMatch'; -import type { CategoryId, ThemeId } from '../types'; +import { getSharedGame } from '../game/sharedGame'; +import type { ThemeGuessGame } from '../game/ThemeGuessGame'; +import type { ThemeId } from '../types'; +import type { PreviewFlowElements } from './previewFlow'; +import { runThemePreview } from './previewFlow'; import type { ThemeGrid } from './themeGrid'; /** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be @@ -16,6 +21,7 @@ export interface SoloConfig { themeId: ThemeId; snippetIndex: number; timeMode: SoloTimeMode; + themeMode: SoloThemeMode; opponent: SoloOpponent; botDifficulty: BotDifficulty; } @@ -31,33 +37,36 @@ export interface SoloConfigFlowElements { botDifficultyWrap: HTMLElement; playBtn: HTMLButtonElement; /** Empty mount point (outside the menu modal) the inline timer/mode - * badge HUD renders itself into. */ + * badge/quit-button HUD renders itself into. */ timerMount: HTMLElement; + /** Topbar badge showing the active theme's name. */ + themeNameBadge: HTMLElement; + /** Mounts for the pre-round "flash the real theme" overlay. */ + preview: PreviewFlowElements; } -export interface SoloConfigFlow { - /** Starts the inline countdown/elapsed-time HUD for a just-started - * round. Call once the round is actually live (e.g. after the theme - * preview finishes), not at Play-click time. Also the "round is live" - * signal this module uses internally to start (or, for 'Alone', - * tear down) the bot opponent's split-view pane — see `handleLocalAssignment`. */ - startTimer(mode: SoloTimeMode): void; - /** `ThemeGuessGame`'s `onCategoryAssigned` callback target. Wire this - * once, at the game's first construction (see `MultiplayerMatch`'s - * two-phase-init doc for the pattern this mirrors) — it's a stable - * dispatch that forwards to whichever bot round `startTimer` most - * recently started, and is a no-op for 'Alone' rounds. */ - handleLocalAssignment(id: CategoryId, hex: string): void; +export interface SoloConfigFlowHooks { + /** Hides every top-level overlay so the board is visible. */ + hideMenu: () => void; + /** Shows the main menu (also hides the result modal, if open — see + * main.ts's `showView`). */ + showMenu: () => void; +} + +function requireEl(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing required element #${id}`); + return el as T; } const TIME_MODE_DEADLINE_MS: Record, number> = { - '1min': 60_000, '2min': 120_000, + '4min': 240_000, }; const TIME_MODE_LABEL: Record = { - '1min': '1 MIN', '2min': '2 MIN', + '4min': '4 MIN', none: 'NO LIMIT', }; @@ -76,21 +85,33 @@ function checkedValue(inputs: HTMLInputElement[], fallback: T) return (inputs.find((input) => input.checked)?.value as T | undefined) ?? fallback; } -/** Minimal inline countdown (1/2 minute modes) or elapsed-time counter - * ("no limit") plus a mode badge, rendered into `mount`. Purely a - * display: it never forces a reveal, since `ThemeGuessGame.reveal()` - * stays manually triggered by the existing Reveal button in every time - * mode, "no limit" included. +interface TimerHud { + /** Starts (or restarts) the countdown/elapsed-time display. `onExpire`, + * on the two clocked modes only, fires exactly once when the countdown + * reaches zero. */ + start(mode: SoloTimeMode, onExpire?: () => void): void; + /** Stops the ticking interval without hiding the HUD. */ + stop(): void; + /** Stops and hides the whole HUD (including the quit button). */ + hide(): void; +} + +/** Minimal inline countdown (2/4 minute modes) or elapsed-time counter + * ("no limit") plus a mode badge and an always-available Quit button, + * rendered into `mount`. * TODO: replace with shared timerHud module once available. */ -function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] { +function createTimerHud(mount: HTMLElement, onQuit: () => void): TimerHud { mount.innerHTML = ` `; const root = mount.querySelector('.solo-timer-hud')!; const badge = mount.querySelector('.solo-timer-badge')!; const clock = mount.querySelector('.solo-timer-clock')!; + const quitBtn = mount.querySelector('.solo-timer-quit')!; + quitBtn.addEventListener('click', onQuit); let intervalId: number | null = null; function stop(): void { @@ -100,7 +121,7 @@ function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] { } } - return function startTimer(mode: SoloTimeMode): void { + function start(mode: SoloTimeMode, onExpire?: () => void): void { stop(); root.classList.remove('hidden'); badge.textContent = TIME_MODE_LABEL[mode]; @@ -113,31 +134,52 @@ function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] { return; } const remaining = TIME_MODE_DEADLINE_MS[mode] - elapsed; - clock.textContent = formatClock(remaining); - if (remaining <= 0) stop(); + clock.textContent = formatClock(Math.max(0, remaining)); + if (remaining <= 0) { + stop(); + onExpire?.(); + } } tick(); intervalId = window.setInterval(tick, 250); - }; + } + + function hide(): void { + stop(); + root.classList.add('hidden'); + } + + return { start, stop, hide }; } /** Wires the Solo menu section's config form (time mode / theme mode / * opponent, with the theme grid and bot difficulty picker revealed - * conditionally) and its inline timer HUD. Fires `onStart` with the - * resolved config once the player clicks Play. */ + * conditionally), its inline timer/quit HUD, and every shared + * result-modal button ("Keep Comparing" / "Play Again") for as long as a + * solo round is the thing driving the shared board — see `soloActive`. */ export function createSoloConfigFlow( elements: SoloConfigFlowElements, themeGrid: ThemeGrid, - onStart: (config: SoloConfig) => void, -): SoloConfigFlow { + hooks: SoloConfigFlowHooks, +): void { const themeModeInputs = radios(elements.section, 'solo-theme-mode'); const opponentInputs = radios(elements.section, 'solo-opponent-mode'); const timeModeInputs = radios(elements.section, 'solo-time-mode'); const botDifficultyInputs = radios(elements.section, 'solo-bot-difficulty'); - const startTimerHud = createTimerHud(elements.timerMount); + const closeResultBtn = requireEl('close-result-btn'); + const playAgainBtn = requireEl('play-again-btn'); const botMatch = new BotMatch(); + let lastConfig: SoloConfig | null = null; + let activeGame: ThemeGuessGame | null = null; + /** True from the moment a solo round starts until the player quits — + * guards the shared, page-global result-modal buttons and the Quit + * button so a stale click from a leftover matchmaking/private-room + * result doesn't reroute solo state, and vice versa. */ + let soloActive = false; + + const timerHud = createTimerHud(elements.timerMount, quit); function syncThemePicker(): void { const mode = checkedValue(themeModeInputs, 'random'); @@ -154,8 +196,42 @@ export function createSoloConfigFlow( syncThemePicker(); syncBotDifficulty(); + /** Starts (or restarts) a live round from a fully-resolved config: + * flashes the theme preview, then swaps the shared `ThemeGuessGame` + * onto it, arms the timer HUD, and starts/stops the bot opponent. */ + function beginRound(config: SoloConfig): void { + lastConfig = config; + soloActive = true; + hooks.hideMenu(); + runThemePreview(elements.preview, config.themeId, () => { + const game = getSharedGame(config.themeId, config.snippetIndex, (id, hex) => botMatch.handleLocalAssignment(id, hex)); + activeGame = game; + botMatch.bindGame(game); + elements.themeNameBadge.textContent = THEMES[config.themeId].name; + sound.playApply(); + + const vsBot = config.opponent === 'bot'; + // Bot rounds end themselves at the same deadline (`BotMatch.start`'s + // own `ROUND_DEADLINE_MS` timeout) and render the two-column + // scoreboard; forwarding `onExpire` there too would race it into + // also opening the solo (single-score) result view. Alone rounds + // have nothing else watching the clock, so they need it. + timerHud.start(config.timeMode, vsBot ? undefined : () => game.reveal()); + if (vsBot) { + botMatch.start({ + themeId: config.themeId, + snippetIndex: config.snippetIndex, + timeMode: config.timeMode, + difficulty: config.botDifficulty, + }); + } else { + botMatch.stop(); + } + }, () => sound.playOpen()); + } + elements.playBtn.addEventListener('click', () => { - const timeMode = checkedValue(timeModeInputs, '1min'); + const timeMode = checkedValue(timeModeInputs, '2min'); const themeMode = checkedValue(themeModeInputs, 'random'); const opponent = checkedValue(opponentInputs, 'alone'); const botDifficulty = checkedValue(botDifficultyInputs, 'easy'); @@ -165,32 +241,55 @@ export function createSoloConfigFlow( ? themeGrid.selected : themeIds[Math.floor(Math.random() * themeIds.length)]; - lastConfig = { + beginRound({ themeId, snippetIndex: pickRandomSnippetIndex(), timeMode, + themeMode, opponent, botDifficulty, - }; - onStart(lastConfig); + }); }); - return { - startTimer(mode) { - startTimerHud(mode); - if (lastConfig?.opponent === 'bot') { - botMatch.start({ - themeId: lastConfig.themeId, - snippetIndex: lastConfig.snippetIndex, - timeMode: lastConfig.timeMode, - difficulty: lastConfig.botDifficulty, - }); - } else { - botMatch.stop(); - } - }, - handleLocalAssignment(id, hex) { - botMatch.handleLocalAssignment(id, hex); - }, - }; + /** "Keep Comparing": lets the player keep tweaking colors after a + * reveal instead of being stuck admiring the score. Unlocks the + * Reveal button for the rest of the round (`enableExtendedPlay`) and + * restarts the HUD in elapsed-time "NO LIMIT" mode — an explicit + * countdown still running would otherwise force another auto-reveal + * mid-tweak (see `beginRound`'s `onExpire`). */ + function keepComparing(): void { + if (!soloActive || !activeGame) return; + activeGame.enableExtendedPlay(); + timerHud.start('none'); + } + + /** "Play Again": a random-theme round immediately rerolls a new theme + * and snippet and starts over, with no extra input needed. A + * chosen-theme round instead reopens the menu (already sitting on + * "choose" with the theme grid visible) so the player picks their + * next theme, same as starting any other chosen-theme round. */ + function playAgain(): void { + if (!soloActive || !lastConfig) return; + if (lastConfig.themeMode === 'choose') { + hooks.showMenu(); + return; + } + const themeIds = Object.keys(THEMES) as ThemeId[]; + const themeId = themeIds[Math.floor(Math.random() * themeIds.length)]; + beginRound({ ...lastConfig, themeId, snippetIndex: pickRandomSnippetIndex() }); + } + + /** Ends the active solo round and returns to the main menu — wired to + * the timer HUD's own Quit button, which is visible for as long as a + * solo round is live (menu, mid-round, and after a reveal alike). */ + function quit(): void { + if (!soloActive) return; + soloActive = false; + botMatch.stop(); + timerHud.hide(); + hooks.showMenu(); + } + + closeResultBtn.addEventListener('click', keepComparing); + playAgainBtn.addEventListener('click', playAgain); }