/**
* Гейт камеры v2 (трек «Режимы камеры»): старт в изо с эталонным кадром,
* third/first — перспектива работает (кадры различаются, поворот/дистанция
* меняют кадр), мышь-ввод (drag крутит орбиту, колесо — дистанция),
* следование за героем во всех режимах, туман в перспективе, окно частиц
* без NaN, возврат в изо ПОПИКСЕЛЬНО равен эталону (главный замок
* обратимости), кнопки дев-панели переключают режим. Гейт начинает и
* заканчивает в изо; режим в сейв не пишется. Скриншоты: /tmp/v2_camera_*.png.
*
* Грабля детерминизма: атмосфера глушится (setAsh(false) — осадки/пепел/
* дымка/блики/листья с общим elapsed уходят), фауна выключается, часы
* перематываются setClock(720) ПЕРЕД каждым снимком + stepTicks(1) —
* тогда кадр при прочих равных воспроизводится бит-в-бит.
*
* Запуск: node apps/game/tools/checks/camera.mjs [--port 5336]
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { 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 ?? 5336);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'camera', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
/** Число различающихся пикселей двух кадров (порог 8 на канал). */
function countDiff(a, b) {
let n = 0;
for (let i = 0; i < a.length; i += 4) {
if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++;
}
return n;
}
const finiteBox = (w) => [w.minX, w.maxX, w.minZ, w.maxZ].every(Number.isFinite);
async function shot(client, path) {
await client.screenshot(path);
return decodePng(readFileSync(path)).rgba;
}
/** Кадр в детерминированном состоянии: часы перемотаны, один тик логики
* (грабля ручного режима: скриншот — последний отрисованный кадр). */
async function stableShot(client, path) {
await client.call('setClock', 720);
await client.call('stepTicks', 1);
return shot(client, path);
}
try {
await server.waitReady;
const browser = await launchBrowser();
try {
const client = await openGame(browser, { url: `http://localhost:${port}/` });
await client.call('startGame', false); // страница стартует в меню (меню/автосейв)
const errs = client.errors();
check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3));
await client.call('setManual', true);
await client.call('setAsh', false); // детерминизм кадра: без атмосферы
await client.call('setFauna', false);
await client.call('setFollow', false); // цель — float-центр спавна
// DOM-оверлей fps обновляется каждый кадр — в кадр-равенства не входит
await client.evaluate(() => { document.getElementById('fps').hidden = true; });
const hero0 = await client.call('heroInfo'); // спавн (герой вернётся сюда)
// ---- Эталон изо-кадра (и его повторяемость) ----
let ci = await client.call('cameraInfo');
check('старт в изо (дефолт режима, в сейв не пишется)', ci.mode === 'iso', ci.mode);
const ref = await stableShot(client, '/tmp/v2_camera_iso.png');
const ref2 = await stableShot(client, '/tmp/v2_camera_iso_ref2.png');
check('эталон повторяем бит-в-бит (детерминизм ручного тика)',
countDiff(ref, ref2) === 0, { diff: countDiff(ref, ref2) });
// ---- Third person: перспектива, поворот, дистанция ----
await client.call('setCameraMode', 'third');
await client.call('stepTicks', 1);
ci = await client.call('cameraInfo');
const third = await shot(client, '/tmp/v2_camera_third.png');
check('→ third: режим активен, кадр отличается от изо',
ci.mode === 'third' && countDiff(third, ref) > 1000,
{ mode: ci.mode, diff: countDiff(third, ref) });
const yaw0 = ci.yaw;
await client.call('setOrbit', { yaw: yaw0 + 1.2 });
await client.call('stepTicks', 1);
const yawed = await shot(client, '/tmp/v2_camera_third_yaw.png');
check('поворот орбиты меняет кадр', countDiff(yawed, third) > 500,
{ diff: countDiff(yawed, third) });
const ci2 = await client.call('cameraInfo');
await client.call('setOrbit', { dist: ci2.dist * 0.5 });
await client.call('stepTicks', 1);
const close = await shot(client, '/tmp/v2_camera_third_dist.png');
check('дистанция орбиты меняет кадр', countDiff(close, yawed) > 500,
{ diff: countDiff(close, yawed) });
// ---- Мышь-ввод: drag крутит yaw, колесо в third — дистанция ----
const before = (await client.call('cameraInfo')).yaw;
await client.evaluate(() => {
const c = document.getElementById('scene');
const opts = (x, y) => ({ bubbles: true, button: 0, pointerId: 7, clientX: x, clientY: y });
c.dispatchEvent(new PointerEvent('pointerdown', opts(200, 200)));
c.dispatchEvent(new PointerEvent('pointermove', opts(320, 200)));
c.dispatchEvent(new PointerEvent('pointerup', opts(320, 200)));
});
await client.call('stepTicks', 1);
const after = (await client.call('cameraInfo')).yaw;
check('drag ЛКМ по канвасу крутит орбиту', Math.abs(after - before) > 0.1,
{ before, after });
const d0 = (await client.call('cameraInfo')).dist;
await client.evaluate(() => {
document.getElementById('scene').dispatchEvent(
new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }),
);
});
await client.call('stepTicks', 1);
const d1 = (await client.call('cameraInfo')).dist;
check('колесо в third отдаляет камеру', d1 > d0 * 1.05, { d0, d1 });
// ---- Следование в third ----
await client.call('heroTeleport', 30, 30);
await client.call('stepTicks', 1);
const hi = await client.call('heroInfo');
const followDx = Math.hypot(hi.camTarget[0] - hi.pos[0], hi.camTarget[2] - hi.pos[2]);
check('следование в third: цель камеры едет за героем', followDx < 2, { followDx });
// ---- First person: глаза героя, герой скрыт, цель едет ----
await client.call('setCameraMode', 'first');
await client.call('stepTicks', 1);
ci = await client.call('cameraInfo');
const first = await shot(client, '/tmp/v2_camera_first.png');
const eyeH = ci.pos[1] - hi.camTarget[1];
check('→ first: режим активен, глаза над целью (~10), кадр другой',
ci.mode === 'first' && Math.abs(eyeH - 10) < 0.5 && countDiff(first, third) > 1000,
{ mode: ci.mode, eyeH, diff: countDiff(first, third) });
await client.call('heroTeleport', 36, 34);
await client.call('stepTicks', 1);
const ci3 = await client.call('cameraInfo');
const movedDx = Math.hypot(ci3.pos[0] - 36, ci3.pos[2] - 34);
check('first: камера (глаза) поехала за героем', movedDx < 2, { movedDx });
// ---- Туман в перспективе: конечные числа, кадр меняется ----
await client.call('setWeather', { fog: 0.8 });
await client.call('stepTicks', 3);
const fi = await client.call('fogInfo');
const fogged = await shot(client, '/tmp/v2_camera_first_fog.png');
const fogFinite = Number.isFinite(fi.strength) && (fi.bands === undefined || Number.isFinite(fi.bands));
check('туман в перспективе: сила конечна, кадр меняется',
fogFinite && fi.strength > 0.5 && countDiff(fogged, first) > 500,
{ fi, diff: countDiff(fogged, first) });
await client.call('setWeather', null); // правка тумана не тащится дальше
await client.call('stepTicks', 1);
// ---- Окно частиц без NaN (перспектива) ----
const w = await client.call('windowInfo', 0, 40);
check('windowInfo в перспективе: окно и камера — конечные числа',
finiteBox(w) && Number.isFinite(w.cam.pos[1]), w.cam);
// ---- Возврат в изо: кадр попиксельно равен эталону ----
await client.call('heroTeleport', hero0.pos[0], hero0.pos[2]); // герой на спавне
await client.call('setAsh', false); // состояние мира = эталонное (погода/частицы)
await client.call('setCameraMode', 'iso');
await client.call('setFollow', false); // та же float-цель спавна, что у эталона
const heroR = await client.call('heroInfo');
const ret = await stableShot(client, '/tmp/v2_camera_iso_return.png');
const ret2 = await stableShot(client, '/tmp/v2_camera_iso_return2.png');
check('возврат в изо: кадр попиксельно равен эталону (обратимость)',
countDiff(ret, ref) === 0, {
diff: countDiff(ret, ref),
rep: countDiff(ret, ret2),
heroYaw: [hero0.yaw, heroR.yaw], phase: [hero0.phase, heroR.phase],
});
// ---- Кнопки дев-панели ----
await client.call('devOpen');
await client.evaluate(() => document.getElementById('dev-cam-third').click());
const mThird = (await client.call('cameraInfo')).mode;
await client.evaluate(() => document.getElementById('dev-cam-iso').click());
const mIso = (await client.call('cameraInfo')).mode;
check('дев-панель: кнопки переключают режим (third → iso)',
mThird === 'third' && mIso === 'iso', { mThird, mIso });
// сброс орбиты возвращает дефолт после ручного поворота
await client.call('setCameraMode', 'third');
await client.call('setOrbit', { yaw: 2.5, pitch: 1.0, dist: 200 });
await client.evaluate(() => document.getElementById('dev-cam-reset').click());
const rs = await client.call('cameraInfo');
check('дев-панель: сброс орбиты (yaw/pitch/dist по умолчанию)',
Math.abs(rs.yaw - yaw0) < 1e-6 && Math.abs(rs.pitch - 0.5) < 1e-6
&& Math.abs(rs.dist - rs.viewSize) < 1e-6, rs);
await client.call('setCameraMode', 'iso'); // гейт заканчивает в изо
await client.call('devClose');
await client.call('stepTicks', 1);
} finally {
await browser.close();
}
} catch (e) {
console.error(e);
report.fatal = String(e);
}
report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));
process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно