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 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<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) {
|
||||
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;
|
||||
</script>
|
||||
@@ -223,7 +288,11 @@
|
||||
<main class="board">
|
||||
<section class="play">
|
||||
<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>
|
||||
{: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>
|
||||
@@ -234,16 +303,32 @@
|
||||
{: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}
|
||||
{/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>
|
||||
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)}
|
||||
<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>
|
||||
{/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 shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
|
||||
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}</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>
|
||||
{#if fault}<p class="warning">{fault}</p>{/if}
|
||||
@@ -256,11 +341,7 @@
|
||||
{/if}
|
||||
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
||||
|
||||
{#if g.over}
|
||||
<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}
|
||||
{#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>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -284,7 +365,7 @@
|
||||
</ul>
|
||||
<p class="muted small">Move {g.moves.length + 1}.</p>
|
||||
</section>
|
||||
<Chronicle {tellings} {mySide} {reviewing} onReview={(ply) => (reviewing = ply)} />
|
||||
<Chronicle {tellings} {mySide} {reviewing} onReview={review} />
|
||||
<TableTalk {room} />
|
||||
</aside>
|
||||
</main>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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. */
|
||||
function key(e: KeyboardEvent, i: 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 };
|
||||
}
|
||||
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)}
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{#each tellings as t (t.ply)}
|
||||
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
|
||||
<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>
|
||||
</button>
|
||||
</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">
|
||||
// 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
|
||||
@@ -53,9 +58,12 @@
|
||||
let status = $state('Setting out the figures…');
|
||||
/** How many of the fifteen kinds of figure have arrived. */
|
||||
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. */
|
||||
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. */
|
||||
let reset = $state(0);
|
||||
/** 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.
|
||||
// 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) {
|
||||
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 = frame.clientWidth < 560 ? 0.7 : 1;
|
||||
const near = nearness();
|
||||
nearUsed = near;
|
||||
controls.dispose();
|
||||
if (which === 'above') {
|
||||
camera = above;
|
||||
@@ -331,7 +342,7 @@
|
||||
} else {
|
||||
camera = perspective;
|
||||
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);
|
||||
}
|
||||
controls = new OrbitControls(camera, canvas);
|
||||
@@ -344,6 +355,13 @@
|
||||
controls.touches = { ONE: -1 as THREE.TOUCH, TWO: THREE.TOUCH.DOLLY_ROTATE };
|
||||
canvas.style.touchAction = 'pan-y';
|
||||
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();
|
||||
shade();
|
||||
}
|
||||
@@ -377,9 +395,9 @@
|
||||
}
|
||||
|
||||
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 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;
|
||||
renderer.setSize(w, h, false);
|
||||
perspective.aspect = aspect;
|
||||
@@ -391,13 +409,14 @@
|
||||
above.top = half;
|
||||
above.bottom = -half;
|
||||
above.updateProjectionMatrix();
|
||||
if (nearUsed && nearness() !== nearUsed) look(view);
|
||||
wake();
|
||||
}
|
||||
const watcher = new ResizeObserver(resize);
|
||||
watcher.observe(frame);
|
||||
addEventListener('resize', resize);
|
||||
resize();
|
||||
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.
|
||||
Promise.all(
|
||||
@@ -436,9 +455,12 @@
|
||||
if (checkSquare >= 0 && checkSquare !== selected) mark(checkSquare, 'ring', MARK.check);
|
||||
wake();
|
||||
});
|
||||
let looked = false;
|
||||
$effect(() => {
|
||||
void reset;
|
||||
look(view);
|
||||
void view;
|
||||
if (looked) look(view);
|
||||
looked = true;
|
||||
});
|
||||
$effect(() => {
|
||||
void sheet;
|
||||
@@ -446,6 +468,8 @@
|
||||
});
|
||||
|
||||
return () => {
|
||||
kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom };
|
||||
removeEventListener('resize', resize);
|
||||
watcher.disconnect();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
@@ -454,13 +478,15 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
{#if status}
|
||||
<div class="status" role="status">
|
||||
<p>{status}</p>
|
||||
{#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
{#if status}
|
||||
<div class="status" role="status">
|
||||
<p>{status}</p>
|
||||
{#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="ways muted small">
|
||||
{#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}
|
||||
@@ -475,17 +501,30 @@
|
||||
{/each}
|
||||
</span>
|
||||
{/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 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>
|
||||
|
||||
<style>
|
||||
.glass {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 960px;
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -531,7 +570,6 @@
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
{/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}
|
||||
<section class="seat">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user