From the two testers' lists: on a phone the status strip carries a jump to each board, with a mark on any board the chosen piece can reach, and a phone on its side shows one board at a time, as wide as the screen, with Page Up and Page Down bringing the others out; a figure held by a Basilisk stands in a pale ring on the glass; the hall says which side and setup are chosen in one line and keeps the choices behind it until asked
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
1 parent
0f4ae0dca4
commit
f2be3b0055
4 files changed
+104
-7
No files matched your search
@@ -135,12 +135,52 @@
|
|||||||
if (step !== 1) resetTokens();
|
if (step !== 1) resetTokens();
|
||||||
if (ply === null) return;
|
if (ply === null) return;
|
||||||
const m = g.moves[ply - 1];
|
const m = g.moves[ply - 1];
|
||||||
|
if (oneBoard && !onGlass) {
|
||||||
|
level = levelOf(m.to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`);
|
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. */
|
/** The game told move by move, replayed from the ledger's moves. */
|
||||||
const tellings = $derived(narrate(g));
|
const tellings = $derived(narrate(g));
|
||||||
function storyOf(ply: number): string {
|
function storyOf(ply: number): string {
|
||||||
@@ -328,6 +368,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
|
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if phone && !onGlass && !pending}
|
||||||
|
<span class="jump">{#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}<button type="button" class="link plain" class:current={oneBoard && level === lv} class:lit={targets.some((t) => levelOf(t) === lv)} onclick={() => goLevel(lv as number)}>{label}</button>{/each}</span>
|
||||||
|
{/if}
|
||||||
{#if reviewing !== null && !pending}
|
{#if reviewing !== null && !pending}
|
||||||
<span class="marked">The boards as they stood after move {reviewing} of {g.moves.length}.
|
<span class="marked">The boards as they stood after move {reviewing} of {g.moves.length}.
|
||||||
<button type="button" class="link" disabled={reviewing <= 1} onclick={() => review(reviewing! - 1)}>Earlier</button> ·
|
<button type="button" class="link" disabled={reviewing <= 1} onclick={() => review(reviewing! - 1)}>Earlier</button> ·
|
||||||
@@ -364,7 +407,7 @@
|
|||||||
<Glass g={v} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
|
<Glass g={v} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="frame">
|
<div class="frame">
|
||||||
<BoardArtwork g={v} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
|
<BoardArtwork g={v} {tokens} levels={oneBoard ? [level] : [3, 2, 1]} climb={(lv) => (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
||||||
@@ -456,6 +499,26 @@
|
|||||||
color: var(--frost);
|
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 {
|
.marked .link:disabled {
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -488,6 +551,13 @@
|
|||||||
margin: 0 auto;
|
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 {
|
.glass-way {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 auto 0.5rem;
|
margin: 0 auto 0.5rem;
|
||||||
|
|||||||
@@ -15,10 +15,11 @@
|
|||||||
// underworld, each twelve by eight in the colours Gygax suggested, with
|
// underworld, each twelve by eight in the colours Gygax suggested, with
|
||||||
// the pieces as discs bearing his letters. Shared by the table, the
|
// the pieces as discs bearing his letters. Shared by the table, the
|
||||||
// lessons and the hall's still of the opening.
|
// 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';
|
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game';
|
||||||
let {
|
let {
|
||||||
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
|
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;
|
g: State;
|
||||||
tokens?: Token[];
|
tokens?: Token[];
|
||||||
@@ -40,9 +41,12 @@
|
|||||||
tap?: (i: number) => void | Promise<void>;
|
tap?: (i: number) => void | Promise<void>;
|
||||||
/** Escape pressed on a square: the caller lets go of whatever it was doing. */
|
/** Escape pressed on a square: the caller lets go of whatever it was doing. */
|
||||||
escape?: () => void;
|
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();
|
} = $props();
|
||||||
|
|
||||||
let svg: SVGSVGElement | undefined = $state();
|
let svg: SVGSVGElement | undefined = $state();
|
||||||
|
const focusSquare = (i: number) => svg?.querySelector<SVGRectElement>(`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. */
|
/** 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<number | null>(null);
|
let visited = $state<number | null>(null);
|
||||||
/** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */
|
/** 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 f = fileOf(i) + df;
|
||||||
const r = rankOf(i) + dr;
|
const r = rankOf(i) + dr;
|
||||||
const lv = levelOf(i) + dl;
|
const lv = levelOf(i) + dl;
|
||||||
if (f >= 0 && f < FILES && r >= 0 && r < RANKS && levels.includes(lv)) svg?.querySelector<SVGRectElement>(`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 === ' ') {
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void tap(i);
|
void tap(i);
|
||||||
@@ -88,7 +98,10 @@
|
|||||||
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
|
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 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 fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
|
||||||
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
||||||
const describe = (i: number) => {
|
const describe = (i: number) => {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
/** The boards' colours, Gygax's: light blue and white, light green and amber, red and brown. */
|
/** The boards' colours, Gygax's: light blue and white, light green and amber, red and brown. */
|
||||||
const SQUARES: Record<number, [string, string]> = { 3: ['#e4ecf2', '#9fb8cc'], 2: ['#e6d7a3', '#8fa66a'], 1: ['#c9937a', '#7a4a3a'] };
|
const SQUARES: Record<number, [string, string]> = { 3: ['#e4ecf2', '#9fb8cc'], 2: ['#e6d7a3', '#8fa66a'], 1: ['#c9937a', '#7a4a3a'] };
|
||||||
const LIVERY: Record<Side, string> = { gold: '#d8a63e', scarlet: '#a4302a' };
|
const LIVERY: Record<Side, string> = { 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. */
|
/** Air between the sheets, in squares: enough for a Dragon's wings. */
|
||||||
const GAP = 4.2;
|
const GAP = 4.2;
|
||||||
const CELL = 1;
|
const CELL = 1;
|
||||||
@@ -447,6 +447,8 @@
|
|||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
marks.clear();
|
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 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 shownSquares) mark(i, 'halo', MARK.last);
|
||||||
for (const i of shownCaptured) mark(i, 'halo', MARK.fallen);
|
for (const i of shownCaptured) mark(i, 'halo', MARK.fallen);
|
||||||
|
|||||||
@@ -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]))
|
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);
|
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. */
|
/** The opening as the host's choices lay it, drawn beside the buttons. */
|
||||||
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
@@ -218,7 +220,9 @@
|
|||||||
</label>
|
</label>
|
||||||
{#if !ready}<p class="option-note muted small">A name first, and the buttons wake. The drake is the computer, playing for the house.</p>{/if}
|
{#if !ready}<p class="option-note muted small">A name first, and the buttons wake. The drake is the computer, playing for the house.</p>{/if}
|
||||||
{#if game.options?.length}
|
{#if game.options?.length}
|
||||||
<div class="options">
|
{@const chosenLabels = game.options.map((o) => `${o.label} ${o.choices.find((c) => c.value === options[o.key])?.label.replace(/^(?!Gold|Scarlet)./, (ch) => ch.toLowerCase()) ?? ''}.`)}
|
||||||
|
<p class="setup muted small">{chosenLabels.join(' ')} <button type="button" class="link plain" aria-expanded={setupOpen} onclick={() => (setupOpen = !setupOpen)}>{setupOpen ? 'Done' : 'Change'}</button></p>
|
||||||
|
<div class="options" hidden={!setupOpen}>
|
||||||
{#each game.options as o (o.key)}
|
{#each game.options as o (o.key)}
|
||||||
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||||||
<div>
|
<div>
|
||||||
@@ -660,6 +664,14 @@
|
|||||||
margin: 0.2rem 0 0.6rem;
|
margin: 0.2rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.options[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup {
|
||||||
|
margin: 0.2rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in new issue
Block a user