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:
@@ -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
@@ -1,33 +1,13 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;background:#14181a}
|
||||
.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}
|
||||
.board{flex:0 0 470px;border-radius:8px;box-shadow:0 20px 50px rgba(0,0,0,.6)}
|
||||
h1{font-weight:300;font-size:92px;letter-spacing:-.01em;margin:0 0 14px}
|
||||
p{font-size:31px;line-height:1.35;color:#9ea59e;margin:0}
|
||||
p b{color:#e6dfcf;font-weight:500}
|
||||
.foot{margin-top:28px;font-size:24px;color:#626b66}
|
||||
</style></head><body><div class="card">
|
||||
<svg class="board" viewBox="0 0 110 110" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="110" height="110" rx="4" fill="#8a5a2b"/>
|
||||
<g id="grid"></g>
|
||||
</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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hnefatafl share card</title>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Generated from BoardArtwork.svelte by npm run art; also produces static/og.png. -->
|
||||
<img src="og-card.svg" width="1200" height="630" alt="Hnefatafl. The Viking board game. Play a housecarl or a friend, Copenhagen or Linnaeus’s Tablut. Free, no accounts. tafl.kestrelsnest.social">
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 57 KiB |
@@ -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 Linnaeus’s 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(() => {});
|
||||
}
|
||||
Generated
+617
@@ -16,6 +16,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"sharp": "^0.35.4",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tsx": "^4.23.15",
|
||||
@@ -24,6 +25,17 @@
|
||||
"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": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -466,6 +478,533 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -828,6 +1367,7 @@
|
||||
"integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
||||
@@ -880,6 +1420,7 @@
|
||||
"integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"magic-string": "^1.0.0",
|
||||
@@ -1010,6 +1551,7 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1708,6 +2250,19 @@
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
|
||||
@@ -1715,6 +2270,56 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -1767,6 +2372,7 @@
|
||||
"integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0",
|
||||
@@ -1860,12 +2466,21 @@
|
||||
"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": {
|
||||
"version": "4.23.15",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
@@ -1885,6 +2500,7 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -1906,6 +2522,7 @@
|
||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
|
||||
+3
-1
@@ -13,7 +13,8 @@
|
||||
"test:watch": "vitest",
|
||||
"server": "tsx watch 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": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -21,6 +22,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"sharp": "^0.35.4",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tsx": "^4.23.15",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 260 KiB |
Reference in New Issue
Block a user