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:
1 parent
b88c57f350
commit
13fc4c5ef3
5 files changed
+194
-77
No files matched your search
+118
-29
@@ -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} · <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} · <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} · <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 reviewing !== null && !pending}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)}
|
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
|
||||||
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null} <button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</p>
|
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}</p>
|
||||||
{/if}
|
|
||||||
{#if pending}
|
|
||||||
<p class="confirm">Move {squareName(pending.from)} to {squareName(pending.to)}?
|
|
||||||
<button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button>
|
|
||||||
<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} · <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} · <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 {
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,13 +478,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="glass" bind:this={frame}>
|
<div class="glass" bind:this={frame}>
|
||||||
<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>
|
<div class="stage">
|
||||||
{#if status}
|
<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>
|
||||||
<div class="status" role="status">
|
{#if status}
|
||||||
<p>{status}</p>
|
<div class="status" role="status">
|
||||||
{#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if}
|
<p>{status}</p>
|
||||||
</div>
|
{#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
{/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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user