feat(frontend): rebalance time modes to 2/4 min and rework scoring weights

- Bump time modes from 1/2 to 2/4 minutes across backend protocol, bot,
  timer HUD, and net message types (more room to read a snippet before
  guessing).
- Extract per-category token counts/weights into a shared
  engine/categoryStats.ts, replacing three independently-drifting
  copies of the same formula in ThemeGuessGame/botMatch/multiplayerMatch.
- Switch scoring.ts from a plain weighted average to a power-mean
  combine so whiffing a high-weight category can't be masked by acing
  the rest; retune colorUtils' similarity falloff divisor to match.
- Update solo config flow, opponent view, and renderer for the new time
  options and scoring feedback.
This commit is contained in:
Gabriel Franco 2026-09-13 17:36:41 -03:00
parent 36b2725498
commit cbc4d8a2eb
22 changed files with 535 additions and 224 deletions

View file

@ -54,8 +54,8 @@ socket if anything else arrives first.
## Round lifecycle (shared by matchmaking and rooms) ## Round lifecycle (shared by matchmaking and rooms)
Both matchmaking matches and private-room matches run the same Both matchmaking matches and private-room matches run the same
round state machine once two players are paired. `timeMode` (`1` or round state machine once two players are paired. `timeMode` (`2` or
`2`, minutes) and the `themeId` for the round are decided upstream `4`, minutes) and the `themeId` for the round are decided upstream
(randomly for matchmaking, per room config for rooms) before (randomly for matchmaking, per room config for rooms) before
`round:start` fires. `round:start` fires.
@ -77,7 +77,7 @@ stateDiagram-v2
"type": "round:start", "type": "round:start",
"snippetIndex": 2, // server-authoritative, see Snippet identity contract below "snippetIndex": 2, // server-authoritative, see Snippet identity contract below
"themeId": "tokyoNight", // ThemeId; server-authoritative "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 "endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline
} }
``` ```
@ -172,7 +172,7 @@ stateDiagram-v2
``` ```
- **`queue:join`** — client -> server. No mode parameter: the server - **`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). player doesn't pick, so pairing isn't constrained by mode preference).
```jsonc ```jsonc
{ "type": "queue:join" } { "type": "queue:join" }
@ -194,7 +194,7 @@ stateDiagram-v2
of the queue. A `round:start` for match 1 follows immediately; there is of the queue. A `round:start` for match 1 follows immediately; there is
no matchmaking equivalent of a room code or lobby. no matchmaking equivalent of a room code or lobby.
```jsonc ```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`) 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 - **`room:create`** — client -> server, creates a room and puts the
creator in it waiting for an opponent. creator in it waiting for an opponent.
```jsonc ```jsonc
{ "type": "room:create", "timeMode": 2, "themeMode": "chosen" } { "type": "room:create", "timeMode": 4, "themeMode": "chosen" }
// timeMode: 1 | 2 (minutes); themeMode: 'random' | 'chosen' // timeMode: 2 | 4 (minutes); themeMode: 'random' | 'chosen'
``` ```
- **`room:created`** — server -> creator, with a short numeric code to - **`room:created`** — server -> creator, with a short numeric code to
share out-of-band with the intended opponent. share out-of-band with the intended opponent.
@ -238,7 +238,7 @@ stateDiagram-v2
"type": "room:joined", "type": "room:joined",
"code": 483920, "code": 483920,
"opponentName": "ada", "opponentName": "ada",
"timeMode": 2, "timeMode": 4,
"themeMode": "chosen" "themeMode": "chosen"
} }
``` ```

View file

@ -100,7 +100,7 @@ function quitSession(session, quitterId) {
/** /**
* Starts one round session between two already-identified players. * 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 * `snippetIndex` are decided upstream (or `snippetIndex` is picked
* randomly here if omitted). Sends round:start to both sockets * randomly here if omitted). Sends round:start to both sockets
* immediately and returns the session object. * immediately and returns the session object.

View file

@ -1,7 +1,7 @@
# matchmaking/ # matchmaking/
Pairs waiting players FIFO-style, randomly assigns per-match timeMode 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 2-minute matchmaking quit ban — see `queue.js` and
`../PROTOCOL.md`'s "Matchmaking" and "Quit" sections. Party/lobby `../PROTOCOL.md`'s "Matchmaking" and "Quit" sections. Party/lobby
handling beyond a single 1v1 queue is not implemented. handling beyond a single 1v1 queue is not implemented.

View file

@ -61,7 +61,7 @@ function tryMatch() {
const playerAId = popOldestWaiting(); const playerAId = popOldestWaiting();
const playerBId = popOldestWaiting(); const playerBId = popOldestWaiting();
const timeMode = Math.random() < 0.5 ? 1 : 2; const timeMode = Math.random() < 0.5 ? 2 : 4;
const themeId = pickRandomThemeId(); const themeId = pickRandomThemeId();
activeMatchmakingPlayerIds.add(playerAId); activeMatchmakingPlayerIds.add(playerAId);

View file

@ -159,7 +159,7 @@ export function registerRoomHandlers() {
if (!ownerId) return; if (!ownerId) return;
const { timeMode, themeMode } = message; const { timeMode, themeMode } = message;
if (timeMode !== 1 && timeMode !== 2) return; if (timeMode !== 2 && timeMode !== 4) return;
if (themeMode !== 'random' && themeMode !== 'chosen') return; if (themeMode !== 'random' && themeMode !== 'chosen') return;
const code = generateCode(); const code = generateCode();

View file

@ -40,7 +40,17 @@
<div id="match-timer-warning" class="hidden"></div> <div id="match-timer-warning" class="hidden"></div>
<main class="board"> <main class="board">
<div class="canvas-wrap"> <div id="split-view" class="split-pane hidden" aria-label="Opponent progress">
<div class="split-pane-label">Opponent</div>
<div class="canvas-wrap split-canvas-wrap">
<canvas id="opponent-canvas" class="split-canvas"></canvas>
<div id="opponent-complete-banner" class="opponent-complete-banner hidden">🏁 Opponent finished!</div>
</div>
</div>
<div class="split-pane yours-pane">
<div class="split-pane-label yours-label">Yours</div>
<div class="canvas-wrap split-canvas-wrap">
<canvas id="code-canvas"></canvas> <canvas id="code-canvas"></canvas>
<div id="picker-popover" class="popover hidden"> <div id="picker-popover" class="popover hidden">
@ -56,6 +66,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<aside class="panel-col" aria-label="Token kinds"> <aside class="panel-col" aria-label="Token kinds">
<div id="category-panel" class="panel"></div> <div id="category-panel" class="panel"></div>
@ -65,21 +76,6 @@
</div> </div>
</aside> </aside>
</main> </main>
<section id="split-view" class="split-view hidden" aria-label="Match progress">
<div class="split-pane">
<div class="split-pane-label">Yours</div>
<div class="canvas-wrap split-canvas-wrap">
<canvas id="split-your-canvas" class="split-canvas"></canvas>
</div>
</div>
<div class="split-pane">
<div class="split-pane-label">Opponent</div>
<div class="canvas-wrap split-canvas-wrap">
<canvas id="opponent-canvas" class="split-canvas"></canvas>
</div>
</div>
</section>
</div> </div>
<div id="intro-modal" class="modal-overlay"> <div id="intro-modal" class="modal-overlay">
@ -95,8 +91,8 @@
<div class="solo-config-group"> <div class="solo-config-group">
<p class="solo-config-label">⏱ TIME</p> <p class="solo-config-label">⏱ TIME</p>
<div class="solo-radio-row"> <div class="solo-radio-row">
<label class="solo-radio"><input type="radio" name="solo-time-mode" value="1min" checked />1 MIN</label> <label class="solo-radio"><input type="radio" name="solo-time-mode" value="2min" checked />2 MIN</label>
<label class="solo-radio"><input type="radio" name="solo-time-mode" value="2min" />2 MIN</label> <label class="solo-radio"><input type="radio" name="solo-time-mode" value="4min" />4 MIN</label>
<label class="solo-radio"><input type="radio" name="solo-time-mode" value="none" />NO LIMIT</label> <label class="solo-radio"><input type="radio" name="solo-time-mode" value="none" />NO LIMIT</label>
</div> </div>
</div> </div>

View file

@ -7,7 +7,7 @@ import type { CategoryId, RGB } from '../types';
import { hexToRgb, rgbToHex, lerpRgb, similarityFromHex } from './colorUtils'; import { hexToRgb, rgbToHex, lerpRgb, similarityFromHex } from './colorUtils';
export type BotDifficulty = 'easy' | 'medium' | 'hard'; export type BotDifficulty = 'easy' | 'medium' | 'hard';
export type BotTimeMode = '1min' | '2min' | 'none'; export type BotTimeMode = '2min' | '4min' | 'none';
export interface BotCategoryInput { export interface BotCategoryInput {
id: CategoryId; id: CategoryId;
@ -33,7 +33,7 @@ export interface BotPick {
// Pacing // Pacing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** 1-minute mode collapses to the 'short' band, 2-minute and no-limit both /** 2-minute mode collapses to the 'short' band, 4-minute and no-limit both
* get the more generous 'long' band (same target pace either way — a * get the more generous 'long' band (same target pace either way — a
* countdown vs. no countdown doesn't change how "fast" the bot thinks). */ * countdown vs. no countdown doesn't change how "fast" the bot thinks). */
type TimeBand = 'short' | 'long'; type TimeBand = 'short' | 'long';
@ -54,13 +54,13 @@ interface TimeModeConfig {
const DEADLINE_SAFETY_MARGIN_MS = 1_500; const DEADLINE_SAFETY_MARGIN_MS = 1_500;
const TIME_MODE_CONFIG: Record<BotTimeMode, TimeModeConfig> = { const TIME_MODE_CONFIG: Record<BotTimeMode, TimeModeConfig> = {
// ~55s target, ±4s jitter -> finishes 51-59s, safely under the 60s cap. // ~1m50s target, ±8s jitter -> finishes 102-118s, safely under the 120s cap.
'1min': { targetMs: 55_000, jitterMs: 4_000, deadlineMs: 60_000 }, '2min': { targetMs: 110_000, jitterMs: 8_000, deadlineMs: 120_000 },
// ~1m45s target, ±5s jitter -> finishes 100-110s, safely under the 120s cap. // ~3m30s target, ±10s jitter -> finishes 200-220s, safely under the 240s cap.
'2min': { targetMs: 105_000, jitterMs: 5_000, deadlineMs: 120_000 }, '4min': { targetMs: 210_000, jitterMs: 10_000, deadlineMs: 240_000 },
// Same ~1m45s target as 2-minute mode (no deadline to clamp against, but // Same ~3m30s target as 4-minute mode (no deadline to clamp against, but
// no reason for the bot to pace itself any differently). // no reason for the bot to pace itself any differently).
none: { targetMs: 105_000, jitterMs: 5_000, deadlineMs: null }, none: { targetMs: 210_000, jitterMs: 10_000, deadlineMs: null },
}; };
/** Splits `totalMs` into `n` ascending pick times that sum to totalMs, with /** Splits `totalMs` into `n` ascending pick times that sum to totalMs, with
@ -162,7 +162,7 @@ function pickHex(targetHex: string, difficulty: BotDifficulty, timeMode: BotTime
if (timeMode === 'none' && Math.random() < NO_LIMIT_PERFECT_CHANCE) { if (timeMode === 'none' && Math.random() < NO_LIMIT_PERFECT_CHANCE) {
return targetHex; return targetHex;
} }
const band: TimeBand = timeMode === '1min' ? 'short' : 'long'; const band: TimeBand = timeMode === '2min' ? 'short' : 'long';
const ceiling = ACCURACY_CEILING[difficulty][band]; const ceiling = ACCURACY_CEILING[difficulty][band];
const floor = Math.max(5, ceiling - ACCURACY_SPREAD[difficulty]); const floor = Math.max(5, ceiling - ACCURACY_SPREAD[difficulty]);
const desiredSimilarity = floor + Math.random() * (ceiling - floor); const desiredSimilarity = floor + Math.random() * (ceiling - floor);

View file

@ -0,0 +1,60 @@
// Derives per-category token counts and scoring weights from a
// tokenized snippet — the shared "how much does each category matter"
// logic `ThemeGuessGame.build()`, `botMatch.ts`, and `multiplayerMatch.ts`
// each need to build a `CategoryStateMap` (directly, or via
// `game/scoring.ts`'s weighted average). Pure and DOM-free, so it lives
// here rather than being re-derived independently in each `game/` call
// site (as it used to be — three copies of the same formula, one typo
// away from silently diverging).
import { CATEGORY_META } from '../data/themes';
import type { CategoryId, Token } from '../types';
export interface CategoryStats {
/** How many times each category's token kind appears in the snippet
* (always 0 for 'background', which isn't a token). Drives the panel
* card's "N tokens" label. */
counts: Record<CategoryId, number>;
/** Scoring weight per category — see `computeCategoryStats`'s doc for
* the reasoning. Consumed by `game/scoring.ts`'s weighted average. */
weights: Record<CategoryId, number>;
}
/** Foreground categories are weighted by their own token frequency —
* a color the player sees 30 times in the snippet should count more
* toward the final score than one they saw only once — floored at 3 so
* a rare kind (often just a single comment) still counts for something.
*
* `background` isn't a token and has no frequency of its own. Weighting
* it by the single *most* frequent category's count (as this used to)
* let one snippet-dependent spike — punctuation or operators easily top
* 70-100 occurrences — dwarf every other category combined, so the
* overall score ended up mostly measuring "did you get punctuation and
* the background close" and barely anything else. Weighting it as one
* *typical* category instead — the mean of the other eleven — keeps it
* meaningfully important (it's the single largest area of the screen)
* without letting it, or its formerly-conflated counterpart, swamp
* every other guess. */
export function computeCategoryStats(tokens: Token[]): CategoryStats {
const counts = {} as Record<CategoryId, number>;
for (const def of CATEGORY_META) counts[def.id] = 0;
for (const t of tokens) {
if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
const id = t.type as CategoryId;
counts[id] += 1;
}
const weights = {} as Record<CategoryId, number>;
let otherSum = 0;
let otherN = 0;
for (const def of CATEGORY_META) {
if (def.id === 'background') continue;
const w = Math.max(counts[def.id], 3);
weights[def.id] = w;
otherSum += w;
otherN += 1;
}
weights.background = Math.round(otherSum / otherN);
return { counts, weights };
}

View file

@ -78,9 +78,13 @@ export function deltaE76(labA: Lab, labB: Lab): number {
} }
// 100 at identical colors, decaying smoothly as perceptual distance grows. // 100 at identical colors, decaying smoothly as perceptual distance grows.
// Divisor tuned so a genuinely close guess (small hue/tone miss, dE~15-20)
// lands around 75-85% while a clearly wrong one (opposite lightness or
// hue, dE~80+) stays well under half — see `computeCategoryStats` for
// the other half of "off/inconsistent" scores: per-category weighting.
export function similarityFromHex(hexA: string | null, hexB: string): number { export function similarityFromHex(hexA: string | null, hexB: string): number {
if (!hexA) return 0; if (!hexA) return 0;
const dE = deltaE76(hexToLab(hexA), hexToLab(hexB)); const dE = deltaE76(hexToLab(hexA), hexToLab(hexB));
const sim = 100 * Math.exp(-dE / 45); const sim = 100 * Math.exp(-dE / 50);
return Math.max(0, Math.min(100, sim)); return Math.max(0, Math.min(100, sim));
} }

View file

@ -1,4 +1,5 @@
import { tokenize } from '../engine/tokenizer'; import { tokenize } from '../engine/tokenizer';
import { computeCategoryStats } from '../engine/categoryStats';
import { CATEGORY_META, THEMES } from '../data/themes'; import { CATEGORY_META, THEMES } from '../data/themes';
import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets'; import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets';
import { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils'; import { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils';
@ -50,6 +51,11 @@ export class ThemeGuessGame {
private compareMode: CompareMode = 'yours'; private compareMode: CompareMode = 'yours';
private lastTime = performance.now(); private lastTime = performance.now();
private pickerOrigin: RGB | null = null; private pickerOrigin: RGB | null = null;
/** Set by `enableExtendedPlay()`; once true, `updateProgress()` stops
* gating `revealBtn` on full category completion. Reset back to
* false by `build()`/`setTheme()` so every fresh round starts
* normally gated again. */
private extendedPlay = false;
private tokens: Token[] = []; private tokens: Token[] = [];
private lineCount = 1; private lineCount = 1;
@ -87,6 +93,7 @@ export class ThemeGuessGame {
// ---------- setup ---------- // ---------- setup ----------
private build(): void { private build(): void {
this.extendedPlay = false;
this.tokens = tokenize(SNIPPETS[this.snippetIndex]); this.tokens = tokenize(SNIPPETS[this.snippetIndex]);
this.lineCount = 1; this.lineCount = 1;
@ -97,23 +104,17 @@ export class ThemeGuessGame {
else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); } else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
} }
const counts: Partial<Record<CategoryId, number>> = {}; const { counts, weights } = computeCategoryStats(this.tokens);
for (const t of this.tokens) {
if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
counts[t.type] = (counts[t.type] ?? 0) + 1;
}
const maxCount = Math.max(1, ...Object.values(counts));
const theme = THEMES[this.themeId]; const theme = THEMES[this.themeId];
const categories = {} as CategoryStateMap; const categories = {} as CategoryStateMap;
for (const def of CATEGORY_META) { for (const def of CATEGORY_META) {
const count = counts[def.id] ?? 0;
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB; const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
categories[def.id] = { categories[def.id] = {
...def, ...def,
actualHex: theme.colors[def.id], actualHex: theme.colors[def.id],
weight: def.id === 'background' ? maxCount : Math.max(count, 3), weight: weights[def.id],
count, count: counts[def.id],
assignedHex: null, assignedHex: null,
currentRgb: { ...unset }, currentRgb: { ...unset },
fromRgb: { ...unset }, fromRgb: { ...unset },
@ -152,6 +153,7 @@ export class ThemeGuessGame {
// ---------- theme switching ---------- // ---------- theme switching ----------
setTheme(themeId: ThemeId): void { setTheme(themeId: ThemeId): void {
this.extendedPlay = false;
this.themeId = themeId; this.themeId = themeId;
const theme = THEMES[themeId]; const theme = THEMES[themeId];
for (const def of CATEGORY_META) { for (const def of CATEGORY_META) {
@ -350,7 +352,7 @@ export class ThemeGuessGame {
const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length; const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length;
this.progressFill.style.width = `${(done / total) * 100}%`; this.progressFill.style.width = `${(done / total) * 100}%`;
this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`; this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`;
this.revealBtn.disabled = done < total; this.revealBtn.disabled = !this.extendedPlay && done < total;
} }
private resetColors(): void { private resetColors(): void {
@ -385,7 +387,14 @@ export class ThemeGuessGame {
// ---------- scoring / reveal ---------- // ---------- scoring / reveal ----------
private reveal(): void { /** Renders the score breakdown/verdict and shows the result modal.
* Normally wired to the Reveal button's click, but also called
* directly when a timed solo round's countdown reaches zero, so
* time running out actually resolves the round instead of leaving
* the player stuck on an un-revealed board. Safe to call more than
* once (e.g. a manual reveal followed by the timer expiring) — it
* just re-renders the same result. */
reveal(): void {
const { overall, rows } = computeMatchResult(this.categories); const { overall, rows } = computeMatchResult(this.categories);
renderBreakdown(this.breakdownEl, rows); renderBreakdown(this.breakdownEl, rows);
@ -397,6 +406,24 @@ export class ThemeGuessGame {
if (overall >= 75) this.fx.spawnConfetti(160, this.width); if (overall >= 75) this.fx.spawnConfetti(160, this.width);
} }
/** Permanently unlocks `revealBtn` for the rest of the current round
* (until the next `build()`/`setTheme()` resets it) — called once the
* player chooses to keep tweaking colors after an initial reveal
* ("Keep Comparing"), so re-revealing after a small tweak doesn't
* require re-filling every category from scratch. */
enableExtendedPlay(): void {
this.extendedPlay = true;
this.updateProgress();
}
/** True once every category has a player-assigned color — the ground
* truth `BotMatch` polls (rather than shadowing it with its own
* tracking, which `resetColors()` wouldn't invalidate) to gate the
* Reveal button on both the player and the bot having finished. */
isFullyAssigned(): boolean {
return CATEGORY_META.every((def) => this.categories[def.id].assignedHex !== null);
}
// ---------- render loop ---------- // ---------- render loop ----------
private loop(t: number): void { private loop(t: number): void {

View file

@ -4,17 +4,21 @@
// two-column scoreboard `MultiplayerMatch` uses once the round ends. // two-column scoreboard `MultiplayerMatch` uses once the round ends.
// //
// Mirrors `multiplayerMatch.ts`'s shape and two-phase-init convention — // Mirrors `multiplayerMatch.ts`'s shape and two-phase-init convention —
// `ThemeGuessGame` has no knowledge of this module either: `soloConfigFlow` // `ThemeGuessGame` has no knowledge of this module's existence (it never
// supplies its `onCategoryAssigned` constructor callback and routes it into // imports `BotMatch`): `soloConfigFlow` supplies its `onCategoryAssigned`
// `BotMatch.handleLocalAssignment`, exactly like `MultiplayerMatch` docs // constructor callback and routes it into `BotMatch.handleLocalAssignment`,
// describe. The one structural difference: there is no server, so this // exactly like `MultiplayerMatch` docs describe, and separately binds the
// module also decides *when* the round ends (timer deadline, or — in // shared instance itself via `bindGame` so the Reveal button can be gated
// no-limit mode, which has no deadline — once the bot's schedule has fully // on the ground-truth `isFullyAssigned()` (see `syncRevealAvailability`)
// played out and the player has manually revealed via the existing Reveal // instead of shadowing that state locally. The one structural difference
// button, matching how `ThemeGuessGame.reveal()` stays manually triggered // from multiplayer: there is no server, so this module also decides
// for solo play in every time mode). // *when* the round ends — the timed modes' deadline, same as always, or
// (in any mode) as soon as both the bot's schedule has fully played out
// and the player has manually revealed via the Reveal button, which
// `syncRevealAvailability` keeps disabled until exactly that point.
import { tokenize } from '../engine/tokenizer'; import { tokenize } from '../engine/tokenizer';
import { computeCategoryStats } from '../engine/categoryStats';
import { rgbToHex } from '../engine/colorUtils'; import { rgbToHex } from '../engine/colorUtils';
import { computeBotSchedule } from '../engine/bot'; import { computeBotSchedule } from '../engine/bot';
import type { BotDifficulty, BotPick, BotTimeMode } from '../engine/bot'; import type { BotDifficulty, BotPick, BotTimeMode } from '../engine/bot';
@ -26,6 +30,7 @@ import { computeMatchResult } from './scoring';
import { renderMultiplayerResult } from './resultView'; import { renderMultiplayerResult } from './resultView';
import type { MultiplayerResultElements } from './resultView'; import type { MultiplayerResultElements } from './resultView';
import { OpponentView } from './opponentView'; import { OpponentView } from './opponentView';
import type { ThemeGuessGame } from './ThemeGuessGame';
function requireEl<T extends HTMLElement>(id: string): T { function requireEl<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -58,25 +63,17 @@ function buildCategoryStateMap(
themeId: ThemeId, themeId: ThemeId,
colors: Record<CategoryId, string>, colors: Record<CategoryId, string>,
): CategoryStateMap { ): CategoryStateMap {
const tokens = tokenize(SNIPPETS[snippetIndex]); const { counts, weights } = computeCategoryStats(tokenize(SNIPPETS[snippetIndex]));
const counts: Partial<Record<CategoryId, number>> = {};
for (const t of tokens) {
if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
const id = t.type as CategoryId;
counts[id] = (counts[id] ?? 0) + 1;
}
const maxCount = Math.max(1, ...Object.values(counts));
const theme = THEMES[themeId]; const theme = THEMES[themeId];
const categories = {} as CategoryStateMap; const categories = {} as CategoryStateMap;
for (const def of CATEGORY_META) { for (const def of CATEGORY_META) {
const count = counts[def.id] ?? 0;
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB; const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
categories[def.id] = { categories[def.id] = {
...def, ...def,
actualHex: theme.colors[def.id], actualHex: theme.colors[def.id],
weight: def.id === 'background' ? maxCount : Math.max(count, 3), weight: weights[def.id],
count, count: counts[def.id],
assignedHex: colors[def.id] ?? null, assignedHex: colors[def.id] ?? null,
currentRgb: { ...unset }, currentRgb: { ...unset },
fromRgb: { ...unset }, fromRgb: { ...unset },
@ -95,8 +92,8 @@ function buildCategoryStateMap(
* (see `multiplayerMatch.ts` vs `ThemeGuessGame.ts`'s category-map * (see `multiplayerMatch.ts` vs `ThemeGuessGame.ts`'s category-map
* builders). No-limit mode has no deadline, so no entry here. */ * builders). No-limit mode has no deadline, so no entry here. */
const ROUND_DEADLINE_MS: Record<Exclude<BotTimeMode, 'none'>, number> = { const ROUND_DEADLINE_MS: Record<Exclude<BotTimeMode, 'none'>, number> = {
'1min': 60_000,
'2min': 120_000, '2min': 120_000,
'4min': 240_000,
}; };
export interface BotMatchConfig { export interface BotMatchConfig {
@ -131,6 +128,8 @@ export class BotMatch {
}; };
private readonly revealBtn = requireEl<HTMLButtonElement>('reveal-btn'); private readonly revealBtn = requireEl<HTMLButtonElement>('reveal-btn');
private game: ThemeGuessGame | null = null;
private opponentView: OpponentView | null = null; private opponentView: OpponentView | null = null;
private schedule: BotPick[] = []; private schedule: BotPick[] = [];
private pickTimeoutIds: number[] = []; private pickTimeoutIds: number[] = [];
@ -139,7 +138,6 @@ export class BotMatch {
private themeId: ThemeId | null = null; private themeId: ThemeId | null = null;
private snippetIndex: number | null = null; private snippetIndex: number | null = null;
private timeMode: BotTimeMode = 'none';
private localColors: Partial<Record<CategoryId, string>> = {}; private localColors: Partial<Record<CategoryId, string>> = {};
private ended = true; private ended = true;
@ -150,14 +148,25 @@ export class BotMatch {
this.revealBtn.addEventListener('click', this.onRevealClick); this.revealBtn.addEventListener('click', this.onRevealClick);
} }
/** Binds the shared `ThemeGuessGame` this match's Reveal-button gating
* polls — see `syncRevealAvailability`. Must be called before `start`;
* `soloConfigFlow` does this right after every `getSharedGame` call,
* bot round or not, since it's a harmless no-op while `ended`. */
bindGame(game: ThemeGuessGame): void {
this.game = game;
}
/** `ThemeGuessGame`'s `onCategoryAssigned` callback target: tracks the /** `ThemeGuessGame`'s `onCategoryAssigned` callback target: tracks the
* local player's running color map for the eventual scoreboard. A * local player's running color map for the eventual scoreboard, and
* no-op once the round has already ended (or before one has started), * re-syncs Reveal-button availability (the player finishing their own
* so it's safe to wire unconditionally regardless of the currently * board is one of the two conditions it's gated on — see
* selected solo opponent. */ * `syncRevealAvailability`). A no-op once the round has already ended
* (or before one has started), so it's safe to wire unconditionally
* regardless of the currently selected solo opponent. */
handleLocalAssignment(id: CategoryId, hex: string): void { handleLocalAssignment(id: CategoryId, hex: string): void {
if (this.ended) return; if (this.ended) return;
this.localColors[id] = hex; this.localColors[id] = hex;
this.syncRevealAvailability();
} }
/** Starts one bot round: shows the split-view pane, (re)builds the /** Starts one bot round: shows the split-view pane, (re)builds the
@ -173,7 +182,6 @@ export class BotMatch {
this.localColors = {}; this.localColors = {};
this.themeId = config.themeId; this.themeId = config.themeId;
this.snippetIndex = config.snippetIndex; this.snippetIndex = config.snippetIndex;
this.timeMode = config.timeMode;
this.splitView.classList.remove('hidden'); this.splitView.classList.remove('hidden');
if (!this.opponentView) { if (!this.opponentView) {
@ -185,6 +193,7 @@ export class BotMatch {
this.resultModal.classList.add('hidden'); this.resultModal.classList.add('hidden');
this.mpScoreboard.classList.add('hidden'); this.mpScoreboard.classList.add('hidden');
this.syncRevealAvailability();
const theme = THEMES[config.themeId]; const theme = THEMES[config.themeId];
const categories = CATEGORY_META.map((def) => ({ id: def.id, hex: theme.colors[def.id] })); const categories = CATEGORY_META.map((def) => ({ id: def.id, hex: theme.colors[def.id] }));
@ -201,7 +210,8 @@ export class BotMatch {
this.remainingPicks -= 1; this.remainingPicks -= 1;
if (this.remainingPicks === 0) { if (this.remainingPicks === 0) {
this.botFinished = true; this.botFinished = true;
this.maybeEndNoLimitRound(); this.syncRevealAvailability();
this.maybeEndRound();
} }
}, pick.atMs); }, pick.atMs);
this.pickTimeoutIds.push(timeoutId); this.pickTimeoutIds.push(timeoutId);
@ -220,17 +230,31 @@ export class BotMatch {
this.splitView.classList.add('hidden'); this.splitView.classList.add('hidden');
} }
/** Reveal is only ever enabled while a bot round is live and both
* sides have finished — the player's own board (per `ThemeGuessGame`'s
* ground-truth `isFullyAssigned`, not `localColors`, so a mid-round
* `resetColors()` can't leave this stuck enabled) and the bot's
* schedule. Runs after every local assignment and the bot's final
* pick — the two events that can flip either half of the condition. */
private syncRevealAvailability(): void {
if (this.ended) return;
const playerDone = this.game?.isFullyAssigned() ?? false;
this.revealBtn.disabled = !(playerDone && this.botFinished);
}
private readonly onRevealClick = (): void => { private readonly onRevealClick = (): void => {
this.playerRevealed = true; this.playerRevealed = true;
this.maybeEndNoLimitRound(); this.maybeEndRound();
}; };
/** No-limit mode has no deadline, so it ends only once both the bot's /** Ends the round as soon as both sides are done and the player has
* schedule has fully played out and the player has manually revealed * manually revealed — the Reveal button is disabled until exactly
* (the existing Reveal button, same manual-trigger convention solo * that point (see `syncRevealAvailability`), so this fires the moment
* play already uses in every time mode). */ * it's clicked, timed mode or not, instead of making the player wait
private maybeEndNoLimitRound(): void { * out a deadline neither side needs anymore. A timed round whose
if (this.timeMode === 'none' && this.playerRevealed && this.botFinished) this.endRound(); * deadline arrives first still ends itself via `endRound` regardless. */
private maybeEndRound(): void {
if (this.playerRevealed && this.botFinished) this.endRound();
} }
private endRound(): void { private endRound(): void {

View file

@ -23,6 +23,7 @@
// wiring so it's ready for #14/#15 to pass in. // wiring so it's ready for #14/#15 to pass in.
import { tokenize } from '../engine/tokenizer'; import { tokenize } from '../engine/tokenizer';
import { computeCategoryStats } from '../engine/categoryStats';
import { rgbToHex } from '../engine/colorUtils'; import { rgbToHex } from '../engine/colorUtils';
import { CATEGORY_META, THEMES } from '../data/themes'; import { CATEGORY_META, THEMES } from '../data/themes';
import { SNIPPETS } from '../data/snippets'; import { SNIPPETS } from '../data/snippets';
@ -74,25 +75,17 @@ function buildCategoryStateMap(
themeId: ThemeId, themeId: ThemeId,
colors: Record<CategoryId, string>, colors: Record<CategoryId, string>,
): CategoryStateMap { ): CategoryStateMap {
const tokens = tokenize(SNIPPETS[snippetIndex]); const { counts, weights } = computeCategoryStats(tokenize(SNIPPETS[snippetIndex]));
const counts: Partial<Record<CategoryId, number>> = {};
for (const t of tokens) {
if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
const id = t.type as CategoryId;
counts[id] = (counts[id] ?? 0) + 1;
}
const maxCount = Math.max(1, ...Object.values(counts));
const theme = THEMES[themeId]; const theme = THEMES[themeId];
const categories = {} as CategoryStateMap; const categories = {} as CategoryStateMap;
for (const def of CATEGORY_META) { for (const def of CATEGORY_META) {
const count = counts[def.id] ?? 0;
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB; const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
categories[def.id] = { categories[def.id] = {
...def, ...def,
actualHex: theme.colors[def.id], actualHex: theme.colors[def.id],
weight: def.id === 'background' ? maxCount : Math.max(count, 3), weight: weights[def.id],
count, count: counts[def.id],
assignedHex: colors[def.id] ?? null, assignedHex: colors[def.id] ?? null,
currentRgb: { ...unset }, currentRgb: { ...unset },
fromRgb: { ...unset }, fromRgb: { ...unset },

View file

@ -1,8 +1,10 @@
import { tokenize } from '../engine/tokenizer'; import { tokenize } from '../engine/tokenizer';
import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets'; import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets';
import type { CategoryId, Token } from '../types'; import { CATEGORY_META } from '../data/themes';
import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants'; import { easeOutCubic, hexToRgb, lerpRgb } from '../engine/colorUtils';
import { drawOpponentFrame } from './renderer'; import type { CategoryId, RGB, Token } from '../types';
import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD, TRANSITION_MS } from './layoutConstants';
import { drawOpponentFrame, OPPONENT_ASSIGNED_HEX, OPPONENT_UNASSIGNED_HEX } from './renderer';
function requireEl<T extends HTMLElement>(id: string): T { function requireEl<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -10,6 +12,20 @@ function requireEl<T extends HTMLElement>(id: string): T {
return el as T; return el as T;
} }
const UNASSIGNED_RGB = hexToRgb(OPPONENT_UNASSIGNED_HEX);
const ASSIGNED_RGB = hexToRgb(OPPONENT_ASSIGNED_HEX);
/** One category's animated fill — a lerp from `from` to `to`, eased and
* timed exactly like `ThemeGuessGame`'s own `CategoryState.currentRgb`
* (see `layoutConstants.TRANSITION_MS`), except `to` is always either
* `UNASSIGNED_RGB` or `ASSIGNED_RGB`: never a real opponent-chosen hex. */
interface FillState {
current: RGB;
from: RGB;
to: RGB;
start: number;
}
/** /**
* Owns the "opponent progress" canvas: a read-only mirror of the shared * Owns the "opponent progress" canvas: a read-only mirror of the shared
* match snippet that reveals only a binary "has the opponent assigned * match snippet that reveals only a binary "has the opponent assigned
@ -19,13 +35,20 @@ function requireEl<T extends HTMLElement>(id: string): T {
* only ever accepts a `CategoryId`, so a real opponent-chosen hex has no * only ever accepts a `CategoryId`, so a real opponent-chosen hex has no
* path into this view by construction. * path into this view by construction.
* *
* Mirrors `ThemeGuessGame`'s canvas-ownership pattern, but far simpler: * Mirrors `ThemeGuessGame`'s canvas-ownership pattern closely: no picker,
* no picker, no clicks, no animation loop — every redraw is a flat, * no clicks, but the same perpetual `requestAnimationFrame` loop easing
* instant swap (see `drawOpponentFrame`). * each category's flat color in (see `FillState`), and the same
* "mutate state, let the loop pick it up next frame" convention — none
* of `setSnippet`/`markAssigned`/`reset` draw synchronously.
*
* Also owns the "opponent finished" banner: once every category has been
* marked assigned, `#opponent-complete-banner` is shown until the next
* `reset`/`setSnippet`.
*/ */
export class OpponentView { export class OpponentView {
private readonly canvas: HTMLCanvasElement; private readonly canvas: HTMLCanvasElement;
private readonly ctx: CanvasRenderingContext2D; private readonly ctx: CanvasRenderingContext2D;
private readonly completeBanner = requireEl<HTMLElement>('opponent-complete-banner');
private snippetIndex: number; private snippetIndex: number;
private tokens: Token[] = []; private tokens: Token[] = [];
@ -35,12 +58,15 @@ export class OpponentView {
private width = 0; private width = 0;
private height = 0; private height = 0;
private assigned = new Set<CategoryId>(); private assigned = new Set<CategoryId>();
private fills = {} as Record<CategoryId, FillState>;
constructor(canvasId: string, snippetIndex: number = pickRandomSnippetIndex()) { constructor(canvasId: string, snippetIndex: number = pickRandomSnippetIndex()) {
this.canvas = requireEl<HTMLCanvasElement>(canvasId); this.canvas = requireEl<HTMLCanvasElement>(canvasId);
this.ctx = this.canvas.getContext('2d')!; this.ctx = this.canvas.getContext('2d')!;
this.snippetIndex = snippetIndex; this.snippetIndex = snippetIndex;
this.resetFills();
this.build(); this.build();
requestAnimationFrame((t) => this.loop(t));
} }
/** Forces a specific snippet (by stable `SNIPPETS` index) and clears /** Forces a specific snippet (by stable `SNIPPETS` index) and clears
@ -49,22 +75,42 @@ export class OpponentView {
setSnippet(index: number): void { setSnippet(index: number): void {
this.snippetIndex = index; this.snippetIndex = index;
this.assigned = new Set(); this.assigned = new Set();
this.resetFills();
this.completeBanner.classList.add('hidden');
this.build(); this.build();
} }
/** Marks one category as assigned by the opponent and redraws. Takes /** Marks one category as assigned by the opponent and starts its
* only a `CategoryId` — never a color — so there is no code path for * fade-to-green transition. Takes only a `CategoryId` — never a
* an opponent's real chosen hex to reach this view. */ * color — so there is no code path for an opponent's real chosen hex
* to reach this view. Shows the "opponent finished" banner once every
* category has been marked. Idempotent per category, so a duplicate
* `round:progress` can't restart an in-flight fade. */
markAssigned(categoryId: CategoryId): void { markAssigned(categoryId: CategoryId): void {
if (this.assigned.has(categoryId)) return;
this.assigned.add(categoryId); this.assigned.add(categoryId);
this.draw();
const fill = this.fills[categoryId];
fill.from = fill.current;
fill.to = { ...ASSIGNED_RGB };
fill.start = performance.now();
if (this.assigned.size === CATEGORY_META.length) this.completeBanner.classList.remove('hidden');
} }
/** Clears every progress signal for reuse across matches, without /** Clears every progress signal (and the finished banner) for reuse
* touching the current snippet/layout. */ * across matches, without touching the current snippet/layout. */
reset(): void { reset(): void {
this.assigned = new Set(); this.assigned = new Set();
this.draw(); this.resetFills();
this.completeBanner.classList.add('hidden');
}
private resetFills(): void {
this.fills = {} as Record<CategoryId, FillState>;
for (const def of CATEGORY_META) {
this.fills[def.id] = { current: { ...UNASSIGNED_RGB }, from: { ...UNASSIGNED_RGB }, to: { ...UNASSIGNED_RGB }, start: 0 };
}
} }
private build(): void { private build(): void {
@ -79,7 +125,6 @@ export class OpponentView {
} }
this.setupCanvasSize(); this.setupCanvasSize();
this.draw();
} }
private setupCanvasSize(): void { private setupCanvasSize(): void {
@ -95,7 +140,26 @@ export class OpponentView {
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
} }
private draw(): void { private loop(now: number): void {
this.tickFills(now);
this.draw(now);
requestAnimationFrame((t) => this.loop(t));
}
private tickFills(now: number): void {
for (const def of CATEGORY_META) {
const fill = this.fills[def.id];
if (!fill.start) continue;
const p = Math.min(1, (now - fill.start) / TRANSITION_MS);
fill.current = lerpRgb(fill.from, fill.to, easeOutCubic(p));
if (p >= 1) fill.start = 0;
}
}
private draw(now: number): void {
const currentRgb = {} as Record<CategoryId, RGB>;
for (const def of CATEGORY_META) currentRgb[def.id] = this.fills[def.id].current;
drawOpponentFrame({ drawOpponentFrame({
ctx: this.ctx, ctx: this.ctx,
width: this.width, width: this.width,
@ -103,7 +167,7 @@ export class OpponentView {
charWidth: this.charWidth, charWidth: this.charWidth,
lineCount: this.lineCount, lineCount: this.lineCount,
tokens: this.tokens, tokens: this.tokens,
assigned: this.assigned, currentRgb,
}, performance.now()); }, now);
} }
} }

View file

@ -1,6 +1,6 @@
import { rgbToHex } from '../engine/colorUtils'; import { rgbToHex } from '../engine/colorUtils';
import type { ParticleSystem } from '../engine/particles'; import type { ParticleSystem } from '../engine/particles';
import type { CategoryId, CategoryStateMap, Token } from '../types'; import type { CategoryId, CategoryStateMap, RGB, Token } from '../types';
import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants'; import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants';
/** Everything one frame of the code canvas needs to draw itself. Kept /** Everything one frame of the code canvas needs to draw itself. Kept
@ -75,9 +75,11 @@ export const OPPONENT_ASSIGNED_HEX = '#5fd75f';
export const OPPONENT_UNASSIGNED_HEX = '#3a3a52'; export const OPPONENT_UNASSIGNED_HEX = '#3a3a52';
/** Everything one frame of the opponent-progress canvas needs. Deliberately /** Everything one frame of the opponent-progress canvas needs. Deliberately
* narrower than `RenderState`: no per-category colors, no gradients/pulse * narrower than `RenderState`: no gradient pulse or particles, and only
* animation, no particles — just which `CategoryId`s are "assigned" so far. * ever a lerp between the two flat opponent-progress colors above — but
* Kept DOM-free like `drawFrame`. */ * (unlike a plain binary signal) `currentRgb` carries `OpponentView`'s
* in-flight per-category animation state, one entry per `CategoryId`
* including 'background'. Kept DOM-free like `drawFrame`. */
export interface OpponentRenderState { export interface OpponentRenderState {
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
width: number; width: number;
@ -85,18 +87,20 @@ export interface OpponentRenderState {
charWidth: number; charWidth: number;
lineCount: number; lineCount: number;
tokens: Token[]; tokens: Token[];
assigned: ReadonlySet<CategoryId>; currentRgb: Record<CategoryId, RGB>;
} }
/** Draws the same snippet as `drawFrame`, but every category is either /** Draws the same snippet as `drawFrame`, but every category is a flat
* flat `OPPONENT_ASSIGNED_HEX` or flat `OPPONENT_UNASSIGNED_HEX` — an * `OPPONENT_UNASSIGNED_HEX`-to-`OPPONENT_ASSIGNED_HEX` fill driven by
* instant swap with no transition, since this pane only ever receives a * `state.currentRgb` — `OpponentView` animates that lerp the same way
* binary per-category signal. */ * `ThemeGuessGame` animates the player's own canvas (see
* `layoutConstants.TRANSITION_MS`), just always between those two flat
* colors instead of an arbitrary chosen hex. */
export function drawOpponentFrame(state: OpponentRenderState, _now: number): void { export function drawOpponentFrame(state: OpponentRenderState, _now: number): void {
const { ctx } = state; const { ctx } = state;
ctx.clearRect(0, 0, state.width, state.height); ctx.clearRect(0, 0, state.width, state.height);
ctx.fillStyle = state.assigned.has('background') ? OPPONENT_ASSIGNED_HEX : OPPONENT_UNASSIGNED_HEX; ctx.fillStyle = rgbToHex(state.currentRgb.background);
ctx.fillRect(0, 0, state.width, state.height); ctx.fillRect(0, 0, state.width, state.height);
ctx.fillStyle = 'rgba(0,0,0,.16)'; ctx.fillStyle = 'rgba(0,0,0,.16)';
@ -122,7 +126,7 @@ export function drawOpponentFrame(state: OpponentRenderState, _now: number): voi
ctx.font = t.type === 'comment' ctx.font = t.type === 'comment'
? `italic ${FONT_SIZE}px ${FONT_STACK}` ? `italic ${FONT_SIZE}px ${FONT_STACK}`
: `${FONT_SIZE}px ${FONT_STACK}`; : `${FONT_SIZE}px ${FONT_STACK}`;
ctx.fillStyle = state.assigned.has(cat) ? OPPONENT_ASSIGNED_HEX : OPPONENT_UNASSIGNED_HEX; ctx.fillStyle = rgbToHex(state.currentRgb[cat]);
ctx.strokeText(t.text, x, y); ctx.strokeText(t.text, x, y);
ctx.fillText(t.text, x, y); ctx.fillText(t.text, x, y);
} }

View file

@ -2,20 +2,38 @@ import { similarityFromHex } from '../engine/colorUtils';
import { CATEGORY_META } from '../data/themes'; import { CATEGORY_META } from '../data/themes';
import type { CategoryStateMap, MatchResult } from '../types'; import type { CategoryStateMap, MatchResult } from '../types';
/** Weighted-by-frequency average of how close each guessed color landed // Power-mean exponent used to combine per-category similarities into one
* to the theme's real color, in perceptual (CIELAB) distance. */ // overall score. 1 would be a plain weighted average — it only cares
// about total similarity, so a guess that nails a few high-weight
// categories and whiffs the rest scores the same as one that lands
// respectably close on everything. Any exponent below 1 makes the mean
// concave in each `sim`, so — by the power-mean inequality — two guesses
// with the *same* weighted-average similarity are no longer scored
// equally: the more evenly that similarity is spread across categories,
// the higher the result, and spikier profiles (some near-perfect, others
// badly missed) score strictly lower. 0.6 was picked empirically: harsh
// enough to reward consistency, but nowhere near the geometric-mean limit
// (exponent -> 0), which would crash the whole score to ~0 whenever a
// single category was left unassigned (sim 0) — a real case, since a
// round can be revealed before every category is filled.
const EVENNESS_EXPONENT = 0.6;
/** Weighted power-mean (see `EVENNESS_EXPONENT`) of how close each
* guessed color landed to the theme's real color, in perceptual
* (CIELAB) distance. */
export function computeMatchResult(categories: CategoryStateMap): MatchResult { export function computeMatchResult(categories: CategoryStateMap): MatchResult {
let totalWeight = 0; let totalWeight = 0;
let weightedSum = 0; let weightedPowerSum = 0;
const rows: MatchResult['rows'] = []; const rows: MatchResult['rows'] = [];
for (const def of CATEGORY_META) { for (const def of CATEGORY_META) {
const cat = categories[def.id]; const cat = categories[def.id];
const sim = similarityFromHex(cat.assignedHex, cat.actualHex); const sim = similarityFromHex(cat.assignedHex, cat.actualHex);
totalWeight += cat.weight; totalWeight += cat.weight;
weightedSum += sim * cat.weight; weightedPowerSum += cat.weight * Math.pow(sim, EVENNESS_EXPONENT);
rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim }); rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim });
} }
return { overall: Math.round(weightedSum / totalWeight), rows }; const overall = Math.pow(weightedPowerSum / totalWeight, 1 / EVENNESS_EXPONENT);
return { overall: Math.round(overall), rows };
} }

View file

@ -15,12 +15,12 @@
* `endsAt` passes — multiplayer uses it to trigger the local player's * `endsAt` passes — multiplayer uses it to trigger the local player's
* `round:submit` without polling `startCountdown`'s return value. */ * `round:submit` without polling `startCountdown`'s return value. */
export type TimerHudOptions = export type TimerHudOptions =
| { timeMode: 1 | 2; endsAt: number; onExpire?: () => void } | { timeMode: 2 | 4; endsAt: number; onExpire?: () => void }
| { timeMode: 'none'; endsAt?: null }; | { timeMode: 'none'; endsAt?: null };
const TIME_MODE_LABEL: Record<1 | 2 | 'none', string> = { const TIME_MODE_LABEL: Record<2 | 4 | 'none', string> = {
1: '1 MIN',
2: '2 MIN', 2: '2 MIN',
4: '4 MIN',
none: 'NO LIMIT', none: 'NO LIMIT',
}; };
@ -50,7 +50,7 @@ function elapsedMarkup(): string {
</div>`; </div>`;
} }
/** 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` * readout into `container`, and starts driving it from `options.endsAt`
* via requestAnimationFrame. Returns a `stop()` that cancels the animation * via requestAnimationFrame. Returns a `stop()` that cancels the animation
* loop; call it on round end/teardown to avoid leaking the RAF handle. */ * loop; call it on round end/teardown to avoid leaking the RAF handle. */

View file

@ -1,9 +1,6 @@
import './style.css'; import './style.css';
import { getSharedGame } from './game/sharedGame';
import { THEMES } from './data/themes';
import { sound } from './engine/sound'; import { sound } from './engine/sound';
import { createThemeGrid } from './ui/themeGrid'; import { createThemeGrid } from './ui/themeGrid';
import { runThemePreview } from './ui/previewFlow';
import { createSoloConfigFlow } from './ui/soloConfigFlow'; import { createSoloConfigFlow } from './ui/soloConfigFlow';
import { createMatchmakingFlow } from './ui/matchmakingFlow'; import { createMatchmakingFlow } from './ui/matchmakingFlow';
import { createPrivateRoomFlow } from './ui/privateRoomFlow'; import { createPrivateRoomFlow } from './ui/privateRoomFlow';
@ -58,24 +55,18 @@ function showView(name: ScreenName | null): void {
const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen()); const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen());
const soloConfigFlow = createSoloConfigFlow( createSoloConfigFlow(
{ {
section: menuSoloSection, section: menuSoloSection,
themePickerWrap: soloThemePickerWrap, themePickerWrap: soloThemePickerWrap,
botDifficultyWrap: soloBotDifficultyWrap, botDifficultyWrap: soloBotDifficultyWrap,
playBtn: startBtn, playBtn: startBtn,
timerMount: soloTimerMount, timerMount: soloTimerMount,
themeNameBadge,
preview: previewElements,
}, },
themeGrid, themeGrid,
(config) => { { hideMenu: () => showView(null), showMenu: () => showView('menu') },
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());
},
); );
const privateRoomFlow = createPrivateRoomFlow( const privateRoomFlow = createPrivateRoomFlow(

View file

@ -7,7 +7,7 @@
import type { CategoryId, ThemeId } from '../types'; import type { CategoryId, ThemeId } from '../types';
export type TimeMode = 1 | 2; export type TimeMode = 2 | 4;
export type ThemeMode = 'random' | 'chosen'; export type ThemeMode = 'random' | 'chosen';
export type RoomErrorReason = 'not_found' | 'full'; export type RoomErrorReason = 'not_found' | 'full';
export type RoomClosedReason = 'quit' | 'matchLimit' | 'finished'; export type RoomClosedReason = 'quit' | 'matchLimit' | 'finished';

View file

@ -285,14 +285,14 @@ body {
.panel-actions .btn { width: 100%; } .panel-actions .btn { width: 100%; }
/* ---------- split view (in-match opponent progress pane) ---------- */ /* ---------- 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 { .split-pane.hidden { display: none; }
flex: 1 1 auto;
min-height: 0;
display: flex;
gap: 14px;
}
.split-view.hidden { display: none; }
.split-pane { .split-pane {
flex: 1 1 0; flex: 1 1 0;
@ -311,12 +311,43 @@ body {
flex: 0 0 auto; 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 { .split-canvas-wrap {
flex: 1 1 auto; flex: 1 1 auto;
} }
.split-canvas { display: block; } .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 { .cat-card {
background: var(--panel-2); background: var(--panel-2);
border: 3px solid var(--ink); border: 3px solid var(--ink);

View file

@ -52,8 +52,8 @@ export interface MatchmakingFlowHooks {
type FlowState = 'idle' | 'searching' | 'banned' | 'found' | 'in-match'; type FlowState = 'idle' | 'searching' | 'banned' | 'found' | 'in-match';
const TIME_MODE_LABEL: Record<TimeMode, string> = { const TIME_MODE_LABEL: Record<TimeMode, string> = {
1: '🎯 1 MINUTE MATCH',
2: '🎯 2 MINUTE MATCH', 2: '🎯 2 MINUTE MATCH',
4: '🎯 4 MINUTE MATCH',
}; };
/** Minimum time the assigned-mode banner stays up before handing off to /** Minimum time the assigned-mode banner stays up before handing off to

View file

@ -169,8 +169,8 @@ export function createPrivateRoomFlow(
<div class="solo-config-group"> <div class="solo-config-group">
<p class="solo-config-label">⏱ TIME</p> <p class="solo-config-label">⏱ TIME</p>
<div class="solo-radio-row"> <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" checked />2 MIN</label>
<label class="solo-radio"><input type="radio" name="pr-time-mode" value="2" />2 MIN</label> <label class="solo-radio"><input type="radio" name="pr-time-mode" value="4" />4 MIN</label>
</div> </div>
</div> </div>
<div class="solo-config-group"> <div class="solo-config-group">
@ -190,7 +190,7 @@ export function createPrivateRoomFlow(
render({ kind: 'hidden' }); render({ kind: 'hidden' });
}); });
mount.querySelector('[data-action="submit"]')!.addEventListener('click', () => { 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; const themeMode = radioValue(mount, 'pr-theme-mode', 'random') as ThemeMode;
client.send({ type: 'room:create', timeMode, themeMode }); client.send({ type: 'room:create', timeMode, themeMode });
}); });

View file

@ -1,8 +1,13 @@
import { THEMES } from '../data/themes'; import { THEMES } from '../data/themes';
import { pickRandomSnippetIndex } from '../data/snippets'; import { pickRandomSnippetIndex } from '../data/snippets';
import type { BotDifficulty, BotTimeMode } from '../engine/bot'; import type { BotDifficulty, BotTimeMode } from '../engine/bot';
import { sound } from '../engine/sound';
import { BotMatch } from '../game/botMatch'; 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'; import type { ThemeGrid } from './themeGrid';
/** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be /** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be
@ -16,6 +21,7 @@ export interface SoloConfig {
themeId: ThemeId; themeId: ThemeId;
snippetIndex: number; snippetIndex: number;
timeMode: SoloTimeMode; timeMode: SoloTimeMode;
themeMode: SoloThemeMode;
opponent: SoloOpponent; opponent: SoloOpponent;
botDifficulty: BotDifficulty; botDifficulty: BotDifficulty;
} }
@ -31,33 +37,36 @@ export interface SoloConfigFlowElements {
botDifficultyWrap: HTMLElement; botDifficultyWrap: HTMLElement;
playBtn: HTMLButtonElement; playBtn: HTMLButtonElement;
/** Empty mount point (outside the menu modal) the inline timer/mode /** 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; 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 { export interface SoloConfigFlowHooks {
/** Starts the inline countdown/elapsed-time HUD for a just-started /** Hides every top-level overlay so the board is visible. */
* round. Call once the round is actually live (e.g. after the theme hideMenu: () => void;
* preview finishes), not at Play-click time. Also the "round is live" /** Shows the main menu (also hides the result modal, if open — see
* signal this module uses internally to start (or, for 'Alone', * main.ts's `showView`). */
* tear down) the bot opponent's split-view pane — see `handleLocalAssignment`. */ showMenu: () => void;
startTimer(mode: SoloTimeMode): void; }
/** `ThemeGuessGame`'s `onCategoryAssigned` callback target. Wire this
* once, at the game's first construction (see `MultiplayerMatch`'s function requireEl<T extends HTMLElement>(id: string): T {
* two-phase-init doc for the pattern this mirrors) — it's a stable const el = document.getElementById(id);
* dispatch that forwards to whichever bot round `startTimer` most if (!el) throw new Error(`Missing required element #${id}`);
* recently started, and is a no-op for 'Alone' rounds. */ return el as T;
handleLocalAssignment(id: CategoryId, hex: string): void;
} }
const TIME_MODE_DEADLINE_MS: Record<Exclude<SoloTimeMode, 'none'>, number> = { const TIME_MODE_DEADLINE_MS: Record<Exclude<SoloTimeMode, 'none'>, number> = {
'1min': 60_000,
'2min': 120_000, '2min': 120_000,
'4min': 240_000,
}; };
const TIME_MODE_LABEL: Record<SoloTimeMode, string> = { const TIME_MODE_LABEL: Record<SoloTimeMode, string> = {
'1min': '1 MIN',
'2min': '2 MIN', '2min': '2 MIN',
'4min': '4 MIN',
none: 'NO LIMIT', none: 'NO LIMIT',
}; };
@ -76,21 +85,33 @@ function checkedValue<T extends string>(inputs: HTMLInputElement[], fallback: T)
return (inputs.find((input) => input.checked)?.value as T | undefined) ?? fallback; return (inputs.find((input) => input.checked)?.value as T | undefined) ?? fallback;
} }
/** Minimal inline countdown (1/2 minute modes) or elapsed-time counter interface TimerHud {
* ("no limit") plus a mode badge, rendered into `mount`. Purely a /** Starts (or restarts) the countdown/elapsed-time display. `onExpire`,
* display: it never forces a reveal, since `ThemeGuessGame.reveal()` * on the two clocked modes only, fires exactly once when the countdown
* stays manually triggered by the existing Reveal button in every time * reaches zero. */
* mode, "no limit" included. 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. */ * TODO: replace with shared timerHud module once available. */
function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] { function createTimerHud(mount: HTMLElement, onQuit: () => void): TimerHud {
mount.innerHTML = ` mount.innerHTML = `
<div class="solo-timer-hud hidden"> <div class="solo-timer-hud hidden">
<span class="solo-timer-badge"></span> <span class="solo-timer-badge"></span>
<span class="solo-timer-clock">00:00</span> <span class="solo-timer-clock">00:00</span>
<button type="button" class="solo-timer-quit btn danger small">✕ Quit</button>
</div>`; </div>`;
const root = mount.querySelector<HTMLElement>('.solo-timer-hud')!; const root = mount.querySelector<HTMLElement>('.solo-timer-hud')!;
const badge = mount.querySelector<HTMLElement>('.solo-timer-badge')!; const badge = mount.querySelector<HTMLElement>('.solo-timer-badge')!;
const clock = mount.querySelector<HTMLElement>('.solo-timer-clock')!; const clock = mount.querySelector<HTMLElement>('.solo-timer-clock')!;
const quitBtn = mount.querySelector<HTMLButtonElement>('.solo-timer-quit')!;
quitBtn.addEventListener('click', onQuit);
let intervalId: number | null = null; let intervalId: number | null = null;
function stop(): void { 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(); stop();
root.classList.remove('hidden'); root.classList.remove('hidden');
badge.textContent = TIME_MODE_LABEL[mode]; badge.textContent = TIME_MODE_LABEL[mode];
@ -113,31 +134,52 @@ function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] {
return; return;
} }
const remaining = TIME_MODE_DEADLINE_MS[mode] - elapsed; const remaining = TIME_MODE_DEADLINE_MS[mode] - elapsed;
clock.textContent = formatClock(remaining); clock.textContent = formatClock(Math.max(0, remaining));
if (remaining <= 0) stop(); if (remaining <= 0) {
stop();
onExpire?.();
}
} }
tick(); tick();
intervalId = window.setInterval(tick, 250); 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 / /** Wires the Solo menu section's config form (time mode / theme mode /
* opponent, with the theme grid and bot difficulty picker revealed * opponent, with the theme grid and bot difficulty picker revealed
* conditionally) and its inline timer HUD. Fires `onStart` with the * conditionally), its inline timer/quit HUD, and every shared
* resolved config once the player clicks Play. */ * 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( export function createSoloConfigFlow(
elements: SoloConfigFlowElements, elements: SoloConfigFlowElements,
themeGrid: ThemeGrid, themeGrid: ThemeGrid,
onStart: (config: SoloConfig) => void, hooks: SoloConfigFlowHooks,
): SoloConfigFlow { ): void {
const themeModeInputs = radios(elements.section, 'solo-theme-mode'); const themeModeInputs = radios(elements.section, 'solo-theme-mode');
const opponentInputs = radios(elements.section, 'solo-opponent-mode'); const opponentInputs = radios(elements.section, 'solo-opponent-mode');
const timeModeInputs = radios(elements.section, 'solo-time-mode'); const timeModeInputs = radios(elements.section, 'solo-time-mode');
const botDifficultyInputs = radios(elements.section, 'solo-bot-difficulty'); const botDifficultyInputs = radios(elements.section, 'solo-bot-difficulty');
const startTimerHud = createTimerHud(elements.timerMount); const closeResultBtn = requireEl<HTMLButtonElement>('close-result-btn');
const playAgainBtn = requireEl<HTMLButtonElement>('play-again-btn');
const botMatch = new BotMatch(); const botMatch = new BotMatch();
let lastConfig: SoloConfig | null = null; 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 { function syncThemePicker(): void {
const mode = checkedValue<SoloThemeMode>(themeModeInputs, 'random'); const mode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
@ -154,8 +196,42 @@ export function createSoloConfigFlow(
syncThemePicker(); syncThemePicker();
syncBotDifficulty(); 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', () => { elements.playBtn.addEventListener('click', () => {
const timeMode = checkedValue<SoloTimeMode>(timeModeInputs, '1min'); const timeMode = checkedValue<SoloTimeMode>(timeModeInputs, '2min');
const themeMode = checkedValue<SoloThemeMode>(themeModeInputs, 'random'); const themeMode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone'); const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
const botDifficulty = checkedValue<BotDifficulty>(botDifficultyInputs, 'easy'); const botDifficulty = checkedValue<BotDifficulty>(botDifficultyInputs, 'easy');
@ -165,32 +241,55 @@ export function createSoloConfigFlow(
? themeGrid.selected ? themeGrid.selected
: themeIds[Math.floor(Math.random() * themeIds.length)]; : themeIds[Math.floor(Math.random() * themeIds.length)];
lastConfig = { beginRound({
themeId, themeId,
snippetIndex: pickRandomSnippetIndex(), snippetIndex: pickRandomSnippetIndex(),
timeMode, timeMode,
themeMode,
opponent, opponent,
botDifficulty, botDifficulty,
}; });
onStart(lastConfig);
}); });
return { /** "Keep Comparing": lets the player keep tweaking colors after a
startTimer(mode) { * reveal instead of being stuck admiring the score. Unlocks the
startTimerHud(mode); * Reveal button for the rest of the round (`enableExtendedPlay`) and
if (lastConfig?.opponent === 'bot') { * restarts the HUD in elapsed-time "NO LIMIT" mode — an explicit
botMatch.start({ * countdown still running would otherwise force another auto-reveal
themeId: lastConfig.themeId, * mid-tweak (see `beginRound`'s `onExpire`). */
snippetIndex: lastConfig.snippetIndex, function keepComparing(): void {
timeMode: lastConfig.timeMode, if (!soloActive || !activeGame) return;
difficulty: lastConfig.botDifficulty, activeGame.enableExtendedPlay();
}); timerHud.start('none');
} else { }
/** "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(); botMatch.stop();
timerHud.hide();
hooks.showMenu();
} }
},
handleLocalAssignment(id, hex) { closeResultBtn.addEventListener('click', keepComparing);
botMatch.handleLocalAssignment(id, hex); playAgainBtn.addEventListener('click', playAgain);
},
};
} }