Stage 17 (#9): animated shuffle — tiles hop along a low parabola to new slots
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 28s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 53s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 28s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 53s
Give each rack slot a stable id permuted with the letters on shuffle, so the keyed rack reorders (rather than relabelling in place) and Svelte's animate directive fires. hop flies each tile along a parabola (apogee ~half a tile height) with a duration that scales with the horizontal distance (arc length): the longest 1<->7 swap takes ~0.5s, shorter swaps land sooner. Ordinary reflow (place/recall) stays instant via a guard. e2e locks that a shuffle preserves the rack's tile multiset.
This commit is contained in:
@@ -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);
|
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 }) => {
|
test('history slides the board down and closes on a board tap', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await page.locator('.burger').click();
|
await page.locator('.burger').click();
|
||||||
|
|||||||
+18
-5
@@ -43,6 +43,10 @@
|
|||||||
let zoomed = $state(false);
|
let zoomed = $state(false);
|
||||||
let selected = $state<number | null>(null);
|
let selected = $state<number | null>(null);
|
||||||
let focus = $state<{ row: number; col: number } | null>(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<number[]>([]);
|
||||||
|
let shuffling = $state(false);
|
||||||
let panel = $state<'none' | 'chat'>('none');
|
let panel = $state<'none' | 'chat'>('none');
|
||||||
let historyOpen = $state(false);
|
let historyOpen = $state(false);
|
||||||
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
||||||
@@ -81,6 +85,7 @@
|
|||||||
view.game.toMove === view.seat,
|
view.game.toMove === view.seat,
|
||||||
);
|
);
|
||||||
const slots = $derived(rackView(placement));
|
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 isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||||
@@ -98,6 +103,7 @@
|
|||||||
moves = hist.moves;
|
moves = hist.moves;
|
||||||
setCachedGame(id, st, hist.moves);
|
setCachedGame(id, st, hist.moves);
|
||||||
placement = newPlacement(st.rack);
|
placement = newPlacement(st.rack);
|
||||||
|
rackIds = st.rack.map((_, i) => i);
|
||||||
preview = null;
|
preview = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
dirOverride = undefined;
|
dirOverride = undefined;
|
||||||
@@ -122,6 +128,7 @@
|
|||||||
view = cached.view;
|
view = cached.view;
|
||||||
moves = cached.moves;
|
moves = cached.moves;
|
||||||
placement = newPlacement(cached.view.rack);
|
placement = newPlacement(cached.view.rack);
|
||||||
|
rackIds = cached.view.rack.map((_, i) => i);
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -373,12 +380,18 @@
|
|||||||
}
|
}
|
||||||
function shuffle() {
|
function shuffle() {
|
||||||
if (placement.pending.length > 0) return;
|
if (placement.pending.length > 0) return;
|
||||||
const r = [...placement.rack];
|
// Shuffle an index permutation, then apply it to both the letters and the slot ids so
|
||||||
for (let i = r.length - 1; i > 0; i--) {
|
// 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));
|
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.
|
// A short "shake": a few quick light taps rather than one.
|
||||||
for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
|
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. -->
|
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} {variant} {selected} ondown={onRackDown} />
|
<Rack slots={rackSlots} {variant} {selected} {shuffling} 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} aria-label={t('game.makeMove')}>✅</button>
|
||||||
|
|||||||
+25
-2
@@ -8,25 +8,48 @@
|
|||||||
slots,
|
slots,
|
||||||
variant,
|
variant,
|
||||||
selected,
|
selected,
|
||||||
|
shuffling = false,
|
||||||
ondown,
|
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;
|
variant: Variant;
|
||||||
selected: number | null;
|
selected: number | null;
|
||||||
|
shuffling?: boolean;
|
||||||
ondown: (e: PointerEvent, index: number) => void;
|
ondown: (e: PointerEvent, index: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Used slots are hidden (the rack shifts left, freeing room on the right for the
|
// 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.
|
// MakeMove control); the slot still exists in the model for per-tile recall.
|
||||||
const visible = $derived(slots.filter((s) => !s.used));
|
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);`,
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rack" data-rack>
|
<div class="rack" data-rack>
|
||||||
{#each visible as slot (slot.index)}
|
{#each visible as slot (slot.id)}
|
||||||
<button
|
<button
|
||||||
class="tile"
|
class="tile"
|
||||||
class:selected={selected === slot.index}
|
class:selected={selected === slot.index}
|
||||||
data-rack-index={slot.index}
|
data-rack-index={slot.index}
|
||||||
|
animate:hop={shuffling}
|
||||||
onpointerdown={(e) => ondown(e, slot.index)}
|
onpointerdown={(e) => ondown(e, slot.index)}
|
||||||
>
|
>
|
||||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user