Игра запускается и играется: три рантайм-бага и адаптивная вёрстка
Проверено вживую в 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:
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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} — ${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`);
|
||||
});
|
||||
Reference in New Issue
Block a user