What an extended playtest found at the table: a move awaiting confirmation is asked in the status strip, which stays in view, and Confirm takes the focus, with Escape to cancel and the selection kept; the squares are one Tab stop, entered at the square last visited; the masthead's buttons wrap at tablet widths instead of running off the page; the glass canvas fits the window's height, seats its camera afresh when the frame turns wide, remembers its view across a spell on the flat boards, keeps its ways to look above the canvas on a phone, and sits a little further from the seat so the far rank fits; a Basilisk's gaze reaches the status line; a move picked from the chronicle is named in the status as lit on the boards as they stand, and its board is brought into view; the end of a game offers a fresh drake, a rematch and the hall together under the result, with the status no longer repeated in the story

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-27 22:43:04 -04:00
1 parent b88c57f350
commit 13fc4c5ef3
5 files changed
+187 -70

No files matched your search

+118 -29
View File
@@ -9,11 +9,12 @@
import { narrate } from '$lib/game/chronicle'; import { narrate } from '$lib/game/chronicle';
import BoardArtwork, { type Token } from './BoardArtwork.svelte'; import BoardArtwork, { type Token } from './BoardArtwork.svelte';
import { import {
legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, frozen, NAMES, SIDE_NAMES, legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, frozen, NAMES, SIDE_NAMES, PER_LEVEL,
type Move, type Side type Move, type Side
} from '$lib/game'; } from '$lib/game';
import { Room } from '$lib/net/room.svelte'; import { Room } from '$lib/net/room.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { prefs, reducedMotion } from '$lib/prefs.svelte'; import { prefs, reducedMotion } from '$lib/prefs.svelte';
let { room }: { room: Room } = $props(); let { room }: { room: Room } = $props();
@@ -109,13 +110,31 @@
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []); const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []); const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
/** A move picked from the record lights its squares on the boards as they stand; the pieces stay where they are. */
function review(ply: number | null) {
reviewing = ply;
if (ply === null) return;
const m = g.moves[ply - 1];
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' });
});
}
/** 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 {
const t = tellings[ply - 1]; const t = tellings[ply - 1];
return t ? t.lines.map((l) => l.text).join(' ') : ''; return t ? t.lines.map((l) => l.text).join(' ') : '';
} }
const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : ''); const shownStory = $derived.by(() => {
const t = tellings[shownPly - 1];
if (!t) return '';
// Once the game is over the status says how it ended; the story keeps to the move.
const lines = g.over && reviewing === null ? t.lines.filter((l) => l.kind !== 'end') : t.lines;
return lines.map((l) => l.text).join(' ');
});
const shownFreeze = $derived(tellings[shownPly - 1]?.lines.some((l) => l.kind === 'freeze') ?? false);
/** What the other side just did, in a few words, for the status line when it is your move. */ /** What the other side just did, in a few words, for the status line when it is your move. */
const lastLine = $derived.by(() => { const lastLine = $derived.by(() => {
@@ -124,7 +143,14 @@
const who = nameOfSide(m.by); const who = nameOfSide(m.by);
if (m.afar) return `${who}’s Dragon struck ${squareName(m.to)} from the air.`; if (m.afar) return `${who}’s Dragon struck ${squareName(m.to)} from the air.`;
const did = m.captured ? `took the ${NAMES[m.captured]} on` : 'went to'; const did = m.captured ? `took the ${NAMES[m.captured]} on` : 'went to';
return `${who}’s ${NAMES[m.kind]} ${did} ${squareName(m.to)}${m.promoted ? ' and is a Hero now' : ''}.`; let line = `${who}’s ${NAMES[m.kind]} ${did} ${squareName(m.to)}${m.promoted ? ' and is a Hero now' : ''}.`;
// A gaze that falls changes what the player can do as much as a capture, so it is said here too.
const over = m.to + PER_LEVEL;
if (m.kind === 'B' && levelOf(m.to) === 1 && frozen(g.cells, over)) {
const owner = sideOf(g.cells[over]) === mySide ? 'Your' : `${nameOfSide(sideOf(g.cells[over])!)}’s`;
line += ` ${owner} ${NAMES[kindOf(g.cells[over])]} on ${squareName(over)} is frozen under it.`;
} else if (levelOf(m.to) === 2 && frozen(g.cells, m.to)) line += ' There it is frozen by the Basilisk beneath.';
return line;
}); });
// --- Choosing and moving --- // --- Choosing and moving ---
@@ -144,12 +170,38 @@
liveText = text; liveText = text;
} }
/** A move awaiting the player's word, when they asked to confirm. */ /** A move awaiting the player's word, when they asked to confirm. It is put in the status strip, which stays in view, and takes the keyboard's focus. */
let pending = $state<{ from: number; to: number } | null>(null); let pending = $state<{ from: number; to: number } | null>(null);
let confirmButton = $state<HTMLButtonElement | null>(null);
/** The square that had the focus before the question was asked, to give it back afterwards. */
let askedFrom: Element | null = null;
$effect(() => {
if (pending && confirmButton) {
askedFrom = document.activeElement;
confirmButton.focus();
}
});
async function answered() {
await tick();
if (askedFrom instanceof HTMLElement || askedFrom instanceof SVGElement) askedFrom.focus();
askedFrom = null;
}
function cancel() {
if (!pending) return;
const { from } = pending;
pending = null;
announce(`Move cancelled. ${squareName(from)} is still selected.`);
void answered();
}
function cancelOnEscape(e: KeyboardEvent) {
if (e.key === 'Escape') cancel();
}
async function send(from: number, to: number) { async function send(from: number, to: number) {
const asked = pending !== null;
pending = null; pending = null;
selected = null; selected = null;
if (asked) void answered();
await room.submit({ from, to }); await room.submit({ from, to });
} }
@@ -167,7 +219,7 @@
if (selected !== null && targets.includes(i)) { if (selected !== null && targets.includes(i)) {
if (prefs.confirmMoves === true) { if (prefs.confirmMoves === true) {
pending = { from: selected, to: i }; pending = { from: selected, to: i };
announce(`Move ${squareName(selected)} to ${squareName(i)}? Confirm or cancel below.`); announce(`Move ${squareName(selected)} to ${squareName(i)}? Confirm or cancel.`);
return; return;
} }
await send(selected, i); await send(selected, i);
@@ -202,6 +254,7 @@
/** A game against a drake can be started over at any time: a fresh table, the same side and setup. */ /** A game against a drake can be started over at any time: a fresh table, the same side and setup. */
const drake = $derived(!room.spectating && room.view.seats.some((s) => s.bot && !s.standIn)); const drake = $derived(!room.spectating && room.view.seats.some((s) => s.bot && !s.standIn));
const foes = $derived(room.others.map((s) => s.name).join(' and '));
let starting = $state(false); let starting = $state(false);
let fault = $state(''); let fault = $state('');
async function again() { async function again() {
@@ -216,6 +269,18 @@
starting = false; starting = false;
} }
} }
/** A rematch keeps this table's company: a new table, with a seat held for each of them. */
async function rematch() {
starting = true;
fault = '';
try {
await goto(`/join/${await room.rematch()}`);
} catch (e) {
fault = e instanceof Error ? e.message : 'The rematch could not be called.';
} finally {
starting = false;
}
}
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length; const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length;
</script> </script>
@@ -223,7 +288,11 @@
<main class="board"> <main class="board">
<section class="play"> <section class="play">
<div class="status" class:yours={yourMove}> <div class="status" class:yours={yourMove}>
{#if g.over} {#if pending}
<strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong>
<button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button>
<button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button>
{:else if g.over}
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span> <strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
{:else if room.spectating} {:else if room.spectating}
<strong>{nameOfSide(g.toMove)} to move</strong> · {SIDE_NAMES[g.toMove]}{#if inCheckNow}&nbsp;· <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span> <strong>{nameOfSide(g.toMove)} to move</strong> · {SIDE_NAMES[g.toMove]}{#if inCheckNow}&nbsp;· <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span>
@@ -234,16 +303,32 @@
{:else} {:else}
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <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}&nbsp;· <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
{/if} {/if}
</div> {#if reviewing !== null && !pending}
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)} <span class="marked">Move {reviewing} is lit on the boards as they stand now; no piece has moved back. <button type="button" class="link" onclick={() => review(null)}>Clear it</button></span>
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null}&nbsp;<button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</p>
{/if} {/if}
{#if pending} </div>
<p class="confirm">Move {squareName(pending.from)} to {squareName(pending.to)}? {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
<button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button> <p class="story" class:capture={shownCaptured.length > 0}>{shownStory}</p>
<button type="button" class="quiet" onclick={() => (pending = null)}>Cancel</button></p>
{/if} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
{#if g.over}
<div class="verdict">
{#if room.spectating}
<a class="commit" href="/">Back to the hall</a>
{:else if room.view.rematch}
<p>{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}. <a href={`/join/${room.view.rematch.to}`}>Take your seat</a></p>
{:else if drake}
<button type="button" class="commit" disabled={starting} onclick={again}>Play a fresh drake</button>
<button type="button" class="quiet" disabled={starting} onclick={rematch}>Rematch {foes}</button>
<a class="quiet" href="/">Back to the hall</a>
<p class="muted small">A fresh drake is a new table at once, your side and setup kept. A rematch seats this table's company at a new one, and waits for everyone to begin.</p>
{:else}
<button type="button" class="commit" disabled={starting} onclick={rematch}>Rematch with this table</button>
<a class="quiet" href="/">Back to the hall</a>
<p class="muted small">The same company sits down at a new table, with a seat held for everyone here.</p>
{/if}
</div>
{/if}
<p class="muted small glass-way"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over}&nbsp;· <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p> <p class="muted small glass-way"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over}&nbsp;· <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
{#if fault}<p class="warning">{fault}</p>{/if} {#if fault}<p class="warning">{fault}</p>{/if}
@@ -256,11 +341,7 @@
{/if} {/if}
<p class="visually-hidden" aria-live="polite">{liveText}</p> <p class="visually-hidden" aria-live="polite">{liveText}</p>
{#if g.over} {#if yourMove}
<div class="verdict">
{#if drake}<button type="button" class="commit" disabled={starting} onclick={again}>Play the drake again</button> <a class="quiet" href="/">Back to the hall</a>{:else}<a class="commit" href="/">Back to the hall</a>{/if}
</div>
{:else if yourMove}
<p class="muted small how">Tap a piece, then a square on any board. Tap any piece, yours or not, to read what it is and what it can do. With a keyboard, the arrows move about a board, Page Up and Page Down change boards, Enter picks up and sets down, and Escape cancels.</p> <p class="muted small how">Tap a piece, then a square on any board. Tap any piece, yours or not, to read what it is and what it can do. With a keyboard, the arrows move about a board, Page Up and Page Down change boards, Enter picks up and sets down, and Escape cancels.</p>
{/if} {/if}
</section> </section>
@@ -284,7 +365,7 @@
</ul> </ul>
<p class="muted small">Move {g.moves.length + 1}.</p> <p class="muted small">Move {g.moves.length + 1}.</p>
</section> </section>
<Chronicle {tellings} {mySide} {reviewing} onReview={(ply) => (reviewing = ply)} /> <Chronicle {tellings} {mySide} {reviewing} onReview={review} />
<TableTalk {room} /> <TableTalk {room} />
</aside> </aside>
</main> </main>
@@ -335,6 +416,18 @@
color: var(--bone); color: var(--bone);
} }
.status .commit,
.status .quiet {
margin-left: 0.3rem;
vertical-align: baseline;
}
.marked {
display: block;
margin-top: 0.2rem;
color: var(--frost);
}
.check { .check {
color: var(--ember); color: var(--ember);
font-weight: 600; font-weight: 600;
@@ -356,15 +449,6 @@
margin: 0 auto 0.5rem; margin: 0 auto 0.5rem;
} }
.confirm {
max-width: 640px;
margin: 0 auto 0.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.frame { .frame {
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
@@ -382,12 +466,17 @@
.verdict { .verdict {
max-width: 640px; max-width: 640px;
margin: 0.8rem auto 0; margin: 0 auto 0.8rem;
} }
.verdict .commit, .verdict .commit,
.verdict .quiet { .verdict .quiet {
display: inline-block; display: inline-block;
margin: 0 0.3rem 0.4rem 0;
}
.verdict p {
margin: 0;
} }
.panel { .panel {
+5 -1
View File
@@ -43,6 +43,8 @@
} = $props(); } = $props();
let svg: SVGSVGElement | undefined = $state(); let svg: SVGSVGElement | undefined = $state();
/** 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);
/** 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. */
function key(e: KeyboardEvent, i: number) { function key(e: KeyboardEvent, i: number) {
const step: Record<string, [number, number, number]> = { const step: Record<string, [number, number, number]> = {
@@ -86,6 +88,7 @@
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 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) => {
@@ -135,7 +138,8 @@
role={decorative ? undefined : 'gridcell'} role={decorative ? undefined : 'gridcell'}
aria-label={decorative ? undefined : describe(i)} aria-label={decorative ? undefined : describe(i)}
aria-selected={decorative ? undefined : selected === i} aria-selected={decorative ? undefined : selected === i}
tabindex={decorative ? undefined : 0} tabindex={decorative ? undefined : entry === i ? 0 : -1}
onfocus={decorative ? undefined : () => (visited = i)}
onclick={decorative ? undefined : () => tap(i)} onclick={decorative ? undefined : () => tap(i)}
onkeydown={decorative ? undefined : (e) => key(e, i)} onkeydown={decorative ? undefined : (e) => key(e, i)}
/> />
+1 -1
View File
@@ -25,7 +25,7 @@
{#each tellings as t (t.ply)} {#each tellings as t (t.ply)}
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}> <article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
<h3> <h3>
<button type="button" class="link plain" title="Show this move on the boards" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}> <button type="button" class="link plain" title="Light this move's squares on the boards as they stand" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
Move {t.ply} <span class="notation">{notation(t.move)}</span> Move {t.ply} <span class="notation">{notation(t.move)}</span>
</button> </button>
</h3> </h3>
+51 -13
View File
@@ -1,3 +1,8 @@
<script module lang="ts">
/** Where the camera was left, so a spell on the flat boards and back finds the view as it was. */
let kept: { view: 'quarter' | 'seat' | 'above'; sheet: 1 | 2 | 3; side: string | null; position: number[]; target: number[]; zoom: number } | null = null;
</script>
<script lang="ts"> <script lang="ts">
// The glass board: the three boards as sheets of tinted glass one above // The glass board: the three boards as sheets of tinted glass one above
// the other on four posts, the pieces as figures standing on them, lit // the other on four posts, the pieces as figures standing on them, lit
@@ -53,9 +58,12 @@
let status = $state('Setting out the figures…'); let status = $state('Setting out the figures…');
/** How many of the fifteen kinds of figure have arrived. */ /** How many of the fifteen kinds of figure have arrived. */
let arrived = $state(0); let arrived = $state(0);
let view = $state<'quarter' | 'seat' | 'above'>('quarter'); /** The view left last time, if it was from the same side of the table: the side at mounting is the one that matters. */
// svelte-ignore state_referenced_locally
let back = kept && kept.side === mySide ? kept : null;
let view = $state<'quarter' | 'seat' | 'above'>(back?.view ?? 'quarter');
/** Seen from above, the sheets stack; this is the topmost one shown, the ones above it set aside. */ /** Seen from above, the sheets stack; this is the topmost one shown, the ones above it set aside. */
let sheet = $state<1 | 2 | 3>(3); let sheet = $state<1 | 2 | 3>(back?.sheet ?? 3);
/** Bumped to put the camera back where the view begins. */ /** Bumped to put the camera back where the view begins. */
let reset = $state(0); let reset = $state(0);
/** Whether a level is set aside in the view from above. */ /** Whether a level is set aside in the view from above. */
@@ -318,10 +326,13 @@
}); });
// The camera: three ways to look, and the seat flips for Scarlet. // The camera: three ways to look, and the seat flips for Scarlet.
// On a narrow screen the camera starts nearer, so the squares are big enough for a finger; the frame turning wide again seats it afresh.
const nearness = () => (frame.clientWidth < 560 ? 0.7 : 1);
let nearUsed = 0;
function look(which: typeof view) { function look(which: typeof view) {
const s = mySide === 'scarlet' ? -1 : 1; const s = mySide === 'scarlet' ? -1 : 1;
// On a narrow screen the camera starts nearer, so the squares are big enough for a finger. const near = nearness();
const near = frame.clientWidth < 560 ? 0.7 : 1; nearUsed = near;
controls.dispose(); controls.dispose();
if (which === 'above') { if (which === 'above') {
camera = above; camera = above;
@@ -331,7 +342,7 @@
} else { } else {
camera = perspective; camera = perspective;
if (which === 'quarter') perspective.position.set(16 * s * near, 17 * near, 19 * s * near); if (which === 'quarter') perspective.position.set(16 * s * near, 17 * near, 19 * s * near);
else perspective.position.set(0, 22 * near, 19 * s * near); else perspective.position.set(0, 23 * near, 21.5 * s * near);
perspective.up.set(0, 1, 0); perspective.up.set(0, 1, 0);
} }
controls = new OrbitControls(camera, canvas); controls = new OrbitControls(camera, canvas);
@@ -344,6 +355,13 @@
controls.touches = { ONE: -1 as THREE.TOUCH, TWO: THREE.TOUCH.DOLLY_ROTATE }; controls.touches = { ONE: -1 as THREE.TOUCH, TWO: THREE.TOUCH.DOLLY_ROTATE };
canvas.style.touchAction = 'pan-y'; canvas.style.touchAction = 'pan-y';
controls.addEventListener('change', wake); controls.addEventListener('change', wake);
if (back) {
camera.position.fromArray(back.position);
controls.target.fromArray(back.target);
above.zoom = back.zoom;
above.updateProjectionMatrix();
back = null;
}
controls.update(); controls.update();
shade(); shade();
} }
@@ -377,9 +395,9 @@
} }
function resize() { function resize() {
// A narrow frame gets a taller canvas, and both cameras open wide enough to take in the sheets' full width. // A narrow frame gets a taller canvas, but never taller than the window less the status strip above and the ways beneath, so a phone on its side still sees the whole table. Both cameras open wide enough to take in the sheets' full width.
const w = frame.clientWidth; const w = frame.clientWidth;
const h = Math.round(w * (w < 560 ? 1.1 : 0.75)); const h = Math.min(Math.round(w * (w < 560 ? 1.1 : 0.75)), Math.max(220, innerHeight - 120));
const aspect = w / h; const aspect = w / h;
renderer.setSize(w, h, false); renderer.setSize(w, h, false);
perspective.aspect = aspect; perspective.aspect = aspect;
@@ -391,13 +409,14 @@
above.top = half; above.top = half;
above.bottom = -half; above.bottom = -half;
above.updateProjectionMatrix(); above.updateProjectionMatrix();
if (nearUsed && nearness() !== nearUsed) look(view);
wake(); wake();
} }
const watcher = new ResizeObserver(resize); const watcher = new ResizeObserver(resize);
watcher.observe(frame); watcher.observe(frame);
addEventListener('resize', resize);
resize(); resize();
look(view); look(view);
controls.addEventListener('change', wake);
// Each kind's figures take their places as its file arrives, so the board fills in while the rest are fetched. // Each kind's figures take their places as its file arrives, so the board fills in while the rest are fetched.
Promise.all( Promise.all(
@@ -436,9 +455,12 @@
if (checkSquare >= 0 && checkSquare !== selected) mark(checkSquare, 'ring', MARK.check); if (checkSquare >= 0 && checkSquare !== selected) mark(checkSquare, 'ring', MARK.check);
wake(); wake();
}); });
let looked = false;
$effect(() => { $effect(() => {
void reset; void reset;
look(view); void view;
if (looked) look(view);
looked = true;
}); });
$effect(() => { $effect(() => {
void sheet; void sheet;
@@ -446,6 +468,8 @@
}); });
return () => { return () => {
kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom };
removeEventListener('resize', resize);
watcher.disconnect(); watcher.disconnect();
controls.dispose(); controls.dispose();
renderer.dispose(); renderer.dispose();
@@ -454,6 +478,7 @@
</script> </script>
<div class="glass" bind:this={frame}> <div class="glass" bind:this={frame}>
<div class="stage">
<canvas bind:this={canvas} aria-label="The three boards as sheets of glass with the pieces standing on them. The flat boards beside this view are the place to play by keyboard."></canvas> <canvas bind:this={canvas} aria-label="The three boards as sheets of glass with the pieces standing on them. The flat boards beside this view are the place to play by keyboard."></canvas>
{#if status} {#if status}
<div class="status" role="status"> <div class="status" role="status">
@@ -461,6 +486,7 @@
{#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if} {#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if}
</div> </div>
{/if} {/if}
</div>
<p class="ways muted small"> <p class="ways muted small">
{#each [['quarter', 'From the corner'], ['seat', 'From your seat'], ['above', 'From above']] as [which, label], k (which)} {#each [['quarter', 'From the corner'], ['seat', 'From your seat'], ['above', 'From above']] as [which, label], k (which)}
{#if k > 0}<span class="sep">·</span>{/if} {#if k > 0}<span class="sep">·</span>{/if}
@@ -475,17 +501,30 @@
{/each} {/each}
</span> </span>
{/if} {/if}
<span class="hint">Drag to turn, or two fingers on a touchscreen. Ctrl or ⌘ with the wheel, or a pinch, draws near. Tap the view you are in to put it back.</span>
</p> </p>
<p class="hint muted small">Drag to turn, or two fingers on a touchscreen. Ctrl or ⌘ with the wheel, or a pinch, draws near. Tap the view you are in to put it back.</p>
</div> </div>
<style> <style>
.glass { .glass {
position: relative; display: flex;
flex-direction: column;
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
} }
.stage {
position: relative;
}
/* On a phone the piece's sheet rises over the foot of the screen, so the ways to look sit above the canvas, out from under it. */
@media (max-width: 860px) {
.ways {
order: -1;
margin: 0 0 0.4rem;
}
}
canvas { canvas {
display: block; display: block;
width: 100%; width: 100%;
@@ -531,7 +570,6 @@
} }
.hint { .hint {
display: block; margin: 0.2rem 0 0;
margin-top: 0.2rem;
} }
</style> </style>
+12 -26
View File
@@ -22,7 +22,6 @@
let prefsOpen = $state(false); let prefsOpen = $state(false);
/** A phrase minted to carry this seat to another device, while it lasts. */ /** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false);
let seatsOpen = $state(false); let seatsOpen = $state(false);
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */ /** Why this browser's seat no longer opens, when the server said so; shown while watching. */
let seatLost = $state(''); let seatLost = $state('');
@@ -154,18 +153,6 @@
} }
} }
/** Call for a rematch, or take the seat held at one already called. */
async function rematch() {
if (!room) return;
rematching = true;
try {
await goto(`/join/${await room.rematch()}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The rematch could not be called.';
} finally {
rematching = false;
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -227,18 +214,6 @@
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p> <p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
{/if} {/if}
{#if room?.view.over && !watching}
<p class="notice muted">
{#if room.view.rematch}
{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}.
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
{:else}
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch with this table</button>
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
{/if}
</p>
{/if}
{#if joining && room?.seatEmpty} {#if joining && room?.seatEmpty}
<section class="seat"> <section class="seat">
<p class="eyebrow">room {roomId}</p> <p class="eyebrow">room {roomId}</p>
@@ -310,11 +285,18 @@
color: var(--bone); color: var(--bone);
} }
.masthead > div {
flex: 1 1 16em;
min-width: 0;
}
/* The buttons wrap onto a second row before they push the title's column aside or run off the page. */
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0; flex: 0 1 auto;
} }
/* On a phone the masthead keeps the title, who is playing and the room code; the rest waits behind one button. */ /* On a phone the masthead keeps the title, who is playing and the room code; the rest waits behind one button. */
@@ -425,6 +407,10 @@
padding: 0.6rem var(--gutter) 0.4rem; padding: 0.6rem var(--gutter) 0.4rem;
} }
.masthead > div {
flex: 0 0 auto;
}
.standfirst { .standfirst {
margin-top: 0.15rem; margin-top: 0.15rem;
} }