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
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.
163 lines
6.1 KiB
Svelte
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>
|