Board artwork: one component draws the game, the hall's still, and the share card, with gradients on the pieces, plank joins and inlaid corners on the oak, and the stone as it was

npm run art renders the standalone SVGs and the 1200 by 630 card through
Sharp, a development dependency only.
This commit is contained in:
Eric Wagoner
2026-09-23 19:32:15 -04:00
parent 145667ea0d
commit a213a238d3
12 changed files with 1089 additions and 382 deletions
+4 -320
View File
@@ -7,12 +7,12 @@
// Tablut evokes an excavated stone board and worn counters. This is an
// artistic treatment, not a claim to reconstruct a particular find.
import TableTalk from './TableTalk.svelte';
import { beyond, cellOf, cornersOf, isEdge, movesFrom, rulesetOf, sideOf, throneOf, type Move, type Side } from '$lib/game';
import BoardArtwork from './BoardArtwork.svelte';
import { beyond, cellOf, cornersOf, 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();
const artId = $props.id();
const g = $derived(room.state!);
const set = $derived(rulesetOf(g));
@@ -36,47 +36,12 @@
const sideOfPly = (ply: number): Side => (ply % 2 === 1 ? 'attackers' : 'defenders');
// --- Coordinates ---------------------------------------------------------
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');
// --- Age: the Tablut board came out of a long hall. A few chipped corners
// and two hairline cracks, placed by a fixed hand so the board is the same
// board every visit; nothing here moves or changes with play.
const chips = $derived.by(() => {
if (!worn) return [] as { x: number; y: number; r: number; d: string }[];
const spots = [
[0, 0, 0], [size - 1, 2, 1], [3, size - 1, 2], [0, size - 3, 3], [size - 4, 0, 1]
] as const;
return spots.map(([cx, cy, corner]) => {
const x = PAD_L + cx * CELL + (corner === 1 || corner === 2 ? CELL : 0);
const y = PAD_T + cy * CELL + (corner >= 2 ? CELL : 0);
const sx = corner === 1 || corner === 2 ? -1 : 1;
const sy = corner >= 2 ? -1 : 1;
const a = 5 + ((cx * 7 + cy * 3) % 4);
const b = 4 + ((cx * 5 + cy) % 3);
return { x, y, r: 0, d: `M${x} ${y} l${sx * a} 0 l${-sx * (a - b)} ${sy * b} l${-sx * b} ${sy * (a - b + 1)} z` };
});
});
const cracks = $derived.by(() => {
if (!worn) return [] as string[];
const w = PAD_L + size * CELL;
const h = PAD_T + size * CELL;
return [
`M${PAD_L + 22} ${PAD_T} l3 11 -2 7 5 12 -1 9 4 8 -2 12 m-1 -29 -8 5 -3 7`,
`M${w} ${h - 62} l-12 3 -6 -2 -9 6 -7 1 -5 6 -10 3 m22 -10 -2 -7 -5 -4`
];
});
// --- Tokens: pieces with a life of their own, so a move slides and a capture fades ---
interface Token {
id: number;
@@ -268,123 +233,8 @@
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
<div class="frame" class:calm>
<svg viewBox="0 0 {width} {height}" role="grid" aria-label="The board">
<defs>
<linearGradient id={`${artId}-surface`} x2="0.85" y2="1">
<stop stop-color="var(--surface-light)" />
<stop offset="0.48" stop-color="var(--wood)" />
<stop offset="1" stop-color="var(--surface-dark)" />
</linearGradient>
<filter id={`${artId}-grain`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency={worn ? '0.48' : '0.025 0.5'} numOctaves="3" seed="7" result="noise" />
<feColorMatrix in="noise" type="saturate" values="0" />
<feComponentTransfer result="soft"><feFuncA type="linear" slope={worn ? 0.2 : 0.13} /></feComponentTransfer>
<feBlend in="SourceGraphic" in2="soft" mode="multiply" />
</filter>
<filter id={`${artId}-worn`} x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="3" result="warp" />
<feDisplacementMap in="SourceGraphic" in2="warp" scale="0.65" xChannelSelector="R" yChannelSelector="G" />
</filter>
<filter id={`${artId}-mottle`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.025" numOctaves="3" seed="11" />
<feColorMatrix type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.17 0 0 0 0 0.12 0.28 0 0 0 0" />
<feComposite in2="SourceGraphic" operator="in" />
</filter>
{#each ['attacker', 'defender', 'king'] as kind (kind)}
<radialGradient id={`${artId}-${kind}`} cx="32%" cy="24%" r="80%">
<stop stop-color={`var(--${kind}-light)`} />
<stop offset="0.65" stop-color={`var(--${kind})`} />
<stop offset="1" stop-color={`var(--${kind}-rim)`} />
</radialGradient>
{/each}
</defs>
<rect class="wood" x="0.7" y="0.7" width={width - 1.4} height={height - 1.4} rx={worn ? 3 : 6} fill={`url(#${artId}-surface)`} filter={`url(#${artId}-grain)`} />
<rect class="board-rim" x="2" y="2" width={width - 4} height={height - 4} rx={worn ? 2 : 5} />
{#if worn}
<rect class="stain" x="2" y="2" width={width - 4} height={height - 4} rx="3" filter={`url(#${artId}-mottle)`} />
{:else}
{#each [0.22, 0.51, 0.79] as part (part)}
<path class="join" d={`M${PAD_L + size * CELL * part} 3 v${height - 6}`} />
{/each}
{/if}
<g class="surface-wear" aria-hidden="true">
{#each chips as chip (chip.d)}<path class="chip" d={chip.d} />{/each}
{#each cracks as crack (crack)}
<path class="crack-light" d={crack} transform="translate(0.35 0.4)" />
<path class="crack" d={crack} />
{/each}
</g>
{#each showCoords ? 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>
{#each g.cells as piece, i (i)}
{@const c = cellOf(size, i)}
{@const x = PAD_L + c.x * CELL}
{@const y = PAD_T + c.y * CELL}
<rect
class="square"
class:throne={i === throne}
class:corner={corners.includes(i)}
class:last={shownSquares.includes(i)}
class:target={targets.includes(i)}
class:fallen={shownCaptured.includes(i)}
class:edge={set.escape === 'edge' && isEdge(size, i)}
data-i={i}
{x}
{y}
width={CELL}
height={CELL}
role="gridcell"
aria-label={`${label(i)}${piece === '.' ? '' : `, ${pieceWord(piece)}`}${targets.includes(i) ? ', a legal move' : ''}`}
aria-selected={selected === i}
tabindex={yourMove ? 0 : -1}
onclick={() => tap(i)}
onkeydown={(e) => key(e, i)}
/>
{#if targets.includes(i)}
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
{/if}
{/each}
{#each [throne, ...corners] as place (place)}
{@const c = cellOf(size, place)}
<g class="inlay" transform={`translate(${PAD_L + c.x * CELL + 20} ${PAD_T + c.y * CELL + 20})`} aria-hidden="true">
<path d="M0 -15 L15 0 0 15 -15 0 Z" />
{#if !worn}<path d="M-7 0 H7 M0 -7 V7" />{/if}
</g>
{/each}
{#each tokens as t (t.id)}
{@const c = cellOf(size, t.at)}
{@const cx = PAD_L + c.x * CELL + CELL / 2}
{@const cy = PAD_T + c.y * CELL + CELL / 2}
<g
class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}"
class:selected={selected === t.at}
class:dying={t.dying}
style="transform: translate({cx}px, {cy}px)"
pointer-events="none"
>
<ellipse class="shadow" cx="1" cy="3" rx="14" ry="12.5" />
<g filter={worn ? `url(#${artId}-worn)` : undefined}>
<circle class="base" cy="1.2" r="13.6" />
<circle class="body" r="13.2" fill={`url(#${artId}-${t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'})`} />
<path class="bevel-light" d="M-11.5 3 A12 12 0 0 1 8 -9" />
<path class="bevel-dark" d="M11.5 -3 A12 12 0 0 1 -7 9.8" />
<circle class="turned" r="10.2" />
{#if worn}
<path class="counter-wear" transform={`rotate(${(t.id * 137) % 360})`} d="M-10 -6 l2 1 M4 10 l2 -1 M-5 -9 l3 0.6" />
{/if}
{#if t.piece === 'k'}
<path class="crown-gleam" transform="translate(0.25 0.55)" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
<path class="crown" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
{/if}
</g>
</g>
{/each}
</g>
</svg>
<div class="frame">
<BoardArtwork {g} {tokens} {showCoords} {selected} {targets} {shownSquares} {shownCaptured} {yourMove} {calm} {tap} {key} />
</div>
<p class="visually-hidden" aria-live="polite">{liveText}</p>
@@ -436,51 +286,6 @@
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
/* Copenhagen: a crafted oak board, polished counters and brass inlay. */
--surface-light: #bd935c;
--surface-dark: #79502d;
--rim-light: #e0bf84;
--attacker-light: #63564a;
--defender-light: #fff4d8;
--king-light: #ffe4a0;
--wood: #a77945;
--wood-line: rgba(30, 16, 6, 0.55);
--coord: #302216;
--square: rgba(255, 240, 210, 0.08);
--square-edge: rgba(255, 250, 235, 0.11);
--throne: rgba(240, 165, 58, 0.35);
--corner: rgba(109, 52, 29, 0.22);
--attacker: #2a2320;
--attacker-rim: #211a15;
--defender: #ece4d0;
--defender-rim: #b9ab8b;
--king: #f0d58a;
--king-rim: #a67c1a;
--crown: #6b4a0f;
}
/* Tablut: an excavated-object aesthetic, with limestone and aged counters. */
.board[data-set='tablut'] {
--surface-light: #aaa18b;
--surface-dark: #797564;
--rim-light: #c4b99e;
--attacker-light: #625e51;
--defender-light: #e2d5b3;
--king-light: #e5cc92;
--wood: #8a8578;
--wood-line: rgba(36, 32, 27, 0.45);
--coord: #37362c;
--square: rgba(255, 250, 235, 0.05);
--square-edge: rgba(255, 250, 235, 0.09);
--throne: rgba(120, 100, 70, 0.4);
--corner: transparent;
--attacker: #3d3530;
--attacker-rim: #29281f;
--defender: #cfc4ad;
--defender-rim: #8c8069;
--king: #c9b27a;
--king-rim: #7d6636;
--crown: #5a4a2a;
}
@media (max-width: 860px) {
@@ -540,132 +345,11 @@
flex-wrap: wrap;
}
.calm .piece,
.calm .square,
.calm .wood {
transition: none;
}
.frame {
max-width: 620px;
margin: 0 auto;
}
svg {
width: 100%;
height: auto;
display: block;
}
.wood {
stroke: var(--surface-dark);
stroke-width: 1.4;
}
.stain {
fill: #fff;
pointer-events: none;
}
.chip {
fill: rgba(28, 24, 20, 0.42);
stroke: rgba(230, 222, 205, 0.18);
stroke-width: 0.6;
pointer-events: none;
}
.crack {
fill: none;
stroke: rgba(43, 39, 29, 0.48);
stroke-width: 0.5;
stroke-linecap: round;
pointer-events: none;
}
.coord {
fill: var(--coord);
font-family: var(--font);
font-size: 9px;
font-weight: 600;
pointer-events: none;
}
.square {
fill: var(--square);
stroke: var(--wood-line);
stroke-width: 0.65;
cursor: default;
transition: fill 0.3s ease;
}
.square.edge {
fill: var(--square-edge);
}
.square.throne {
fill: var(--throne);
}
.square.corner {
fill: var(--corner);
}
.square.last {
fill: rgba(140, 200, 224, 0.28);
}
.square.target {
cursor: pointer;
}
.square.fallen {
fill: rgba(224, 101, 92, 0.45);
}
.square:focus-visible {
outline: none;
stroke: var(--frost);
stroke-width: 2.5;
}
.hint {
fill: var(--frost);
opacity: 0.85;
}
.piece {
transition:
transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
opacity 0.3s ease;
}
.piece.dying {
opacity: 0;
}
.board-rim { fill: none; stroke: var(--rim-light); stroke-width: 0.7; opacity: 0.65; pointer-events: none; }
.join { fill: none; stroke: #50351f; stroke-width: 0.6; opacity: 0.18; pointer-events: none; }
.surface-wear { pointer-events: none; }
.crack-light { fill: none; stroke: #e1d6bd; stroke-width: 0.55; opacity: 0.45; }
.inlay { fill: none; stroke: var(--rim-light); stroke-width: 0.8; opacity: 0.75; pointer-events: none; }
.board[data-set='tablut'] .inlay { stroke: #494638; opacity: 0.45; }
.piece { --piece-rim: var(--defender-rim); }
.piece.attacker { --piece-rim: var(--attacker-rim); }
.piece.king { --piece-rim: var(--king-rim); }
.piece .shadow { fill: #16130e; opacity: 0.28; }
.piece .base { fill: var(--piece-rim); }
.piece .body { stroke: var(--piece-rim); stroke-width: 0.65; }
.bevel-light { fill: none; stroke: #fff1cc; stroke-width: 0.9; opacity: 0.48; stroke-linecap: round; }
.bevel-dark { fill: none; stroke: #211d16; stroke-width: 0.8; opacity: 0.3; stroke-linecap: round; }
.turned { fill: none; stroke: var(--piece-rim); stroke-width: 0.45; opacity: 0.35; }
.counter-wear { fill: none; stroke: #534b37; stroke-width: 0.5; opacity: 0.3; stroke-linecap: round; }
.attacker .counter-wear { stroke: #d6c6a3; opacity: 0.23; }
.board[data-set='tablut'] .bevel-light { opacity: 0.25; stroke-width: 1.1; }
.board[data-set='tablut'] .turned { opacity: 0.12; }
.piece .crown { fill: var(--crown); stroke: var(--crown); stroke-width: 0.7; stroke-linejoin: round; }
.piece .crown-gleam { fill: #f8e5b2; stroke: #f8e5b2; stroke-width: 0.9; opacity: 0.7; }
.piece.selected .body { stroke: var(--frost); stroke-width: 2.5; }
.how {
max-width: 620px;
+348
View File
@@ -0,0 +1,348 @@
<script lang="ts">
// Shared by the interactive board, the hall, and the generated share artwork.
import { cellOf, cornersOf, isEdge, rulesetOf, throneOf, type State } from '$lib/game';
let { g, tokens, showCoords = false, selected = null, targets = [], shownSquares = [], shownCaptured = [], yourMove = false, calm = true, decorative = false, tap = () => {}, key = () => {} }: {
g: State;
tokens?: {id: number; piece: string; at: number; dying: boolean}[];
showCoords?: boolean; selected?: number | null; targets?: number[]; shownSquares?: number[]; shownCaptured?: number[];
yourMove?: boolean; calm?: boolean; decorative?: boolean;
tap?: (i: number) => void | Promise<void>; key?: (e: KeyboardEvent, i: number) => void;
} = $props();
const artId = $props.id();
const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => piece === '.' ? [] : [{ id: at, piece, at, dying: false }]));
const set = $derived(rulesetOf(g));
const size = $derived(g.size);
const worn = $derived(set.id === 'tablut');
const throne = $derived(throneOf(size));
const corners = $derived(set.markedCorners ? cornersOf(size) : []);
const CELL = 40;
const PAD_L = $derived(showCoords ? 16 : 6);
const PAD_T = 6;
const PAD_R = 6;
const PAD_B = $derived(showCoords ? 16 : 6);
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');
// --- Age: the Tablut board came out of a long hall. A few chipped corners
// and two hairline cracks, placed by a fixed hand so the board is the same
// board every visit; nothing here moves or changes with play.
const chips = $derived.by(() => {
if (!worn) return [] as { x: number; y: number; r: number; d: string }[];
const spots = [
[0, 0, 0], [size - 1, 2, 1], [3, size - 1, 2], [0, size - 3, 3], [size - 4, 0, 1]
] as const;
return spots.map(([cx, cy, corner]) => {
const x = PAD_L + cx * CELL + (corner === 1 || corner === 2 ? CELL : 0);
const y = PAD_T + cy * CELL + (corner >= 2 ? CELL : 0);
const sx = corner === 1 || corner === 2 ? -1 : 1;
const sy = corner >= 2 ? -1 : 1;
const a = 5 + ((cx * 7 + cy * 3) % 4);
const b = 4 + ((cx * 5 + cy) % 3);
return { x, y, r: 0, d: `M${x} ${y} l${sx * a} 0 l${-sx * (a - b)} ${sy * b} l${-sx * b} ${sy * (a - b + 1)} z` };
});
});
const cracks = $derived.by(() => {
if (!worn) return [] as string[];
const w = PAD_L + size * CELL;
const h = PAD_T + size * CELL;
return [
`M${PAD_L + 22} ${PAD_T} l3 11 -2 7 5 12 -1 9 4 8 -2 12 m-1 -29 -8 5 -3 7`,
`M${w} ${h - 62} l-12 3 -6 -2 -9 6 -7 1 -5 6 -10 3 m22 -10 -2 -7 -5 -4`
];
});
</script>
<svg xmlns="http://www.w3.org/2000/svg" class="artwork" data-set={set.id} class:calm viewBox="0 0 {width} {height}" role={decorative ? "img" : "grid"} aria-label={decorative ? `${set.name} board in its opening position` : "The board"}>
<defs>
<linearGradient id={`${artId}-surface`} x2="0.85" y2="1">
<stop stop-color="var(--surface-light)" />
<stop offset="0.48" stop-color="var(--wood)" />
<stop offset="1" stop-color="var(--surface-dark)" />
</linearGradient>
<filter id={`${artId}-grain`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency={worn ? '0.48' : '0.025 0.5'} numOctaves="3" seed="7" result="noise" />
<feColorMatrix in="noise" type="saturate" values="0" />
<feComponentTransfer result="soft"><feFuncA type="linear" slope={worn ? 0.2 : 0.13} /></feComponentTransfer>
<feBlend in="SourceGraphic" in2="soft" mode="multiply" />
</filter>
<filter id={`${artId}-worn`} x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="3" result="warp" />
<feDisplacementMap in="SourceGraphic" in2="warp" scale="1.15" xChannelSelector="R" yChannelSelector="G" />
</filter>
<filter id={`${artId}-mottle`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.025" numOctaves="3" seed="11" />
<feColorMatrix type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.17 0 0 0 0 0.12 0.28 0 0 0 0" />
<feComposite in2="SourceGraphic" operator="in" />
</filter>
{#each ['attacker', 'defender', 'king'] as kind (kind)}
<radialGradient id={`${artId}-${kind}`} cx="32%" cy="24%" r="80%">
<stop stop-color={`var(--${kind}-light)`} />
<stop offset={worn ? "0.85" : "0.65"} stop-color={`var(--${kind})`} />
<stop offset="1" stop-color={`var(--${kind}${worn ? "" : "-rim"})`} />
</radialGradient>
{/each}
</defs>
<rect class="wood" x="0.7" y="0.7" width={width - 1.4} height={height - 1.4} rx={worn ? 3 : 6} fill={`url(#${artId}-surface)`} filter={`url(#${artId}-grain)`} />
<rect class="board-rim" x="2" y="2" width={width - 4} height={height - 4} rx={worn ? 2 : 5} />
{#if worn}
<rect class="stain" x="2" y="2" width={width - 4} height={height - 4} rx="3" filter={`url(#${artId}-mottle)`} />
{:else}
{#each [0.22, 0.51, 0.79] as part (part)}
<path class="join" d={`M${PAD_L + size * CELL * part} 3 v${height - 6}`} />
{/each}
{/if}
<g class="surface-wear" aria-hidden="true">
{#each chips as chip (chip.d)}<path class="chip" d={chip.d} />{/each}
{#each cracks as crack (crack)}
<path class="crack-light" d={crack} transform="translate(0.35 0.4)" />
<path class="crack" d={crack} />
{/each}
</g>
{#each showCoords ? 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>
{#each g.cells as piece, i (i)}
{@const c = cellOf(size, i)}
{@const x = PAD_L + c.x * CELL}
{@const y = PAD_T + c.y * CELL}
<!-- Interactive squares have gridcell roles; illustrations have no tab stops. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<rect
class="square"
class:throne={i === throne}
class:corner={corners.includes(i)}
class:last={shownSquares.includes(i)}
class:target={targets.includes(i)}
class:fallen={shownCaptured.includes(i)}
class:edge={set.escape === 'edge' && isEdge(size, i)}
data-i={i}
{x}
{y}
width={CELL}
height={CELL}
role={decorative ? undefined : "gridcell"}
aria-label={`${label(i)}${piece === '.' ? '' : `, ${pieceWord(piece)}`}${targets.includes(i) ? ', a legal move' : ''}`}
aria-selected={decorative ? undefined : selected === i}
tabindex={decorative ? undefined : yourMove ? 0 : -1}
onclick={decorative ? undefined : () => tap(i)}
onkeydown={decorative ? undefined : (e) => key(e, i)}
/>
{#if targets.includes(i)}
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
{/if}
{/each}
{#each [throne, ...corners] as place (place)}
{@const c = cellOf(size, place)}
<g class="inlay" transform={`translate(${PAD_L + c.x * CELL + 20} ${PAD_T + c.y * CELL + 20})`} aria-hidden="true">
<path d="M0 -15 L15 0 0 15 -15 0 Z" />
{#if !worn}<path d="M-7 0 H7 M0 -7 V7" />{/if}
</g>
{/each}
{#each artworkTokens as t (t.id)}
{@const c = cellOf(size, t.at)}
{@const cx = PAD_L + c.x * CELL + CELL / 2}
{@const cy = PAD_T + c.y * CELL + CELL / 2}
<g
class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}"
class:selected={selected === t.at}
class:dying={t.dying}
style="transform: translate({cx}px, {cy}px)"
pointer-events="none"
>
<ellipse class="shadow" cx="1" cy="3" rx="14" ry="12.5" />
<g filter={worn ? `url(#${artId}-worn)` : undefined}>
<circle class="base" cy="1.2" r="13.6" />
<ellipse class="body" rx="13.2" ry={worn ? 12.6 + (t.id % 3) * 0.25 : 13.2} fill={`url(#${artId}-${t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'})`} />
<path class="bevel-light" d="M-11.5 3 A12 12 0 0 1 8 -9" />
<path class="bevel-dark" d="M11.5 -3 A12 12 0 0 1 -7 9.8" />
<circle class="turned" r="10.2" />
{#if worn}
{#each [0, 1, 2, 3, 4] as pit (pit)}
<circle class="pit" cx={((t.id * 7 + pit * 11) % 17) - 8} cy={((t.id * 3 + pit * 7) % 15) - 7} r={0.18 + (pit % 3) * 0.1} />
{/each}
<path class="counter-wear" transform={`rotate(${(t.id * 137) % 360})`} d="M-10 -6 l2 1 M4 10 l2 -1 M-5 -9 l3 0.6" />
{/if}
{#if t.piece === 'k'}
<path class="crown-gleam" transform="translate(0.25 0.55)" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
<path class="crown" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
{/if}
</g>
</g>
{/each}
</g>
</svg>
<style>
.artwork {
/* Copenhagen: a crafted oak board, polished counters and brass inlay. */
--surface-light: #bd935c;
--surface-dark: #79502d;
--rim-light: #e0bf84;
--attacker-light: #63564a;
--defender-light: #fff4d8;
--king-light: #ffe4a0;
--wood: #a77945;
--wood-line: rgba(30, 16, 6, 0.55);
--coord: #302216;
--square: rgba(255, 240, 210, 0.08);
--square-edge: rgba(255, 250, 235, 0.11);
--throne: rgba(240, 165, 58, 0.35);
--corner: rgba(109, 52, 29, 0.22);
--attacker: #2a2320;
--attacker-rim: #211a15;
--defender: #ece4d0;
--defender-rim: #b9ab8b;
--king: #f0d58a;
--king-rim: #a67c1a;
--crown: #6b4a0f;
}
/* Tablut: an excavated-object aesthetic, with limestone and aged counters. */
.artwork[data-set='tablut'] {
--surface-light: #aaa18b;
--surface-dark: #797564;
--rim-light: #c4b99e;
--attacker-light: #534f43;
--defender-light: #ded0ae;
--king-light: #e5cc92;
--wood: #8a8578;
--wood-line: rgba(36, 32, 27, 0.45);
--coord: #37362c;
--square: rgba(255, 250, 235, 0.05);
--square-edge: rgba(255, 250, 235, 0.09);
--throne: rgba(120, 100, 70, 0.4);
--corner: transparent;
--attacker: #3d3530;
--attacker-rim: #29281f;
--defender: #cfc4ad;
--defender-rim: #8c8069;
--king: #c9b27a;
--king-rim: #7d6636;
--crown: #5a4a2a;
}
.artwork {
width: 100%;
height: auto;
display: block;
}
.wood {
stroke: var(--surface-dark);
stroke-width: 1.4;
}
.stain {
fill: #fff;
pointer-events: none;
}
.chip {
fill: rgba(28, 24, 20, 0.42);
stroke: rgba(230, 222, 205, 0.18);
stroke-width: 0.6;
pointer-events: none;
}
.crack {
fill: none;
stroke: rgba(43, 39, 29, 0.48);
stroke-width: 0.5;
stroke-linecap: round;
pointer-events: none;
}
.coord {
fill: var(--coord);
font-family: var(--font);
font-size: 9px;
font-weight: 600;
pointer-events: none;
}
.square {
fill: var(--square);
stroke: var(--wood-line);
stroke-width: 0.65;
cursor: default;
transition: fill 0.3s ease;
}
.square.edge {
fill: var(--square-edge);
}
.square.throne {
fill: var(--throne);
}
.square.corner {
fill: var(--corner);
}
.square.last {
fill: rgba(140, 200, 224, 0.28);
}
.square.target {
cursor: pointer;
}
.square.fallen {
fill: rgba(224, 101, 92, 0.45);
}
.square:focus-visible {
outline: none;
stroke: var(--frost);
stroke-width: 2.5;
}
.hint {
fill: var(--frost);
opacity: 0.85;
}
.piece {
transition:
transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
opacity 0.3s ease;
}
.piece.dying {
opacity: 0;
}
.board-rim { fill: none; stroke: var(--rim-light); stroke-width: 0.7; opacity: 0.65; pointer-events: none; }
.join { fill: none; stroke: #50351f; stroke-width: 0.6; opacity: 0.18; pointer-events: none; }
.surface-wear { pointer-events: none; }
.crack-light { fill: none; stroke: #e1d6bd; stroke-width: 0.55; opacity: 0.45; }
.inlay { fill: none; stroke: var(--rim-light); stroke-width: 0.8; opacity: 0.75; pointer-events: none; }
.artwork[data-set='tablut'] .inlay { stroke: #494638; opacity: 0.45; }
.piece { --piece-rim: var(--defender-rim); }
.piece.attacker { --piece-rim: var(--attacker-rim); }
.piece.king { --piece-rim: var(--king-rim); }
.piece .shadow { fill: #16130e; opacity: 0.28; }
.piece .base { fill: var(--piece-rim); }
.piece .body { stroke: var(--piece-rim); stroke-width: 0.65; }
.bevel-light { fill: none; stroke: #fff1cc; stroke-width: 0.9; opacity: 0.48; stroke-linecap: round; }
.bevel-dark { fill: none; stroke: #211d16; stroke-width: 0.8; opacity: 0.3; stroke-linecap: round; }
.turned { fill: none; stroke: var(--piece-rim); stroke-width: 0.45; opacity: 0.35; }
.counter-wear { fill: none; stroke: #534b37; stroke-width: 0.5; opacity: 0.3; stroke-linecap: round; }
.attacker .counter-wear { stroke: #d6c6a3; opacity: 0.23; }
.artwork[data-set='tablut'] .bevel-light { opacity: 0.25; stroke-width: 1.1; }
.artwork[data-set='tablut'] .turned { display: none; }
.pit { fill: #544b38; opacity: 0.3; }
.attacker .pit { fill: #d6c6a3; opacity: 0.2; }
.piece .crown { fill: var(--crown); stroke: var(--crown); stroke-width: 0.7; stroke-linejoin: round; }
.piece .crown-gleam { fill: #f8e5b2; stroke: #f8e5b2; stroke-width: 0.9; opacity: 0.7; }
.piece.selected .body { stroke: var(--frost); stroke-width: 2.5; }
.calm .piece, .calm .square { transition: none; }
</style>
+4 -29
View File
@@ -11,7 +11,8 @@
import { unreadTalk } from '$lib/net/talk';
import type { RoomView } from '$lib/net/view';
import type { State } from '$lib/game';
import { game } from '$lib/game';
import { game, createGame } from '$lib/game';
import BoardArtwork from './BoardArtwork.svelte';
let name = $state(playerName());
let code = $state('');
@@ -22,15 +23,7 @@
);
let prefsOpen = $state(false);
/** 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);
const opening = $derived(createGame({ preview: 'Defenders', opponent: 'Attackers' }, 0, undefined, options));
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
@@ -217,21 +210,7 @@
</div>
<div class="lid-look" 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={still.n * 10} height={still.n * 10} rx="4" fill={still.wood} />
{#each Array.from({ length: still.n }, (_, r) => r) as r (r)}
{#each Array.from({ length: still.n }, (_, c) => c) as c (c)}
<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 still.a as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill={still.dark} stroke={still.darkRim} stroke-width="0.6" />
{/each}
{#each still.d as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill={still.pale} stroke={still.paleRim} stroke-width="0.6" />
{/each}
<circle cx={mid * 10 + 5} cy={mid * 10 + 5} r="3.6" fill={still.king} stroke={still.kingRim} stroke-width="0.6" />
</svg>
<div class="still"><BoardArtwork g={opening} decorative /></div>
</div>
{#if seats.length}
@@ -641,12 +620,8 @@
display: block;
margin: 0 auto;
border-radius: 4px;
transition: filter 0.9s ease;
}
.still.worn {
filter: contrast(0.85) sepia(0.15);
}
.tally {
display: grid;
grid-template-columns: max-content 1fr;