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">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import type { BoardCell } from '../lib/board';
|
import type { BoardCell } from '../lib/board';
|
||||||
import type { Premium } from '../lib/premiums';
|
import type { Premium } from '../lib/premiums';
|
||||||
import { valueForLetter } from '../lib/alphabet';
|
import { valueForLetter } from '../lib/alphabet';
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
lines,
|
lines,
|
||||||
locale,
|
locale,
|
||||||
focus,
|
focus,
|
||||||
|
dropTarget,
|
||||||
oncell,
|
oncell,
|
||||||
ontogglezoom,
|
ontogglezoom,
|
||||||
onrecall,
|
onrecall,
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
lines: boolean;
|
lines: boolean;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
focus: { row: number; col: number } | null;
|
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;
|
oncell: (row: number, col: number) => void;
|
||||||
ontogglezoom: (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. */
|
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
|
||||||
@@ -52,27 +56,106 @@
|
|||||||
let viewport = $state<HTMLElement>();
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
|
// 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
|
// works in every browser. The pan is interpolated toward a PRE-CLAMPED final scroll as
|
||||||
// (the board widens over ~0.25s) so it magnifies *into* that cell, rather than growing
|
// the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly
|
||||||
// from the top-left corner and then jumping to centre once the transition ends.
|
// 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(() => {
|
$effect(() => {
|
||||||
|
const on = zoomed;
|
||||||
const vp = viewport;
|
const vp = viewport;
|
||||||
if (!vp || !zoomed || !focus) return;
|
if (!vp) return;
|
||||||
const f = focus;
|
const f = untrack(() => focus);
|
||||||
const start = performance.now();
|
const clientW = vp.clientWidth;
|
||||||
let raf = requestAnimationFrame(function tick(now) {
|
const clientH = vp.clientHeight;
|
||||||
const cell = vp.scrollWidth / 15; // grows frame by frame as the board widens
|
if (clientW === 0) return;
|
||||||
vp.scrollLeft = (f.col + 0.5) * cell - vp.clientWidth / 2;
|
const fitW = clientW; // board width at scale 1 (fills the viewport)
|
||||||
vp.scrollTop = (f.row + 0.5) * cell - vp.clientHeight / 2;
|
const fullW = clientW * Z; // board width at full zoom
|
||||||
if (now - start < 300) raf = requestAnimationFrame(tick);
|
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);
|
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
|
// 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
|
// 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
|
// 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 lastTap = 0;
|
||||||
let lastCell = '';
|
let lastCell = '';
|
||||||
function onTap(row: number, col: number) {
|
function onTap(row: number, col: number) {
|
||||||
@@ -111,6 +194,7 @@
|
|||||||
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
|
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
|
||||||
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
||||||
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
|
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
|
||||||
|
class:droptarget={dropTarget?.row === r && dropTarget?.col === c}
|
||||||
data-cell
|
data-cell
|
||||||
data-row={r}
|
data-row={r}
|
||||||
data-col={c}
|
data-col={c}
|
||||||
@@ -213,6 +297,12 @@
|
|||||||
inset 0 -2px 0 var(--tile-edge),
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
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 {
|
.cell.hl {
|
||||||
background: var(--tile-recent);
|
background: var(--tile-recent);
|
||||||
/* The bottom edge goes darker than the highlighted fill (not lighter, as the plain
|
/* The bottom edge goes darker than the highlighted fill (not lighter, as the plain
|
||||||
|
|||||||
+43
-11
@@ -15,6 +15,7 @@
|
|||||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||||
import { replay } from '../lib/board';
|
import { replay } from '../lib/board';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
|
import { variantNameKey } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
@@ -180,12 +181,33 @@
|
|||||||
let swallowClick = false;
|
let swallowClick = false;
|
||||||
let hoverKey = '';
|
let hoverKey = '';
|
||||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
|
||||||
|
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
|
||||||
|
let dropTarget = $state<{ row: number; col: number } | null>(null);
|
||||||
|
|
||||||
|
let dragPointerId = -1;
|
||||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||||
downInfo = { src, x0: e.clientX, y0: e.clientY };
|
downInfo = { src, x0: e.clientX, y0: e.clientY };
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
|
dragPointerId = e.pointerId;
|
||||||
window.addEventListener('pointermove', onWinMove);
|
window.addEventListener('pointermove', onWinMove);
|
||||||
window.addEventListener('pointerup', onWinUp);
|
window.addEventListener('pointerup', onWinUp);
|
||||||
|
window.addEventListener('pointerdown', onExtraPointer);
|
||||||
|
}
|
||||||
|
// A second finger touching down turns the gesture into a pinch (Board handles it), so any
|
||||||
|
// drag started by the first finger — e.g. a pinch that began on a pending tile — is aborted.
|
||||||
|
// The starting pointer's own event also bubbles here, so ignore it by id.
|
||||||
|
function onExtraPointer(e: PointerEvent) {
|
||||||
|
if (downInfo && e.pointerId !== dragPointerId) cancelDrag();
|
||||||
|
}
|
||||||
|
function cancelDrag() {
|
||||||
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
|
window.removeEventListener('pointerdown', onExtraPointer);
|
||||||
|
clearHover();
|
||||||
|
downInfo = null;
|
||||||
|
dragMoved = false;
|
||||||
|
drag = null;
|
||||||
}
|
}
|
||||||
function onRackDown(e: PointerEvent, index: number) {
|
function onRackDown(e: PointerEvent, index: number) {
|
||||||
if (!isMyTurn || busy) return;
|
if (!isMyTurn || busy) return;
|
||||||
@@ -208,6 +230,7 @@
|
|||||||
if (hoverTimer) clearTimeout(hoverTimer);
|
if (hoverTimer) clearTimeout(hoverTimer);
|
||||||
hoverTimer = null;
|
hoverTimer = null;
|
||||||
hoverKey = '';
|
hoverKey = '';
|
||||||
|
dropTarget = null;
|
||||||
}
|
}
|
||||||
function onWinMove(e: PointerEvent) {
|
function onWinMove(e: PointerEvent) {
|
||||||
if (!downInfo) return;
|
if (!downInfo) return;
|
||||||
@@ -223,14 +246,19 @@
|
|||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
drag = { ...drag, x: e.clientX, y: e.clientY };
|
drag = { ...drag, x: e.clientX, y: e.clientY };
|
||||||
const c = cellUnder(e.clientX, e.clientY);
|
const c = cellUnder(e.clientX, e.clientY);
|
||||||
|
// Highlight the aimed-at cell as a drop target, but only when it is free (no committed
|
||||||
|
// or pending tile there).
|
||||||
|
dropTarget = c && !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
|
||||||
const ck = c ? `${c.row},${c.col}` : '';
|
const ck = c ? `${c.row},${c.col}` : '';
|
||||||
if (ck !== hoverKey) {
|
if (ck !== hoverKey) {
|
||||||
hoverKey = ck;
|
hoverKey = ck;
|
||||||
if (hoverTimer) clearTimeout(hoverTimer);
|
if (hoverTimer) clearTimeout(hoverTimer);
|
||||||
hoverTimer = c
|
hoverTimer =
|
||||||
|
c && !zoomed
|
||||||
? setTimeout(() => {
|
? setTimeout(() => {
|
||||||
// Still holding the tile over this cell: magnify into it.
|
// Still holding the tile over this cell: magnify into it. Only the first
|
||||||
if (drag && isCoarse()) {
|
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
||||||
|
if (drag && isCoarse() && !zoomed) {
|
||||||
focus = c;
|
focus = c;
|
||||||
zoomed = true;
|
zoomed = true;
|
||||||
telegramHaptic('light');
|
telegramHaptic('light');
|
||||||
@@ -242,6 +270,7 @@
|
|||||||
function onWinUp(e: PointerEvent) {
|
function onWinUp(e: PointerEvent) {
|
||||||
window.removeEventListener('pointermove', onWinMove);
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
window.removeEventListener('pointerup', onWinUp);
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
|
window.removeEventListener('pointerdown', onExtraPointer);
|
||||||
clearHover();
|
clearHover();
|
||||||
const di = downInfo;
|
const di = downInfo;
|
||||||
downInfo = null;
|
downInfo = null;
|
||||||
@@ -268,6 +297,7 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('pointermove', onWinMove);
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
window.removeEventListener('pointerup', onWinUp);
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
|
window.removeEventListener('pointerdown', onExtraPointer);
|
||||||
clearHover();
|
clearHover();
|
||||||
telegramClosingConfirmation(false);
|
telegramClosingConfirmation(false);
|
||||||
});
|
});
|
||||||
@@ -547,7 +577,7 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Screen title={t('app.title')} back="/" growNav column scroll={false}>
|
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<Menu items={menuItems} />
|
<Menu items={menuItems} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -600,6 +630,7 @@
|
|||||||
lines={app.boardLines}
|
lines={app.boardLines}
|
||||||
locale={app.locale}
|
locale={app.locale}
|
||||||
{focus}
|
{focus}
|
||||||
|
{dropTarget}
|
||||||
oncell={onCell}
|
oncell={onCell}
|
||||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||||
onrecall={onRecall}
|
onrecall={onRecall}
|
||||||
@@ -624,10 +655,10 @@
|
|||||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||||
<div class="rack-row" class:inert={gameOver}>
|
<div class="rack-row" class:inert={gameOver}>
|
||||||
<div class="rack-wrap">
|
<div class="rack-wrap">
|
||||||
<Rack slots={rackSlots} {variant} {selected} {shuffling} ondown={onRackDown} />
|
<Rack slots={rackSlots} {variant} {selected} shuffling={shuffling && !app.reduceMotion} ondown={onRackDown} />
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0}
|
{#if !gameOver && placement.pending.length > 0}
|
||||||
<button class="make" onclick={commit} disabled={busy} aria-label={t('game.makeMove')}>✅</button>
|
<button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -883,18 +914,19 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 (Stage 17). */
|
||||||
.make {
|
.make {
|
||||||
min-width: 56px;
|
min-width: 56px;
|
||||||
background: var(--accent);
|
background: none;
|
||||||
color: var(--accent-text);
|
color: var(--text);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 1.6rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
.make:disabled {
|
.make:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
.pop {
|
.pop {
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
|
|
||||||
// hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile
|
// hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile
|
||||||
// height). The duration scales with the horizontal distance — i.e. the arc length — so
|
// height). The duration scales with the horizontal distance — i.e. the arc length — so
|
||||||
// the longest swap (slot 1 ↔ 7) takes ~0.5s and shorter swaps land sooner. It runs only
|
// the longest swap (slot 1 ↔ 7) takes ~0.3s and shorter swaps land sooner. It runs only
|
||||||
// while a shuffle is in progress; ordinary reflow (placing/recalling a tile) is instant.
|
// while a shuffle is in progress (and motion is not reduced); ordinary reflow
|
||||||
|
// (placing/recalling a tile) is instant.
|
||||||
function hop(node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, active: boolean) {
|
function hop(node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, active: boolean) {
|
||||||
const dx = from.left - to.left;
|
const dx = from.left - to.left;
|
||||||
const dy = from.top - to.top;
|
const dy = from.top - to.top;
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
const span = node.parentElement?.getBoundingClientRect().width || dist;
|
const span = node.parentElement?.getBoundingClientRect().width || dist;
|
||||||
const lift = (to.height || from.height) * 0.5;
|
const lift = (to.height || from.height) * 0.5;
|
||||||
return {
|
return {
|
||||||
duration: Math.max(160, Math.min(500, (dist / span) * 560)),
|
duration: Math.max(120, Math.min(300, (dist / span) * 340)),
|
||||||
css: (t: number, u: number) =>
|
css: (t: number, u: number) =>
|
||||||
`transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`,
|
`transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export const en = {
|
|||||||
|
|
||||||
'new.title': 'New game',
|
'new.title': 'New game',
|
||||||
'new.subtitle': 'Auto-match with another player',
|
'new.subtitle': 'Auto-match with another player',
|
||||||
'new.english': 'English',
|
'new.english': 'Scrabble',
|
||||||
'new.russian': 'Russian',
|
'new.russian': 'Scrabble',
|
||||||
'new.erudit': 'Эрудит',
|
'new.erudit': 'Erudite',
|
||||||
'new.find': 'Find a game',
|
'new.find': 'Find a game',
|
||||||
'new.searching': 'Looking for an opponent…',
|
'new.searching': 'Looking for an opponent…',
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
|
|
||||||
'new.title': 'Новая игра',
|
'new.title': 'Новая игра',
|
||||||
'new.subtitle': 'Автоподбор соперника',
|
'new.subtitle': 'Автоподбор соперника',
|
||||||
'new.english': 'Английский',
|
'new.english': 'Скрэббл',
|
||||||
'new.russian': 'Русский',
|
'new.russian': 'Скрэббл',
|
||||||
'new.erudit': 'Эрудит',
|
'new.erudit': 'Эрудит',
|
||||||
'new.find': 'Найти игру',
|
'new.find': 'Найти игру',
|
||||||
'new.searching': 'Ищем соперника…',
|
'new.searching': 'Ищем соперника…',
|
||||||
|
|||||||
+10
-1
@@ -11,13 +11,22 @@ export interface VariantOption {
|
|||||||
label: MessageKey;
|
label: MessageKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALL_VARIANTS lists every variant in display order.
|
// ALL_VARIANTS lists every variant in display order. The labels are display names, not
|
||||||
|
// language names: both Scrabble variants render as "Scrabble"/"Скрэббл" and Erudit as
|
||||||
|
// "Erudite"/"Эрудит" (Stage 17) — the offered list is language-gated, so within one
|
||||||
|
// language the names stay distinct.
|
||||||
export const ALL_VARIANTS: VariantOption[] = [
|
export const ALL_VARIANTS: VariantOption[] = [
|
||||||
{ id: 'english', label: 'new.english' },
|
{ id: 'english', label: 'new.english' },
|
||||||
{ id: 'russian_scrabble', label: 'new.russian' },
|
{ id: 'russian_scrabble', label: 'new.russian' },
|
||||||
{ id: 'erudit', label: 'new.erudit' },
|
{ id: 'erudit', label: 'new.erudit' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// variantNameKey returns the i18n key for a variant's display name (used by the in-game
|
||||||
|
// title and the lobby cards).
|
||||||
|
export function variantNameKey(v: Variant): MessageKey {
|
||||||
|
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
|
||||||
|
}
|
||||||
|
|
||||||
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
|
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
|
||||||
// ru -> Russian + Эрудит.
|
// ru -> Russian + Эрудит.
|
||||||
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
|
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
|
||||||
|
|||||||
Reference in New Issue
Block a user