/**
* Скриншот-гейт сцен v2 (трек «сцены»): стек SceneManager (меню поверх игры),
* фейд-переходы out→swap→in (DOM-слой #fade), автосохранение при уходе в
* меню, «Продолжить» восстанавливает состояние, параллельные переходы
* глотаются. Ручной режим цикла — переход продвигается stepTicks.
* Скриншоты: /tmp/v2_scenes_*.png.
*
* Запуск: node apps/demo/tools/checks/scenes.mjs [--port 5310]
*/
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
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 ?? 5310);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));
const server = startDevServer({ port, cwd: DEMO_DIR });
const report = { name: 'scenes', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
/** Тиков на полный переход 0.4+0.4 с (out+in = 48 по 1/60) + запас. */
const PASS = 60;
try {
await server.waitReady;
const browser = await launchBrowser();
try {
const client = await openGame(browser, { url: `http://localhost:${port}/` });
const errs = client.errors();
check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3));
await client.call('setManual', true);
// Старт: меню сверху, игры без перехода
let si = await client.call('sceneInfo');
check('на старте текущая сцена — меню, без перехода, автосейва нет',
si.current === 'menu' && si.transitioning === false && si.autoSaved === false, si);
const shotMenu = await client.screenshot('/tmp/v2_scenes_menu.png');
const imgMenu = decodePng(readFileSync(shotMenu));
// «Войти в мир»: переход начинается затуханием — сцена ещё меню
await client.call('startGame', false);
si = await client.call('sceneInfo');
check('startGame: переход идёт, сцена пока меню (фаза out)',
si.transitioning === true && si.current === 'menu', si);
// Конец фазы out: фейд накрыл экран чёрным (24 тика = 0.4 с)
await client.call('stepTicks', 24);
const shotFade = await client.screenshot('/tmp/v2_scenes_fade.png');
const imgFade = decodePng(readFileSync(shotFade));
check('фейд в середине перехода — кадр чёрный', isBlack(imgFade.rgba));
// Переход завершён: игра сверху, меню скрыто, кадр другой
await client.call('stepTicks', PASS);
si = await client.call('sceneInfo');
check('переход завершён: сцена игра, перехода нет',
si.current === 'game' && si.transitioning === false, si);
const shotGame = await client.screenshot('/tmp/v2_scenes_game.png');
const imgGame = decodePng(readFileSync(shotGame));
const mgDiff = countDiff(imgMenu.rgba, imgGame.rgba);
check('кадры меню и игры различаются', mgDiff > 5000, { mgDiff });
// Esc в меню: автосохранение + возврат с переходом
const beforePos = (await client.call('heroInfo')).pos;
await client.call('gotoMenu');
const list = await client.call('saveList');
check('gotoMenu записал автосейв «auto»',
list.length === 1 && list[0].slot === 'auto'
&& list[0].meta.title === 'Автосохранение', list);
await client.call('stepTicks', PASS);
si = await client.call('sceneInfo');
check('возврат в меню завершён', si.current === 'menu' && si.transitioning === false, si);
// Ломаем состояние: герой уезжает — «Продолжить» должен вернуть
await client.call('heroTeleport', 60, 12);
await client.call('stepTicks', 2);
await client.call('startGame', true);
await client.call('stepTicks', PASS);
si = await client.call('sceneInfo');
const pos = (await client.call('heroInfo')).pos;
check('«Продолжить» загрузил автосейв: герой на месте сохранения',
si.current === 'game'
&& Math.abs(pos[0] - beforePos[0]) < 0.01
&& Math.abs(pos[2] - beforePos[2]) < 0.01, { current: si.current, pos });
// Параллельный переход глотается: startGame во время перехода игнорируется
await client.call('gotoMenu'); // push-переход 0.4 с
await client.call('startGame', false); // должен быть проглочен
await client.call('stepTicks', PASS);
si = await client.call('sceneInfo');
check('параллельный переход глотается: меню осталось сверху',
si.current === 'menu', si);
// Второй переход (меню → игра) проходит нормально
await client.call('gotoMenu'); // теперь это возврат в игру
await client.call('stepTicks', PASS);
si = await client.call('sceneInfo');
check('повторный переход меню → игра работает', si.current === 'game', si);
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);
/** Число различающихся RGBA-пикселей двух кадров одного размера. */
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;
}
/** Кадр почти целиком чёрный (фейд накрыл экран). */
function isBlack(rgba) {
let bright = 0;
let n = 0;
for (let i = 0; i < rgba.length; i += 4) {
n++;
if (rgba[i] + rgba[i + 1] + rgba[i + 2] > 30) bright++;
}
return bright / n < 0.01;
}