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:
parent
36b2725498
commit
cbc4d8a2eb
22 changed files with 535 additions and 224 deletions
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,17 @@
|
|||
<div id="match-timer-warning" class="hidden"></div>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="picker-popover" class="popover hidden">
|
||||
|
|
@ -56,6 +66,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel-col" aria-label="Token kinds">
|
||||
<div id="category-panel" class="panel"></div>
|
||||
|
|
@ -65,21 +76,6 @@
|
|||
</div>
|
||||
</aside>
|
||||
</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 id="intro-modal" class="modal-overlay">
|
||||
|
|
@ -95,8 +91,8 @@
|
|||
<div class="solo-config-group">
|
||||
<p class="solo-config-label">⏱ TIME</p>
|
||||
<div class="solo-radio-row">
|
||||
<label class="solo-radio"><input type="radio" name="solo-time-mode" value="1min" checked />1 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="2min" checked />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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { CategoryId, RGB } from '../types';
|
|||
import { hexToRgb, rgbToHex, lerpRgb, similarityFromHex } from './colorUtils';
|
||||
|
||||
export type BotDifficulty = 'easy' | 'medium' | 'hard';
|
||||
export type BotTimeMode = '1min' | '2min' | 'none';
|
||||
export type BotTimeMode = '2min' | '4min' | 'none';
|
||||
|
||||
export interface BotCategoryInput {
|
||||
id: CategoryId;
|
||||
|
|
@ -33,7 +33,7 @@ export interface BotPick {
|
|||
// 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
|
||||
* countdown vs. no countdown doesn't change how "fast" the bot thinks). */
|
||||
type TimeBand = 'short' | 'long';
|
||||
|
|
@ -54,13 +54,13 @@ interface TimeModeConfig {
|
|||
const DEADLINE_SAFETY_MARGIN_MS = 1_500;
|
||||
|
||||
const TIME_MODE_CONFIG: Record<BotTimeMode, TimeModeConfig> = {
|
||||
// ~55s target, ±4s jitter -> finishes 51-59s, safely under the 60s cap.
|
||||
'1min': { targetMs: 55_000, jitterMs: 4_000, deadlineMs: 60_000 },
|
||||
// ~1m45s target, ±5s jitter -> finishes 100-110s, safely under the 120s cap.
|
||||
'2min': { targetMs: 105_000, jitterMs: 5_000, deadlineMs: 120_000 },
|
||||
// Same ~1m45s target as 2-minute mode (no deadline to clamp against, but
|
||||
// ~1m50s target, ±8s jitter -> finishes 102-118s, safely under the 120s cap.
|
||||
'2min': { targetMs: 110_000, jitterMs: 8_000, deadlineMs: 120_000 },
|
||||
// ~3m30s target, ±10s jitter -> finishes 200-220s, safely under the 240s cap.
|
||||
'4min': { targetMs: 210_000, jitterMs: 10_000, deadlineMs: 240_000 },
|
||||
// Same ~3m30s target as 4-minute mode (no deadline to clamp against, but
|
||||
// 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
|
||||
|
|
@ -162,7 +162,7 @@ function pickHex(targetHex: string, difficulty: BotDifficulty, timeMode: BotTime
|
|||
if (timeMode === 'none' && Math.random() < NO_LIMIT_PERFECT_CHANCE) {
|
||||
return targetHex;
|
||||
}
|
||||
const band: TimeBand = timeMode === '1min' ? 'short' : 'long';
|
||||
const band: TimeBand = timeMode === '2min' ? 'short' : 'long';
|
||||
const ceiling = ACCURACY_CEILING[difficulty][band];
|
||||
const floor = Math.max(5, ceiling - ACCURACY_SPREAD[difficulty]);
|
||||
const desiredSimilarity = floor + Math.random() * (ceiling - floor);
|
||||
|
|
|
|||
60
frontend/src/engine/categoryStats.ts
Normal file
60
frontend/src/engine/categoryStats.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -78,9 +78,13 @@ export function deltaE76(labA: Lab, labB: Lab): number {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
if (!hexA) return 0;
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { tokenize } from '../engine/tokenizer';
|
||||
import { computeCategoryStats } from '../engine/categoryStats';
|
||||
import { CATEGORY_META, THEMES } from '../data/themes';
|
||||
import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets';
|
||||
import { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils';
|
||||
|
|
@ -50,6 +51,11 @@ export class ThemeGuessGame {
|
|||
private compareMode: CompareMode = 'yours';
|
||||
private lastTime = performance.now();
|
||||
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 lineCount = 1;
|
||||
|
|
@ -87,6 +93,7 @@ export class ThemeGuessGame {
|
|||
// ---------- setup ----------
|
||||
|
||||
private build(): void {
|
||||
this.extendedPlay = false;
|
||||
this.tokens = tokenize(SNIPPETS[this.snippetIndex]);
|
||||
|
||||
this.lineCount = 1;
|
||||
|
|
@ -97,23 +104,17 @@ export class ThemeGuessGame {
|
|||
else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
|
||||
}
|
||||
|
||||
const counts: Partial<Record<CategoryId, number>> = {};
|
||||
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 { counts, weights } = computeCategoryStats(this.tokens);
|
||||
|
||||
const theme = THEMES[this.themeId];
|
||||
const categories = {} as CategoryStateMap;
|
||||
for (const def of CATEGORY_META) {
|
||||
const count = counts[def.id] ?? 0;
|
||||
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||
categories[def.id] = {
|
||||
...def,
|
||||
actualHex: theme.colors[def.id],
|
||||
weight: def.id === 'background' ? maxCount : Math.max(count, 3),
|
||||
count,
|
||||
weight: weights[def.id],
|
||||
count: counts[def.id],
|
||||
assignedHex: null,
|
||||
currentRgb: { ...unset },
|
||||
fromRgb: { ...unset },
|
||||
|
|
@ -152,6 +153,7 @@ export class ThemeGuessGame {
|
|||
// ---------- theme switching ----------
|
||||
|
||||
setTheme(themeId: ThemeId): void {
|
||||
this.extendedPlay = false;
|
||||
this.themeId = themeId;
|
||||
const theme = THEMES[themeId];
|
||||
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;
|
||||
this.progressFill.style.width = `${(done / total) * 100}%`;
|
||||
this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`;
|
||||
this.revealBtn.disabled = done < total;
|
||||
this.revealBtn.disabled = !this.extendedPlay && done < total;
|
||||
}
|
||||
|
||||
private resetColors(): void {
|
||||
|
|
@ -385,7 +387,14 @@ export class ThemeGuessGame {
|
|||
|
||||
// ---------- 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);
|
||||
|
||||
renderBreakdown(this.breakdownEl, rows);
|
||||
|
|
@ -397,6 +406,24 @@ export class ThemeGuessGame {
|
|||
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 ----------
|
||||
|
||||
private loop(t: number): void {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,21 @@
|
|||
// two-column scoreboard `MultiplayerMatch` uses once the round ends.
|
||||
//
|
||||
// Mirrors `multiplayerMatch.ts`'s shape and two-phase-init convention —
|
||||
// `ThemeGuessGame` has no knowledge of this module either: `soloConfigFlow`
|
||||
// supplies its `onCategoryAssigned` constructor callback and routes it into
|
||||
// `BotMatch.handleLocalAssignment`, exactly like `MultiplayerMatch` docs
|
||||
// describe. The one structural difference: there is no server, so this
|
||||
// module also decides *when* the round ends (timer deadline, or — in
|
||||
// no-limit mode, which has no deadline — once the bot's schedule has fully
|
||||
// played out and the player has manually revealed via the existing Reveal
|
||||
// button, matching how `ThemeGuessGame.reveal()` stays manually triggered
|
||||
// for solo play in every time mode).
|
||||
// `ThemeGuessGame` has no knowledge of this module's existence (it never
|
||||
// imports `BotMatch`): `soloConfigFlow` supplies its `onCategoryAssigned`
|
||||
// constructor callback and routes it into `BotMatch.handleLocalAssignment`,
|
||||
// exactly like `MultiplayerMatch` docs describe, and separately binds the
|
||||
// shared instance itself via `bindGame` so the Reveal button can be gated
|
||||
// on the ground-truth `isFullyAssigned()` (see `syncRevealAvailability`)
|
||||
// instead of shadowing that state locally. The one structural difference
|
||||
// from multiplayer: there is no server, so this module also decides
|
||||
// *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 { computeCategoryStats } from '../engine/categoryStats';
|
||||
import { rgbToHex } from '../engine/colorUtils';
|
||||
import { computeBotSchedule } from '../engine/bot';
|
||||
import type { BotDifficulty, BotPick, BotTimeMode } from '../engine/bot';
|
||||
|
|
@ -26,6 +30,7 @@ import { computeMatchResult } from './scoring';
|
|||
import { renderMultiplayerResult } from './resultView';
|
||||
import type { MultiplayerResultElements } from './resultView';
|
||||
import { OpponentView } from './opponentView';
|
||||
import type { ThemeGuessGame } from './ThemeGuessGame';
|
||||
|
||||
function requireEl<T extends HTMLElement>(id: string): T {
|
||||
const el = document.getElementById(id);
|
||||
|
|
@ -58,25 +63,17 @@ function buildCategoryStateMap(
|
|||
themeId: ThemeId,
|
||||
colors: Record<CategoryId, string>,
|
||||
): CategoryStateMap {
|
||||
const tokens = 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 { counts, weights } = computeCategoryStats(tokenize(SNIPPETS[snippetIndex]));
|
||||
const theme = THEMES[themeId];
|
||||
|
||||
const categories = {} as CategoryStateMap;
|
||||
for (const def of CATEGORY_META) {
|
||||
const count = counts[def.id] ?? 0;
|
||||
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||
categories[def.id] = {
|
||||
...def,
|
||||
actualHex: theme.colors[def.id],
|
||||
weight: def.id === 'background' ? maxCount : Math.max(count, 3),
|
||||
count,
|
||||
weight: weights[def.id],
|
||||
count: counts[def.id],
|
||||
assignedHex: colors[def.id] ?? null,
|
||||
currentRgb: { ...unset },
|
||||
fromRgb: { ...unset },
|
||||
|
|
@ -95,8 +92,8 @@ function buildCategoryStateMap(
|
|||
* (see `multiplayerMatch.ts` vs `ThemeGuessGame.ts`'s category-map
|
||||
* builders). No-limit mode has no deadline, so no entry here. */
|
||||
const ROUND_DEADLINE_MS: Record<Exclude<BotTimeMode, 'none'>, number> = {
|
||||
'1min': 60_000,
|
||||
'2min': 120_000,
|
||||
'4min': 240_000,
|
||||
};
|
||||
|
||||
export interface BotMatchConfig {
|
||||
|
|
@ -131,6 +128,8 @@ export class BotMatch {
|
|||
};
|
||||
private readonly revealBtn = requireEl<HTMLButtonElement>('reveal-btn');
|
||||
|
||||
private game: ThemeGuessGame | null = null;
|
||||
|
||||
private opponentView: OpponentView | null = null;
|
||||
private schedule: BotPick[] = [];
|
||||
private pickTimeoutIds: number[] = [];
|
||||
|
|
@ -139,7 +138,6 @@ export class BotMatch {
|
|||
|
||||
private themeId: ThemeId | null = null;
|
||||
private snippetIndex: number | null = null;
|
||||
private timeMode: BotTimeMode = 'none';
|
||||
private localColors: Partial<Record<CategoryId, string>> = {};
|
||||
|
||||
private ended = true;
|
||||
|
|
@ -150,14 +148,25 @@ export class BotMatch {
|
|||
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
|
||||
* local player's running color map for the eventual scoreboard. 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. */
|
||||
* local player's running color map for the eventual scoreboard, and
|
||||
* re-syncs Reveal-button availability (the player finishing their own
|
||||
* board is one of the two conditions it's gated on — see
|
||||
* `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 {
|
||||
if (this.ended) return;
|
||||
this.localColors[id] = hex;
|
||||
this.syncRevealAvailability();
|
||||
}
|
||||
|
||||
/** Starts one bot round: shows the split-view pane, (re)builds the
|
||||
|
|
@ -173,7 +182,6 @@ export class BotMatch {
|
|||
this.localColors = {};
|
||||
this.themeId = config.themeId;
|
||||
this.snippetIndex = config.snippetIndex;
|
||||
this.timeMode = config.timeMode;
|
||||
|
||||
this.splitView.classList.remove('hidden');
|
||||
if (!this.opponentView) {
|
||||
|
|
@ -185,6 +193,7 @@ export class BotMatch {
|
|||
|
||||
this.resultModal.classList.add('hidden');
|
||||
this.mpScoreboard.classList.add('hidden');
|
||||
this.syncRevealAvailability();
|
||||
|
||||
const theme = THEMES[config.themeId];
|
||||
const categories = CATEGORY_META.map((def) => ({ id: def.id, hex: theme.colors[def.id] }));
|
||||
|
|
@ -201,7 +210,8 @@ export class BotMatch {
|
|||
this.remainingPicks -= 1;
|
||||
if (this.remainingPicks === 0) {
|
||||
this.botFinished = true;
|
||||
this.maybeEndNoLimitRound();
|
||||
this.syncRevealAvailability();
|
||||
this.maybeEndRound();
|
||||
}
|
||||
}, pick.atMs);
|
||||
this.pickTimeoutIds.push(timeoutId);
|
||||
|
|
@ -220,17 +230,31 @@ export class BotMatch {
|
|||
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 => {
|
||||
this.playerRevealed = true;
|
||||
this.maybeEndNoLimitRound();
|
||||
this.maybeEndRound();
|
||||
};
|
||||
|
||||
/** No-limit mode has no deadline, so it ends only once both the bot's
|
||||
* schedule has fully played out and the player has manually revealed
|
||||
* (the existing Reveal button, same manual-trigger convention solo
|
||||
* play already uses in every time mode). */
|
||||
private maybeEndNoLimitRound(): void {
|
||||
if (this.timeMode === 'none' && this.playerRevealed && this.botFinished) this.endRound();
|
||||
/** Ends the round as soon as both sides are done and the player has
|
||||
* manually revealed — the Reveal button is disabled until exactly
|
||||
* that point (see `syncRevealAvailability`), so this fires the moment
|
||||
* it's clicked, timed mode or not, instead of making the player wait
|
||||
* out a deadline neither side needs anymore. A timed round whose
|
||||
* deadline arrives first still ends itself via `endRound` regardless. */
|
||||
private maybeEndRound(): void {
|
||||
if (this.playerRevealed && this.botFinished) this.endRound();
|
||||
}
|
||||
|
||||
private endRound(): void {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
// wiring so it's ready for #14/#15 to pass in.
|
||||
|
||||
import { tokenize } from '../engine/tokenizer';
|
||||
import { computeCategoryStats } from '../engine/categoryStats';
|
||||
import { rgbToHex } from '../engine/colorUtils';
|
||||
import { CATEGORY_META, THEMES } from '../data/themes';
|
||||
import { SNIPPETS } from '../data/snippets';
|
||||
|
|
@ -74,25 +75,17 @@ function buildCategoryStateMap(
|
|||
themeId: ThemeId,
|
||||
colors: Record<CategoryId, string>,
|
||||
): CategoryStateMap {
|
||||
const tokens = 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 { counts, weights } = computeCategoryStats(tokenize(SNIPPETS[snippetIndex]));
|
||||
const theme = THEMES[themeId];
|
||||
|
||||
const categories = {} as CategoryStateMap;
|
||||
for (const def of CATEGORY_META) {
|
||||
const count = counts[def.id] ?? 0;
|
||||
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||
categories[def.id] = {
|
||||
...def,
|
||||
actualHex: theme.colors[def.id],
|
||||
weight: def.id === 'background' ? maxCount : Math.max(count, 3),
|
||||
count,
|
||||
weight: weights[def.id],
|
||||
count: counts[def.id],
|
||||
assignedHex: colors[def.id] ?? null,
|
||||
currentRgb: { ...unset },
|
||||
fromRgb: { ...unset },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { tokenize } from '../engine/tokenizer';
|
||||
import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets';
|
||||
import type { CategoryId, Token } from '../types';
|
||||
import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants';
|
||||
import { drawOpponentFrame } from './renderer';
|
||||
import { CATEGORY_META } from '../data/themes';
|
||||
import { easeOutCubic, hexToRgb, lerpRgb } from '../engine/colorUtils';
|
||||
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 {
|
||||
const el = document.getElementById(id);
|
||||
|
|
@ -10,6 +12,20 @@ function requireEl<T extends HTMLElement>(id: string): 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
|
||||
* 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
|
||||
* path into this view by construction.
|
||||
*
|
||||
* Mirrors `ThemeGuessGame`'s canvas-ownership pattern, but far simpler:
|
||||
* no picker, no clicks, no animation loop — every redraw is a flat,
|
||||
* instant swap (see `drawOpponentFrame`).
|
||||
* Mirrors `ThemeGuessGame`'s canvas-ownership pattern closely: no picker,
|
||||
* no clicks, but the same perpetual `requestAnimationFrame` loop easing
|
||||
* 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 {
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
private readonly completeBanner = requireEl<HTMLElement>('opponent-complete-banner');
|
||||
|
||||
private snippetIndex: number;
|
||||
private tokens: Token[] = [];
|
||||
|
|
@ -35,12 +58,15 @@ export class OpponentView {
|
|||
private width = 0;
|
||||
private height = 0;
|
||||
private assigned = new Set<CategoryId>();
|
||||
private fills = {} as Record<CategoryId, FillState>;
|
||||
|
||||
constructor(canvasId: string, snippetIndex: number = pickRandomSnippetIndex()) {
|
||||
this.canvas = requireEl<HTMLCanvasElement>(canvasId);
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
this.snippetIndex = snippetIndex;
|
||||
this.resetFills();
|
||||
this.build();
|
||||
requestAnimationFrame((t) => this.loop(t));
|
||||
}
|
||||
|
||||
/** Forces a specific snippet (by stable `SNIPPETS` index) and clears
|
||||
|
|
@ -49,22 +75,42 @@ export class OpponentView {
|
|||
setSnippet(index: number): void {
|
||||
this.snippetIndex = index;
|
||||
this.assigned = new Set();
|
||||
this.resetFills();
|
||||
this.completeBanner.classList.add('hidden');
|
||||
this.build();
|
||||
}
|
||||
|
||||
/** Marks one category as assigned by the opponent and redraws. Takes
|
||||
* only a `CategoryId` — never a color — so there is no code path for
|
||||
* an opponent's real chosen hex to reach this view. */
|
||||
/** Marks one category as assigned by the opponent and starts its
|
||||
* fade-to-green transition. Takes only a `CategoryId` — never a
|
||||
* 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 {
|
||||
if (this.assigned.has(categoryId)) return;
|
||||
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
|
||||
* touching the current snippet/layout. */
|
||||
/** Clears every progress signal (and the finished banner) for reuse
|
||||
* across matches, without touching the current snippet/layout. */
|
||||
reset(): void {
|
||||
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 {
|
||||
|
|
@ -79,7 +125,6 @@ export class OpponentView {
|
|||
}
|
||||
|
||||
this.setupCanvasSize();
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private setupCanvasSize(): void {
|
||||
|
|
@ -95,7 +140,26 @@ export class OpponentView {
|
|||
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({
|
||||
ctx: this.ctx,
|
||||
width: this.width,
|
||||
|
|
@ -103,7 +167,7 @@ export class OpponentView {
|
|||
charWidth: this.charWidth,
|
||||
lineCount: this.lineCount,
|
||||
tokens: this.tokens,
|
||||
assigned: this.assigned,
|
||||
}, performance.now());
|
||||
currentRgb,
|
||||
}, now);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { rgbToHex } from '../engine/colorUtils';
|
||||
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';
|
||||
|
||||
/** 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';
|
||||
|
||||
/** Everything one frame of the opponent-progress canvas needs. Deliberately
|
||||
* narrower than `RenderState`: no per-category colors, no gradients/pulse
|
||||
* animation, no particles — just which `CategoryId`s are "assigned" so far.
|
||||
* Kept DOM-free like `drawFrame`. */
|
||||
* narrower than `RenderState`: no gradient pulse or particles, and only
|
||||
* ever a lerp between the two flat opponent-progress colors above — but
|
||||
* (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 {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
width: number;
|
||||
|
|
@ -85,18 +87,20 @@ export interface OpponentRenderState {
|
|||
charWidth: number;
|
||||
lineCount: number;
|
||||
tokens: Token[];
|
||||
assigned: ReadonlySet<CategoryId>;
|
||||
currentRgb: Record<CategoryId, RGB>;
|
||||
}
|
||||
|
||||
/** Draws the same snippet as `drawFrame`, but every category is either
|
||||
* flat `OPPONENT_ASSIGNED_HEX` or flat `OPPONENT_UNASSIGNED_HEX` — an
|
||||
* instant swap with no transition, since this pane only ever receives a
|
||||
* binary per-category signal. */
|
||||
/** Draws the same snippet as `drawFrame`, but every category is a flat
|
||||
* `OPPONENT_UNASSIGNED_HEX`-to-`OPPONENT_ASSIGNED_HEX` fill driven by
|
||||
* `state.currentRgb` — `OpponentView` animates that lerp the same way
|
||||
* `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 {
|
||||
const { ctx } = state;
|
||||
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.fillStyle = 'rgba(0,0,0,.16)';
|
||||
|
|
@ -122,7 +126,7 @@ export function drawOpponentFrame(state: OpponentRenderState, _now: number): voi
|
|||
ctx.font = t.type === 'comment'
|
||||
? `italic ${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.fillText(t.text, x, y);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,38 @@ import { similarityFromHex } from '../engine/colorUtils';
|
|||
import { CATEGORY_META } from '../data/themes';
|
||||
import type { CategoryStateMap, MatchResult } from '../types';
|
||||
|
||||
/** Weighted-by-frequency average of how close each guessed color landed
|
||||
* to the theme's real color, in perceptual (CIELAB) distance. */
|
||||
// Power-mean exponent used to combine per-category similarities into one
|
||||
// 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 {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
let weightedPowerSum = 0;
|
||||
const rows: MatchResult['rows'] = [];
|
||||
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = categories[def.id];
|
||||
const sim = similarityFromHex(cat.assignedHex, cat.actualHex);
|
||||
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 });
|
||||
}
|
||||
|
||||
return { overall: Math.round(weightedSum / totalWeight), rows };
|
||||
const overall = Math.pow(weightedPowerSum / totalWeight, 1 / EVENNESS_EXPONENT);
|
||||
return { overall: Math.round(overall), rows };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@
|
|||
* `endsAt` passes — multiplayer uses it to trigger the local player's
|
||||
* `round:submit` without polling `startCountdown`'s return value. */
|
||||
export type TimerHudOptions =
|
||||
| { timeMode: 1 | 2; endsAt: number; onExpire?: () => void }
|
||||
| { timeMode: 2 | 4; endsAt: number; onExpire?: () => void }
|
||||
| { timeMode: 'none'; endsAt?: null };
|
||||
|
||||
const TIME_MODE_LABEL: Record<1 | 2 | 'none', string> = {
|
||||
1: '1 MIN',
|
||||
const TIME_MODE_LABEL: Record<2 | 4 | 'none', string> = {
|
||||
2: '2 MIN',
|
||||
4: '4 MIN',
|
||||
none: 'NO LIMIT',
|
||||
};
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ function elapsedMarkup(): string {
|
|||
</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`
|
||||
* via requestAnimationFrame. Returns a `stop()` that cancels the animation
|
||||
* loop; call it on round end/teardown to avoid leaking the RAF handle. */
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ export interface MatchmakingFlowHooks {
|
|||
type FlowState = 'idle' | 'searching' | 'banned' | 'found' | 'in-match';
|
||||
|
||||
const TIME_MODE_LABEL: Record<TimeMode, string> = {
|
||||
1: '🎯 1 MINUTE MATCH',
|
||||
2: '🎯 2 MINUTE MATCH',
|
||||
4: '🎯 4 MINUTE MATCH',
|
||||
};
|
||||
|
||||
/** Minimum time the assigned-mode banner stays up before handing off to
|
||||
|
|
|
|||
|
|
@ -169,8 +169,8 @@ export function createPrivateRoomFlow(
|
|||
<div class="solo-config-group">
|
||||
<p class="solo-config-label">⏱ TIME</p>
|
||||
<div class="solo-radio-row">
|
||||
<label class="solo-radio"><input type="radio" name="pr-time-mode" value="1" checked />1 MIN</label>
|
||||
<label class="solo-radio"><input type="radio" name="pr-time-mode" value="2" />2 MIN</label>
|
||||
<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="4" />4 MIN</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="solo-config-group">
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<T extends HTMLElement>(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<Exclude<SoloTimeMode, 'none'>, number> = {
|
||||
'1min': 60_000,
|
||||
'2min': 120_000,
|
||||
'4min': 240_000,
|
||||
};
|
||||
|
||||
const TIME_MODE_LABEL: Record<SoloTimeMode, string> = {
|
||||
'1min': '1 MIN',
|
||||
'2min': '2 MIN',
|
||||
'4min': '4 MIN',
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 = `
|
||||
<div class="solo-timer-hud hidden">
|
||||
<span class="solo-timer-badge"></span>
|
||||
<span class="solo-timer-clock">00:00</span>
|
||||
<button type="button" class="solo-timer-quit btn danger small">✕ Quit</button>
|
||||
</div>`;
|
||||
const root = mount.querySelector<HTMLElement>('.solo-timer-hud')!;
|
||||
const badge = mount.querySelector<HTMLElement>('.solo-timer-badge')!;
|
||||
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;
|
||||
|
||||
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<HTMLButtonElement>('close-result-btn');
|
||||
const playAgainBtn = requireEl<HTMLButtonElement>('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<SoloThemeMode>(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<SoloTimeMode>(timeModeInputs, '1min');
|
||||
const timeMode = checkedValue<SoloTimeMode>(timeModeInputs, '2min');
|
||||
const themeMode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
|
||||
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
|
||||
const botDifficulty = checkedValue<BotDifficulty>(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 {
|
||||
/** "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();
|
||||
}
|
||||
},
|
||||
handleLocalAssignment(id, hex) {
|
||||
botMatch.handleLocalAssignment(id, hex);
|
||||
},
|
||||
};
|
||||
|
||||
closeResultBtn.addEventListener('click', keepComparing);
|
||||
playAgainBtn.addEventListener('click', playAgain);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue