feat(ui): move in-game status to the board — highlight, score badge, full-width rack
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) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
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) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals: - bag count -> a badge on the exchange/pass control + the foot of the move table - whose-turn / win-lose -> a thin strip above the score plaques - the tentative-move caption -> the board itself: staged tiles tint green (legal) or pink (illegal), the board tiles a formed word runs through go a shade darker, and an orange score badge sits on the main word (digit sized like a tile value, clamped on-board) The word geometry (covered cells + badge anchor) is a new pure client-side helper (ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or network preview path alike; the badge's number still comes from the preview score. Rack: a seven-column grid filling the tray width in both layouts — square, full-width tiles — with the confirm control in the fixed 7th slot. Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted hint word is never left off-screen. Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
|
||||
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
|
||||
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
|
||||
// coordinates, and the board highlight plus the score badge must work even while the local
|
||||
// dictionary is still warming up. Legality and the score come from the preview; the geometry is
|
||||
// derived here purely from the board and the staged tiles. Both functions are pure (testable in
|
||||
// lib/formed.test.ts); the board stays the source of truth for legality.
|
||||
|
||||
import type { Board } from './board';
|
||||
import { BOARD_SIZE } from './premiums';
|
||||
|
||||
/** Play axis: horizontal or vertical. */
|
||||
export type Dir = 'H' | 'V';
|
||||
|
||||
/** MainWord locates the play's main word: its start cell, axis and length in cells. */
|
||||
export interface MainWord {
|
||||
row: number;
|
||||
col: number;
|
||||
dir: Dir;
|
||||
len: number;
|
||||
}
|
||||
|
||||
/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
|
||||
* "row,col" key) that the main word and any cross words cover — committed tiles included. */
|
||||
export interface FormedGeometry {
|
||||
main: MainWord;
|
||||
cells: Set<string>;
|
||||
}
|
||||
|
||||
/** A minimal cell reference; PendingTile satisfies it structurally. */
|
||||
interface Cell {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
const key = (r: number, c: number): string => `${r},${c}`;
|
||||
|
||||
/**
|
||||
* filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
|
||||
* board: either a committed tile sits there or a pending tile is being placed there.
|
||||
*/
|
||||
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
|
||||
const pend = new Set(pending.map((p) => key(p.row, p.col)));
|
||||
return (r, c) =>
|
||||
r >= 0 &&
|
||||
r < BOARD_SIZE &&
|
||||
c >= 0 &&
|
||||
c < BOARD_SIZE &&
|
||||
(board[r]?.[c] != null || pend.has(key(r, c)));
|
||||
}
|
||||
|
||||
/**
|
||||
* span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
|
||||
* run's start index and length on the moving axis (columns for H, rows for V).
|
||||
*/
|
||||
function span(
|
||||
filled: (r: number, c: number) => boolean,
|
||||
dir: Dir,
|
||||
row: number,
|
||||
col: number,
|
||||
): { start: number; len: number } {
|
||||
if (dir === 'H') {
|
||||
let s = col;
|
||||
while (filled(row, s - 1)) s--;
|
||||
let e = col;
|
||||
while (filled(row, e + 1)) e++;
|
||||
return { start: s, len: e - s + 1 };
|
||||
}
|
||||
let s = row;
|
||||
while (filled(s - 1, col)) s--;
|
||||
let e = row;
|
||||
while (filled(e + 1, col)) e++;
|
||||
return { start: s, len: e - s + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* formedGeometry derives the composed play's word geometry from the board and the staged tiles.
|
||||
* It returns the main word (the maximal run along the play axis through the staged tiles) and the
|
||||
* set of every cell the main word and each cross word (a perpendicular run of two or more cells
|
||||
* through a staged tile) cover. It returns null when nothing is staged, or when the staged tiles
|
||||
* are not on a single line — a shape only an illegal play produces, which the caller never asks
|
||||
* about (it is invoked only for a legal preview). The play axis is fixed by the staged tiles when
|
||||
* two or more are colinear; a lone tile takes whichever axis forms the longer word.
|
||||
*/
|
||||
export function formedGeometry(board: Board, pending: readonly Cell[]): FormedGeometry | null {
|
||||
if (pending.length === 0) return null;
|
||||
const filled = filledPredicate(board, pending);
|
||||
const first = pending[0];
|
||||
|
||||
let dir: Dir;
|
||||
if (pending.length === 1) {
|
||||
const h = span(filled, 'H', first.row, first.col);
|
||||
const v = span(filled, 'V', first.row, first.col);
|
||||
dir = h.len >= v.len ? 'H' : 'V';
|
||||
} else if (pending.every((p) => p.row === first.row)) {
|
||||
dir = 'H';
|
||||
} else if (pending.every((p) => p.col === first.col)) {
|
||||
dir = 'V';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cells = new Set<string>();
|
||||
|
||||
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
|
||||
// committed tiles between them), collected from any one staged tile.
|
||||
const ms = span(filled, dir, first.row, first.col);
|
||||
let main: MainWord;
|
||||
if (dir === 'H') {
|
||||
main = { row: first.row, col: ms.start, dir, len: ms.len };
|
||||
for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
|
||||
} else {
|
||||
main = { row: ms.start, col: first.col, dir, len: ms.len };
|
||||
for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
|
||||
}
|
||||
|
||||
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word
|
||||
// (two or more cells).
|
||||
const cross: Dir = dir === 'H' ? 'V' : 'H';
|
||||
for (const p of pending) {
|
||||
const cs = span(filled, cross, p.row, p.col);
|
||||
if (cs.len < 2) continue;
|
||||
if (cross === 'H') {
|
||||
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
|
||||
} else {
|
||||
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
|
||||
}
|
||||
}
|
||||
|
||||
return { main, cells };
|
||||
}
|
||||
|
||||
/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
|
||||
export interface BadgeAnchor {
|
||||
row: number;
|
||||
col: number;
|
||||
corner: 'tr' | 'bl';
|
||||
}
|
||||
|
||||
/**
|
||||
* badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
|
||||
* points into the board and away from the nearest edge (the board clamps the final pill fully
|
||||
* on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
|
||||
* corner (horizontal → its first letter, vertical → its last letter); a word touching the left or
|
||||
* bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last
|
||||
* letter, vertical → its first letter). A full-width horizontal word (it touches both side edges)
|
||||
* anchors at the last letter's top-right, which the board then clamps leftward.
|
||||
*/
|
||||
export function badgePlacement(main: MainWord): BadgeAnchor {
|
||||
const last = BOARD_SIZE - 1;
|
||||
const h = main.dir === 'H';
|
||||
const minRow = main.row;
|
||||
const maxRow = h ? main.row : main.row + main.len - 1;
|
||||
const minCol = main.col;
|
||||
const maxCol = h ? main.col + main.len - 1 : main.col;
|
||||
|
||||
if (h && main.len === BOARD_SIZE) {
|
||||
return { row: main.row, col: maxCol, corner: 'tr' };
|
||||
}
|
||||
if (maxCol === last || minRow === 0) {
|
||||
return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
|
||||
}
|
||||
return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
|
||||
}
|
||||
Reference in New Issue
Block a user