diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 4046c4b..d3e3697 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -135,12 +135,52 @@ if (step !== 1) resetTokens(); if (ply === null) return; const m = g.moves[ply - 1]; + if (oneBoard && !onGlass) { + level = levelOf(m.to); + return; + } requestAnimationFrame(() => { const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`); - target?.scrollIntoView({ block: matchMedia('(max-width: 860px)').matches ? 'center' : 'nearest', behavior: calm ? 'auto' : 'smooth' }); + target?.scrollIntoView({ block: phone ? 'center' : 'nearest', behavior: calm ? 'auto' : 'smooth' }); }); } + // --- Phones: the status strip carries a jump to each board, and a phone on its side shows one board at a time --- + let phone = $state(false); + let oneBoard = $state(false); + $effect(() => { + const narrow = matchMedia('(max-width: 860px)'); + const short = matchMedia('(max-width: 1000px) and (max-height: 540px)'); + const read = () => { + phone = narrow.matches; + oneBoard = short.matches; + }; + read(); + narrow.addEventListener('change', read); + short.addEventListener('change', read); + return () => { + narrow.removeEventListener('change', read); + short.removeEventListener('change', read); + }; + }); + /** The board shown when there is room for one at a time; a move made on another brings that one forward. */ + let level = $state(2); + $effect(() => { + const m = g.moves.at(-1); + if (m) level = levelOf(m.to); + }); + function goLevel(lv: number) { + if (oneBoard) { + level = lv; + return; + } + const world = document.querySelector(`.play g.world[data-level="${lv}"]`); + const strip = document.querySelector('.play .status'); + if (!world) return; + const top = world.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14); + scrollTo({ top, behavior: calm ? 'auto' : 'smooth' }); + } + /** The game told move by move, replayed from the ledger's moves. */ const tellings = $derived(narrate(g)); function storyOf(ply: number): string { @@ -328,6 +368,9 @@ {:else} {nameOfSide(g.toMove)} is thinking · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · {nameOfSide(g.toMove)} is in check{/if} {/if} + {#if phone && !onGlass && !pending} + {#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}{/each} + {/if} {#if reviewing !== null && !pending} The boards as they stood after move {reviewing} of {g.moves.length}. · @@ -364,7 +407,7 @@ {:else}
- + (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
{/if}

{liveText}

@@ -456,6 +499,26 @@ color: var(--frost); } + /* The jumps: one a board, and a mark on any board the chosen piece can reach. */ + .jump { + display: flex; + gap: 1rem; + margin-top: 0.25rem; + font-size: 0.85rem; + } + + .jump .current { + color: var(--ember); + } + + .jump .lit::after { + content: ' ●'; + font-size: 0.6em; + vertical-align: middle; + color: var(--frost); + } + + .marked .link:disabled { color: var(--bone-faint); text-decoration: none; @@ -488,6 +551,13 @@ margin: 0 auto; } + /* A phone on its side has no height for three boards: one shows at a time, as wide as the screen allows. */ + @media (max-width: 1000px) and (max-height: 540px) { + .frame { + max-width: none; + } + } + .glass-way { max-width: 640px; margin: 0 auto 0.5rem; diff --git a/src/lib/components/BoardArtwork.svelte b/src/lib/components/BoardArtwork.svelte index 0c33384..f48debb 100644 --- a/src/lib/components/BoardArtwork.svelte +++ b/src/lib/components/BoardArtwork.svelte @@ -15,10 +15,11 @@ // underworld, each twelve by eight in the colours Gygax suggested, with // the pieces as discs bearing his letters. Shared by the table, the // lessons and the hall's still of the opening. + import { tick } from 'svelte'; import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game'; let { g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1, - calm = true, decorative = false, tap = () => {}, escape = () => {} + calm = true, decorative = false, tap = () => {}, escape = () => {}, climb }: { g: State; tokens?: Token[]; @@ -40,9 +41,12 @@ tap?: (i: number) => void | Promise; /** Escape pressed on a square: the caller lets go of whatever it was doing. */ escape?: () => void; + /** Page Up or Page Down towards a board that is not drawn: the caller may bring it out. */ + climb?: (level: number) => void; } = $props(); let svg: SVGSVGElement | undefined = $state(); + const focusSquare = (i: number) => svg?.querySelector(`rect.square[data-i="${i}"]`)?.focus(); /** The one square Tab reaches: the grid is entered once, at the square last visited, and the keys walk it from there. */ let visited = $state(null); /** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */ @@ -57,7 +61,13 @@ const f = fileOf(i) + df; const r = rankOf(i) + dr; const lv = levelOf(i) + dl; - if (f >= 0 && f < FILES && r >= 0 && r < RANKS && levels.includes(lv)) svg?.querySelector(`rect.square[data-i="${idx(lv, f, r)}"]`)?.focus(); + if (f < 0 || f >= FILES || r < 0 || r >= RANKS || lv < 1 || lv > 3) return; + const to = idx(lv, f, r); + if (levels.includes(lv)) focusSquare(to); + else if (climb) { + climb(lv); + void tick().then(() => focusSquare(to)); + } } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void tap(i); @@ -88,7 +98,10 @@ return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL }; } const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n))); - const entry = $derived(visited ?? selected ?? (levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0])); + const entry = $derived.by(() => { + for (const c of [visited, selected]) if (c !== null && drawn.includes(c)) return c; + return levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0]; + }); const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n]; const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n); const describe = (i: number) => { diff --git a/src/lib/components/Glass.svelte b/src/lib/components/Glass.svelte index b1cb7ee..bceaa52 100644 --- a/src/lib/components/Glass.svelte +++ b/src/lib/components/Glass.svelte @@ -37,7 +37,7 @@ /** The boards' colours, Gygax's: light blue and white, light green and amber, red and brown. */ const SQUARES: Record = { 3: ['#e4ecf2', '#9fb8cc'], 2: ['#e6d7a3', '#8fa66a'], 1: ['#c9937a', '#7a4a3a'] }; const LIVERY: Record = { gold: '#d8a63e', scarlet: '#a4302a' }; - const MARK = { selected: '#8cc8e0', target: '#8cc8e0', reach: '#8cc8e0', last: '#f0a53a', fallen: '#e0655c', check: '#e0655c' }; + const MARK = { selected: '#8cc8e0', target: '#8cc8e0', reach: '#8cc8e0', last: '#f0a53a', fallen: '#e0655c', check: '#e0655c', frozen: '#b9c9d2' }; /** Air between the sheets, in squares: enough for a Dragon's wings. */ const GAP = 4.2; const CELL = 1; @@ -447,6 +447,8 @@ }); $effect(() => { marks.clear(); + // A figure held by a Basilisk stands in a pale ring, since its dimmed livery is easy to miss among forty. + for (let i = 0; i < g.cells.length; i++) if (g.cells[i] !== '' && frozen(g.cells, i) && i !== selected) mark(i, 'ring', MARK.frozen, 0.8); for (const i of reach) mark(i, g.cells[i] === '' ? 'dot' : 'ring', MARK.reach, 0.85); for (const i of shownSquares) mark(i, 'halo', MARK.last); for (const i of shownCaptured) mark(i, 'halo', MARK.fallen); diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index a0fc31d..e57e7ce 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -24,6 +24,8 @@ Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default])) ); let prefsOpen = $state(false); + /** The side and the Sylphs' setup wait behind one line until asked for. */ + let setupOpen = $state(false); /** The opening as the host's choices lay it, drawn beside the buttons. */ const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options)); let busy = $state(false); @@ -218,7 +220,9 @@ {#if !ready}

A name first, and the buttons wake. The drake is the computer, playing for the house.

{/if} {#if game.options?.length} -
+ {@const chosenLabels = game.options.map((o) => `${o.label} ${o.choices.find((c) => c.value === options[o.key])?.label.replace(/^(?!Gold|Scarlet)./, (ch) => ch.toLowerCase()) ?? ''}.`)} +

{chosenLabels.join(' ')}

+