Newer
Older
rpg / v2 / apps / game / tools / checks / cutscenes.mjs
/**
 * Скриншот-гейт катсцен (трек 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));