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