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

Проверено вживую в 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
+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`);
});