feat(frontend): add in-match split-view opponent progress pane
This commit is contained in:
parent
fea76e94cc
commit
4b8c402e43
4 changed files with 220 additions and 0 deletions
|
|
@ -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">
|
||||
|
|
|
|||
109
frontend/src/game/opponentView.ts
Normal file
109
frontend/src/game/opponentView.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue