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:
co-authored by
Claude Fable 5.1
parent
bac7182915
commit
5db89edbc1
+235
-75
@@ -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} <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} 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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||||||
|
<div class="option-block">
|
||||||
<label class="option">
|
<label class="option">
|
||||||
<span>{o.label}</span>
|
<span>{o.label}</span>
|
||||||
<select bind:value={options[o.key]}>
|
<select bind:value={options[o.key]}>
|
||||||
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
|
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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;
|
||||||
|
|||||||
@@ -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'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user