// Builds every sprite the game ships from its sources, so the art in
// assets/art is reproducible rather than hand-placed.
//
// node scripts/build-art.mjs
//
// Card faces are rendered from the vector sources in art-src/cards (see
// assets/art/cards/ATTRIBUTION.md). Everything else — the card back, the table,
// the UI chrome — is drawn here as SVG and rasterised, which keeps it tweakable
// in one place instead of scattered across binary files.
//
// Sizes follow docs/ART.md: the screen is 1080 design units wide, so nothing is
// authored larger than it will ever be drawn.
import { mkdirSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import sharp from 'sharp';
const CARD_SRC = 'art-src/cards';
const CARDS_OUT = 'assets/art/cards';
const UI_OUT = 'assets/art/ui';
/** Card sprite size. 37 of these pack into a single 2048² atlas with room to spare. */
export const CARD_W = 220;
export const CARD_H = 320;
const INK = '#141414';
const FELT_DARK = '#0b3c22';
const FELT_LIGHT = '#1b7a48';
// ---------------------------------------------------------------------------
// Drawings
// ---------------------------------------------------------------------------
/**
* The back of a card. Matches the silhouette of the faces — same rounded
* rectangle, same dark hairline — so a face-down card sits in a fan without
* looking like a different object.
*/
function cardBack() {
const inset = 11;
const w = CARD_W;
const h = CARD_H;
return ``;
}
/**
* The table. Authored at half the design resolution and drawn at 2× — it is a
* soft gradient with no detail to lose, and that saves three quarters of the
* texture memory for no visible difference.
*/
function tableBackground() {
const w = 540;
const h = 960;
return ``;
}
/** Outline of a place where a card can go. Drawn under the table slots. */
function cardSlot() {
return ``;
}
/** Nine-slice border used by every tile below, and by the sprites in the scene. */
export const NINE_SLICE_BORDER = 40;
/**
* A nine-slice tile: 120×120 with a 40px border, so only the flat middle 40×40
* is ever stretched and a button scales to any size without distorting its
* corners. Nothing with detail may cross into that middle band — the gloss
* below stops just short of it for exactly that reason.
*/
function nineSlice({ fill, stroke, strokeWidth = 4, radius = 34, highlight = null, fillOpacity = 1 }) {
const size = 120;
const gradient = typeof fill === 'object'
? ``
: '';
const paint = typeof fill === 'object' ? 'url(#f)' : fill;
const gloss = highlight
? ``
: '';
return ``;
}
const DRAWINGS = {
[`${CARDS_OUT}/card_back.png`]: cardBack(),
[`${UI_OUT}/table_bg.png`]: tableBackground(),
[`${UI_OUT}/card_slot.png`]: cardSlot(),
[`${UI_OUT}/panel.png`]: nineSlice({
fill: '#062718', fillOpacity: 0.88, stroke: '#2f7a52', strokeWidth: 4, radius: 34,
}),
[`${UI_OUT}/panel_light.png`]: nineSlice({
fill: '#ffffff', fillOpacity: 0.12, stroke: '#ffffff', strokeWidth: 3, radius: 30,
}),
[`${UI_OUT}/button.png`]: nineSlice({
fill: { from: '#f4c245', to: '#c98a0e' }, stroke: '#7d5406', strokeWidth: 5,
radius: 34, highlight: '#ffffff',
}),
[`${UI_OUT}/button_pressed.png`]: nineSlice({
fill: { from: '#c99a20', to: '#a06d08' }, stroke: '#6a4605', strokeWidth: 5, radius: 34,
}),
[`${UI_OUT}/button_disabled.png`]: nineSlice({
fill: { from: '#6d6d6d', to: '#4a4a4a' }, stroke: '#333333', strokeWidth: 5, radius: 34,
}),
[`${UI_OUT}/button_quiet.png`]: nineSlice({
fill: '#0d3624', fillOpacity: 0.9, stroke: '#3f8f63', strokeWidth: 4, radius: 34,
}),
};
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
async function buildCardFaces() {
const sources = readdirSync(CARD_SRC).filter((f) => f.endsWith('.svg'));
if (sources.length !== 36) {
throw new Error(`Expected 36 card sources in ${CARD_SRC}, found ${sources.length}`);
}
for (const file of sources) {
// A high density makes librsvg rasterise from the vectors at full size
// before the resize, instead of scaling up a small bitmap.
await sharp(join(CARD_SRC, file), { density: 600 })
.resize(CARD_W, CARD_H, { fit: 'fill' })
.png({ compressionLevel: 9 })
.toFile(join(CARDS_OUT, file.replace(/\.svg$/, '.png')));
}
return sources.length;
}
async function buildDrawings() {
for (const [target, svg] of Object.entries(DRAWINGS)) {
// Rasterise well above the final size and scale back down, so the
// rounded corners and hairlines come out properly anti-aliased. The
// authored size is taken from the SVG itself.
const width = Number(/width="(\d+)"/.exec(svg)[1]);
const height = Number(/height="(\d+)"/.exec(svg)[1]);
await sharp(Buffer.from(svg), { density: 72 * 4 })
.resize(width, height, { fit: 'fill' })
.png({ compressionLevel: 9 })
.toFile(target);
}
return Object.keys(DRAWINGS).length;
}
async function main() {
mkdirSync(CARDS_OUT, { recursive: true });
mkdirSync(UI_OUT, { recursive: true });
const faces = await buildCardFaces();
const drawings = await buildDrawings();
console.log(`${faces} card faces -> ${CARDS_OUT}`);
console.log(`${drawings} generated sprites`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});