Files
fool-cardgame/scripts/build-art.mjs
T
ftcandClaude Opus 5 2056673946 M4: художественный конвейер
- scripts/build-art.mjs: все спрайты игры собираются из исходников,
  а не кладутся руками. Лица карт растеризуются из векторов,
  рубашка/стол/панели/кнопки нарисованы SVG-ом прямо в скрипте
- 36 лиц 220x320 + рубашка, автоатлас 2048x2048, обрезка выключена
  (раскладка рассчитывает на точный размер карты)
- девятипатчевые плитки 120x120 с рамкой 40
- фон стола 540x960, рисуется в 2x — градиенту детализация не нужна,
  а памяти под текстуру уходит вчетверо меньше
- исходные векторы перенесены из local/ в art-src/cards
- ATTRIBUTION.md: LGPL-3.0, атрибуция обязательна; там же написано,
  что менять, если такая лицензия не подходит для релиза

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:52:37 +03:00

220 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<defs>
<linearGradient id="cloth" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a4f8f"/>
<stop offset="1" stop-color="#16305c"/>
</linearGradient>
<pattern id="lattice" width="26" height="26" patternUnits="userSpaceOnUse" patternTransform="rotate(45 0 0)">
<rect width="26" height="26" fill="none"/>
<path d="M13 0 V26 M0 13 H26" stroke="#4a78c4" stroke-width="2" stroke-opacity="0.55"/>
<circle cx="13" cy="13" r="3.2" fill="#7ea6e6" fill-opacity="0.5"/>
</pattern>
<clipPath id="inner">
<rect x="${inset}" y="${inset}" width="${w - inset * 2}" height="${h - inset * 2}" rx="10"/>
</clipPath>
</defs>
<rect x="1.5" y="1.5" width="${w - 3}" height="${h - 3}" rx="16" fill="#ffffff" stroke="${INK}" stroke-width="3"/>
<rect x="${inset}" y="${inset}" width="${w - inset * 2}" height="${h - inset * 2}" rx="10" fill="url(#cloth)"/>
<g clip-path="url(#inner)"><rect x="0" y="0" width="${w}" height="${h}" fill="url(#lattice)"/></g>
<rect x="${inset}" y="${inset}" width="${w - inset * 2}" height="${h - inset * 2}" rx="10"
fill="none" stroke="#0d1f3d" stroke-width="2"/>
<g transform="translate(${w / 2} ${h / 2})">
<path d="M0 -52 L34 0 L0 52 L-34 0 Z" fill="#16305c" stroke="#d9c07a" stroke-width="3"/>
<path d="M0 -30 L20 0 L0 30 L-20 0 Z" fill="none" stroke="#d9c07a" stroke-width="2.5"/>
<circle cx="0" cy="0" r="7" fill="#d9c07a"/>
</g>
</svg>`;
}
/**
* 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<defs>
<radialGradient id="felt" cx="0.5" cy="0.44" r="0.78">
<stop offset="0" stop-color="${FELT_LIGHT}"/>
<stop offset="0.55" stop-color="#145e37"/>
<stop offset="1" stop-color="${FELT_DARK}"/>
</radialGradient>
<linearGradient id="shade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000000" stop-opacity="0.28"/>
<stop offset="0.3" stop-color="#000000" stop-opacity="0"/>
<stop offset="0.75" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.42"/>
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="url(#felt)"/>
<rect width="${w}" height="${h}" fill="url(#shade)"/>
</svg>`;
}
/** Outline of a place where a card can go. Drawn under the table slots. */
function cardSlot() {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_W}" height="${CARD_H}" viewBox="0 0 ${CARD_W} ${CARD_H}">
<rect x="3" y="3" width="${CARD_W - 6}" height="${CARD_H - 6}" rx="16"
fill="#ffffff" fill-opacity="0.06"
stroke="#ffffff" stroke-opacity="0.34" stroke-width="4" stroke-dasharray="18 14"/>
</svg>`;
}
/** 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'
? `<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${fill.from}"/>
<stop offset="1" stop-color="${fill.to}"/>
</linearGradient>`
: '';
const paint = typeof fill === 'object' ? 'url(#f)' : fill;
const gloss = highlight
? `<path d="M${radius} ${strokeWidth + 2} H${size - radius}
A${radius - 4} ${radius - 4} 0 0 1 ${size - strokeWidth - 2} ${radius}
V${NINE_SLICE_BORDER - 2} H${strokeWidth + 2} V${radius}
A${radius - 4} ${radius - 4} 0 0 1 ${radius} ${strokeWidth + 2} Z"
fill="${highlight}" fill-opacity="0.30"/>`
: '';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<defs>${gradient}</defs>
<rect x="${strokeWidth / 2}" y="${strokeWidth / 2}"
width="${size - strokeWidth}" height="${size - strokeWidth}" rx="${radius}"
fill="${paint}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>
${gloss}
</svg>`;
}
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);
});