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>
This commit is contained in:
ftc
2026-09-13 10:52:37 +03:00
co-authored by Claude Opus 5
parent 51117a698c
commit 2056673946
134 changed files with 17075 additions and 8 deletions
+219
View File
@@ -0,0 +1,219 @@
// 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);
});