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:
@@ -118,6 +118,9 @@ export const app = $state<{
|
||||
locale: Locale;
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
/** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and
|
||||
* landscape layouts already fit the whole board and never auto-zoom). On by default. */
|
||||
zoomBoard: boolean;
|
||||
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
|
||||
onboarding: OnboardingState;
|
||||
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
|
||||
@@ -172,6 +175,7 @@ export const app = $state<{
|
||||
locale: 'en',
|
||||
reduceMotion: false,
|
||||
boardLabels: 'beginner',
|
||||
zoomBoard: true,
|
||||
onboarding: { lobbyDone: false, gameDone: false },
|
||||
coachActive: false,
|
||||
notifications: 0,
|
||||
@@ -813,6 +817,7 @@ export async function bootstrap(): Promise<void> {
|
||||
app.theme = prefs.theme ?? 'auto';
|
||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||
app.zoomBoard = prefs.zoomBoard ?? true;
|
||||
app.onboarding = await loadOnboarding();
|
||||
applyTheme(app.theme);
|
||||
applyReduceMotion(app.reduceMotion);
|
||||
@@ -1232,6 +1237,7 @@ function persistPrefs(): void {
|
||||
locale: app.locale,
|
||||
reduceMotion: app.reduceMotion,
|
||||
boardLabels: app.boardLabels,
|
||||
zoomBoard: app.zoomBoard,
|
||||
});
|
||||
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
||||
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
||||
@@ -1321,6 +1327,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
export function setZoomBoard(on: boolean): void {
|
||||
app.zoomBoard = on;
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
||||
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
||||
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formedGeometry, badgePlacement } from './formed';
|
||||
import type { Board } from './board';
|
||||
|
||||
const SIZE = 15;
|
||||
|
||||
// board builds a 15×15 empty board with the given committed tiles placed.
|
||||
function board(tiles: Array<[number, number, string]> = []): Board {
|
||||
const b: Board = [];
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
const row: (Board[number][number])[] = [];
|
||||
for (let c = 0; c < SIZE; c++) row.push(null);
|
||||
b.push(row);
|
||||
}
|
||||
for (const [r, c, letter] of tiles) b[r][c] = { letter, blank: false };
|
||||
return b;
|
||||
}
|
||||
|
||||
const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set())].sort();
|
||||
|
||||
describe('formedGeometry', () => {
|
||||
it('returns null with nothing staged', () => {
|
||||
expect(formedGeometry(board(), [])).toBeNull();
|
||||
});
|
||||
|
||||
it('finds a fresh horizontal word with no cross words', () => {
|
||||
const g = formedGeometry(board(), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 7, col: 8 },
|
||||
]);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||
});
|
||||
|
||||
it('finds a fresh vertical word', () => {
|
||||
const g = formedGeometry(board(), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 8, col: 7 },
|
||||
]);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '8,7']);
|
||||
});
|
||||
|
||||
it('extends a committed tile into the main word (committed cell in the set)', () => {
|
||||
// A committed A at (7,7); a lone tile to its right forms the two-letter main word.
|
||||
const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }]);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||
});
|
||||
|
||||
it('collects a cross word through a staged tile, committed cells included', () => {
|
||||
// Committed X above and Y below (7,8) leave a one-cell gap; a horizontal play through it also
|
||||
// completes the vertical X-_-Y cross word.
|
||||
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 7, col: 8 },
|
||||
]);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']);
|
||||
});
|
||||
|
||||
it('takes the longer axis as the main word for a lone connecting tile', () => {
|
||||
// (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical
|
||||
// is longer, so it is the main word and the horizontal becomes a cross word.
|
||||
const g = formedGeometry(
|
||||
board([
|
||||
[7, 6, 'A'],
|
||||
[7, 7, 'B'],
|
||||
[5, 8, 'C'],
|
||||
[6, 8, 'D'],
|
||||
[8, 8, 'E'],
|
||||
]),
|
||||
[{ row: 7, col: 8 }],
|
||||
);
|
||||
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 });
|
||||
expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('badgePlacement', () => {
|
||||
it('anchors a mid-board horizontal word at the last letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 7, col: 5, dir: 'H', len: 3 })).toEqual({ row: 7, col: 7, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a right-edge horizontal word at the first letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 7, col: 12, dir: 'H', len: 3 })).toEqual({ row: 7, col: 12, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a top-edge horizontal word at the first letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 0, col: 5, dir: 'H', len: 3 })).toEqual({ row: 0, col: 5, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a left-edge horizontal word at the last letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 3 })).toEqual({ row: 7, col: 2, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a bottom-edge horizontal word at the last letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 14, col: 5, dir: 'H', len: 3 })).toEqual({ row: 14, col: 7, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a mid-board vertical word at the first letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 5, col: 7, dir: 'V', len: 3 })).toEqual({ row: 5, col: 7, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a top-edge vertical word at the last letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 0, col: 7, dir: 'V', len: 3 })).toEqual({ row: 2, col: 7, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a right-edge vertical word at the last letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 5, col: 14, dir: 'V', len: 3 })).toEqual({ row: 7, col: 14, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a full-width horizontal word at the last letter, top-right (clamped by the board)', () => {
|
||||
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 15 })).toEqual({ row: 7, col: 14, corner: 'tr' });
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -104,8 +104,6 @@ export const en = {
|
||||
'game.checkWord': 'Check word',
|
||||
'game.dictionary': 'Dictionary',
|
||||
'game.dropGame': 'Drop game',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Not a legal move',
|
||||
'game.oneWordRule': 'One word per turn',
|
||||
'game.chooseBlank': 'Choose a letter for the blank',
|
||||
'game.exchangeTitle': 'Exchange or pass',
|
||||
@@ -215,6 +213,7 @@ export const en = {
|
||||
'settings.labelsClassic': 'Classic',
|
||||
'settings.labelsNone': 'None',
|
||||
'settings.reduceMotion': 'Reduce motion',
|
||||
'settings.zoomBoard': 'Zoom the board',
|
||||
'settings.offlineMode': 'Play mode',
|
||||
'settings.online': 'Online',
|
||||
'settings.offline': 'Offline',
|
||||
|
||||
@@ -104,8 +104,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.checkWord': 'Проверить слово',
|
||||
'game.dictionary': 'Словарь',
|
||||
'game.dropGame': 'Покинуть игру',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Недопустимый ход',
|
||||
'game.oneWordRule': 'Одно слово за ход',
|
||||
'game.chooseBlank': 'Выберите букву для бланка',
|
||||
'game.exchangeTitle': 'Обмен или пас',
|
||||
@@ -215,6 +213,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.labelsClassic': 'Классика',
|
||||
'settings.labelsNone': 'Без текста',
|
||||
'settings.reduceMotion': 'Меньше анимаций',
|
||||
'settings.zoomBoard': 'Приближать доску',
|
||||
'settings.offlineMode': 'Режим игры',
|
||||
'settings.online': 'Онлайн',
|
||||
'settings.offline': 'Оффлайн',
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface Prefs {
|
||||
locale: Locale;
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
zoomBoard: boolean;
|
||||
}
|
||||
|
||||
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||
|
||||
Reference in New Issue
Block a user