Files
scrabble-game/ui/src/game/Rack.svelte
T
Ilia Denisov 28afcff551
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
feat(ui): explicit offline state inside an online game (+ offline word check)
When an online game loses the connection the game screen now says so and freezes
instead of silently greying out: a "connection lost" banner appears and the rack,
the move controls, the add-friend/block controls and the chat/dictionary entry all
disable (a started move stays a draft, committed by the player on reconnect). It is
driven off the net-state machine (netState.offline), so it also covers the
Telegram/VK mini-apps, where a lost connection was previously mute in-game.

If the player is already in the dictionary when the drop happens, the word check
falls back to the game's pinned on-device dictionary (exact when that dawg is
cached; "unavailable offline" otherwise) and the network-only complaint + external
look-up hide. Chat, when already open, keeps its existing read-only degrade
(send/nudge disable). New pure helper localWordCheck is unit-tested; the in-game
gating gets an e2e.
2026-07-14 08:53:18 +02:00

163 lines
6.1 KiB
Svelte

<script lang="ts">
import type { RackSlot } from '../lib/placement';
import { BLANK } from '../lib/placement';
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,
shuffling = false,
draggingId = null,
dropIndex = null,
frozen = false,
confirm,
ondown,
}: {
// Each slot carries a stable id that travels with its tile through a shuffle, so the
// keyed list reorders (rather than relabelling in place) and the hop animation fires.
slots: (RackSlot & { id: number })[];
variant: Variant;
selected: number | null;
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;
/** frozen disables tile interaction (offline in an online game): tiles cannot be picked up to
* compose or reorder a move until the connection returns. The confirm control is gated
* separately by the parent. */
frozen?: boolean;
/** 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();
// Used slots are hidden (the rack shifts left, freeing room on the right for the
// MakeMove control); the slot still exists in the model for per-tile recall. While
// reordering, the dragged tile is lifted out (the ghost shows it).
const visible = $derived(slots.filter((s) => !s.used));
const shown = $derived(draggingId == null ? visible : visible.filter((s) => s.id !== draggingId));
// 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
// the longest swap (slot 1 ↔ 7) takes ~0.3s and shorter swaps land sooner. It runs only
// 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) {
const dx = from.left - to.left;
const dy = from.top - to.top;
const dist = Math.hypot(dx, dy);
if (!active || dist < 2) return { duration: 0 };
const span = node.parentElement?.getBoundingClientRect().width || dist;
const lift = (to.height || from.height) * 0.5;
return {
duration: Math.max(120, Math.min(300, (dist / span) * 340)),
css: (t: number, u: number) =>
`transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`,
};
}
</script>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"
class:selected={selected === slot.index}
class:shift={dropIndex != null && i >= dropIndex}
data-rack-index={slot.index}
disabled={frozen}
animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)}
>
{#if slot.letter === BLANK}
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
{:else}
<span class="letter">{slot.letter}</span>
<span class="val">{valueForLetter(variant, slot.letter)}</span>
{/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: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
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;
aspect-ratio: 1;
background: var(--tile-bg);
color: var(--tile-text);
border: none;
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
touch-action: none;
user-select: none;
-webkit-user-select: none;
/* iOS shows a tap/active highlight that can linger on the neighbour sliding into a
dragged tile's slot; suppress it so only our own styles mark a tile. */
-webkit-tap-highlight-color: transparent;
}
.tile.selected {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* Frozen (offline in an online game): the tray is inert until the connection returns. */
.tile:disabled {
opacity: 0.5;
}
/* 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 {
transition: transform 0.14s ease;
}
.tile.shift {
transform: translateX(calc(100% + 5px));
}
.letter {
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: 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
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
.star {
position: absolute;
top: calc(0.5% - 1px);
left: 0;
right: 0;
text-align: center;
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw;
}
</style>