Stage 17 round 6 (#3): drag-reorder rack tiles with a visual gap
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Dragging a rack tile and dropping it back on the rack reorders it: the dragged tile is lifted out (the drag ghost stands in) and the tiles at/after the pointer's drop slot slide right to open a gap there, so the drop position is visible. On drop the rack and its stable ids are permuted (reorderIndices, unit-tested). Reorder applies only with no pending tiles, so it stays a clean permutation; dropping on a board cell still places as before. Server persistence of the order follows (#4).
This commit is contained in:
+63
-4
@@ -28,6 +28,7 @@
|
||||
placementFromHint,
|
||||
rackView,
|
||||
recallAt,
|
||||
reorderIndices,
|
||||
reset,
|
||||
toSubmit,
|
||||
type Placement,
|
||||
@@ -193,6 +194,11 @@
|
||||
// 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);
|
||||
// Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id
|
||||
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
|
||||
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
|
||||
let reorderDragId = $state<number | null>(null);
|
||||
let reorderTo = $state<number | null>(null);
|
||||
|
||||
let dragPointerId = -1;
|
||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||
@@ -214,6 +220,7 @@
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
window.removeEventListener('pointerdown', onExtraPointer);
|
||||
clearHover();
|
||||
clearReorder();
|
||||
downInfo = null;
|
||||
dragMoved = false;
|
||||
drag = null;
|
||||
@@ -241,6 +248,33 @@
|
||||
hoverKey = '';
|
||||
dropTarget = null;
|
||||
}
|
||||
function clearReorder() {
|
||||
reorderDragId = null;
|
||||
reorderTo = null;
|
||||
}
|
||||
// overRack reports whether y is within the rack's row (a small margin makes the target
|
||||
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
|
||||
function overRack(y: number): boolean {
|
||||
const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect();
|
||||
return !!r && y >= r.top - 24 && y <= r.bottom + 24;
|
||||
}
|
||||
function dropSlotAt(x: number): number {
|
||||
const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[];
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
const r = tiles[i].getBoundingClientRect();
|
||||
if (x < r.left + r.width / 2) return i;
|
||||
}
|
||||
return tiles.length;
|
||||
}
|
||||
// reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and
|
||||
// its stable ids. Only valid with no pending tiles (the rack is then a clean permutation).
|
||||
function reorderRack(fromIndex: number, toSlot: number) {
|
||||
if (placement.pending.length > 0) return;
|
||||
const order = reorderIndices(placement.rack.length, fromIndex, toSlot);
|
||||
rackIds = order.map((i) => rackIds[i] ?? i);
|
||||
placement = newPlacement(order.map((i) => placement.rack[i]));
|
||||
selected = null;
|
||||
}
|
||||
function onWinMove(e: PointerEvent) {
|
||||
if (!downInfo) return;
|
||||
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
|
||||
@@ -249,15 +283,26 @@
|
||||
const letter =
|
||||
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
|
||||
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
|
||||
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it).
|
||||
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
|
||||
// No zoom on drag start: the player may still change their mind. Holding the tile
|
||||
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
|
||||
}
|
||||
if (!drag) return;
|
||||
drag = { ...drag, x: e.clientX, y: 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;
|
||||
// Preview where the drop lands: a drop-target ring on a free board cell, or — for a
|
||||
// rack-source drag over the rack with no pending tiles — a reorder gap at that slot.
|
||||
if (c) {
|
||||
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
|
||||
reorderTo = null;
|
||||
} else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) {
|
||||
reorderTo = dropSlotAt(e.clientX);
|
||||
dropTarget = null;
|
||||
} else {
|
||||
dropTarget = null;
|
||||
reorderTo = null;
|
||||
}
|
||||
const ck = c ? `${c.row},${c.col}` : '';
|
||||
if (ck !== hoverKey) {
|
||||
hoverKey = ck;
|
||||
@@ -287,8 +332,12 @@
|
||||
drag = null;
|
||||
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
|
||||
const cell = cellUnder(e.clientX, e.clientY);
|
||||
const to = reorderTo;
|
||||
if (di.src.from === 'rack' && cell) {
|
||||
attemptPlace(di.src.index, cell.row, cell.col);
|
||||
} else if (di.src.from === 'rack' && onRack && to != null) {
|
||||
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
|
||||
reorderRack(di.src.index, to);
|
||||
} else if (di.src.from === 'board' && onRack) {
|
||||
// Dropped a pending tile back onto the rack → recall it to its original slot.
|
||||
placement = recallAt(placement, di.src.row, di.src.col);
|
||||
@@ -302,12 +351,14 @@
|
||||
} else {
|
||||
drag = null;
|
||||
}
|
||||
clearReorder();
|
||||
}
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
window.removeEventListener('pointerdown', onExtraPointer);
|
||||
clearHover();
|
||||
clearReorder();
|
||||
telegramClosingConfirmation(false);
|
||||
});
|
||||
|
||||
@@ -667,7 +718,15 @@
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
<div class="rack-row" class:inert={gameOver}>
|
||||
<div class="rack-wrap">
|
||||
<Rack slots={rackSlots} {variant} {selected} shuffling={shuffling && !app.reduceMotion} ondown={onRackDown} />
|
||||
<Rack
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0}
|
||||
<button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
||||
|
||||
+20
-3
@@ -9,6 +9,8 @@
|
||||
variant,
|
||||
selected,
|
||||
shuffling = false,
|
||||
draggingId = null,
|
||||
dropIndex = null,
|
||||
ondown,
|
||||
}: {
|
||||
// Each slot carries a stable id that travels with its tile through a shuffle, so the
|
||||
@@ -17,12 +19,18 @@
|
||||
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 (Stage 17).
|
||||
draggingId?: number | null;
|
||||
dropIndex?: number | null;
|
||||
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.
|
||||
// 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
|
||||
@@ -44,11 +52,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rack" data-rack>
|
||||
{#each visible as slot (slot.id)}
|
||||
<div class="rack" class:reordering={draggingId != 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}
|
||||
animate:hop={shuffling}
|
||||
onpointerdown={(e) => ondown(e, slot.index)}
|
||||
@@ -87,6 +96,14 @@
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
/* 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%;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
rackView,
|
||||
recallAt,
|
||||
recallIndex,
|
||||
reorderIndices,
|
||||
reset,
|
||||
toSubmit,
|
||||
} from './placement';
|
||||
@@ -122,3 +123,12 @@ describe('placementFromHint', () => {
|
||||
expect(p.pending.map((t) => t.letter)).toEqual(['A']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderIndices', () => {
|
||||
it('lifts an element and drops it at the given slot among the others', () => {
|
||||
expect(reorderIndices(4, 0, 2)).toEqual([1, 2, 0, 3]);
|
||||
expect(reorderIndices(4, 3, 0)).toEqual([3, 0, 1, 2]);
|
||||
expect(reorderIndices(4, 1, 1)).toEqual([0, 1, 2, 3]); // back to identity
|
||||
expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,19 @@ export function reset(p: Placement): Placement {
|
||||
return { ...p, pending: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* reorderIndices returns the permutation of [0, n) that lifts the element at `from` and
|
||||
* drops it at slot `toSlot` among the remaining elements (clamped to a valid slot). It is
|
||||
* applied in parallel to the rack letters and their stable ids when a tile is dragged to a
|
||||
* new rack position.
|
||||
*/
|
||||
export function reorderIndices(n: number, from: number, toSlot: number): number[] {
|
||||
const order: number[] = [];
|
||||
for (let i = 0; i < n; i++) if (i !== from) order.push(i);
|
||||
order.splice(Math.max(0, Math.min(toSlot, order.length)), 0, from);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* direction infers the play orientation from the pending tiles: H if they share a row,
|
||||
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).
|
||||
|
||||
Reference in New Issue
Block a user