Newer
Older
rpg / v2 / apps / demo / tools / checks / hero.mjs
/**
 * Скриншот-гейт героя v2 (трек «каркас ядра + герой»): управление от агента
 * через виртуальный стик (мост), проекция экранных осей в мир, стена дома
 * останавливает, фаза walk-цикла растёт в движении и замирает в покое,
 * камера следит за героем. Ручной режим цикла (setManual + stepTicks) —
 * детерминизм. Скриншоты: /tmp/v2_hero_*.png.
 *
 * Запуск: node apps/demo/tools/checks/hero.mjs [--port 5302]
 */
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { sleep, 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 ?? 5302);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));

const server = startDevServer({ port, cwd: DEMO_DIR });
const report = { name: 'hero', ok: false, checks: [] };
const check = (title, ok, info = null) => {
    report.checks.push({ title, ok, info });
    console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};

// Геометрия сцены: стена дома на x=44 (z=44..48), земля — грань y=2.
const HOUSE_WALL_X = 44;
const GROUND_Y = 2;

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));

        // Детерминизм: цикл в ручной режим, шаги только через stepTicks
        await client.call('setManual', true);
        const idle = await client.call('heroInfo');
        check('герой на сцене, стоит на земле',
            idle && idle.grounded === true && Math.abs(idle.pos[1] - GROUND_Y) < 0.02, idle);

        // Управление: экранный стик (1,1) при yaw 45° = ровно +x мира
        await client.call('heroTeleport', 36, 46); // перед южной стеной дома
        await client.call('setInput', 1, 1);
        await client.call('stepTicks', 10);
        const moving = await client.call('heroInfo');
        check('ввод агента двигает героя: +x мира, z не плывёт',
            moving.pos[0] > 36.1 && Math.abs(moving.pos[2] - 46) < 0.02 && moving.vel[0] > 0, moving);

        // Walk-фаза растёт пропорционально пути (без скольжения стоп)
        const phaseA = moving.phase;
        await client.call('stepTicks', 60);
        const phaseB = await client.call('heroInfo');
        const grown = phaseB.phase - phaseA;
        // 60 тиков = 1 с при скорости 4 и stride 3.2 → ровно 1.25 цикла
        check('фаза walk-цикла растёт пропорционально пути (1.25 цикла/с)',
            grown > 1.0 && grown < 1.4, { grown, speed: phaseB.vel[0] });

        // Стена дома: герой зажат по западной грани, дальше не идёт
        await client.call('stepTicks', 600);
        const atWall = await client.call('heroInfo');
        const expectedX = HOUSE_WALL_X - 1.5 - 1e-4; // AABB 3 шириной
        check('стена дома останавливает героя по грани',
            Math.abs(atWall.pos[0] - expectedX) < 0.01, { pos: atWall.pos[0], expectedX });

        // Покой: ввод отпущен — фаза замирает
        await client.call('setInput', null, null);
        const phaseWall = atWall.phase;
        await client.call('stepTicks', 120);
        const rested = await client.call('heroInfo');
        check('в покое фаза не растёт', rested.phase === phaseWall, { before: phaseWall, after: rested.phase });

        // Скриншоты: герой у стены (кадр «стоит») vs идёт (кадр меняется)
        await client.call('heroTeleport', 38, 46);
        await client.call('setInput', 1, 1);
        await client.call('stepTicks', 30);
        const goShot = await client.screenshot('/tmp/v2_hero_go.png');
        await client.call('stepTicks', 210);
        const stopShot = await client.screenshot('/tmp/v2_hero_stop.png');
        await client.call('setInput', null, null); // стик отпущен перед проверкой камеры
        const go = decodePng(readFileSync(goShot));
        const stop = decodePng(readFileSync(stopShot));
        const walkDiff = countDiff(go.rgba, stop.rgba);
        check('кадр меняется: герой идёт к стене', walkDiff > 150, { walkDiff });

        // Камера следит: телепорт далеко — кадр уезжает за героем
        await client.call('heroTeleport', 60, 12);
        await client.call('stepTicks', 30);
        const farInfo = await client.call('heroInfo');
        const far = Math.abs(farInfo.camTarget[0] - 60) + Math.abs(farInfo.camTarget[2] - 12);
        check('камера следит: цель камеры = герой', far < 1.5, farInfo.camTarget);
        const farShot = await client.screenshot('/tmp/v2_hero_far.png');
        const farImg = decodePng(readFileSync(farShot));
        const camDiff = countDiff(stop.rgba, farImg.rgba);
        check('камера переехала: кадр у стены vs дальняя точка различаются', camDiff > 5000, { camDiff });

        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;
}