Files
ftcandClaude Opus 5 7fbe4c324d Игра запускается и играется: три рантайм-бага и адаптивная вёрстка
Проверено вживую в 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>
2026-09-13 12:41:47 +03:00

232 lines
9.6 KiB
JavaScript
Raw Permalink 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 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);
});