// Builds every sprite the game ships from its sources, so the art in // assets/art and assets/resources 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/resources/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/resources/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 ` `; } /** * A plain opaque square, used as a full-screen dimmer behind dialogs. It has to * be fully opaque so that tinting it decides the darkness — tinting an already * translucent sprite multiplies the two and barely darkens anything. */ function fill() { 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 ` ${gradient} ${gloss} `; } const DRAWINGS = { [`${CARDS_OUT}/card_back.png`]: cardBack(), [`${UI_OUT}/table_bg.png`]: tableBackground(), [`${UI_OUT}/card_slot.png`]: cardSlot(), [`${UI_OUT}/fill.png`]: fill(), [`${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); });