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:
+2
-2
@@ -261,7 +261,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
|
||||
await page.locator('button.danger').click(); // confirm in the modal
|
||||
await expect(page.locator('.status .over')).toBeVisible();
|
||||
await expect(page.locator('.turnstrip.result')).toBeVisible();
|
||||
});
|
||||
|
||||
test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
|
||||
@@ -283,7 +283,7 @@ test('resigning reveals the full board: closes the history and zooms out', async
|
||||
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
|
||||
await page.locator('button.danger').click(); // confirm
|
||||
|
||||
await expect(page.locator('.status .over')).toBeVisible(); // the game ended
|
||||
await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended
|
||||
await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
|
||||
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// The in-game composition UX after the under-board status strip was removed:
|
||||
// - whose turn (or the final result) shows in a thin strip above the score plaques;
|
||||
// - the tiles left in the bag ride the exchange control as a badge and repeat at the foot of the
|
||||
// move table;
|
||||
// - staging a play tints its tiles on the board and shows the orange move-score badge, and the
|
||||
// confirm control sits in the rack's fixed 7th slot.
|
||||
// Mock transport only: the mock has no dictionary (Game.svelte skips the local evaluator under the
|
||||
// mock mode) and its network evaluator accepts any placement, so a staged tile always reads as legal.
|
||||
|
||||
test('in-game UX: turn strip, bag badge + table footer, staged-play highlight and score badge', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /🎲/ }).click();
|
||||
await page.getByRole('button', { name: 'Random player' }).click();
|
||||
await page.locator('.variant').first().click();
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
// Attach the opponent deterministically, then it is the player's turn.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
|
||||
// The old under-board status line is gone; a thin turn strip sits above the plaques instead.
|
||||
await expect(page.locator('.turnstrip')).toBeVisible();
|
||||
await expect(page.locator('.status')).toHaveCount(0);
|
||||
|
||||
// The bag count rides the exchange control (the first tab) as a badge, and repeats at the foot of
|
||||
// the move table, out of the scrolling grid.
|
||||
await expect(page.locator('.tab').first().locator('.badge')).toBeVisible();
|
||||
await page.locator('.scoreboard').click(); // open the history drawer
|
||||
await expect(page.locator('.hbagfoot')).toContainText(/bag/i);
|
||||
await page.locator('.scoreboard').click(); // close it again
|
||||
|
||||
// Stage a play: the pending tile reads as legal (green), the orange move-score badge shows on the
|
||||
// board, and the confirm control takes the rack's 7th slot.
|
||||
await page.locator('.rack .tile').first().click();
|
||||
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // centre (row 7, col 7)
|
||||
await expect(page.locator('[data-cell].pending.legal')).toHaveCount(1);
|
||||
await expect(page.locator('.scorebadge')).toBeVisible();
|
||||
await expect(page.locator('.make')).toBeVisible();
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
|
||||
await page.locator('.scoreboard').click();
|
||||
await page.getByRole('button', { name: 'Drop game' }).click();
|
||||
await page.locator('button.danger').click();
|
||||
await expect(page.locator('.status .over')).toBeVisible();
|
||||
await expect(page.locator('.turnstrip.result')).toBeVisible();
|
||||
|
||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
||||
// no comms entry at all.
|
||||
|
||||
@@ -22,8 +22,8 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
|
||||
await rackTile.click();
|
||||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||
// The score preview appears where the hints count used to be.
|
||||
await expect(page.locator('.scores')).toContainText(/\d/);
|
||||
// The move-score badge appears on the board for the staged play.
|
||||
await expect(page.locator('.scorebadge')).toContainText(/\d/);
|
||||
|
||||
// The contextual MakeMove control (✅) appears once a tile is pending.
|
||||
await expect(page.locator('.make')).toBeVisible();
|
||||
|
||||
+12
-14
@@ -74,11 +74,11 @@ test.describe('touch placement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
|
||||
// zoom state does not change, so without an explicit recenter the board stays parked where the
|
||||
// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
|
||||
// zooming into the top-left corner first and then hinting must pan the board toward the centre.
|
||||
test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
|
||||
// A hint taken while the board is ALREADY zoomed in now zooms OUT to the whole board, so the played
|
||||
// word — highlighted while composing — is guaranteed visible rather than parked off-screen under the
|
||||
// old zoom (the owner changed this from the former recentre-on-the-word behaviour, so the word can
|
||||
// never be lost off-screen). The mock hint plays at the centre star (7,7).
|
||||
test('a hint zooms out an already-zoomed board so the played word is visible', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
@@ -91,19 +91,17 @@ test('a hint recentres an already-zoomed board on the played word', async ({ pag
|
||||
el.click();
|
||||
el.click();
|
||||
});
|
||||
const viewport = page.locator('.viewport.zoomed');
|
||||
await expect(viewport).toBeVisible();
|
||||
await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
|
||||
const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
||||
await expect(page.locator('.viewport.zoomed')).toBeVisible();
|
||||
|
||||
// Take a hint (the control confirms on a second tap), which plays at the centre.
|
||||
const hint = page.getByRole('button', { name: 'Hint' });
|
||||
await hint.click();
|
||||
await hint.click();
|
||||
await page.waitForTimeout(300); // the board pans to the hint word
|
||||
|
||||
const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
||||
// The board panned from the top-left toward the centre word: both offsets grew.
|
||||
expect(after.left).toBeGreaterThan(before.left + 20);
|
||||
expect(after.top).toBeGreaterThan(before.top + 20);
|
||||
// The board zoomed out — no zoomed viewport — with the hint's play staged (the confirm control
|
||||
// shows). The mock hint plays on the centre star, which the seeded game already occupies, so the
|
||||
// staged tile hides under the committed one; the confirm control is the reliable "a play is
|
||||
// staged" signal.
|
||||
await expect(page.locator('.viewport.zoomed')).toHaveCount(0);
|
||||
await expect(page.locator('.make')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
locale: lc,
|
||||
reduceMotion: prefs.reduceMotion ?? false,
|
||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||
zoomBoard: prefs.zoomBoard ?? true,
|
||||
});
|
||||
prefs = { ...prefs, locale: lc };
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
--tile-edge: #d8c190;
|
||||
--tile-text: #2a2113;
|
||||
--tile-pending: #f2cf73;
|
||||
/* In-composition highlight of the staged play (see game/Board.svelte): a calm reddish-pink
|
||||
when the tiles form no word, a light green for the player's own tiles once they do, a
|
||||
deeper green for the board tiles a formed word runs through, and the orange move-score
|
||||
badge. Tuned per theme; refined on the contour. */
|
||||
--tile-pending-illegal: #f2c7bf;
|
||||
--tile-pending-legal: #c9e7bd;
|
||||
--tile-formed: #a5cf93;
|
||||
--score-badge: #e5811d;
|
||||
/* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against
|
||||
the lighter tile the perceived contrast needs it, so the two are tuned per theme. */
|
||||
--tile-recent: #9c5849;
|
||||
@@ -84,6 +92,10 @@
|
||||
--tile-edge: #b6a473;
|
||||
--tile-text: #20190d;
|
||||
--tile-pending: #d8b75e;
|
||||
--tile-pending-illegal: #d8a99f;
|
||||
--tile-pending-legal: #a9cf94;
|
||||
--tile-formed: #83b571;
|
||||
--score-badge: #e88f31;
|
||||
--tile-recent: #8c4a3c;
|
||||
--prem-tw: #9c3f34; /* 3x word: a touch darker red */
|
||||
--prem-dw: #a8636b; /* 2x word: softer, pinker */
|
||||
@@ -115,6 +127,10 @@
|
||||
--tile-edge: #b6a473;
|
||||
--tile-text: #20190d;
|
||||
--tile-pending: #f0d98f;
|
||||
--tile-pending-illegal: #d8a99f;
|
||||
--tile-pending-legal: #a9cf94;
|
||||
--tile-formed: #83b571;
|
||||
--score-badge: #e88f31;
|
||||
/* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the
|
||||
near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per
|
||||
theme; perceived contrast depends on the surrounding board). */
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
focus,
|
||||
recenter,
|
||||
dropTarget,
|
||||
previewLegal,
|
||||
formed,
|
||||
scoreBadge,
|
||||
oncell,
|
||||
ontogglezoom,
|
||||
onrecall,
|
||||
@@ -48,6 +51,15 @@
|
||||
recenter: number;
|
||||
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
|
||||
dropTarget: { row: number; col: number } | null;
|
||||
/** While composing: true when the staged tiles form a legal play, false when not, null when
|
||||
* there is no preview (off-turn, nothing staged, or a recall drag) — tints the pending tiles
|
||||
* green vs a calm pink. */
|
||||
previewLegal: boolean | null;
|
||||
/** "row,col" keys of every committed board cell a formed word runs through, greened a shade
|
||||
* darker than the player's own staged tiles. */
|
||||
formed: Set<string>;
|
||||
/** The orange move-score badge for the legal play being composed, or null. */
|
||||
scoreBadge: { row: number; col: number; corner: 'tr' | 'bl'; score: number } | null;
|
||||
oncell: (row: number, col: number) => void;
|
||||
ontogglezoom: (row: number, col: number) => void;
|
||||
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
|
||||
@@ -60,6 +72,20 @@
|
||||
const z = $derived(zoomed ? Z : 1);
|
||||
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
|
||||
|
||||
// The score badge sits on a corner of its anchor cell, clamped so the whole pill stays on the
|
||||
// board. Its position is a percentage of the (15-cell) board; CSS centres the pill on the point.
|
||||
const BADGE_MARGIN = 2.4;
|
||||
const badgePos = $derived.by(() => {
|
||||
const b = scoreBadge;
|
||||
if (!b) return null;
|
||||
const cell = 100 / 15;
|
||||
let x = b.corner === 'tr' ? (b.col + 1) * cell : b.col * cell;
|
||||
let y = b.corner === 'tr' ? b.row * cell : (b.row + 1) * cell;
|
||||
x = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, x));
|
||||
y = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, y));
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
let viewport = $state<HTMLElement>();
|
||||
|
||||
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
|
||||
@@ -315,6 +341,9 @@
|
||||
class="cell {premClass[premium[r][c]]}"
|
||||
class:filled={!!cell}
|
||||
class:pending={!!p && !cell}
|
||||
class:legal={!!p && !cell && previewLegal === true}
|
||||
class:illegal={!!p && !cell && previewLegal === false}
|
||||
class:formed={!!cell && formed.has(key(r, c))}
|
||||
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
|
||||
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
||||
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
|
||||
@@ -342,6 +371,9 @@
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if scoreBadge && badgePos}
|
||||
<span class="scorebadge" style="left:{badgePos.x}%; top:{badgePos.y}%">{scoreBadge.score}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,6 +429,8 @@
|
||||
gap: 0;
|
||||
background: var(--board-bg);
|
||||
padding: 0;
|
||||
/* Anchor for the absolutely-positioned score badge (its percentages resolve against the board). */
|
||||
position: relative;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
@@ -445,6 +479,19 @@
|
||||
board) instead of the touch starting a board pan. */
|
||||
touch-action: none;
|
||||
}
|
||||
/* While composing, the staged play recolours its tiles: a calm reddish-pink when they form no
|
||||
word, a light green for the player's own tiles once they do; committed board tiles a formed
|
||||
word runs through go a shade darker (see lib/formed + Game.svelte). .formed sits after .filled
|
||||
so it wins on committed cells (equal specificity). */
|
||||
.cell.pending.illegal {
|
||||
background: var(--tile-pending-illegal);
|
||||
}
|
||||
.cell.pending.legal {
|
||||
background: var(--tile-pending-legal);
|
||||
}
|
||||
.cell.formed {
|
||||
background: var(--tile-formed);
|
||||
}
|
||||
.cell.droptarget {
|
||||
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
||||
target reads clearly while dragging without obscuring the bonus label underneath. */
|
||||
@@ -545,4 +592,25 @@
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The move-score badge for the legal play being composed: an orange pill centred on a corner of
|
||||
the main word's tile (see lib/formed.badgePlacement + Game.svelte), its digit sized like a
|
||||
tile's point value so it scales with the zoom. Decorative — no pointer events — and drawn above
|
||||
the tiles. */
|
||||
.scorebadge {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
border-radius: 999px;
|
||||
background: var(--score-badge);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
padding: 2px 5px; /* px fallback for Chrome < 105 (no cqw) */
|
||||
padding: 0.4cqw 0.9cqw;
|
||||
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||
font-size: 2.4cqw;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
+103
-101
@@ -49,6 +49,7 @@
|
||||
type Placement,
|
||||
} from '../lib/placement';
|
||||
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
|
||||
import { badgePlacement, formedGeometry } from '../lib/formed';
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
@@ -483,6 +484,21 @@
|
||||
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
|
||||
// they return the moment the tile is dragged back out over the board.
|
||||
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
|
||||
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a
|
||||
// formed word runs through — greened on the board — and the main word that anchors the score
|
||||
// badge. Derived only for a legal preview, and never while dragging a staged tile back over the
|
||||
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
|
||||
const EMPTY_FORMED = new Set<string>();
|
||||
const formedGeom = $derived(
|
||||
preview?.legal && !recallOverRack ? formedGeometry(board, placement.pending) : null,
|
||||
);
|
||||
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
|
||||
const boardBadge = $derived(
|
||||
formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
|
||||
);
|
||||
// The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
|
||||
// the play's legality — green when legal, a calm pink when not.
|
||||
const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null);
|
||||
|
||||
let dragPointerId = -1;
|
||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||
@@ -610,7 +626,7 @@
|
||||
? setTimeout(() => {
|
||||
// Still holding the tile over this cell: magnify into it. Only the first
|
||||
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
||||
if (drag && isCoarse() && !landscape && !zoomed) {
|
||||
if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) {
|
||||
focus = c;
|
||||
zoomed = true;
|
||||
haptic('light');
|
||||
@@ -729,8 +745,8 @@
|
||||
if (pendingMap.has(`${row},${col}`)) return;
|
||||
focus = { row, col };
|
||||
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
|
||||
// only hides the rest of the position.
|
||||
if (isCoarse() && !landscape && !zoomed) zoomed = true;
|
||||
// only hides the rest of the position. The "Zoom the board" setting can turn it off.
|
||||
if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
|
||||
if (placement.rack[index] === BLANK) {
|
||||
blankPrompt = { rackIndex: index, row, col };
|
||||
return;
|
||||
@@ -1030,23 +1046,11 @@
|
||||
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
|
||||
// not now — showing it here would interrupt placing the preview and revert the board on close.
|
||||
hintUsedThisTurn = true;
|
||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
||||
// focus the centre of the laid tiles' bounding box.
|
||||
const p = placement.pending;
|
||||
if (p.length) {
|
||||
const rows = p.map((tt) => tt.row);
|
||||
const cols = p.map((tt) => tt.col);
|
||||
focus = {
|
||||
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
||||
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
|
||||
};
|
||||
// Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres)
|
||||
// on the next line (portrait only); this nonce also recentres a board that is already
|
||||
// zoomed in, where the unchanged zoom state would otherwise leave it parked where the
|
||||
// player was looking.
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse() && !landscape) zoomed = true;
|
||||
// A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
|
||||
// word — highlighted green while composing — is guaranteed visible rather than possibly
|
||||
// parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
|
||||
// focus/recenter step is gone with the zoom-in.
|
||||
if (zoomed) zoomed = false;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
syncWallet(h.walletBalance);
|
||||
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
||||
@@ -1486,15 +1490,16 @@
|
||||
{#if landscape}
|
||||
<div class="game-land">
|
||||
<div class="leftpane">
|
||||
{@render turnStrip()}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="history land">{@render historyBody()}</div>
|
||||
{@render statusBlock()}
|
||||
{@render rackRow()}
|
||||
<TabBar>{@render controlButtons()}</TabBar>
|
||||
</div>
|
||||
<div class="rightpane">{@render boardBlock()}</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render turnStrip()}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||
{#if historyOpen}
|
||||
@@ -1502,7 +1507,6 @@
|
||||
{/if}
|
||||
{@render boardBlock()}
|
||||
</div>
|
||||
{@render statusBlock()}
|
||||
{@render rackRow()}
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -1517,6 +1521,21 @@
|
||||
|
||||
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
|
||||
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
|
||||
{#snippet turnStrip()}
|
||||
<!-- A thin line above the score plaques: whose turn it is during play, or the viewer's
|
||||
result once the game ends. A hotseat game shows its result as per-seat medals on the
|
||||
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
|
||||
{#if view && !(gameOver && view.game.hotseat)}
|
||||
<div class="turnstrip" class:result={gameOver}>
|
||||
{#if gameOver}
|
||||
{resultText()}
|
||||
{:else}
|
||||
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet scoreboardBlock()}
|
||||
{#if view}
|
||||
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
||||
@@ -1603,10 +1622,11 @@
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{#if view.game.endReason === 'aborted'}
|
||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if view.game.endReason === 'aborted'}
|
||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||
{/if}
|
||||
<div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1639,6 +1659,9 @@
|
||||
{focus}
|
||||
{recenter}
|
||||
{dropTarget}
|
||||
{previewLegal}
|
||||
formed={formedCells}
|
||||
scoreBadge={boardBadge}
|
||||
oncell={onCell}
|
||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||
onrecall={onRecall}
|
||||
@@ -1647,25 +1670,6 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet statusBlock()}
|
||||
{#if view}
|
||||
<div class="status">
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
|
||||
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
|
||||
game keeps the "you won/lost" text (one human). -->
|
||||
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rackRow()}
|
||||
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
@@ -1680,23 +1684,26 @@
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet confirmBtn()}
|
||||
<!-- The confirm-move control lives in the rack's fixed 7th slot (see Rack). Shown only while a
|
||||
play is staged; disabled until the preview says it is legal. -->
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet controlButtons()}
|
||||
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
<span class="sq" data-coach="game-turn">🔄{#if view && view.bagLen > 0}<span class="badge">{view.bagLen}</span>{/if}</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
{#if view?.game.hotseat}
|
||||
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||
@@ -1906,6 +1913,23 @@
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* The thin turn/result line above the score plaques (see turnStrip): whose turn it is during
|
||||
play, accented for the viewer's win/lose result once the game ends. */
|
||||
.turnstrip {
|
||||
flex: none;
|
||||
padding: 4px var(--pad);
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--bg-elev);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.turnstrip.result {
|
||||
color: var(--accent);
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
/* The board is the only part that scrolls vertically when the game does not fit;
|
||||
@@ -1924,16 +1948,12 @@
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 2;
|
||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border
|
||||
and its shadow pin to the board immediately instead of tracking the table as
|
||||
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
|
||||
column does not jump left/right when the list overflows. */
|
||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border and its shadow
|
||||
pin to the board immediately instead of tracking the table as moves accumulate. It is a flex
|
||||
column: the header and the pinned bag footer stay put while the grid (.hgridwrap) scrolls. */
|
||||
height: 62%;
|
||||
overflow: auto;
|
||||
/* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its
|
||||
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
||||
overscroll-behavior: none;
|
||||
scrollbar-gutter: stable;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -1945,8 +1965,26 @@
|
||||
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
||||
plaques. */
|
||||
.hgridwrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
/* No iOS rubber-band inside the scroller: the moves list does not elastically bounce past its
|
||||
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
||||
overscroll-behavior: none;
|
||||
/* Reserve the scrollbar so the centred word column does not jump left/right on overflow. */
|
||||
scrollbar-gutter: stable;
|
||||
padding: 8px var(--pad);
|
||||
}
|
||||
/* The bag count pinned bottom-left of the move table, out of the scrolling grid, so it stays put
|
||||
as moves accumulate. The same count also rides the exchange control as a badge (see
|
||||
controlButtons), which is the always-visible indicator when the table is closed. */
|
||||
.hbagfoot {
|
||||
flex: none;
|
||||
padding: 6px var(--pad);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.hgrid {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
@@ -1991,28 +2029,6 @@
|
||||
.boardwrap.slid :global(.viewport) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px var(--pad) 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.turn-ind {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.over {
|
||||
color: var(--accent);
|
||||
}
|
||||
.scores {
|
||||
font-weight: 600;
|
||||
color: var(--ok);
|
||||
min-width: 64px;
|
||||
text-align: right;
|
||||
}
|
||||
/* The single-word-rule label centred in the history header between its two icons. */
|
||||
.oneword-label {
|
||||
flex: 1;
|
||||
@@ -2037,36 +2053,22 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
|
||||
while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
|
||||
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
|
||||
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
|
||||
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
|
||||
preview caption above (.status shares that padding). */
|
||||
/* The confirm-move control occupies the rack's fixed 7th column while a play is staged (a
|
||||
borderless icon button, like the tab bar). It is rendered inside the rack grid (see Rack), so it
|
||||
lines up with the tiles and stays the rightmost slot no matter how many tiles remain. */
|
||||
.make {
|
||||
position: absolute;
|
||||
right: var(--pad);
|
||||
top: 0;
|
||||
bottom: 6px;
|
||||
width: 56px;
|
||||
grid-column: 7;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
display: grid;
|
||||
place-items: center end;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.make:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
|
||||
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
|
||||
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
|
||||
so it sits inside the freed slot with its right edge level with the rack's right edge — the
|
||||
mirror of the first tile's left edge. */
|
||||
.game-land .make {
|
||||
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
|
||||
}
|
||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||
.hhead {
|
||||
|
||||
+23
-43
@@ -4,15 +4,16 @@
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
slots,
|
||||
variant,
|
||||
selected,
|
||||
landscape = false,
|
||||
shuffling = false,
|
||||
draggingId = null,
|
||||
dropIndex = null,
|
||||
confirm,
|
||||
ondown,
|
||||
}: {
|
||||
// Each slot carries a stable id that travels with its tile through a shuffle, so the
|
||||
@@ -20,14 +21,14 @@
|
||||
slots: (RackSlot & { id: number })[];
|
||||
variant: Variant;
|
||||
selected: number | null;
|
||||
/** Landscape layout: size the tiles to share the (narrow) left-panel width instead of the
|
||||
* viewport-width measure, so seven tiles never overflow the panel. */
|
||||
landscape?: boolean;
|
||||
shuffling?: boolean;
|
||||
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
|
||||
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||||
draggingId?: number | null;
|
||||
dropIndex?: number | null;
|
||||
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
|
||||
* (the parent owns the button and its enablement; the rack only positions it). */
|
||||
confirm?: Snippet;
|
||||
ondown: (e: PointerEvent, index: number) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
|
||||
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
|
||||
{#each shown as slot, i (slot.id)}
|
||||
<button
|
||||
class="tile"
|
||||
@@ -75,21 +76,26 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{@render confirm?.()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Seven equal columns filling the whole tray width, so the tiles are square and as large as the
|
||||
row allows — the same in portrait and landscape. The tile glyphs use cqw against the rack width
|
||||
(the query container) so they track the tile size, the way the board sizes its labels on its
|
||||
.scaler. */
|
||||
.rack {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
|
||||
footer the same size as during play — no layout jump between states. */
|
||||
align-items: start;
|
||||
container-type: inline-size;
|
||||
/* Reserve about one tile's height so an empty rack (e.g. a finished game) keeps the footer the
|
||||
same size as during play — no layout jump between states. */
|
||||
min-height: min(12.5vw, 46px);
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: min(12.5vw, 46px);
|
||||
aspect-ratio: 1;
|
||||
background: var(--tile-bg);
|
||||
color: var(--tile-text);
|
||||
@@ -97,7 +103,6 @@
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -109,35 +114,6 @@
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
||||
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
||||
viewport-width measure that would overflow seven tiles in a column. */
|
||||
.rack.landscape {
|
||||
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
|
||||
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
|
||||
resolves unreliably, so the rack — which has a definite width — is the query container. A
|
||||
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
|
||||
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
.rack.landscape .tile {
|
||||
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
|
||||
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
|
||||
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
|
||||
tiles leave. */
|
||||
flex: 0 0 auto;
|
||||
width: calc((100% - 6 * 5px) / 7);
|
||||
max-width: 46px;
|
||||
}
|
||||
.rack.landscape .letter {
|
||||
font-size: 6cqw;
|
||||
}
|
||||
.rack.landscape .val {
|
||||
font-size: 3.1cqw;
|
||||
}
|
||||
.rack.landscape .star {
|
||||
font-size: 7.6cqw;
|
||||
}
|
||||
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
||||
tile width plus the rack gap), so the drop position is visible. */
|
||||
.rack.reordering .tile {
|
||||
@@ -150,12 +126,15 @@
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
left: 14%;
|
||||
font-size: 6vmin; /* cqw fallback for Chrome < 105 — approximates the portrait rack ≈ viewport width */
|
||||
font-size: 6cqw;
|
||||
}
|
||||
.val {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 1px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 3.1vmin; /* cqw fallback for Chrome < 105 */
|
||||
font-size: 3.1cqw;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
|
||||
@@ -167,6 +146,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 1.7rem;
|
||||
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
|
||||
font-size: 7.6cqw;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
setLocalePref,
|
||||
setReduceMotion,
|
||||
setTheme,
|
||||
setZoomBoard,
|
||||
} from '../lib/app.svelte';
|
||||
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
@@ -15,6 +16,10 @@
|
||||
import { isStandalone } from '../lib/pwa';
|
||||
import InstallApp from '../components/InstallApp.svelte';
|
||||
|
||||
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
|
||||
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
|
||||
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
|
||||
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
|
||||
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
|
||||
@@ -102,6 +107,16 @@
|
||||
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
{#if coarsePointer}
|
||||
<label class="row">
|
||||
<span>{t('settings.zoomBoard')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.zoomBoard}
|
||||
onchange={(e) => setZoomBoard(e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if offlineEligible}
|
||||
|
||||
Reference in New Issue
Block a user