From aa4f86bc56b930a383b52dc6c18582e138b5b916 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 18:54:55 -0400 Subject: [PATCH] Preferences: motion, confirming a move, coordinates on the board, and the last table choices remembered Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- src/lib/components/Board.svelte | 49 ++++++++++++-- src/lib/components/Hall.svelte | 23 ++++++- src/lib/components/Preferences.svelte | 97 +++++++++++++++++++++++++++ src/lib/game/index.ts | 1 + src/lib/game/spec.ts | 2 + src/lib/prefs.svelte.ts | 65 ++++++++++++++++++ src/routes/join/[code]/+page.svelte | 4 ++ 7 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 src/lib/components/Preferences.svelte create mode 100644 src/lib/prefs.svelte.ts diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 4343d0b..86bbc15 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -9,6 +9,7 @@ import TableTalk from './TableTalk.svelte'; 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 { prefs, reducedMotion } from '$lib/prefs.svelte'; let { room }: { room: Room } = $props(); @@ -59,7 +60,9 @@ let shown = $state(0); let shownSet = $state(''); let animating = $state(false); - const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const calm = $derived(prefs.motion === 'reduced' || reducedMotion()); + const showCoords = $derived(prefs.coordinates !== false); + const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms)); function resetTokens() { let id = 0; @@ -149,15 +152,28 @@ liveText = text; } + /** A move awaiting the player's word, when they asked to confirm. */ + let pending = $state<{ from: number; to: number } | null>(null); + + async function send(from: number, to: number) { + pending = null; + selected = null; + await room.submit({ from, to }); + } + async function tap(i: number) { if (!yourMove || room.sending || animating) return; reviewing = null; if (selected !== null && targets.includes(i)) { - const from = selected; - selected = null; - await room.submit({ from, to: i }); + if (prefs.confirmMoves === true) { + pending = { from: selected, to: i }; + announce(`Move ${label(selected)} to ${label(i)}? Confirm or cancel below.`); + return; + } + await send(selected, i); return; } + pending = null; if (sideOf(g.cells[i]) === mySide) { selected = selected === i ? null : i; if (selected !== null) { @@ -188,6 +204,7 @@ } else if (e.key === 'Escape') { selected = null; reviewing = null; + pending = null; announce('Selection cleared.'); } } @@ -215,9 +232,14 @@ {#if shownStory && (shownCaptured.length || reviewing !== null)}

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

{/if} + {#if pending} +

Move {label(pending.from)} to {label(pending.to)}? + +

+ {/if} {#if room.error}

{room.error}

{/if} -
+
@@ -232,7 +254,7 @@ - {#each Array.from({ length: size }, (_, n) => n) as n (n)} + {#each showCoords ? Array.from({ length: size }, (_, n) => n) : [] as n (n)} {String.fromCharCode(97 + n)} {size - n} {/each} @@ -419,6 +441,21 @@ margin: 0 auto 0.5rem; } + .confirm { + max-width: 620px; + margin: 0 auto 0.5rem; + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + } + + .calm .piece, + .calm .square, + .calm .wood { + transition: none; + } + .frame { max-width: 620px; margin: 0 auto; diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 437355d..7a6e7eb 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -4,6 +4,8 @@ // about panel with the rules, the guide and a way to reach the keeper. import { goto } from '$app/navigation'; import { otherGames, type FamilyGame } from '$lib/family'; + import Preferences from './Preferences.svelte'; + import { prefs, setPref } from '$lib/prefs.svelte'; import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client'; import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { unreadTalk } from '$lib/net/talk'; @@ -14,7 +16,14 @@ let name = $state(playerName()); let code = $state(''); /** The host's choices for the next table, from the game's declared options. */ - let options = $state>(Object.fromEntries((game.options ?? []).map((o) => [o.key, o.default]))); + // The host's last choices come back as the defaults; a regular does not re-pick them each visit. + let options = $state>( + Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default])) + ); + $effect(() => { + for (const o of game.options ?? []) if (prefs[`table.${o.key}`] !== options[o.key]) setPref(`table.${o.key}`, options[o.key]); + }); + let prefsOpen = $state(false); /** The hall's still of the opening position, on whichever board the selector names. */ const still = $derived( @@ -167,7 +176,8 @@

Hnefatafl

The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.

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.

- + +
@@ -655,4 +665,13 @@ .tally dd { margin: 0; } + .link-like { + background: none; + border: 0; + padding: 0; + font: inherit; + color: inherit; + text-decoration: underline; + cursor: pointer; + } diff --git a/src/lib/components/Preferences.svelte b/src/lib/components/Preferences.svelte new file mode 100644 index 0000000..952d0ae --- /dev/null +++ b/src/lib/components/Preferences.svelte @@ -0,0 +1,97 @@ + + + + +{#if open} + + +{/if} + + diff --git a/src/lib/game/index.ts b/src/lib/game/index.ts index b204311..212879f 100644 --- a/src/lib/game/index.ts +++ b/src/lib/game/index.ts @@ -397,6 +397,7 @@ export const game: GameSpec = { }, { key: 'side', label: 'You play', choices: SIDES, default: 'defenders' } ], + preferences: [{ key: 'coordinates', label: 'Coordinates on the board', kind: 'toggle', default: true }], create: createGame, resolve: resolveRound, needsInput: (state, seat) => !state.over && state.players[seat]?.side === state.toMove, diff --git a/src/lib/game/spec.ts b/src/lib/game/spec.ts index dd5c4fe..a7636b6 100644 --- a/src/lib/game/spec.ts +++ b/src/lib/game/spec.ts @@ -39,6 +39,8 @@ export interface GameSpec { seatIds: SeatId[]; /** What the host may choose when opening a table; empty for a game with one way to play. */ options?: TableOption[]; + /** The game's own settings for the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */ + preferences?: { key: string; label: string; kind: 'toggle' | 'choice'; choices?: { value: string; label: string }[]; default: boolean | string; note?: string }[]; minSeats: number; /** Names the server draws for bots, in preference order. */ botNames: string[]; diff --git a/src/lib/prefs.svelte.ts b/src/lib/prefs.svelte.ts new file mode 100644 index 0000000..6c126cf --- /dev/null +++ b/src/lib/prefs.svelte.ts @@ -0,0 +1,65 @@ +// Preferences: what this browser remembers about how its player likes to +// play. The kit's own settings are motion and whether a move is confirmed +// before it is sent; a game declares more in GameSpec.preferences, and the +// panel renders every declaration. Nothing here leaves the browser. + +import { game } from '$lib/game'; + +export interface Preference { + key: string; + label: string; + /** A toggle holds a boolean; a choice holds one of its choices' values. */ + kind: 'toggle' | 'choice'; + choices?: { value: string; label: string }[]; + default: boolean | string; + note?: string; +} + +/** The settings every game in the kit shares. */ +export const KIT_PREFERENCES: Preference[] = [ + { key: 'motion', label: 'Motion', kind: 'choice', choices: [{ value: 'full', label: 'pieces move and fade' }, { value: 'reduced', label: 'changes appear at once' }], default: 'full' }, + { key: 'confirmMoves', label: 'Confirm a move before it is sent', kind: 'toggle', default: false } +]; + +export const PREFERENCES: Preference[] = [...KIT_PREFERENCES, ...(game.preferences ?? [])]; + +const KEY = 'hnefatafl:prefs'; + +function defaults(): Record { + return Object.fromEntries(PREFERENCES.map((p) => [p.key, p.default])); +} + +function load(): Record { + const base = defaults(); + try { + const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record; + for (const p of PREFERENCES) { + const v = saved[p.key]; + if (p.kind === 'toggle' && typeof v === 'boolean') base[p.key] = v; + if (p.kind === 'choice' && typeof v === 'string' && p.choices?.some((c) => c.value === v)) base[p.key] = v; + } + // The hall's last table options ride along under table., undeclared. + for (const [k, v] of Object.entries(saved)) if (k.startsWith('table.') && typeof v === 'string') base[k] = v; + } catch { + // Without storage the defaults apply for this visit. + } + return base; +} + +/** The live preferences; read them where they matter, set them through setPref. */ +export const prefs = $state>(typeof localStorage === 'undefined' ? defaults() : load()); + +export function setPref(key: string, value: boolean | string): void { + prefs[key] = value; + try { + localStorage.setItem(KEY, JSON.stringify(prefs)); + } catch { + // The change still applies for this visit. + } +} + +/** The reduced-motion preference, or the system's, whichever asks for calm. */ +export function reducedMotion(): boolean { + if (prefs.motion === 'reduced') return true; + return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; +} diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index 6bbb509..60378ce 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -4,6 +4,7 @@ import Board from '$lib/components/Board.svelte'; import Lobby from '$lib/components/Lobby.svelte'; import ReportSlip from '$lib/components/ReportSlip.svelte'; + import Preferences from '$lib/components/Preferences.svelte'; import { seatFor } from '$lib/net/client'; import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; @@ -16,6 +17,7 @@ let error = $state(''); let copied = $state(false); let reporting = $state(false); + 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); @@ -120,6 +122,7 @@ {/if} {/if} + How to play Rules {#if room} @@ -129,6 +132,7 @@ + {#if room} {/if}