diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index a74e8bf..0a98c19 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -9,11 +9,12 @@ import { narrate } from '$lib/game/chronicle'; import BoardArtwork, { type Token } from './BoardArtwork.svelte'; 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 } from '$lib/game'; import { Room } from '$lib/net/room.svelte'; import { goto } from '$app/navigation'; + import { tick } from 'svelte'; import { prefs, reducedMotion } from '$lib/prefs.svelte'; let { room }: { room: Room } = $props(); @@ -109,13 +110,31 @@ const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, 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. */ const tellings = $derived(narrate(g)); function storyOf(ply: number): string { const t = tellings[ply - 1]; 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. */ const lastLine = $derived.by(() => { @@ -124,7 +143,14 @@ const who = nameOfSide(m.by); 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'; - 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 --- @@ -144,12 +170,38 @@ 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 confirmButton = $state(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) { + const asked = pending !== null; pending = null; selected = null; + if (asked) void answered(); await room.submit({ from, to }); } @@ -167,7 +219,7 @@ if (selected !== null && targets.includes(i)) { if (prefs.confirmMoves === true) { 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; } 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. */ 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 fault = $state(''); async function again() { @@ -216,6 +269,18 @@ 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; @@ -223,7 +288,11 @@
- {#if g.over} + {#if pending} + Move {squareName(pending.from)} to {squareName(pending.to)}? + + + {:else if g.over} {verdict} {g.over.reason} {:else if room.spectating} {nameOfSide(g.toMove)} to move · {SIDE_NAMES[g.toMove]}{#if inCheckNow} · in check{/if} · move {g.moves.length + 1} @@ -234,16 +303,32 @@ {:else} {nameOfSide(g.toMove)} is thinking · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · {nameOfSide(g.toMove)} is in check{/if} {/if} + {#if reviewing !== null && !pending} + Move {reviewing} is lit on the boards as they stand now; no piece has moved back. + {/if}
- {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)} -

0}>{shownStory}{#if reviewing !== null} {/if}

- {/if} - {#if pending} -

Move {squareName(pending.from)} to {squareName(pending.to)}? - -

+ {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)} +

0}>{shownStory}

{/if} {#if room.error}

{room.error}

{/if} + {#if g.over} +
+ {#if room.spectating} + Back to the hall + {:else if room.view.rematch} +

{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}. Take your seat

+ {:else if drake} + + + Back to the hall +

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.

+ {:else} + + Back to the hall +

The same company sits down at a new table, with a seat held for everyone here.

+ {/if} +
+ {/if}

{#if drake && !g.over} · {/if}

{#if fault}

{fault}

{/if} @@ -256,11 +341,7 @@ {/if}

{liveText}

- {#if g.over} -
- {#if drake} Back to the hall{:else}Back to the hall{/if} -
- {:else if yourMove} + {#if yourMove}

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.

{/if}
@@ -284,7 +365,7 @@

Move {g.moves.length + 1}.

- (reviewing = ply)} /> +
@@ -335,6 +416,18 @@ 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 { color: var(--ember); font-weight: 600; @@ -356,15 +449,6 @@ 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 { max-width: 640px; margin: 0 auto; @@ -382,12 +466,17 @@ .verdict { max-width: 640px; - margin: 0.8rem auto 0; + margin: 0 auto 0.8rem; } .verdict .commit, .verdict .quiet { display: inline-block; + margin: 0 0.3rem 0.4rem 0; + } + + .verdict p { + margin: 0; } .panel { diff --git a/src/lib/components/BoardArtwork.svelte b/src/lib/components/BoardArtwork.svelte index 35e6104..0c33384 100644 --- a/src/lib/components/BoardArtwork.svelte +++ b/src/lib/components/BoardArtwork.svelte @@ -43,6 +43,8 @@ } = $props(); 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(null); /** 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) { const step: Record = { @@ -86,6 +88,7 @@ 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 fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n]; const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n); const describe = (i: number) => { @@ -135,7 +138,8 @@ role={decorative ? undefined : 'gridcell'} aria-label={decorative ? undefined : describe(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)} onkeydown={decorative ? undefined : (e) => key(e, i)} /> diff --git a/src/lib/components/Chronicle.svelte b/src/lib/components/Chronicle.svelte index 4960deb..514c4f1 100644 --- a/src/lib/components/Chronicle.svelte +++ b/src/lib/components/Chronicle.svelte @@ -25,7 +25,7 @@ {#each tellings as t (t.ply)}

-

diff --git a/src/lib/components/Glass.svelte b/src/lib/components/Glass.svelte index 546e7d6..b1cb7ee 100644 --- a/src/lib/components/Glass.svelte +++ b/src/lib/components/Glass.svelte @@ -1,3 +1,8 @@ + +
- - {#if status} -
-

{status}

- {#if !ready}{arrived} of {Object.keys(FIGURE).length}{/if} -
- {/if} +
+ + {#if status} +
+

{status}

+ {#if !ready}{arrived} of {Object.keys(FIGURE).length}{/if} +
+ {/if} +

{#each [['quarter', 'From the corner'], ['seat', 'From your seat'], ['above', 'From above']] as [which, label], k (which)} {#if k > 0}·{/if} @@ -475,17 +501,30 @@ {/each} {/if} - 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.

+

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.

diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index 71bd7fb..3133873 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -22,7 +22,6 @@ let prefsOpen = $state(false); /** A phrase minted to carry this seat to another device, while it lasts. */ let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); - let rematching = $state(false); let seatsOpen = $state(false); /** Why this browser's seat no longer opens, when the server said so; shown while watching. */ 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; - } - } @@ -227,18 +214,6 @@

On the other device, open the hall and claim this phrase within ten minutes: {phrase.phrase}

{/if} -{#if room?.view.over && !watching} -

- {#if room.view.rematch} - {room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}. - Take your seat - {:else} - - The same company sits down at a new table, with a seat held for everyone here. - {/if} -

-{/if} - {#if joining && room?.seatEmpty}

room {roomId}

@@ -310,11 +285,18 @@ 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 { display: flex; flex-wrap: wrap; + justify-content: flex-end; 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. */ @@ -425,6 +407,10 @@ padding: 0.6rem var(--gutter) 0.4rem; } + .masthead > div { + flex: 0 0 auto; + } + .standfirst { margin-top: 0.15rem; }