/**
* Гейт turntable-листов: визуальная проверка сгенерированных моделей
* (урок №2). Лист = 6 моделей (деревья/валуны × варианты) × 4 поворота.
* Считает экранные центры ячеек той же проекцией, что VoxelRenderer,
* и попиксельно сравнивает кадр с моделями и кадр без них.
* Скриншоты: /tmp/v2_turntable.png, /tmp/v2_turntable_pads.png.
*
* Запуск: node apps/demo/tools/checks/turntable.mjs [--port 5299]
*/
import { readFileSync } from 'node:fs';
import { sleep, launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs';
import { decodePng } from '@rpg/engine/tools/png.mjs';
const args = parseArgs(process.argv.slice(2));
const port = Number(args.port ?? 5299);
const server = startDevServer({ port, cwd: new URL('../../', import.meta.url).pathname });
const report = { name: 'turntable', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
try {
await server.waitReady;
// страница 960×720 — вьюпорт с запасом, иначе низ листа обрезается кадром
const browser = await launchBrowser({ viewport: { width: 960, height: 760 } });
try {
const client = await openGame(browser, { url: `http://localhost:${port}/turntable.html` });
const errs = client.errors();
check('нет pageerror при загрузке листа', errs.length === 0, errs.slice(0, 3));
const info = await client.call('sheetInfo');
const stats = await client.call('stats');
check('меш листа собран (24 модели)', stats.triangles > 3000, stats);
check('лист: 6 моделей × 4 поворота', info.cols === 6 && info.rows === 4, info.models);
const full = decodePng(readFileSync(await client.screenshot('/tmp/v2_turntable.png')));
await client.call('setModels', false);
await sleep(300);
const pads = decodePng(readFileSync(await client.screenshot('/tmp/v2_turntable_pads.png')));
await client.call('setModels', true);
// Ячейки: проекция мирового центра ячейки той же матрицей, что камера
const cells = [];
for (let col = 0; col < info.cols; col++)
for (let row = 0; row < info.rows; row++) {
// центр ячейки — как на странице (целый: floor(pitch/2))
const wx = col * info.pitch + Math.floor(info.pitch / 2);
const wz = row * info.pitch + Math.floor(info.pitch / 2);
const [px, py] = worldToPx([wx, 3, wz], info);
cells.push({ col, row, px, py, diff: cellDiff(full, pads, px, py) });
}
const empty = cells.filter((c) => c.diff <= 150);
check('во всех 24 ячейках модель видна', empty.length === 0,
empty.length ? empty : { minDiff: Math.min(...cells.map((c) => c.diff)) });
// Варианты попарно различаются внутри группы (деревья 0-2, валуны 0-2)
const pairwise = [];
for (const [a, b] of [[0, 1], [1, 2], [0, 2], [3, 4], [4, 5], [3, 5]])
for (let row = 0; row < info.rows; row++) {
const ca = cells[a * info.rows + row], cb = cells[b * info.rows + row];
pairwise.push(cellDiff(full, full, ca.px, ca.py, cb.px, cb.py));
}
const same = pairwise.filter((d) => d <= 150);
check('варианты попарно различаются (6 пар × 4 ракурса)', same.length === 0,
same.length ? { same } : { minDiff: Math.min(...pairwise) });
report.ok = report.checks.every((c) => c.ok);
} finally {
await browser.close();
}
} catch (e) {
check('прогон без исключений', false, String(e));
} finally {
server.stop();
}
console.log(JSON.stringify(report, null, 2));
process.exit(report.ok ? 0 : 1);
/**
* Экранные координаты мировой точки — зеркалим VoxelRenderer: диметрия 2:1
* (yaw π/4, тангенс возвышения 1/2), ортокамера, глаз на target + dir*4*viewSize.
* Возвращает центр ячейки в пикселях скриншота (internal × scale).
*/
function worldToPx(p, info) {
const norm = (v) => { const l = Math.hypot(...v); return v.map((c) => c / l); };
const cross = (a, b) => [
a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0],
];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const dir = norm([Math.cos(Math.PI / 4) * 0.5, 1, Math.sin(Math.PI / 4) * 0.5]);
const xAxis = norm(cross([0, 1, 0], dir));
const yAxis = cross(dir, xAxis);
const eye = info.target.map((t, i) => t + dir[i] * info.viewSize * 4);
const rel = p.map((c, i) => c - eye[i]);
const aspect = info.width / info.height;
const ndcX = dot(rel, xAxis) / (info.viewSize * aspect);
const ndcY = dot(rel, yAxis) / info.viewSize;
return [
((ndcX + 1) / 2) * info.width * info.scale,
(1 - (ndcY + 1) / 2) * info.height * info.scale,
];
}
/**
* Число различающихся пикселей в эллипсе вокруг центров ячеек.
* Один центр (cA=cB) → сравнение двух кадров; два центра → двух ячеек.
* Эллипс 48×42 вписан в ромб подставки (8×8 юнитов, ~5.6 px/юнит,
* полуоси ромба ~32×28 px) — не заезжает на соседние ячейки.
*/
function cellDiff(a, b, pxA, pyA, pxB = pxA, pyB = pyA) {
const RX = 24, RY = 21;
let diff = 0;
for (let dy = -RY; dy <= RY; dy++)
for (let dx = -RX; dx <= RX; dx++) {
if ((dx / RX) ** 2 + (dy / RY) ** 2 > 1) continue;
const ia = pixelIndex(a, pxA + dx, pyA + dy);
const ib = pixelIndex(b, pxB + dx, pyB + dy);
for (let k = 0; k < 3; k++) if (Math.abs(a.rgba[ia + k] - b.rgba[ib + k]) > 8) { diff++; break; }
}
return diff;
}
function pixelIndex(png, x, y) {
const cx = Math.min(png.width - 1, Math.max(0, Math.round(x)));
const cy = Math.min(png.height - 1, Math.max(0, Math.round(y)));
return (cy * png.width + cx) * 4;
}