feat(frontend): add in-match orchestrator and multiplayer scoreboard

New multiplayerMatch.ts (MultiplayerMatch class) drives one round: syncs
ThemeGuessGame/OpponentView to a round:start payload, relays local
category assignments as round:progress, forwards opponent round:progress
to OpponentView, drives timerHud's countdown (submitting the local color
map as round:submit at expiry), and renders a two-column scoreboard on
round:reveal via computeMatchResult.

ThemeGuessGame gains an optional onCategoryAssigned?(id, hex) constructor
callback, invoked as the last step of applyColor.

timerHud's TimerHudOptions gains an optional onExpire?() callback on the
countdown variant, fired once when endsAt passes.

resultView.ts gains renderMultiplayerResult() for the two-column
you/opponent scoreboard + winner banner (equal overall -> explicit draw).

index.html/style.css: additive #mp-scoreboard/#mp-winner-banner/#mp-you-*/
#mp-opponent-* markup and .mp-* styles; existing single-player result
markup untouched aside from adding id="solo-score-meter-wrap" so it can
be toggled off during a multiplayer reveal.
This commit is contained in:
Gabriel Franco 2026-09-10 12:16:15 -03:00
parent a7cc3e7d8c
commit 5190ec19a3
6 changed files with 357 additions and 5 deletions

View file

@ -156,7 +156,7 @@
<div id="result-modal" class="modal-overlay hidden">
<div class="modal result-modal">
<h2>MATCH RESULT</h2>
<div class="score-meter-wrap">
<div class="score-meter-wrap" id="solo-score-meter-wrap">
<div class="score-meter-number" id="score-number">0%</div>
<div class="score-meter-track" id="score-meter"></div>
</div>
@ -166,6 +166,27 @@
<button data-mode="yours" class="toggle-btn active">Your Palette</button>
<button data-mode="original" class="toggle-btn">Original</button>
</div>
<div id="mp-scoreboard" class="mp-scoreboard hidden">
<div id="mp-winner-banner" class="mp-winner-banner"></div>
<div class="mp-columns">
<div class="mp-column">
<div class="mp-column-label">You</div>
<div class="score-meter-wrap">
<div class="score-meter-number" id="mp-you-number">0%</div>
<div class="score-meter-track" id="mp-you-meter"></div>
</div>
<div class="breakdown" id="mp-you-breakdown"></div>
</div>
<div class="mp-column">
<div class="mp-column-label">Opponent</div>
<div class="score-meter-wrap">
<div class="score-meter-number" id="mp-opponent-number">0%</div>
<div class="score-meter-track" id="mp-opponent-meter"></div>
</div>
<div class="breakdown" id="mp-opponent-breakdown"></div>
</div>
</div>
</div>
<div class="modal-actions">
<button id="close-result-btn" class="btn ghost">Keep Comparing</button>
<button id="change-theme-btn" class="btn ghost">🔄 Change Theme</button>

View file

@ -59,7 +59,16 @@ export class ThemeGuessGame {
private height = 0;
private categories!: CategoryStateMap;
constructor(themeId: ThemeId, snippetIndex: number = pickRandomSnippetIndex()) {
/** `onCategoryAssigned`, when provided, fires at the end of every
* `applyColor` with the category and hex just assigned — multiplayer
* uses it to relay a bare `round:progress` and to track the local
* player's running color map for `round:submit`, without this class
* needing to know anything about the network. */
constructor(
themeId: ThemeId,
snippetIndex: number = pickRandomSnippetIndex(),
private readonly onCategoryAssigned?: (id: CategoryId, hex: string) => void,
) {
this.themeId = themeId;
this.snippetIndex = snippetIndex;
this.build();
@ -311,6 +320,7 @@ export class ThemeGuessGame {
for (const c of this.tokenCentersFor(id, 10)) this.fx.spawnBurst(c.x, c.y, hex, 10);
sound.playApply();
this.updateProgress();
this.onCategoryAssigned?.(id, hex);
}
private tokenCentersFor(id: CategoryId, max: number): { x: number; y: number }[] {

View file

@ -0,0 +1,229 @@
// Orchestrates one live multiplayer round: wires an existing
// `ThemeGuessGame` + `OpponentView` + `timerHud` to a `GameClient`'s
// round:start/round:progress/round:timeWarning/round:reveal messages
// (see backend/src/PROTOCOL.md), and renders the two-column scoreboard
// once the server reveals both players' submissions.
//
// `ThemeGuessGame` has no network knowledge of its own: this module
// supplies its `onCategoryAssigned` constructor callback (see
// `ThemeGuessGame`'s constructor doc) and routes it into
// `handleLocalAssignment`. Composition therefore happens in two steps —
// construct `MultiplayerMatch` first, then construct `ThemeGuessGame`
// with `(id, hex) => match.handleLocalAssignment(id, hex)`, then call
// `match.bindGame(game)` — so the callback closure can reference a
// `MultiplayerMatch` instance that already exists.
import { tokenize } from '../engine/tokenizer';
import { rgbToHex } from '../engine/colorUtils';
import { CATEGORY_META, THEMES } from '../data/themes';
import { SNIPPETS } from '../data/snippets';
import { getOrCreatePlayerId } from '../net/identity';
import type { GameClient } from '../net/client';
import type { RoundRevealMessage, RoundStartMessage } from '../net/messages';
import type { CategoryId, CategoryStateMap, ThemeId } from '../types';
import { UNSET_BG_RGB, UNSET_FG_RGB } from './layoutConstants';
import { showTimeWarning, startCountdown } from './timerHud';
import { computeMatchResult } from './scoring';
import { renderMultiplayerResult } from './resultView';
import type { OpponentView } from './opponentView';
import type { ThemeGuessGame } from './ThemeGuessGame';
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 FALLBACK_BG_HEX = rgbToHex(UNSET_BG_RGB);
const FALLBACK_FG_HEX = rgbToHex(UNSET_FG_RGB);
/** Every category defaulted to its "never painted" placeholder — used
* both to pad a local submission that's missing categories at time-up,
* and to stand in for an opponent who never submitted at all. */
function defaultColorMap(): Record<CategoryId, string> {
const result = {} as Record<CategoryId, string>;
for (const def of CATEGORY_META) {
result[def.id] = def.id === 'background' ? FALLBACK_BG_HEX : FALLBACK_FG_HEX;
}
return result;
}
/** Rebuilds the same per-category `weight`/`actualHex` derivation
* `ThemeGuessGame`'s private `build()` does from a snippet + theme, then
* overlays a submitted color map as `assignedHex`. `computeMatchResult`
* only reads `assignedHex`/`actualHex`/`weight`/`id` off a
* `CategoryStateMap`, so this lets `round:reveal` be scored for either
* player without `ThemeGuessGame` needing to expose its internal state —
* the animation-only fields are filled with inert placeholders. */
function buildCategoryStateMap(
snippetIndex: number,
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 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,
assignedHex: colors[def.id] ?? null,
currentRgb: { ...unset },
fromRgb: { ...unset },
toRgb: { ...unset },
transitionStart: 0,
pulsePhase: 0,
};
}
return categories;
}
export class MultiplayerMatch {
private readonly localPlayerId = getOrCreatePlayerId();
private readonly timerEl = requireEl<HTMLElement>('match-timer-hud');
private readonly timerWarningEl = requireEl<HTMLElement>('match-timer-warning');
private readonly resultModal = requireEl<HTMLElement>('result-modal');
private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard');
private readonly soloResultEls = [
requireEl<HTMLElement>('solo-score-meter-wrap'),
requireEl<HTMLElement>('score-verdict'),
requireEl<HTMLElement>('score-breakdown'),
requireEl<HTMLElement>('compare-toggle'),
];
private readonly mpResultElements = {
banner: requireEl<HTMLElement>('mp-winner-banner'),
you: {
number: requireEl<HTMLElement>('mp-you-number'),
meter: requireEl<HTMLElement>('mp-you-meter'),
breakdown: requireEl<HTMLElement>('mp-you-breakdown'),
},
opponent: {
number: requireEl<HTMLElement>('mp-opponent-number'),
meter: requireEl<HTMLElement>('mp-opponent-meter'),
breakdown: requireEl<HTMLElement>('mp-opponent-breakdown'),
},
};
private game: ThemeGuessGame | null = null;
private themeId: ThemeId | null = null;
private snippetIndex: number | null = null;
private localColors: Partial<Record<CategoryId, string>> = {};
private stopTimer: (() => void) | null = null;
private unsubProgress: (() => void) | null = null;
private unsubWarning: (() => void) | null = null;
private unsubReveal: (() => void) | null = null;
constructor(private readonly client: GameClient, private readonly opponentView: OpponentView) {}
/** Binds the `ThemeGuessGame` this match drives via `setSnippet`/
* `setTheme`. Must be constructed with its `onCategoryAssigned`
* pointed at this instance's `handleLocalAssignment` — see this
* module's header comment for the construction order. */
bindGame(game: ThemeGuessGame): void {
this.game = game;
}
/** `ThemeGuessGame`'s `onCategoryAssigned` callback target: tracks the
* local player's running color map (for the eventual `round:submit`)
* and relays a bare `round:progress` — never a color — to the
* opponent, per protocol. */
handleLocalAssignment(id: CategoryId, hex: string): void {
this.localColors[id] = hex;
this.client.send({ type: 'round:progress', categoryId: id });
}
/** Starts one round from a `round:start` payload: syncs the local
* board and opponent pane to the server-assigned snippet/theme, starts
* the countdown, and wires this round's progress/timeWarning/reveal
* listeners. Tears down any previous round's listeners/timer first. */
startRound(payload: RoundStartMessage): void {
if (!this.game) throw new Error('MultiplayerMatch: no ThemeGuessGame bound; call bindGame() first');
this.teardownRound();
this.localColors = {};
this.themeId = payload.themeId;
this.snippetIndex = payload.snippetIndex;
this.game.setTheme(payload.themeId);
this.game.setSnippet(payload.snippetIndex);
this.opponentView.setSnippet(payload.snippetIndex);
this.opponentView.reset();
this.resultModal.classList.add('hidden');
this.mpScoreboard.classList.add('hidden');
this.unsubProgress = this.client.on('round:progress', (msg) => {
this.opponentView.markAssigned(msg.categoryId);
});
this.unsubWarning = this.client.on('round:timeWarning', () => {
showTimeWarning(this.timerWarningEl);
});
this.unsubReveal = this.client.on('round:reveal', (msg) => {
this.handleReveal(msg);
});
this.stopTimer = startCountdown(this.timerEl, {
timeMode: payload.timeMode,
endsAt: payload.endsAt,
onExpire: () => this.submitFinalColors(),
});
}
/** Pads the tracked local color map with placeholders for any
* category the player never assigned, so `round:submit` always
* carries a full `Record<CategoryId, string>`. */
private finalizeColors(): Record<CategoryId, string> {
return { ...defaultColorMap(), ...this.localColors };
}
private submitFinalColors(): void {
this.client.send({ type: 'round:submit', colors: this.finalizeColors() });
}
private handleReveal(msg: RoundRevealMessage): void {
if (this.snippetIndex === null || this.themeId === null) return;
const opponentId = Object.keys(msg.colors).find((id) => id !== this.localPlayerId) ?? null;
const youColors = msg.colors[this.localPlayerId] ?? this.finalizeColors();
const opponentColors = (opponentId && msg.colors[opponentId]) || defaultColorMap();
const youResult = computeMatchResult(buildCategoryStateMap(this.snippetIndex, this.themeId, youColors));
const opponentResult = computeMatchResult(
buildCategoryStateMap(this.snippetIndex, this.themeId, opponentColors),
);
this.teardownRound();
this.resultModal.classList.remove('hidden');
for (const el of this.soloResultEls) el.classList.add('hidden');
this.mpScoreboard.classList.remove('hidden');
renderMultiplayerResult(this.mpResultElements, youResult, opponentResult);
}
/** Stops the countdown and unsubscribes this round's listeners.
* Idempotent — safe to call before a round has started. */
private teardownRound(): void {
this.stopTimer?.();
this.stopTimer = null;
this.unsubProgress?.();
this.unsubProgress = null;
this.unsubWarning?.();
this.unsubWarning = null;
this.unsubReveal?.();
this.unsubReveal = null;
}
}

View file

@ -1,6 +1,6 @@
import { easeOutCubic } from '../engine/colorUtils';
import { METER_BLOCKS } from './layoutConstants';
import type { MatchResultRow } from '../types';
import type { MatchResult, MatchResultRow } from '../types';
export function barColor(sim: number): string {
if (sim < 40) return '#ff5555';
@ -71,3 +71,45 @@ export function renderScoreMeter(meterEl: HTMLElement, numberEl: HTMLElement, ov
};
requestAnimationFrame(animate);
}
/** DOM refs for one side (you/opponent) of the two-column multiplayer
* scoreboard — mirrors the single-player score-meter + breakdown pair. */
export interface MultiplayerResultColumnElements {
number: HTMLElement;
meter: HTMLElement;
breakdown: HTMLElement;
}
export interface MultiplayerResultElements {
banner: HTMLElement;
you: MultiplayerResultColumnElements;
opponent: MultiplayerResultColumnElements;
}
const WINNER_BANNER_CLASSES = ['mp-winner-you', 'mp-winner-opponent', 'mp-draw'];
/** Renders both players' `MatchResult`s side by side into the two-column
* scoreboard, plus a winner banner: higher `overall` wins; an equal
* `overall` is an explicit draw, with no other tie-break. */
export function renderMultiplayerResult(
elements: MultiplayerResultElements,
you: MatchResult,
opponent: MatchResult,
): void {
renderScoreMeter(elements.you.meter, elements.you.number, you.overall);
renderBreakdown(elements.you.breakdown, you.rows);
renderScoreMeter(elements.opponent.meter, elements.opponent.number, opponent.overall);
renderBreakdown(elements.opponent.breakdown, opponent.rows);
elements.banner.classList.remove(...WINNER_BANNER_CLASSES);
if (you.overall === opponent.overall) {
elements.banner.textContent = "🤝 IT'S A DRAW!";
elements.banner.classList.add('mp-draw');
} else if (you.overall > opponent.overall) {
elements.banner.textContent = '🏆 YOU WIN!';
elements.banner.classList.add('mp-winner-you');
} else {
elements.banner.textContent = '💀 OPPONENT WINS';
elements.banner.classList.add('mp-winner-opponent');
}
}

View file

@ -10,9 +10,12 @@
* deadline (or omit it with one). `timeMode` doubles as the mode-badge
* source and the progress bar's total duration (in minutes). `'none'` is
* solo's infinite-time mode: no deadline, so no progress bar — the clock
* counts elapsed time up from when `startCountdown` was called instead. */
* counts elapsed time up from when `startCountdown` was called instead.
* `onExpire`, on the countdown variant only, fires exactly once when
* `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 }
| { timeMode: 1 | 2; endsAt: number; onExpire?: () => void }
| { timeMode: 'none'; endsAt?: null };
const TIME_MODE_LABEL: Record<1 | 2 | 'none', string> = {
@ -87,6 +90,7 @@ export function startCountdown(container: HTMLElement, options: TimerHudOptions)
}
if (remaining <= 0) {
stop();
options.onExpire?.();
return;
}
rafId = requestAnimationFrame(tick);

View file

@ -662,6 +662,52 @@ body {
}
.toggle-btn.active { background: var(--panel-3); color: var(--fg); }
/* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */
.mp-scoreboard.hidden { display: none; }
.mp-winner-banner {
text-align: center;
font-family: var(--pixel);
font-size: 13px;
padding: 10px;
margin-bottom: 14px;
border: 3px solid var(--ink);
background: var(--panel-2);
color: var(--fg);
}
.mp-winner-banner.mp-winner-you { background: var(--green); color: var(--ink); }
.mp-winner-banner.mp-winner-opponent { background: var(--red); color: var(--ink); }
.mp-winner-banner.mp-draw { background: var(--yellow); color: var(--ink); }
.mp-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.mp-column {
border: 3px solid var(--ink);
background: var(--panel-2);
padding: 12px 10px;
min-width: 0;
}
.mp-column-label {
text-align: center;
font-family: var(--pixel);
font-size: 10px;
color: var(--fg-dim);
margin-bottom: 8px;
letter-spacing: .5px;
}
.mp-column .score-meter-number { font-size: 20px; }
.mp-column .score-meter-track { gap: 2px; }
.mp-column .meter-block { width: 8px; height: 14px; }
.mp-column .breakdown { max-height: 200px; }
@media (max-width: 640px) {
.mp-columns { grid-template-columns: 1fr; }
}
.modal-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.modal-actions .btn { flex: 1 1 auto; min-width: 110px; }