Files
scrabble-game/ui/src/lib/history.ts
T
Ilia Denisov a0eacf3011
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
feat(game): finished-game export as a PNG image behind a format chooser
The history header's export button now opens a chooser — Telegram's
native popup inside Telegram, the app's own modal elsewhere — offering
the GCG file and a new client-rendered PNG of the final position
(lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies):
light theme, classic A..O/1..15 axes, label-free premium fills, and a
fixed-typography per-seat scoresheet with GCG-style move coordinates,
multi-word sub-lines, endgame rack-settlement row, winner trophy and a
hostname + device-locale finish date footer; a long game stretches the
board, never the typography.

Delivery mirrors the GCG rules (Web Share with no blob fallback, else
download) except on Android Telegram/VK WebViews and the desktop VK
iframe, where a binary PNG has no clipboard-text fallback: those get a
preview modal with a long-press/right-click save hint and a copy-image
button where ClipboardItem exists.
2026-07-02 16:20:35 +02:00

80 lines
3.5 KiB
TypeScript

// Pure layout of the move journal for the in-game history panel. The panel mirrors the
// score plaque above the board: one column per seat (a seat's moves fill its own column,
// top to bottom) instead of a single chronological list. Keeping the arrangement here (not
// in Game.svelte) lets it be unit-tested without a DOM; the component only renders the
// resulting cells with i18n/CSS.
import type { MoveRecord } from './model';
/** A single cell of the history grid. The grid is row-major with one column per seat: a
* seat's k-th move sits in row k of its column. */
export interface HistoryCell {
kind: 'play' | 'action' | 'thinking' | 'empty';
/** Seat index this cell belongs to (its column). */
player: number;
/** Words formed, main word first — set for kind 'play'. */
words?: string[];
/** Points scored by the move — set for kind 'play'. */
score?: number;
/** The main word's board coordinate in classic notation (see moveCoord) — set for kind
* 'play'. The in-game panel does not show it; the game-image export does. */
coord?: string;
/** The non-play action ('pass' | 'exchange' | 'resign' | 'timeout' | …) — set for kind
* 'action'; the component localizes it. */
action?: string;
}
/**
* moveCoord renders a play's board coordinate in the classic notation the GCG export uses
* (the backend's gcgPos): row-then-column (e.g. "8G") for an across play, column-then-row
* ("H8") for a down play. Rows are 1-based numbers top to bottom, columns letters from "A"
* left to right.
*/
export function moveCoord(mv: Pick<MoveRecord, 'dir' | 'mainRow' | 'mainCol'>): string {
const col = String.fromCharCode(65 + mv.mainCol);
const row = String(mv.mainRow + 1);
return mv.dir === 'V' ? col + row : row + col;
}
/**
* historyGrid arranges the move journal into a row-major matrix with one column per seat.
* Each seat's moves fill its column top to bottom, so a seat's k-th move lands in row k of
* its column; with a regular turn order the rows read as rounds. When thinkingSeat is
* non-null, that seat's next (still empty) cell becomes a 'thinking' placeholder — the move
* being awaited. Positions with no move are 'empty', so every row is exactly seatCount wide
* and the table rules evenly. Pass thinkingSeat=null when the game is over or when the
* awaited mover is the viewer themselves (the viewer's own pending cell stays empty).
*/
export function historyGrid(
moves: MoveRecord[],
seatCount: number,
thinkingSeat: number | null,
): HistoryCell[][] {
const cols: HistoryCell[][] = Array.from({ length: seatCount }, () => []);
for (const m of moves) {
if (m.player < 0 || m.player >= seatCount) continue; // defensive: drop an out-of-range seat
cols[m.player].push(
m.action === 'play'
? { kind: 'play', player: m.player, words: m.words, score: m.score, coord: moveCoord(m) }
: { kind: 'action', player: m.player, action: m.action },
);
}
const think =
thinkingSeat !== null && thinkingSeat >= 0 && thinkingSeat < seatCount ? thinkingSeat : null;
let rows = cols.reduce((n, c) => Math.max(n, c.length), 0);
if (think !== null) rows = Math.max(rows, cols[think].length + 1);
const grid: HistoryCell[][] = [];
for (let r = 0; r < rows; r++) {
const row: HistoryCell[] = [];
for (let p = 0; p < seatCount; p++) {
if (r < cols[p].length) row.push(cols[p][r]);
else if (think === p && r === cols[p].length) row.push({ kind: 'thinking', player: p });
else row.push({ kind: 'empty', player: p });
}
grid.push(row);
}
return grid;
}