diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 4ec5b1c..7095035 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -48,6 +48,19 @@ test('a pending tile recalls on double-tap, not on a single tap', async ({ page await expect(page.locator('[data-cell].pending')).toHaveCount(0); }); +test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => { + await openGame(page); + const before = await page.locator('.rack .tile').allTextContents(); + expect(before.length).toBeGreaterThan(1); + + await page.locator('button:has-text("🔀")').click(); // the shuffle tab (no pending tiles) + await page.waitForTimeout(650); // let the hop animation settle + + // Same multiset of tiles after the shuffle — no tile is dropped or duplicated. + const after = await page.locator('.rack .tile').allTextContents(); + expect([...after].sort()).toEqual([...before].sort()); +}); + test('history slides the board down and closes on a board tap', async ({ page }) => { await openGame(page); await page.locator('.burger').click(); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 8290075..6c2b1a7 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -43,6 +43,10 @@ let zoomed = $state(false); let selected = $state(null); let focus = $state<{ row: number; col: number } | null>(null); + // A stable id per rack slot, permuted together with the letters on shuffle, so the rack + // tiles fly to their new positions (Rack's hop animation) instead of relabelling in place. + let rackIds = $state([]); + let shuffling = $state(false); let panel = $state<'none' | 'chat'>('none'); let historyOpen = $state(false); let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null); @@ -81,6 +85,7 @@ view.game.toMove === view.seat, ); const slots = $derived(rackView(placement)); + const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index }))); const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); const gameOver = $derived(!!view && view.game.status !== 'active'); const bagEmpty = $derived((view?.bagLen ?? 0) === 0); @@ -98,6 +103,7 @@ moves = hist.moves; setCachedGame(id, st, hist.moves); placement = newPlacement(st.rack); + rackIds = st.rack.map((_, i) => i); preview = null; selected = null; dirOverride = undefined; @@ -122,6 +128,7 @@ view = cached.view; moves = cached.moves; placement = newPlacement(cached.view.rack); + rackIds = cached.view.rack.map((_, i) => i); } void load(); }); @@ -373,12 +380,18 @@ } function shuffle() { if (placement.pending.length > 0) return; - const r = [...placement.rack]; - for (let i = r.length - 1; i > 0; i--) { + // Shuffle an index permutation, then apply it to both the letters and the slot ids so + // each tile keeps its id as it flies to a new position (driving Rack's hop animation). + const order = placement.rack.map((_, i) => i); + for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); - [r[i], r[j]] = [r[j], r[i]]; + [order[i], order[j]] = [order[j], order[i]]; } - placement = newPlacement(r); + rackIds = order.map((i) => rackIds[i] ?? i); + placement = newPlacement(order.map((i) => placement.rack[i])); + selected = null; + shuffling = true; + setTimeout(() => (shuffling = false), 600); // A short "shake": a few quick light taps rather than one. for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55); } @@ -589,7 +602,7 @@ a finished game shows the final rack greyed out and the controls disabled. -->
- +
{#if !gameOver && placement.pending.length > 0} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 01642b5..369e320 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -8,25 +8,48 @@ slots, variant, selected, + shuffling = false, ondown, }: { - slots: RackSlot[]; + // 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; 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. const visible = $derived(slots.filter((s) => !s.used)); + + // 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.5s and shorter swaps land sooner. It runs only + // while a shuffle is in progress; 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(160, Math.min(500, (dist / span) * 560)), + css: (t: number, u: number) => + `transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`, + }; + }
- {#each visible as slot (slot.index)} + {#each visible as slot (slot.id)}