Проверено вживую в 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>
103 lines
3.8 KiB
JavaScript
103 lines
3.8 KiB
JavaScript
// 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} — ${width}×${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`);
|
|
});
|