/**
* Скриншот-гейт катсцен (трек 20): каталог сцен валиден при старте
* (validateCutscenes + resolveCutscenes — битый каталог роняет страницу),
* каждая сюжетная сцена доигрывается stepTicks-ом до конца (шаги идут,
* герой стоит), камера глайдит к точке шага и ВОЗВРАЩАЕТСЯ к герою в
* финале. Сцены играются через мост playCutscene(id) в «своих» областях
* (точки камеры — тайлы конкретной области). Скриншоты: /tmp/v2_cutscene_*.png.
*
* Запуск: node apps/game/tools/checks/cutscenes.mjs [--port 5314]
*/
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 ?? 5314);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'cutscenes', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
function colorCount(rgba) {
const seen = new Set();
for (let i = 0; i < rgba.length; i += 4) {
seen.add((rgba[i] >> 3) + (rgba[i + 1] >> 3) * 32 + (rgba[i + 2] >> 3) * 1024);
}
return seen.size;
}
/** Сухой прогон одной сцены: мост playCutscene → mid-проверка → доиграть
* до конца → камера вернулась к герою (camTarget = герой на земле). */
async function dryRun(client, id, area, of) {
await client.call('gotoArea', area);
await client.call('stepTicks', 5);
const hero0 = await client.call('heroInfo'); // герой на спавне области
const started = await client.call('playCutscene', id);
check(`${id}: старт (шагов ${of})`, started.active && started.of === of, started);
await client.call('stepTicks', 40); // ~0.66 с — внутри первого шага (dur 0.8–1.2)
const mid = await client.call('cutsceneInfo');
const hero1 = await client.call('heroInfo');
check(`${id}: шаг играет, герой стоит`,
mid.active && mid.t > 0 && hero1.tile.x === hero0.tile.x && hero1.tile.y === hero0.tile.y,
{ step: mid.step, t: mid.t, tile: hero1.tile });
await client.call('stepTicks', 400); // до конца с запасом
const end = await client.call('cutsceneInfo');
const hero2 = await client.call('heroInfo');
const camBack = Math.round(hero2.camTarget[0]) === Math.round(hero2.pos[0])
&& Math.round(hero2.camTarget[2]) === Math.round(hero2.pos[2]);
check(`${id}: конец — камера вернулась к герою`, !end.active && camBack, { cam: hero2.camTarget, pos: hero2.pos });
}
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 при загрузке (каталог валиден, иначе throw при старте)', errs.length === 0, errs.slice(0, 3));
await client.call('setManual', true);
await client.call('stepTicks', 10);
// Каталог сцен виден мосту; неизвестный id молча не играет
const info = await client.call('cutsceneInfo');
check('каталог катсцен в мосте (3 сцены)',
Array.isArray(info.catalog) && info.catalog.includes('hand_in')
&& info.catalog.includes('tune') && info.catalog.includes('machine_lull'), info.catalog);
const ghost = await client.call('playCutscene', 'no_such_scene');
check('неизвестный id не играет', !ghost.active, ghost);
// Dry-run всех трёх сцен: шаги, герой стоит, камера к герою
await dryRun(client, 'hand_in', 'meadows', 4); // сдача цветов: посадка → башня → зелень → тост
await dryRun(client, 'tune', 'rust_grove', 4); // настройка резонатора (акт 2)
await dryRun(client, 'machine_lull', 'machine_hall', 4); // усыпление Машины
// Скриншот середины dry-run tune: камера у дерева рощи — кадр не пустой
await client.call('playCutscene', 'tune');
await client.call('stepTicks', 90); // шаг 3: камера глайдит к дереву
const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_cutscene_tune.png')));
check('кадр середины tune не пустой', colorCount(shot.rgba) >= 8, {});
await client.call('stepTicks', 400);
} finally {
await browser.close();
}
} catch (e) {
report.error = String(e && e.stack ? e.stack : e);
console.error(report.error);
} finally {
server.stop();
}
report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));