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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user