Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header #23

Merged
developer merged 2 commits from feature/game-ux into development 2026-06-08 18:28:21 +00:00
4 changed files with 41 additions and 17 deletions
Showing only changes of commit b720907db2 - Show all commits
+2 -2
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР">
<rect width="24" height="16" fill="#cc0000"/> <rect width="24" height="16" fill="#cc0000"/>
<!-- small five-pointed star --> <!-- five-pointed star (scaled up ~25% around its centre per review) -->
<path fill="#ffd700" d="M6 1.9 L6.32 2.86 7.33 2.87 6.51 3.47 6.82 4.43 6 3.84 5.18 4.43 5.49 3.47 4.67 2.87 5.68 2.86 Z"/> <path fill="#ffd700" transform="translate(6 3.17) scale(1.25) translate(-6 -3.17)" d="M6 1.9 L6.32 2.86 7.33 2.87 6.51 3.47 6.82 4.43 6 3.84 5.18 4.43 5.49 3.47 4.67 2.87 5.68 2.86 Z"/>
<g fill="none" stroke="#ffd700" stroke-linecap="round" stroke-linejoin="round" transform="translate(6.8 6) scale(0.667) translate(-6.8 -6)"> <g fill="none" stroke="#ffd700" stroke-linecap="round" stroke-linejoin="round" transform="translate(6.8 6) scale(0.667) translate(-6.8 -6)">
<!-- sickle: a crescent blade + short handle, mirrored across a diagonal through its centre <!-- sickle: a crescent blade + short handle, mirrored across a diagonal through its centre
so it reads as the canonical sickle (blade sweeping down-right); the hammer is untouched --> so it reads as the canonical sickle (blade sweeping down-right); the hammer is untouched -->

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

-9
View File
@@ -96,13 +96,4 @@
:global(html.tg-fullscreen) .bar { :global(html.tg-fullscreen) .bar {
padding-top: var(--tg-content-top); 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;
}
</style> </style>
+3
View File
@@ -277,6 +277,9 @@
} }
.cell.pending { .cell.pending {
background: var(--tile-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 /* 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 reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
+36 -6
View File
@@ -70,7 +70,11 @@
const premium = $derived(premiumGrid(variant)); const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant)); const ctr = $derived(centre(variant));
const pendingMap = $derived( 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); 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 // 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. // (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
let reorderDragId = $state<number | null>(null); let reorderDragId = $state<number | null>(null);
let reorderTo = $state<number | null>(null); let reorderTo = $state<number | null>(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; let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) { function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -261,10 +268,10 @@
if (busy || gameOver) return; if (busy || gameOver) return;
beginDrag({ from: 'rack', index }, e); beginDrag({ from: 'rack', index }, e);
} }
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when // A placed (pending) tile can be dragged to relocate it on the board or back to the rack —
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap. // 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) { function onBoardDown(e: PointerEvent, row: number, col: number) {
if (busy || zoomed || gameOver) return; if (busy || gameOver) return;
beginDrag({ from: 'board', row, col }, e); beginDrag({ from: 'board', row, col }, e);
} }
function cellUnder(x: number, y: number): { row: number; col: number } | null { function cellUnder(x: number, y: number): { row: number; col: number } | null {
@@ -283,6 +290,7 @@
function clearReorder() { function clearReorder() {
reorderDragId = null; reorderDragId = null;
reorderTo = null; reorderTo = null;
draggingPend = null;
} }
// overRack reports whether y is within the rack's row (a small margin makes the target // 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. // forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
@@ -316,8 +324,10 @@
const letter = const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.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' }; 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; 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 // 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. // 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) { } else if (di.src.from === 'rack' && onRack && to != null) {
// Dropped a rack tile back onto the rack → reorder it to the drop slot. // Dropped a rack tile back onto the rack → reorder it to the drop slot.
reorderRack(di.src.index, to); 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) { } 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); placement = recallAt(placement, di.src.row, di.src.col);
selected = null;
recompute(); recompute();
scheduleDraftSave(); scheduleDraftSave();
} }
@@ -416,6 +430,22 @@
} }
function onRecall(row: number, col: number) { function onRecall(row: number, col: number) {
placement = recallAt(placement, row, col); 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(); recompute();
scheduleDraftSave(); scheduleDraftSave();
} }