diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index beafaac..36db4ff 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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(null); + let reorderTo = $state(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. -->
- +
{#if !gameOver && placement.pending.length > 0} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 0a50e1b..6a11f3b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -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 @@ } -
- {#each visible as slot (slot.id)} +
+ {#each shown as slot, i (slot.id)}