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
+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(() => {});
}