diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg index 58e4d8b..eb4d881 100644 --- a/ui/public/flag-ussr.svg +++ b/ui/public/flag-ussr.svg @@ -1,7 +1,7 @@ - - + + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index d1cb200..e2b5a2c 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -96,13 +96,4 @@ :global(html.tg-fullscreen) .bar { padding-top: var(--tg-content-top); } - :global(html.tg-fullscreen) .end { - position: fixed; - top: 0; - left: 50%; - transform: translateX(-50%); - height: var(--tg-content-top); - justify-content: center; - z-index: 30; - } diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index a434519..fd68828 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -277,6 +277,9 @@ } .cell.pending { background: var(--tile-pending); + /* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed + board) instead of the touch starting a board pan (Stage 17). */ + touch-action: none; } /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 4af1517..058f299 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -70,7 +70,11 @@ const premium = $derived(premiumGrid(variant)); const ctr = $derived(centre(variant)); const pendingMap = $derived( - new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])), + new Map( + placement.pending + .filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col)) + .map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]), + ), ); const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null); // Highlight the last word with a dark tile bg; while placing, only the pending tiles @@ -228,6 +232,9 @@ // (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); + // While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell — + // hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17). + let draggingPend = $state<{ row: number; col: number } | null>(null); let dragPointerId = -1; function beginDrag(src: DragSrc, e: PointerEvent) { @@ -261,10 +268,10 @@ if (busy || gameOver) return; beginDrag({ from: 'rack', index }, e); } - // A pending tile can be dragged back to the rack, but only on the unzoomed board: when - // zoomed the one-finger gesture scrolls the board, so recall there is via double-tap. + // A placed (pending) tile can be dragged to relocate it on the board or back to the rack — + // works zoomed too (the tile has touch-action:none, so its drag wins over the board pan). function onBoardDown(e: PointerEvent, row: number, col: number) { - if (busy || zoomed || gameOver) return; + if (busy || gameOver) return; beginDrag({ from: 'board', row, col }, e); } function cellUnder(x: number, y: number): { row: number; col: number } | null { @@ -283,6 +290,7 @@ function clearReorder() { reorderDragId = null; reorderTo = null; + draggingPend = 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. @@ -316,8 +324,10 @@ 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, touch: e.pointerType === 'touch' }; - // A rack tile is lifted out of the rack while dragged (the ghost stands in for it). + // A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a + // placed board tile is likewise lifted off its cell while relocated. reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null; + draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : 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. } @@ -371,9 +381,13 @@ } 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' && cell) { + // Dropped a placed tile on another board cell → relocate it there. + relocatePending(di.src.row, di.src.col, cell.row, cell.col); } else if (di.src.from === 'board' && onRack) { - // Dropped a pending tile back onto the rack → recall it to its original slot. + // Dropped a placed tile back onto the rack → recall it to its original slot. placement = recallAt(placement, di.src.row, di.src.col); + selected = null; recompute(); scheduleDraftSave(); } @@ -416,6 +430,22 @@ } function onRecall(row: number, col: number) { placement = recallAt(placement, row, col); + selected = null; + recompute(); + scheduleDraftSave(); + } + // relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one + // (a board→board drag), keeping its rack slot and any blank letter (Stage 17). + function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) { + const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol); + if (!pt) return; + if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) { + return; + } + let p = recallAt(placement, fromRow, fromCol); + p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined); + placement = p; + focus = { row: toRow, col: toCol }; recompute(); scheduleDraftSave(); }