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
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.
80 lines
3.5 KiB
TypeScript
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;
|
|
}
|