Coordinates on the board, moves in the record shown on the board, a status strip above it with the capture in words, notes under the variant selector and a still that follows it, keyboard play, and a way back to the game from the rules

From a tester's review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 18:48:53 -04:00
co-authored by Claude Fable 5.1
parent bac7182915
commit 5db89edbc1
7 changed files with 329 additions and 107 deletions
+235 -75
View File
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
// The board: a grid of squares, the pieces on them, and two taps to // The board: a grid of squares with coordinates on its edges, the pieces
// move. Copenhagen is fresh oak and clean stone; Tablut, the older game, // on them as tokens that slide and fade, and two taps or a keyboard to
// is grey, bleached and a little uneven, by the tokens under data-set // move. Above it, a strip says whose move it is and what the side is
// and a heavier grain and warp in the filters. // for; beside it, the record of moves, each one a click from being shown
// on the board. Copenhagen is fresh oak and clean stone; Tablut, the
// older game, is grey, bleached and a little uneven, by the tokens under
// data-set and a heavier grain and warp in the filters.
import TableTalk from './TableTalk.svelte'; import TableTalk from './TableTalk.svelte';
import { cellOf, cornersOf, isEdge, movesFrom, rulesetOf, sideOf, throneOf, type Side } from '$lib/game'; import { beyond, cellOf, cornersOf, isEdge, movesFrom, rulesetOf, sideOf, throneOf, type Move, type Side } from '$lib/game';
import type { Room } from '$lib/net/room.svelte'; import type { Room } from '$lib/net/room.svelte';
let { room }: { room: Room } = $props(); let { room }: { room: Room } = $props();
@@ -23,14 +26,28 @@
}; };
/** Linnaeus called the sides the Swedes and the Muscovites; Copenhagen calls them attackers and defenders. */ /** Linnaeus called the sides the Swedes and the Muscovites; Copenhagen calls them attackers and defenders. */
const sideWord = (side: Side) => (worn ? (side === 'attackers' ? 'the Muscovites' : 'the Swedes') : side === 'attackers' ? 'the attackers' : 'the defenders'); const sideWord = (side: Side) => (worn ? (side === 'attackers' ? 'the Muscovites' : 'the Swedes') : side === 'attackers' ? 'the attackers' : 'the defenders');
const sideName = (side: Side) => {
const w = sideWord(side);
return w.charAt(0).toUpperCase() + w.slice(1);
};
const objective = (side: Side) => (side === 'attackers' ? 'Take the king.' : set.escape === 'corner' ? 'Get the crowned king to a corner.' : 'Get the crowned king to any edge square.');
const sideOfPly = (ply: number): Side => (ply % 2 === 1 ? 'attackers' : 'defenders');
let selected = $state<number | null>(null); // --- Coordinates ---------------------------------------------------------
const targets = $derived(selected === null ? [] : movesFrom(g, selected)); const CELL = 40;
const PAD_L = 16;
const PAD_T = 6;
const PAD_R = 6;
const PAD_B = 16;
const width = $derived(PAD_L + size * CELL + PAD_R);
const height = $derived(PAD_T + size * CELL + PAD_B);
const label = (i: number) => {
const c = cellOf(size, i);
return `${String.fromCharCode(97 + c.x)}${size - c.y}`;
};
const pieceWord = (p: string) => (p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender');
// Pieces are shown as tokens with a life of their own, so a move slides // --- Tokens: pieces with a life of their own, so a move slides and a capture fades ---
// and a capture fades, one move at a time. The server's state may be
// two moves ahead (yours and the bot's answer); the tokens catch up
// move by move, with a beat between, so an exchange can be watched.
interface Token { interface Token {
id: number; id: number;
piece: string; piece: string;
@@ -70,6 +87,7 @@
tokens = tokens.filter((t) => !t.dying); tokens = tokens.filter((t) => !t.dying);
} }
shown += 1; shown += 1;
announce(storyOf(shown));
if (shown < g.moves.length) await wait(260); if (shown < g.moves.length) await wait(260);
} }
} finally { } finally {
@@ -83,37 +101,97 @@
else if (total > shown) void catchUp(); else if (total > shown) void catchUp();
}); });
/** The move the tokens have just played, for the shaded squares. */ // --- What the board shows: the last move played, or a move picked from the record ---
const shownLast = $derived(shown > 0 ? g.moves[shown - 1] : null); let reviewing = $state<number | null>(null);
$effect(() => {
void g.moves.length;
reviewing = null;
selected = null;
});
const shownPly = $derived(reviewing ?? shown);
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
const shownSquares = $derived(shownLast ? [shownLast.from, shownLast.to] : []); const shownSquares = $derived(shownLast ? [shownLast.from, shownLast.to] : []);
const shownCaptured = $derived(shownLast?.captured ?? []); const shownCaptured = $derived(shownLast?.captured ?? []);
// A new position clears any half-made move. /** A move in words, and each capture as a trap: who took what, between which squares. */
$effect(() => { function storyOf(ply: number): string {
void g.moves.length; const m = g.moves[ply - 1];
selected = null; if (!m) return '';
}); const side = sideOfPly(ply);
const yours = !!mySide && mySide === side;
const who = yours ? 'You' : nameOfSide(side);
const moverPiece = ply === g.moves.length ? (g.cells[m.to] ?? '.') : '.';
const moverWord = moverPiece !== '.' ? pieceWord(moverPiece) : side === 'attackers' ? 'an attacker' : 'a piece';
let text = `${who}: ${moverWord} from ${label(m.from)} to ${label(m.to)}.`;
for (const c of m.captured) {
const anvil = beyond(size, m.to, c);
const kingFell = side === 'attackers' && !g.cells.includes('k') && ply === g.moves.length;
const victim = kingFell ? 'the king' : side === 'attackers' ? (yours ? 'a defender' : 'your defender') : yours ? 'an attacker' : 'your attacker';
const against =
anvil === -1
? 'the edge'
: corners.includes(anvil)
? `the corner at ${label(anvil)}`
: anvil === throne && g.cells[anvil] === '.'
? `the empty throne at ${label(anvil)}`
: `${label(anvil)}`;
text += ` ${victim.charAt(0).toUpperCase() + victim.slice(1)} on ${label(c)} was trapped between ${label(m.to)} and ${against}.`;
}
return text;
}
const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : '');
// --- Choosing and moving ---------------------------------------------------
let selected = $state<number | null>(null);
const targets = $derived(selected === null ? [] : movesFrom(g, selected));
let liveText = $state('');
function announce(text: string) {
liveText = text;
}
async function tap(i: number) { async function tap(i: number) {
if (!yourMove || room.sending || animating) return; if (!yourMove || room.sending || animating) return;
reviewing = null;
if (selected !== null && targets.includes(i)) { if (selected !== null && targets.includes(i)) {
const from = selected; const from = selected;
selected = null; selected = null;
await room.submit({ from, to: i }); await room.submit({ from, to: i });
return; return;
} }
if (sideOf(g.cells[i]) === mySide) selected = selected === i ? null : i; if (sideOf(g.cells[i]) === mySide) {
else selected = null; selected = selected === i ? null : i;
if (selected !== null) {
const to = movesFrom(g, i);
announce(`Selected ${pieceWord(g.cells[i])} on ${label(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(label).join(', ') : ''}.`);
} else announce('Selection cleared.');
} else selected = null;
} }
const CELL = 40; function focusSquare(i: number) {
const PAD = 6; document.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
const span = $derived(size * CELL + PAD * 2); }
const label = (i: number) => {
function key(e: KeyboardEvent, i: number) {
const c = cellOf(size, i); const c = cellOf(size, i);
return `${String.fromCharCode(97 + c.x)}${size - c.y}`; const step: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
}; if (e.key in step) {
const pieceWord = (p: string) => (p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender'); e.preventDefault();
const [dx, dy] = step[e.key];
const x = c.x + dx;
const y = c.y + dy;
if (x >= 0 && y >= 0 && x < size && y < size) focusSquare(y * size + x);
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void tap(i);
} else if (e.key === 'Escape') {
selected = null;
reviewing = null;
announce('Selection cleared.');
}
}
const verdict = $derived.by(() => { const verdict = $derived.by(() => {
if (!g.over) return ''; if (!g.over) return '';
if (g.over.winner === null) return 'A draw.'; if (g.over.winner === null) return 'A draw.';
@@ -123,8 +201,24 @@
<main class="board" data-set={set.id}> <main class="board" data-set={set.id}>
<section class="play"> <section class="play">
<div class="status" class:yours={yourMove}>
{#if g.over}
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
{:else if room.spectating}
<strong>{nameOfSide(g.toMove)} to move</strong> · {sideName(g.toMove)} · <span class="muted">turn {g.moves.length + 1}</span>
{:else if yourMove}
<strong>Your move</strong> · {sideName(mySide!)} · <span class="goal">{objective(mySide!)}</span>
{:else}
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {sideWord(mySide!)} · <span class="goal">{objective(mySide!)}</span>
{/if}
</div>
{#if shownStory && (shownCaptured.length || reviewing !== null)}
<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 room.error}<p class="warning">{room.error}</p>{/if}
<div class="frame"> <div class="frame">
<svg viewBox="0 0 {span} {span}" role="grid" aria-label="The board"> <svg viewBox="0 0 {width} {height}" role="grid" aria-label="The board">
<defs> <defs>
<filter id="grain" x="0" y="0" width="100%" height="100%"> <filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" result="noise" /> <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" result="noise" />
@@ -137,12 +231,16 @@
<feDisplacementMap in="SourceGraphic" in2="warp" scale={worn ? 2.4 : 0} xChannelSelector="R" yChannelSelector="G" /> <feDisplacementMap in="SourceGraphic" in2="warp" scale={worn ? 2.4 : 0} xChannelSelector="R" yChannelSelector="G" />
</filter> </filter>
</defs> </defs>
<rect class="wood" x="0" y="0" width={span} height={span} rx="6" filter="url(#grain)" /> <rect class="wood" x="0" y="0" {width} {height} rx="6" filter="url(#grain)" />
{#each Array.from({ length: size }, (_, n) => n) as n (n)}
<text class="coord" x={PAD_L + n * CELL + CELL / 2} y={height - 4} text-anchor="middle">{String.fromCharCode(97 + n)}</text>
<text class="coord" x={PAD_L - 4} y={PAD_T + n * CELL + CELL / 2 + 3} text-anchor="end">{size - n}</text>
{/each}
<g filter="url(#worn)"> <g filter="url(#worn)">
{#each g.cells as piece, i (i)} {#each g.cells as piece, i (i)}
{@const c = cellOf(size, i)} {@const c = cellOf(size, i)}
{@const x = PAD + c.x * CELL} {@const x = PAD_L + c.x * CELL}
{@const y = PAD + c.y * CELL} {@const y = PAD_T + c.y * CELL}
<rect <rect
class="square" class="square"
class:throne={i === throne} class:throne={i === throne}
@@ -151,20 +249,17 @@
class:target={targets.includes(i)} class:target={targets.includes(i)}
class:fallen={shownCaptured.includes(i)} class:fallen={shownCaptured.includes(i)}
class:edge={set.escape === 'edge' && isEdge(size, i)} class:edge={set.escape === 'edge' && isEdge(size, i)}
data-i={i}
{x} {x}
{y} {y}
width={CELL} width={CELL}
height={CELL} height={CELL}
role="gridcell" role="gridcell"
aria-label={`${label(i)}${piece === '.' ? '' : `, ${pieceWord(piece)}`}`} aria-label={`${label(i)}${piece === '.' ? '' : `, ${pieceWord(piece)}`}${targets.includes(i) ? ', a legal move' : ''}`}
aria-selected={selected === i}
tabindex={yourMove ? 0 : -1} tabindex={yourMove ? 0 : -1}
onclick={() => tap(i)} onclick={() => tap(i)}
onkeydown={(e) => { onkeydown={(e) => key(e, i)}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tap(i);
}
}}
/> />
{#if targets.includes(i)} {#if targets.includes(i)}
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" /> <circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
@@ -172,8 +267,8 @@
{/each} {/each}
{#each tokens as t (t.id)} {#each tokens as t (t.id)}
{@const c = cellOf(size, t.at)} {@const c = cellOf(size, t.at)}
{@const cx = PAD + c.x * CELL + CELL / 2} {@const cx = PAD_L + c.x * CELL + CELL / 2}
{@const cy = PAD + c.y * CELL + CELL / 2} {@const cy = PAD_T + c.y * CELL + CELL / 2}
<g <g
class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}" class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}"
class:selected={selected === t.at} class:selected={selected === t.at}
@@ -191,27 +286,14 @@
</g> </g>
</svg> </svg>
</div> </div>
<p class="visually-hidden" aria-live="polite">{liveText}</p>
{#if g.over} {#if g.over}
<div class="verdict"> <div class="verdict">
<h2>{verdict}</h2>
<p>{g.over.reason}</p>
<a class="commit" href="/">Back to the hall</a> <a class="commit" href="/">Back to the hall</a>
</div> </div>
{:else if room.spectating} {:else if yourMove}
<section class="move"> <p class="muted small how">Tap a piece, then a square. With a keyboard: arrows to move about the board, Enter to pick up and to set down, Escape to cancel.</p>
<h2>You watch from the Peanut Gallery</h2>
<p class="muted">{nameOfSide(g.toMove)}, {sideWord(g.toMove)}, to move. Turn {g.moves.length + 1}.</p>
</section>
{:else}
<section class="move">
<h2>{yourMove ? 'Your move' : `Waiting on ${nameOfSide(g.toMove)}`}</h2>
<p class="muted">
You play {sideWord(mySide!)}: {mySide === 'attackers' ? 'take the king.' : set.escape === 'corner' ? 'bring the king to a corner.' : 'bring the king to the edge.'}
{#if yourMove}Tap a piece, then a square.{/if}
</p>
{#if room.error}<p class="warning">{room.error}</p>{/if}
</section>
{/if} {/if}
</section> </section>
@@ -227,13 +309,17 @@
</li> </li>
{/each} {/each}
</ul> </ul>
<p class="muted small">Turn {g.moves.length + 1}.</p> <p class="muted small">Turn {g.moves.length + 1}.{#if g.moves.length}&nbsp;Click a move to see it on the board.{/if}</p>
{#if g.moves.length} {#if g.moves.length}
<ol class="record muted small" reversed start={g.moves.length}> <ol class="record muted small" reversed start={g.moves.length}>
{#each g.moves.slice(-6).reverse() as m, i (g.moves.length - i)} {#each g.moves.slice(-8).reverse() as m, i (g.moves.length - i)}
{@const ply = g.moves.length - i} {@const ply = g.moves.length - i}
{@const side = ply % 2 === 1 ? 'attackers' : 'defenders'} {@const side = sideOfPly(ply)}
<li class:mine={mySide === side}>{nameOfSide(side)}: {label(m.from)} to {label(m.to)}{#if m.captured.length}, <strong>taking {m.captured.map(label).join(', ')}</strong>{/if}</li> <li class:mine={mySide === side} class:reviewed={reviewing === ply}>
<button type="button" class="link" onclick={() => (reviewing = reviewing === ply ? null : ply)}>
{nameOfSide(side)}: {label(m.from)} to {label(m.to)}{#if m.captured.length}, <strong>taking {m.captured.map(label).join(', ')}</strong>{/if}
</button>
</li>
{/each} {/each}
</ol> </ol>
{/if} {/if}
@@ -253,6 +339,7 @@
/* Copenhagen: fresh oak, clean stone. */ /* Copenhagen: fresh oak, clean stone. */
--wood: #8a5a2b; --wood: #8a5a2b;
--wood-line: rgba(30, 16, 6, 0.55); --wood-line: rgba(30, 16, 6, 0.55);
--coord: rgba(255, 240, 210, 0.55);
--square: rgba(255, 240, 210, 0.08); --square: rgba(255, 240, 210, 0.08);
--square-edge: rgba(255, 250, 235, 0.11); --square-edge: rgba(255, 250, 235, 0.11);
--throne: rgba(240, 165, 58, 0.35); --throne: rgba(240, 165, 58, 0.35);
@@ -270,6 +357,7 @@
.board[data-set='tablut'] { .board[data-set='tablut'] {
--wood: #8d8577; --wood: #8d8577;
--wood-line: rgba(40, 34, 28, 0.4); --wood-line: rgba(40, 34, 28, 0.4);
--coord: rgba(30, 26, 20, 0.5);
--square: rgba(255, 250, 235, 0.05); --square: rgba(255, 250, 235, 0.05);
--square-edge: rgba(255, 250, 235, 0.09); --square-edge: rgba(255, 250, 235, 0.09);
--throne: rgba(120, 100, 70, 0.4); --throne: rgba(120, 100, 70, 0.4);
@@ -289,6 +377,48 @@
} }
} }
.status {
max-width: 620px;
margin: 0 auto 0.5rem;
padding: 0.45rem 0.8rem;
border: 1px solid var(--rule);
border-radius: 6px;
font-size: 0.95rem;
color: var(--bone-dim);
}
.status strong {
color: var(--bone);
}
.status.yours {
border-color: var(--ember);
}
.status.yours strong {
color: var(--ember);
}
.goal {
color: var(--bone);
}
.story {
max-width: 620px;
margin: 0 auto 0.5rem;
font-size: 0.9rem;
color: var(--bone-dim);
}
.story.capture {
color: var(--bone);
}
.warning {
max-width: 620px;
margin: 0 auto 0.5rem;
}
.frame { .frame {
max-width: 620px; max-width: 620px;
margin: 0 auto; margin: 0 auto;
@@ -305,6 +435,13 @@
transition: fill 1.2s ease; transition: fill 1.2s ease;
} }
.coord {
fill: var(--coord);
font-family: var(--font);
font-size: 9px;
pointer-events: none;
}
.square { .square {
fill: var(--square); fill: var(--square);
stroke: var(--wood-line); stroke: var(--wood-line);
@@ -340,7 +477,7 @@
.square:focus-visible { .square:focus-visible {
outline: none; outline: none;
stroke: var(--frost); stroke: var(--frost);
stroke-width: 2; stroke-width: 2.5;
} }
.hint { .hint {
@@ -364,7 +501,9 @@
.piece .body { .piece .body {
stroke-width: 2; stroke-width: 2;
transition: fill 1.2s ease, stroke 1.2s ease; transition:
fill 1.2s ease,
stroke 1.2s ease;
} }
.piece.attacker .body { .piece.attacker .body {
@@ -391,20 +530,14 @@
stroke-width: 3; stroke-width: 3;
} }
.move, .how {
max-width: 620px;
margin: 0.5rem auto 0;
}
.verdict { .verdict {
margin-top: 1rem; max-width: 620px;
} margin: 0.8rem auto 0;
.move h2,
.verdict h2 {
font-size: 1.4rem;
margin-bottom: 0.3rem;
}
.verdict .commit {
display: inline-block;
margin-top: 0.6rem;
} }
.sides h2 { .sides h2 {
@@ -462,8 +595,35 @@
color: var(--bone); color: var(--bone);
} }
.record li.reviewed .link {
color: var(--frost);
}
.record strong { .record strong {
color: var(--ember); color: var(--ember);
font-weight: 500; font-weight: 500;
} }
.link {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.link:hover {
text-decoration: underline;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style> </style>
+41 -17
View File
@@ -15,6 +15,16 @@
let code = $state(''); let code = $state('');
/** The host's choices for the next table, from the game's declared options. */ /** The host's choices for the next table, from the game's declared options. */
let options = $state<Record<string, string>>(Object.fromEntries((game.options ?? []).map((o) => [o.key, o.default]))); let options = $state<Record<string, string>>(Object.fromEntries((game.options ?? []).map((o) => [o.key, o.default])));
/** The hall's still of the opening position, on whichever board the selector names. */
const still = $derived(
options.set === 'tablut'
? { n: 9, wood: '#8d8577', line: 'rgba(40,34,28,0.5)', dark: '#3d3530', darkRim: '#1c1714', pale: '#cfc4ad', paleRim: '#8c8069', king: '#c9b27a', kingRim: '#7d6636', corners: false,
a: [[3,0],[4,0],[5,0],[4,1],[0,3],[8,3],[0,4],[1,4],[7,4],[8,4],[0,5],[8,5],[4,7],[3,8],[4,8],[5,8]], d: [[4,2],[4,3],[2,4],[3,4],[5,4],[6,4],[4,5],[4,6]] }
: { n: 11, wood: '#8a5a2b', line: 'rgba(30,16,6,0.5)', dark: '#2a2320', darkRim: '#0d0a08', pale: '#ece4d0', paleRim: '#b9ab8b', king: '#f0d58a', kingRim: '#a67c1a', corners: true,
a: [[3,0],[4,0],[5,0],[6,0],[7,0],[5,1],[0,3],[10,3],[0,4],[10,4],[0,5],[1,5],[9,5],[10,5],[0,6],[10,6],[0,7],[10,7],[5,9],[3,10],[4,10],[5,10],[6,10],[7,10]], d: [[5,3],[4,4],[5,4],[6,4],[3,5],[4,5],[6,5],[7,5],[4,6],[5,6],[6,6],[5,7]] }
);
const mid = $derived((still.n - 1) / 2);
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let seats = $state(allSeats()); let seats = $state(allSeats());
@@ -156,7 +166,7 @@
<div class="lid-head"> <div class="lid-head">
<h1>Hnefatafl</h1> <h1>Hnefatafl</h1>
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p> <p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p>
<p class="tag">Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach a corner. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.</p> <p class="tag">Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach safety. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game a labor of love</a></p> <p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game a labor of love</a></p>
</div> </div>
@@ -168,12 +178,16 @@
{#if game.options?.length} {#if game.options?.length}
<div class="options"> <div class="options">
{#each game.options as o (o.key)} {#each game.options as o (o.key)}
<label class="option"> {@const chosen = o.choices.find((c) => c.value === options[o.key])}
<span>{o.label}</span> <div class="option-block">
<select bind:value={options[o.key]}> <label class="option">
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each} <span>{o.label}</span>
</select> <select bind:value={options[o.key]}>
</label> {#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{#if chosen?.note}<p class="option-note muted small">{chosen.note}</p>{/if}
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -196,20 +210,20 @@
</div> </div>
<div class="lid-look" aria-hidden="true"> <div class="lid-look" aria-hidden="true">
<svg class="still" viewBox="0 0 110 110" aria-hidden="true"> <svg class="still" class:worn={options.set === 'tablut'} viewBox="0 0 {still.n * 10} {still.n * 10}" aria-hidden="true">
<rect x="0" y="0" width="110" height="110" rx="4" fill="#8a5a2b" /> <rect x="0" y="0" width={still.n * 10} height={still.n * 10} rx="4" fill={still.wood} />
{#each Array.from({ length: 11 }, (_, r) => r) as r (r)} {#each Array.from({ length: still.n }, (_, r) => r) as r (r)}
{#each Array.from({ length: 11 }, (_, c) => c) as c (c)} {#each Array.from({ length: still.n }, (_, c) => c) as c (c)}
<rect x={c * 10} y={r * 10} width="10" height="10" fill={(r === 0 || r === 10) && (c === 0 || c === 10) ? 'rgba(224,101,92,0.4)' : r === 5 && c === 5 ? 'rgba(240,165,58,0.4)' : 'rgba(255,240,210,0.08)'} stroke="rgba(30,16,6,0.5)" stroke-width="0.4" /> <rect x={c * 10} y={r * 10} width="10" height="10" fill={still.corners && (r === 0 || r === still.n - 1) && (c === 0 || c === still.n - 1) ? 'rgba(224,101,92,0.4)' : r === mid && c === mid ? 'rgba(240,165,58,0.4)' : 'rgba(255,240,210,0.08)'} stroke={still.line} stroke-width="0.4" />
{/each} {/each}
{/each} {/each}
{#each [[3,0],[4,0],[5,0],[6,0],[7,0],[5,1],[0,3],[10,3],[0,4],[10,4],[0,5],[1,5],[9,5],[10,5],[0,6],[10,6],[0,7],[10,7],[5,9],[3,10],[4,10],[5,10],[6,10],[7,10]] as [x, y] (`${x},${y}`)} {#each still.a as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill="#2a2320" stroke="#0d0a08" stroke-width="0.6" /> <circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill={still.dark} stroke={still.darkRim} stroke-width="0.6" />
{/each} {/each}
{#each [[5,3],[4,4],[5,4],[6,4],[3,5],[4,5],[6,5],[7,5],[4,6],[5,6],[6,6],[5,7]] as [x, y] (`${x},${y}`)} {#each still.d as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill="#ece4d0" stroke="#b9ab8b" stroke-width="0.6" /> <circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill={still.pale} stroke={still.paleRim} stroke-width="0.6" />
{/each} {/each}
<circle cx="55" cy="55" r="3.6" fill="#f0d58a" stroke="#a67c1a" stroke-width="0.6" /> <circle cx={mid * 10 + 5} cy={mid * 10 + 5} r="3.6" fill={still.king} stroke={still.kingRim} stroke-width="0.6" />
</svg> </svg>
</div> </div>
@@ -609,12 +623,22 @@
gap: 0.5rem; gap: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.option-note {
margin: 0.2rem 0 0;
max-width: 22em;
}
.still { .still {
width: 100%; width: 100%;
max-width: 18rem; max-width: 18rem;
display: block; display: block;
margin: 0 auto; margin: 0 auto;
border-radius: 4px; border-radius: 4px;
transition: filter 0.9s ease;
}
.still.worn {
filter: contrast(0.85) sepia(0.15);
} }
.tally { .tally {
display: grid; display: grid;
+6 -6
View File
@@ -44,9 +44,9 @@ export interface State {
export type Input = { from: number; to: number }; export type Input = { from: number; to: number };
export const SIDES: { value: Side; label: string }[] = [ export const SIDES: { value: Side; label: string; note: string }[] = [
{ value: 'defenders', label: 'the defenders, with the king' }, { value: 'defenders', label: 'the defenders, with the king', note: 'Fewer pieces, and the king must reach safety before the ring closes.' },
{ value: 'attackers', label: 'the attackers' } { value: 'attackers', label: 'the attackers', note: 'Twice the pieces, and the first move; close every road and take the king.' }
]; ];
// --- The board --------------------------------------------------------------- // --- The board ---------------------------------------------------------------
@@ -91,7 +91,7 @@ function neighbors(size: number, i: number): number[] {
} }
/** The square on the far side of `mid` from `from`, or -1 off the board. */ /** The square on the far side of `mid` from `from`, or -1 off the board. */
function beyond(size: number, from: number, mid: number): number { export function beyond(size: number, from: number, mid: number): number {
const a = cellOf(size, from); const a = cellOf(size, from);
const b = cellOf(size, mid); const b = cellOf(size, mid);
const c = { x: b.x + (b.x - a.x), y: b.y + (b.y - a.y) }; const c = { x: b.x + (b.x - a.x), y: b.y + (b.y - a.y) };
@@ -390,8 +390,8 @@ export const game: GameSpec<State, Input> = {
key: 'set', key: 'set',
label: 'Rules', label: 'Rules',
choices: [ choices: [
{ value: 'copenhagen', label: 'Copenhagen, 11 by 11' }, { value: 'copenhagen', label: 'Copenhagen, 11 by 11', note: 'The modern tournament game. The king escapes to a corner and falls only to four attackers, three beside the throne.' },
{ value: 'tablut', label: 'Tablut, 9 by 9, after Linnaeus' } { value: 'tablut', label: 'Tablut, 9 by 9, after Linnaeus', note: 'The older game. The king escapes to any edge square, and away from the throne two attackers take him like any piece.' }
], ],
default: 'copenhagen' default: 'copenhagen'
}, },
+2 -1
View File
@@ -25,7 +25,8 @@ export interface Outcome {
export interface TableOption { export interface TableOption {
key: string; key: string;
label: string; label: string;
choices: { value: string; label: string }[]; /** A choice's note is shown under the selector while it is chosen: the one line a player needs to pick. */
choices: { value: string; label: string; note?: string }[];
/** The value a table gets when the host chooses nothing. */ /** The value a table gets when the host chooses nothing. */
default: string; default: string;
} }
+35 -3
View File
@@ -12,7 +12,7 @@
<h1>How to play</h1> <h1>How to play</h1>
<p class="lede">A walk through the page, from the hall to a first game. The game's own rules are on <a href="/rules">the rules page</a>.</p> <p class="lede">A walk through the page, from the hall to a first game. The game's own rules are on <a href="/rules">the rules page</a>.</p>
<nav> <nav>
<a href="#hall">The hall</a> · <a href="#table">The table</a> · <a href="#board">The board</a> · <a href="#gallery">The Peanut Gallery and table talk</a> · <a href="#hall">The hall</a> · <a href="#table">The table</a> · <a href="#board">The board</a> · <a href="#capture">A capture</a> · <a href="#gallery">The Peanut Gallery and table talk</a> ·
<a href="#first">A first game</a> <a href="#first">A first game</a>
</nav> </nav>
</header> </header>
@@ -37,12 +37,27 @@
<section id="board"> <section id="board">
<h2>The board</h2> <h2>The board</h2>
<p> <p>
Tap one of your pieces and its reachable squares light up; tap a square to move there. The last move is shaded, and a square a piece was just taken from shows red for a Tap one of your pieces and its reachable squares light up; tap a square to move there. The last move is shaded, a square a piece was just taken from shows red until the next
moment. The attackers are the dark pieces and move first; the defenders are the pale pieces around the crowned king on the throne. In Copenhagen the four corners are marked: move, and the strip above the board says whose move it is and what your side is for. The record beside the board lists the moves; click one to see it on the board. The attackers are the dark pieces and move first; the defenders are the pale pieces around the crowned king on the throne. In Copenhagen the four corners are marked:
only the king may enter them, and reaching one wins. In Tablut the board is older and plainer, and the king needs only the edge. only the king may enter them, and reaching one wins. In Tablut the board is older and plainer, and the king needs only the edge.
</p> </p>
</section> </section>
<section id="capture">
<h2>How a capture works</h2>
<figure class="capture">
<svg viewBox="0 0 90 30" aria-label="An attacker moves beside a defender that already has an attacker on its other side; the defender is taken.">
<rect width="90" height="30" rx="2" fill="#8a5a2b" />
{#each [0, 1, 2] as c (c)}<rect x={c * 30} y="0" width="30" height="30" fill="rgba(255,240,210,0.08)" stroke="rgba(30,16,6,0.5)" stroke-width="0.6" />{/each}
<circle cx="15" cy="15" r="9" fill="#2a2320" stroke="#0d0a08" stroke-width="1.2" />
<circle cx="45" cy="15" r="9" fill="#ece4d0" stroke="#b9ab8b" stroke-width="1.2" opacity="0.5" />
<circle cx="75" cy="15" r="9" fill="#2a2320" stroke="#0d0a08" stroke-width="1.2" />
<path d="M62 4 h11 l-3 -3 m3 3 l-3 3" fill="none" stroke="#9cd0dc" stroke-width="1.4" />
</svg>
<figcaption>The right-hand attacker arrives; the pale defender is caught between two enemies and is taken. Only the mover captures: a piece may step into that gap safely on its own turn.</figcaption>
</figure>
</section>
<section id="gallery"> <section id="gallery">
<h2>The Peanut Gallery and table talk</h2> <h2>The Peanut Gallery and table talk</h2>
<p> <p>
@@ -99,6 +114,23 @@
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
.capture {
margin: 0.6rem 0 0;
}
.capture svg {
width: 12rem;
display: block;
border-radius: 3px;
}
.capture figcaption {
margin-top: 0.5rem;
font-size: 0.9rem;
color: var(--bone-dim);
max-width: 38em;
}
.word { .word {
margin-top: 2rem; margin-top: 2rem;
color: var(--bone-dim); color: var(--bone-dim);
+1 -1
View File
@@ -121,7 +121,7 @@
{/if} {/if}
{/if} {/if}
<a class="quiet" href="/guide">How to play</a> <a class="quiet" href="/guide">How to play</a>
<a class="quiet" href="/rules">Rules</a> <a class="quiet" href={room ? `/rules?room=${roomId}` : '/rules'}>Rules</a>
{#if room} {#if room}
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button> <button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
{/if} {/if}
+9 -4
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
// The rules page: Copenhagen as this table plays it, then Tablut and the // The rules page: Copenhagen as this table plays it, then Tablut and the
// gaps in Linnaeus that every reconstruction fills for itself. // gaps in Linnaeus that every reconstruction fills for itself.
import { page } from '$app/state';
/** Opened from a table, the page offers the way back to it. */
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
</script> </script>
<svelte:head> <svelte:head>
@@ -9,7 +13,7 @@
<main class="rules"> <main class="rules">
<header> <header>
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/guide">How to play</a> {#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href="/guide">How to play</a>
<h1>The rules</h1> <h1>The rules</h1>
<p class="lede"> <p class="lede">
Hnefatafl is the tafl family's best-known member: a Viking board game played across Scandinavia and the North Atlantic from the fourth century until chess displaced it. No Hnefatafl is the tafl family's best-known member: a Viking board game played across Scandinavia and the North Atlantic from the fourth century until chess displaced it. No
@@ -30,8 +34,9 @@
<section id="moving"> <section id="moving">
<h2>Moving</h2> <h2>Moving</h2>
<p> <p>
Every piece moves like a rook in chess: any number of empty squares along a row or column, never diagonally, never over another piece. Only the king may stop on the throne Every piece moves like a rook in chess: any number of empty squares along a row or column, never diagonally, never over another piece. Only the king may stop on the throne, and
or on a corner square. In Copenhagen an ordinary piece may pass across the empty throne; in Tablut it may not. in Copenhagen only the king may enter the four marked corners; in Tablut the corners are ordinary squares. In Copenhagen an ordinary piece may pass across the empty throne;
in Tablut it may not.
</p> </p>
</section> </section>
@@ -39,7 +44,7 @@
<h2>Capturing</h2> <h2>Capturing</h2>
<p> <p>
A piece is captured when the moving piece closes it between itself and another enemy piece on the opposite side, along a row or column. A piece may safely move between two A piece is captured when the moving piece closes it between itself and another enemy piece on the opposite side, along a row or column. A piece may safely move between two
enemies: only the mover captures. The four corners and the empty throne stand in for an enemy piece, so a piece pinned against one of them by a single enemy is taken. The enemies: only the mover captures. The empty throne stands in for an enemy piece, and in Copenhagen so do the four marked corners, so a piece pinned against one of them by a single enemy is taken. The
king takes part in captures like any piece. king takes part in captures like any piece.
</p> </p>
<p> <p>