Проверено вживую в Chrome: лобби -> матч -> бот ходит -> результат -> статистика в лобби. Полный матч отыгран, оба исхода видел. Баги, которые собирались и типизировались, но падали только в рантайме: 1. Canvas не менял размер под экран. Своего cc.Widget у него не было, а alignCanvasWithScreen правит только камеру. Canvas оставался 1280x720, и все растянутые по нему дети — тоже. 2. [...map.values()] превращается сборкой в [].concat(map.values()), а concat не разворачивает итератор, а кладёт его одним элементом. Отсюда "view.cancelAnimations is not a function" на первой же уборке карт. Теперь Array.from. 3. Рубашка козыря рисовалась поверх колоды, а не под ней. Вёрстка переделана под требование UI.md (4:3 .. 21:9): - fitWidth даёт постоянную ширину, но не высоту: на 4:3 видно только ±720 юнитов. Кнопка "Leave" на y=840 и низ руки на -860 были за экраном именно там, где это важнее всего - рука и панель действий считаются от нижней границы экрана, "Leave" прибит виджетом к левому верхнему углу - стол — один ряд до шести карт вместо двух рядов по три: экономит ~215 юнитов высоты, которых на 4:3 нет. Ряд центрируется по тем картам, что реально на столе - затемнение под окном результата рисуется непрозрачным спрайтом: тонировать и без того полупрозрачный спрайт почти ничего не даёт Инструменты: - npm run serve — отдаёт сборку и роут /_aspects: игра в трёх фиксированных iframe-ах 4:3 / 16:9 / 21:9. Движок подгоняет себя под окно, поэтому iframe — единственный простой способ увидеть вёрстку в форме, которой окно не имеет - npm run check:spreads — ловит спред неитерируемого в массив - npm run check — чистота core, спреды, сцены, типы, тесты Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
9.6 KiB
JavaScript
232 lines
9.6 KiB
JavaScript
// 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 `<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>`;
|
||
}
|
||
|
||
/**
|
||
* 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 `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
|
||
<rect width="8" height="8" fill="#ffffff"/>
|
||
</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}/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);
|
||
});
|