Stage 17 round 5 — board interaction & UI polish
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
- Even zoom: interpolate the board scroll toward a pre-clamped target as the real width grows/shrinks, so it magnifies A->B in one motion instead of lurching and snapping back near the edges/centre. Recentre only on a zoom toggle, never on a focus change — so a 2nd+ placed tile and a hovered dragged tile no longer jump the board. - Drag: highlight the aimed-at empty cell as a drop target; hover-hold auto-zoom now fires only for the first (zoom-in) hold. - Pinch zoom: two-finger spread/close toggles zoom toward the pinch midpoint (preventDefault only for two touches, so one-finger scroll stays native); a second finger aborts a drag. - Shuffle hop capped at 0.3s and disabled under reduce-motion. - Make-move is a borderless icon button, disabled while the pending word is known illegal. - Variant display names: english & russian_scrabble -> Scrabble/Скрэббл, erudit -> Erudite/Эрудит; the in-game title shows the variant name (was always 'Scrabble').
This commit is contained in:
+102
-12
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { BoardCell } from '../lib/board';
|
||||
import type { Premium } from '../lib/premiums';
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
@@ -19,6 +20,7 @@
|
||||
lines,
|
||||
locale,
|
||||
focus,
|
||||
dropTarget,
|
||||
oncell,
|
||||
ontogglezoom,
|
||||
onrecall,
|
||||
@@ -37,6 +39,8 @@
|
||||
lines: boolean;
|
||||
locale: Locale;
|
||||
focus: { row: number; col: number } | null;
|
||||
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
|
||||
dropTarget: { row: number; col: 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. */
|
||||
@@ -52,27 +56,106 @@
|
||||
let viewport = $state<HTMLElement>();
|
||||
|
||||
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
|
||||
// works in every browser. Keep the focus cell centred on every frame of the zoom-in
|
||||
// (the board widens over ~0.25s) so it magnifies *into* that cell, rather than growing
|
||||
// from the top-left corner and then jumping to centre once the transition ends.
|
||||
// works in every browser. The pan is interpolated toward a PRE-CLAMPED final scroll as
|
||||
// the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly
|
||||
// from A to B in one motion instead of chasing a per-frame target that the scroll bounds
|
||||
// clamp — which made the board lurch one way and then snap back near the edges/centre.
|
||||
// It runs only on a zoom toggle (`zoomed`); changing `focus` while already zoomed does
|
||||
// not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board.
|
||||
$effect(() => {
|
||||
const on = zoomed;
|
||||
const vp = viewport;
|
||||
if (!vp || !zoomed || !focus) return;
|
||||
const f = focus;
|
||||
const start = performance.now();
|
||||
let raf = requestAnimationFrame(function tick(now) {
|
||||
const cell = vp.scrollWidth / 15; // grows frame by frame as the board widens
|
||||
vp.scrollLeft = (f.col + 0.5) * cell - vp.clientWidth / 2;
|
||||
vp.scrollTop = (f.row + 0.5) * cell - vp.clientHeight / 2;
|
||||
if (now - start < 300) raf = requestAnimationFrame(tick);
|
||||
if (!vp) return;
|
||||
const f = untrack(() => focus);
|
||||
const clientW = vp.clientWidth;
|
||||
const clientH = vp.clientHeight;
|
||||
if (clientW === 0) return;
|
||||
const fitW = clientW; // board width at scale 1 (fills the viewport)
|
||||
const fullW = clientW * Z; // board width at full zoom
|
||||
const startSL = vp.scrollLeft;
|
||||
const startST = vp.scrollTop;
|
||||
let finalSL = 0;
|
||||
let finalST = 0;
|
||||
if (on && f) {
|
||||
const cell = fullW / 15;
|
||||
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW));
|
||||
finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH));
|
||||
}
|
||||
const fromW = on ? fitW : fullW; // board width when this transition begins
|
||||
const toW = on ? fullW : fitW;
|
||||
let raf = requestAnimationFrame(function tick() {
|
||||
const curW = vp.scrollWidth || fromW;
|
||||
let prog = (curW - fromW) / (toW - fromW);
|
||||
prog = prog < 0 ? 0 : prog > 1 ? 1 : prog;
|
||||
vp.scrollLeft = startSL + (finalSL - startSL) * prog;
|
||||
vp.scrollTop = startST + (finalST - startST) * prog;
|
||||
if (prog < 1) raf = requestAnimationFrame(tick);
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// Pinch zoom (Stage 17): a two-finger spread zooms in toward the pinch midpoint, a pinch
|
||||
// close zooms out. preventDefault fires only for two touches, so the one-finger native
|
||||
// scroll of the zoomed board is left untouched. It maps to the same two-state zoom as
|
||||
// double-tap, toggling toward the midpoint cell.
|
||||
$effect(() => {
|
||||
const vp = viewport;
|
||||
if (!vp) return;
|
||||
let startDist = 0;
|
||||
let acted = false;
|
||||
const span = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
|
||||
const midCell = (t: TouchList) => {
|
||||
const x = (t[0].clientX + t[1].clientX) / 2;
|
||||
const y = (t[0].clientY + t[1].clientY) / 2;
|
||||
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as HTMLElement | null;
|
||||
if (!el?.dataset.row || !el.dataset.col) return null;
|
||||
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
|
||||
};
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
startDist = span(e.touches);
|
||||
acted = false;
|
||||
}
|
||||
};
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 2 || startDist === 0) return;
|
||||
e.preventDefault(); // claim the two-finger gesture; one finger still scrolls natively
|
||||
if (acted) return;
|
||||
const ratio = span(e.touches) / startDist;
|
||||
if (ratio > 1.25 && !zoomed) {
|
||||
const c = midCell(e.touches);
|
||||
if (c) {
|
||||
acted = true;
|
||||
ontogglezoom(c.row, c.col);
|
||||
}
|
||||
} else if (ratio < 0.8 && zoomed) {
|
||||
const c = midCell(e.touches) ?? centre;
|
||||
acted = true;
|
||||
ontogglezoom(c.row, c.col);
|
||||
}
|
||||
};
|
||||
const onEnd = (e: TouchEvent) => {
|
||||
if (e.touches.length < 2) {
|
||||
startDist = 0;
|
||||
acted = false;
|
||||
}
|
||||
};
|
||||
vp.addEventListener('touchstart', onStart, { passive: true });
|
||||
vp.addEventListener('touchmove', onMove, { passive: false });
|
||||
vp.addEventListener('touchend', onEnd);
|
||||
vp.addEventListener('touchcancel', onEnd);
|
||||
return () => {
|
||||
vp.removeEventListener('touchstart', onStart);
|
||||
vp.removeEventListener('touchmove', onMove);
|
||||
vp.removeEventListener('touchend', onEnd);
|
||||
vp.removeEventListener('touchcancel', onEnd);
|
||||
};
|
||||
});
|
||||
|
||||
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
|
||||
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
|
||||
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
|
||||
// scroll of the zoomed board is never hijacked by a pinch gesture.
|
||||
// scroll of the zoomed board is never hijacked.
|
||||
let lastTap = 0;
|
||||
let lastCell = '';
|
||||
function onTap(row: number, col: number) {
|
||||
@@ -111,6 +194,7 @@
|
||||
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}
|
||||
class:droptarget={dropTarget?.row === r && dropTarget?.col === c}
|
||||
data-cell
|
||||
data-row={r}
|
||||
data-col={c}
|
||||
@@ -213,6 +297,12 @@
|
||||
inset 0 -2px 0 var(--tile-edge),
|
||||
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.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. */
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--cell-bg));
|
||||
}
|
||||
.cell.hl {
|
||||
background: var(--tile-recent);
|
||||
/* The bottom edge goes darker than the highlighted fill (not lighter, as the plain
|
||||
|
||||
Reference in New Issue
Block a user