feat(frontend): add in-match split-view opponent progress pane

This commit is contained in:
Gabriel Franco 2026-09-10 12:03:42 -03:00
parent fea76e94cc
commit 4b8c402e43
4 changed files with 220 additions and 0 deletions

View file

@ -59,6 +59,21 @@
</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">

View file

@ -0,0 +1,109 @@
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';
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;
}
/**
* Owns the "opponent progress" canvas: a read-only mirror of the shared
* match snippet that reveals only a binary "has the opponent assigned
* this category yet?" signal, driven by `round:progress` messages
* (see PROTOCOL.md — that message carries a bare `CategoryId`, never a
* color). `markAssigned` is the only way to light up a category, and it
* 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`).
*/
export class OpponentView {
private readonly canvas: HTMLCanvasElement;
private readonly ctx: CanvasRenderingContext2D;
private snippetIndex: number;
private tokens: Token[] = [];
private lineCount = 1;
private maxCols = 0;
private charWidth = 0;
private width = 0;
private height = 0;
private assigned = new Set<CategoryId>();
constructor(canvasId: string, snippetIndex: number = pickRandomSnippetIndex()) {
this.canvas = requireEl<HTMLCanvasElement>(canvasId);
this.ctx = this.canvas.getContext('2d')!;
this.snippetIndex = snippetIndex;
this.build();
}
/** Forces a specific snippet (by stable `SNIPPETS` index) and clears
* progress — mirrors `ThemeGuessGame.setSnippet`, used to keep both
* players' panes on the same source file each match. */
setSnippet(index: number): void {
this.snippetIndex = index;
this.assigned = new Set();
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. */
markAssigned(categoryId: CategoryId): void {
this.assigned.add(categoryId);
this.draw();
}
/** Clears every progress signal for reuse across matches, without
* touching the current snippet/layout. */
reset(): void {
this.assigned = new Set();
this.draw();
}
private build(): void {
this.tokens = tokenize(SNIPPETS[this.snippetIndex]);
this.lineCount = 1;
this.maxCols = 0;
let col = 0;
for (const t of this.tokens) {
if (t.type === 'newline') { this.lineCount++; col = 0; }
else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
}
this.setupCanvasSize();
this.draw();
}
private setupCanvasSize(): void {
const dpr = window.devicePixelRatio || 1;
this.ctx.font = `${FONT_SIZE}px ${FONT_STACK}`;
this.charWidth = this.ctx.measureText('M').width;
this.width = GUTTER + PAD * 2 + this.maxCols * this.charWidth;
this.height = PAD * 2 + this.lineCount * LINE_HEIGHT;
this.canvas.style.width = `${this.width}px`;
this.canvas.style.height = `${this.height}px`;
this.canvas.width = this.width * dpr;
this.canvas.height = this.height * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
private draw(): void {
drawOpponentFrame({
ctx: this.ctx,
width: this.width,
height: this.height,
charWidth: this.charWidth,
lineCount: this.lineCount,
tokens: this.tokens,
assigned: this.assigned,
}, performance.now());
}
}

View file

@ -64,3 +64,66 @@ export function drawFrame(state: RenderState, now: number): void {
state.particles.draw(ctx);
}
/** Flat color used to signal "opponent has assigned this category" in
* the opponent-progress pane. Never a real chosen hex — the opponent's
* actual color is never transmitted or known here (see PROTOCOL.md
* `round:progress`), so this is the only color this view can ever show
* for a picked category. */
export const OPPONENT_ASSIGNED_HEX = '#5fd75f';
/** Flat grey used for categories the opponent hasn't picked yet. */
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`. */
export interface OpponentRenderState {
ctx: CanvasRenderingContext2D;
width: number;
height: number;
charWidth: number;
lineCount: number;
tokens: Token[];
assigned: ReadonlySet<CategoryId>;
}
/** 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. */
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.fillRect(0, 0, state.width, state.height);
ctx.fillStyle = 'rgba(0,0,0,.16)';
ctx.fillRect(0, 0, GUTTER, state.height);
ctx.fillStyle = '#665c54';
ctx.font = `${FONT_SIZE - 3}px ${FONT_STACK}`;
ctx.textBaseline = 'alphabetic';
for (let l = 0; l < state.lineCount; l++) {
ctx.fillText(String(l + 1).padStart(2, ' '), 8, PAD + l * LINE_HEIGHT + FONT_SIZE - 4);
}
// Stroked outline so foreground text stays legible even when a token's
// category and the background happen to share the same flat color.
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(10,10,18,.65)';
ctx.lineJoin = 'round';
for (const t of state.tokens) {
if (t.type === 'whitespace' || t.type === 'newline') continue;
const cat = t.type as CategoryId;
const x = GUTTER + PAD + t.col * state.charWidth;
const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 4;
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.strokeText(t.text, x, y);
ctx.fillText(t.text, x, y);
}
}

View file

@ -223,6 +223,39 @@ body {
}
.panel-actions .btn { width: 100%; }
/* ---------- split view (in-match opponent progress pane) ---------- */
.split-view {
flex: 1 1 auto;
min-height: 0;
display: flex;
gap: 14px;
}
.split-view.hidden { display: none; }
.split-pane {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.split-pane-label {
font-family: var(--pixel);
font-size: 10px;
letter-spacing: .5px;
color: var(--fg-dim);
flex: 0 0 auto;
}
.split-canvas-wrap {
flex: 1 1 auto;
}
.split-canvas { display: block; }
.cat-card {
background: var(--panel-2);
border: 3px solid var(--ink);