Игра запускается и играется: три рантайм-бага и адаптивная вёрстка

Проверено вживую в 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>
This commit is contained in:
ftc
2026-09-13 12:41:47 +03:00
co-authored by Claude Opus 5
parent 43f5bc5df3
commit 7fbe4c324d
14 changed files with 710 additions and 152 deletions
+12
View File
@@ -96,6 +96,17 @@ function tableBackground() {
</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}">
@@ -144,6 +155,7 @@ 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,
}),
+23
View File
@@ -49,6 +49,8 @@ function checkLayers(path, objects) {
}
}
const ALIGN_ALL = 45; // top | bottom | left | right
function checkCanvas(path, objects) {
const canvases = objects.filter((o) => o && o.__type__ === 'cc.Canvas');
if (path.endsWith('.scene') && canvases.length === 0) {
@@ -58,6 +60,27 @@ function checkCanvas(path, objects) {
if (!canvas._cameraComponent) {
problems.push(`${path}: a Canvas has no camera assigned`);
}
// The Canvas node needs its own Widget stretched to all four edges. It
// is what keeps the Canvas the size of the screen; without it the Canvas
// keeps whatever size it was authored at (1280x720 by default) and every
// widget-stretched child inherits that wrong size. `alignCanvasWithScreen`
// does NOT do this — it only adjusts the camera.
const node = objects[canvas.node?.__id__];
const components = (node?._components ?? []).map((ref) => objects[ref.__id__]);
const widget = components.find((c) => c && c.__type__ === 'cc.Widget');
if (!widget) {
problems.push(
`${path}: the Canvas node has no cc.Widget, so it will not resize `
+ `to the screen and every stretched child will be the wrong size`,
);
} else if (widget._alignFlags !== ALIGN_ALL) {
problems.push(
`${path}: the Canvas Widget is not stretched to all four edges `
+ `(alignFlags ${widget._alignFlags}, expected ${ALIGN_ALL})`,
);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// Flags array-spread of things that are not arrays.
//
// npm run check:spreads
//
// The engine's build transpiles `[...a, ...b]` to `[].concat(a, b)`. That is
// correct for arrays, but `Array.prototype.concat` does NOT expand an arbitrary
// iterable — it appends it as a single element. So
//
// for (const view of [...this._views.values()]) // Map iterator
//
// silently loops once, over the iterator object itself, and every property
// access on it is undefined. It type-checks, it reads perfectly, and it only
// fails at runtime on a real build. That cost a debugging session; hence this
// check. Use `Array.from(...)` instead, which transpiles safely.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = 'assets/scripts';
/** Spreads of an iterator or collection, rather than an array. */
const HAZARDS = [
{ pattern: /\.\.\.[\w.$]*\.values\(\)/, what: '.values()' },
{ pattern: /\.\.\.[\w.$]*\.keys\(\)/, what: '.keys()' },
{ pattern: /\.\.\.[\w.$]*\.entries\(\)/, what: '.entries()' },
{ pattern: /\.\.\.new (?:Map|Set)\b/, what: 'a new Map/Set' },
{ pattern: /\.\.\.[\w.$]*(?:Map|Set)\b(?!\s*\()/, what: 'something named like a Map/Set' },
];
const offenders = [];
function walk(dir) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) walk(path);
else if (path.endsWith('.ts')) inspect(path);
}
}
function inspect(path) {
readFileSync(path, 'utf8').split('\n').forEach((line, index) => {
const code = line.trim();
if (code.startsWith('*') || code.startsWith('//')) return;
for (const { pattern, what } of HAZARDS) {
if (pattern.test(code)) {
offenders.push(`${path}:${index + 1} spreads ${what} — use Array.from(...)\n ${code}`);
return;
}
}
});
}
walk(ROOT);
if (offenders.length > 0) {
console.error(
'Array spread of a non-array. The build turns [...x] into [].concat(x),\n'
+ 'which appends an iterator instead of expanding it:\n - '
+ offenders.join('\n - '),
);
process.exit(1);
}
console.log('spreads OK');
+102
View File
@@ -0,0 +1,102 @@
// Serves a build over HTTP for testing in a browser.
//
// npm run serve # build/web-mobile on http://localhost:8123
// npm run serve -- 9000 # a different port
//
// Also serves one route of its own, `/_aspects`, which loads the game in fixed
// size iframes at the portrait extremes docs/UI.md asks for (4:3 to 21:9). The
// engine resizes itself to fill whatever window it is in, so an iframe is the
// only straightforward way to see the layout at a shape the desktop window is
// not — and the harness lives here rather than in the build folder because a
// rebuild wipes everything inside it.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize, resolve } from 'node:path';
const ROOT = resolve(process.argv[3] ?? 'build/web-mobile');
const PORT = Number(process.argv[2] ?? 8123);
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.css': 'text/css',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.webp': 'image/webp',
'.wasm': 'application/wasm',
'.bin': 'application/octet-stream',
};
/** width:height as a portrait phone reports it, from the widest to the tallest. */
const RATIOS = [
['4:3 — widest', 3 / 4],
['16:9', 9 / 16],
['21:9 — tallest', 9 / 21],
];
function aspectsPage(height, only) {
// Booting three engines at once is slow enough to be painful; `?only=16:9`
// renders a single viewport when that is all you need.
const wanted = only
? RATIOS.filter(([label]) => label.toLowerCase().startsWith(only.toLowerCase()))
: RATIOS;
const cells = (wanted.length > 0 ? wanted : RATIOS).map(([label, ratio]) => {
const width = Math.round(height * ratio);
return `<div class="cell">
<h2>${label} &mdash; ${width}&times;${height}</h2>
<iframe src="index.html" width="${width}" height="${height}" loading="eager"></iframe>
</div>`;
}).join('\n');
return `<!doctype html>
<meta charset="utf-8">
<title>Fool — portrait layout at 4:3, 16:9 and 21:9</title>
<style>
body { margin: 0; background: #17181c; color: #ccd; font: 13px system-ui, sans-serif; }
.row { display: flex; gap: 18px; padding: 14px; align-items: flex-start; }
.cell h2 { font: 600 12px system-ui; margin: 0 0 6px; color: #8fa; text-align: center; }
iframe { border: 1px solid #3a3d45; background: #000; display: block; }
</style>
<div class="row">
${cells}
</div>`;
}
createServer(async (request, response) => {
const path = decodeURIComponent((request.url ?? '/').split('?')[0]);
if (path === '/_aspects' || path === '/_aspects.html') {
const query = new URL(request.url ?? '/', 'http://x').searchParams;
const height = Number(query.get('h')) || 760;
response.writeHead(200, { 'Content-Type': TYPES['.html'], 'Cache-Control': 'no-store' });
response.end(aspectsPage(height, query.get('only')));
return;
}
const file = join(ROOT, normalize(path === '/' ? '/index.html' : path));
if (!file.startsWith(ROOT)) {
response.writeHead(403);
response.end('forbidden');
return;
}
try {
const body = await readFile(file);
response.writeHead(200, {
'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream',
'Cache-Control': 'no-store',
});
response.end(body);
} catch {
response.writeHead(404);
response.end('not found');
}
}).listen(PORT, () => {
console.log(`serving ${ROOT}`);
console.log(` game http://localhost:${PORT}/`);
console.log(` aspects http://localhost:${PORT}/_aspects`);
console.log(` one http://localhost:${PORT}/_aspects?only=16:9&h=900`);
});