Stage 7 (wip): UI shell, libs, mock transport, screens (lobby->game), e2e smoke
- plain Svelte 5 + TS + Vite (no SvelteKit); CSS-token design system (Telegram-ready), hash router, IndexedDB session - pure libs: domain model, premium/value maps ported from solver, board replay, placement state machine, i18n en/ru - in-memory mock transport + seed data; pnpm start runs lobby->active game->board with no backend - board: pointer-drag + tap placement, MakeMove (popup / 1s-hold commit), two-state zoom, blank chooser, exchange, hint, word-check, chat - Playwright smoke (mock) green; svelte-check clean; mock bundle ~37 KB gzip
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import type { BoardCell } from '../lib/board';
|
||||
import type { Premium } from '../lib/premiums';
|
||||
import { tileValue } from '../lib/premiums';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
let {
|
||||
board,
|
||||
premium,
|
||||
pending,
|
||||
recent,
|
||||
centre,
|
||||
zoomed,
|
||||
variant,
|
||||
oncell,
|
||||
ontogglezoom,
|
||||
}: {
|
||||
board: (BoardCell | null)[][];
|
||||
premium: Premium[][];
|
||||
pending: Map<string, { letter: string; blank: boolean }>;
|
||||
recent: Set<string>;
|
||||
centre: { row: number; col: number };
|
||||
zoomed: boolean;
|
||||
variant: Variant;
|
||||
oncell: (row: number, col: number) => void;
|
||||
ontogglezoom: () => void;
|
||||
} = $props();
|
||||
|
||||
const premClass: Record<Premium, string> = {
|
||||
'': '',
|
||||
TW: 'tw',
|
||||
DW: 'dw',
|
||||
TL: 'tl',
|
||||
DL: 'dl',
|
||||
};
|
||||
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
|
||||
|
||||
// Double-tap toggles zoom.
|
||||
let lastTap = 0;
|
||||
function onTap(row: number, col: number) {
|
||||
const now = Date.now();
|
||||
if (now - lastTap < 300) {
|
||||
ontogglezoom();
|
||||
lastTap = 0;
|
||||
return;
|
||||
}
|
||||
lastTap = now;
|
||||
oncell(row, col);
|
||||
}
|
||||
|
||||
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out.
|
||||
const pts = new Map<number, { x: number; y: number }>();
|
||||
let startDist = 0;
|
||||
function dist(): number {
|
||||
const p = [...pts.values()];
|
||||
if (p.length < 2) return 0;
|
||||
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
|
||||
}
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pts.size === 2) startDist = dist();
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!pts.has(e.pointerId)) return;
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pts.size === 2 && startDist > 0) {
|
||||
const d = dist();
|
||||
if (!zoomed && d > startDist * 1.25) {
|
||||
ontogglezoom();
|
||||
startDist = 0;
|
||||
} else if (zoomed && d < startDist * 0.8) {
|
||||
ontogglezoom();
|
||||
startDist = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
pts.delete(e.pointerId);
|
||||
if (pts.size < 2) startDist = 0;
|
||||
}
|
||||
|
||||
function key(r: number, c: number): string {
|
||||
return `${r},${c}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="viewport"
|
||||
class:zoomed
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<div class="grid" class:zoomed>
|
||||
{#each board as rowCells, r (r)}
|
||||
{#each rowCells as cell, c (c)}
|
||||
{@const p = pending.get(key(r, c))}
|
||||
{@const letter = cell?.letter ?? p?.letter ?? ''}
|
||||
{@const blank = cell?.blank ?? p?.blank ?? false}
|
||||
<button
|
||||
class="cell {premClass[premium[r][c]]}"
|
||||
class:filled={!!cell}
|
||||
class:pending={!!p && !cell}
|
||||
class:recent={recent.has(key(r, c))}
|
||||
data-cell
|
||||
data-row={r}
|
||||
data-col={c}
|
||||
onclick={() => onTap(r, c)}
|
||||
>
|
||||
{#if letter}
|
||||
<span class="letter">{letter}</span>
|
||||
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
||||
{:else if r === centre.row && c === centre.col}
|
||||
<span class="star">★</span>
|
||||
{:else if premLabel[premium[r][c]]}
|
||||
<span class="plabel">{premLabel[premium[r][c]]}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewport {
|
||||
width: 100%;
|
||||
background: var(--board-bg);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
touch-action: none;
|
||||
}
|
||||
.viewport.zoomed {
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(15, 1fr);
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
.grid.zoomed {
|
||||
grid-template-columns: repeat(15, 2.6rem);
|
||||
width: max-content;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--cell-bg);
|
||||
color: var(--prem-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cell.tw {
|
||||
background: var(--prem-tw);
|
||||
}
|
||||
.cell.dw {
|
||||
background: var(--prem-dw);
|
||||
}
|
||||
.cell.tl {
|
||||
background: var(--prem-tl);
|
||||
}
|
||||
.cell.dl {
|
||||
background: var(--prem-dl);
|
||||
}
|
||||
.cell.filled,
|
||||
.cell.pending {
|
||||
background: var(--tile-bg);
|
||||
color: var(--tile-text);
|
||||
box-shadow: inset 0 -2px 0 var(--tile-edge);
|
||||
}
|
||||
.cell.pending {
|
||||
background: var(--tile-pending);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.cell.recent {
|
||||
box-shadow:
|
||||
inset 0 -2px 0 var(--tile-edge),
|
||||
0 0 0 2px var(--warn);
|
||||
}
|
||||
.letter {
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.grid:not(.zoomed) .letter {
|
||||
font-size: 2.6vw;
|
||||
}
|
||||
.val {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
bottom: 0;
|
||||
font-size: 0.55em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.plabel {
|
||||
opacity: 0.85;
|
||||
font-weight: 600;
|
||||
}
|
||||
.star {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user