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
+19
View File
@@ -0,0 +1,19 @@
# Board artwork
`src/lib/components/BoardArtwork.svelte` owns both palettes, surface filters,
wear, grid, inlays and counters. `Board.svelte` supplies interactive state;
`Hall.svelte` supplies an opening position from the game engine. Update the
shared component to change either illustration and the game together.
After editing the artwork, run:
```sh
npm run art
```
This renders the same Svelte component into `static/board-copenhagen.svg` and
`static/board-tablut.svg`, then regenerates `docs/og-card.svg` and the 1200 × 630
`static/og.png`. Preview the share composition in `docs/og-card.html`.
The share composition and copy live in `docs/render-art.mjs`. Commit generated
assets with source changes. The export uses Sharp as a development dependency;
it does not add any image-processing code to the shipped app.
+12 -32
View File
@@ -1,33 +1,13 @@
<!doctype html> <!doctype html>
<html><head><meta charset="utf-8"><style> <html lang="en">
html,body{margin:0;background:#14181a} <head>
.card{width:1200px;height:630px;display:flex;align-items:center;gap:64px;padding:0 80px;box-sizing:border-box;background:#14181a;color:#e6dfcf;font-family:'Iowan Old Style',Georgia,serif} <meta charset="utf-8">
.board{flex:0 0 470px;border-radius:8px;box-shadow:0 20px 50px rgba(0,0,0,.6)} <meta name="viewport" content="width=device-width, initial-scale=1">
h1{font-weight:300;font-size:92px;letter-spacing:-.01em;margin:0 0 14px} <title>Hnefatafl share card</title>
p{font-size:31px;line-height:1.35;color:#9ea59e;margin:0} <style>html,body{margin:0;background:#14181a}img{display:block;width:1200px;height:630px;max-width:100%;object-fit:contain;object-position:top left}</style>
p b{color:#e6dfcf;font-weight:500} </head>
.foot{margin-top:28px;font-size:24px;color:#626b66} <body>
</style></head><body><div class="card"> <!-- Generated from BoardArtwork.svelte by npm run art; also produces static/og.png. -->
<svg class="board" viewBox="0 0 110 110" xmlns="http://www.w3.org/2000/svg"> <img src="og-card.svg" width="1200" height="630" alt="Hnefatafl. The Viking board game. Play a housecarl or a friend, Copenhagen or Linnaeuss Tablut. Free, no accounts. tafl.kestrelsnest.social">
<rect width="110" height="110" rx="4" fill="#8a5a2b"/> </body>
<g id="grid"></g> </html>
</svg>
<div>
<h1>Hnefatafl</h1>
<p><b>The Viking board game.</b> One king, one throne, four corners, and a ring of attackers closing in. Play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut.</p>
<p class="foot">tafl.kestrelsnest.social · free, no accounts</p>
</div></div>
<script>
const g = document.getElementById('grid'); const ns = 'http://www.w3.org/2000/svg';
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); g.appendChild(e); };
for (let r = 0; r < 11; r++) for (let c = 0; c < 11; c++) {
const corner = (r === 0 || r === 10) && (c === 0 || c === 10), throne = r === 5 && c === 5;
el('rect', { x: c * 10, y: r * 10, width: 10, height: 10, fill: corner ? 'rgba(224,101,92,0.4)' : throne ? 'rgba(240,165,58,0.4)' : 'rgba(255,240,210,0.08)', stroke: 'rgba(30,16,6,0.5)', 'stroke-width': 0.4 });
}
const 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]];
const 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]];
for (const [x, y] of a) el('circle', { cx: x * 10 + 5, cy: y * 10 + 5, r: 3.4, fill: '#2a2320', stroke: '#0d0a08', 'stroke-width': 0.6 });
for (const [x, y] of d) el('circle', { cx: x * 10 + 5, cy: y * 10 + 5, r: 3.4, fill: '#ece4d0', stroke: '#b9ab8b', 'stroke-width': 0.6 });
el('circle', { cx: 55, cy: 55, r: 3.6, fill: '#f0d58a', stroke: '#a67c1a', 'stroke-width': 0.6 });
el('path', { d: 'M52.6 56.4 l0 -2.6 l1.3 1.4 l1.1 -2.3 l1.1 2.3 l1.3 -1.4 l0 2.6 z', fill: '#6b4a0f' });
</script></body></html>
+16
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

+56
View File
@@ -0,0 +1,56 @@
// Run with npm run art. Both illustrations originate in the live board component.
import { readFile, writeFile, unlink } from 'node:fs/promises';
import { compile } from 'svelte/compiler';
import { render } from 'svelte/server';
import sharp from 'sharp';
import { createGame } from '../src/lib/game/index.ts';
const root = new URL('../', import.meta.url);
const source = await readFile(new URL('src/lib/components/BoardArtwork.svelte', root), 'utf8');
const compiled = compile(source.replace("'$lib/game'", "'../src/lib/game/index.ts'"), { generate: 'server', css: 'injected', filename: 'BoardArtwork.svelte' });
const temporary = new URL(`.board-art-${process.pid}.mjs`, import.meta.url);
try {
await writeFile(temporary, compiled.js.code);
const { default: BoardArtwork } = await import(temporary.href);
for (const set of ['copenhagen', 'tablut']) {
const result = render(BoardArtwork, { props: { g: createGame({ a: 'Defenders', b: 'Attackers' }, 0, undefined, { set }), decorative: true } });
let svg = result.body.replace(/<!--[\s\S]*?-->/g, '');
const css = [...result.head.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map(m => m[1]).join('\n');
svg = svg.replace(/(<svg[^>]*>)/, '$1<style>' + css + '</style>');
// Resolve custom properties for standalone SVG rasterizers, which do not
// consistently implement CSS variable inheritance. Browsers use the component.
const paletteSource = source.slice(source.indexOf('/* Copenhagen:'));
const base = paletteSource.slice(0, paletteSource.indexOf('/* Tablut:'));
const overrides = set === 'tablut' ? paletteSource.slice(paletteSource.indexOf('/* Tablut:'), paletteSource.indexOf('\n\t.artwork {')) : '';
const values = Object.fromEntries([...`${base}\n${overrides}`.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map(m => [m[1], m[2]]));
values['--piece-rim'] = values['--defender-rim'];
values['--font'] = 'Georgia, serif';
values['--frost'] = '#9ccbdd';
svg = svg.replace(/:where\((\.[\w-]+)\)/g, '$1');
svg = svg.replace(/var\((--[\w-]+)\)/g, (_, key) => values[key] ?? 'inherit');
svg = svg.replace('</style>', ['attacker', 'defender', 'king'].map(kind => `.piece.${kind} .base{fill:${values[`--${kind}-rim`]}}.piece.${kind} .body,.piece.${kind} .turned{stroke:${values[`--${kind}-rim`]}}`).join('') + '</style>');
await writeFile(new URL(`static/board-${set}.svg`, root), svg);
}
const board = await readFile(new URL('static/board-copenhagen.svg', root), 'utf8');
const image = Buffer.from(board).toString('base64');
const share = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#14181a"/>
<image x="62" y="80" width="470" height="470" href="data:image/svg+xml;base64,${image}"/>
<g font-family="Georgia, serif">
<text x="590" y="180" font-size="82" fill="#e6dfcf">Hnefatafl</text>
<text x="594" y="246" font-size="30" fill="#e6dfcf">The Viking board game.</text>
<text x="594" y="294" font-size="27" fill="#aeb3ab">
<tspan x="594">One king, one throne, four corners,</tspan>
<tspan x="594" dy="38">and a ring of attackers closing in.</tspan>
<tspan x="594" dy="50">Play a housecarl or a friend.</tspan>
<tspan x="594" dy="38">Copenhagen or Linnaeuss Tablut.</tspan>
</text>
<text x="594" y="491" font-size="24" fill="#c5ae7b">tafl.kestrelsnest.social</text>
<text x="594" y="526" font-size="22" fill="#9ea59e">Free · no accounts</text>
</g>
</svg>`;
await writeFile(new URL('docs/og-card.svg', root), share);
await sharp(Buffer.from(share)).png().toFile(new URL('static/og.png', root).pathname);
console.log('Generated both board illustrations and static/og.png (1200 × 630).');
} finally {
await unlink(temporary).catch(() => {});
}
+617
View File
@@ -16,6 +16,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.6.2", "@types/node": "^26.6.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"sharp": "^0.35.4",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tsx": "^4.23.15", "tsx": "^4.23.15",
@@ -24,6 +25,17 @@
"vitest": "^5.0.1" "vitest": "^5.0.1"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -466,6 +478,533 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -828,6 +1367,7 @@
"integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/acorn-typescript": "^1.0.9",
@@ -880,6 +1420,7 @@
"integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"magic-string": "^1.0.0", "magic-string": "^1.0.0",
@@ -1010,6 +1551,7 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1708,6 +2250,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
@@ -1715,6 +2270,56 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1767,6 +2372,7 @@
"integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"@jridgewell/sourcemap-codec": "^1.6.0", "@jridgewell/sourcemap-codec": "^1.6.0",
@@ -1860,12 +2466,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.23.15", "version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.28.0" "esbuild": "~0.28.0"
}, },
@@ -1885,6 +2500,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -1906,6 +2522,7 @@
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
+3 -1
View File
@@ -13,7 +13,8 @@
"test:watch": "vitest", "test:watch": "vitest",
"server": "tsx watch server/src/index.ts", "server": "tsx watch server/src/index.ts",
"server:start": "tsx server/src/index.ts", "server:start": "tsx server/src/index.ts",
"check:server": "tsc --noEmit -p server" "check:server": "tsc --noEmit -p server",
"art": "node --import tsx docs/render-art.mjs"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@@ -21,6 +22,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.6.2", "@types/node": "^26.6.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"sharp": "^0.35.4",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tsx": "^4.23.15", "tsx": "^4.23.15",
+4 -320
View File
@@ -7,12 +7,12 @@
// Tablut evokes an excavated stone board and worn counters. This is an // Tablut evokes an excavated stone board and worn counters. This is an
// artistic treatment, not a claim to reconstruct a particular find. // artistic treatment, not a claim to reconstruct a particular find.
import TableTalk from './TableTalk.svelte'; 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 type { Room } from '$lib/net/room.svelte';
import { prefs, reducedMotion } from '$lib/prefs.svelte'; import { prefs, reducedMotion } from '$lib/prefs.svelte';
let { room }: { room: Room } = $props(); let { room }: { room: Room } = $props();
const artId = $props.id();
const g = $derived(room.state!); const g = $derived(room.state!);
const set = $derived(rulesetOf(g)); const set = $derived(rulesetOf(g));
@@ -36,47 +36,12 @@
const sideOfPly = (ply: number): Side => (ply % 2 === 1 ? 'attackers' : 'defenders'); const sideOfPly = (ply: number): Side => (ply % 2 === 1 ? 'attackers' : 'defenders');
// --- Coordinates --------------------------------------------------------- // --- 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 label = (i: number) => {
const c = cellOf(size, i); const c = cellOf(size, i);
return `${String.fromCharCode(97 + c.x)}${size - c.y}`; return `${String.fromCharCode(97 + c.x)}${size - c.y}`;
}; };
const pieceWord = (p: string) => (p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender'); 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 --- // --- Tokens: pieces with a life of their own, so a move slides and a capture fades ---
interface Token { interface Token {
id: number; id: number;
@@ -268,123 +233,8 @@
{/if} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
<div class="frame" class:calm> <div class="frame">
<svg viewBox="0 0 {width} {height}" role="grid" aria-label="The board"> <BoardArtwork {g} {tokens} {showCoords} {selected} {targets} {shownSquares} {shownCaptured} {yourMove} {calm} {tap} {key} />
<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> </div>
<p class="visually-hidden" aria-live="polite">{liveText}</p> <p class="visually-hidden" aria-live="polite">{liveText}</p>
@@ -436,51 +286,6 @@
padding: 0 var(--gutter); padding: 0 var(--gutter);
max-width: 1280px; max-width: 1280px;
margin: 0 auto; 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) { @media (max-width: 860px) {
@@ -540,132 +345,11 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.calm .piece,
.calm .square,
.calm .wood {
transition: none;
}
.frame { .frame {
max-width: 620px; max-width: 620px;
margin: 0 auto; 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 { .how {
max-width: 620px; 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 { unreadTalk } from '$lib/net/talk';
import type { RoomView } from '$lib/net/view'; import type { RoomView } from '$lib/net/view';
import type { State } from '$lib/game'; 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 name = $state(playerName());
let code = $state(''); let code = $state('');
@@ -22,15 +23,7 @@
); );
let prefsOpen = $state(false); let prefsOpen = $state(false);
/** The hall's still of the opening position, on whichever board the selector names. */ const opening = $derived(createGame({ preview: 'Defenders', opponent: 'Attackers' }, 0, undefined, options));
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());
@@ -217,21 +210,7 @@
</div> </div>
<div class="lid-look" aria-hidden="true"> <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"> <div class="still"><BoardArtwork g={opening} decorative /></div>
<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> </div>
{#if seats.length} {#if seats.length}
@@ -641,12 +620,8 @@
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;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr;
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 260 KiB