Newer
Older
rpg / v2 / apps / game / tools / checks / camera.mjs
/**
 * Гейт камеры v2 (трек «Режимы камеры»): старт в изо с эталонным кадром,
 * third/first — перспектива работает (кадры различаются, поворот/дистанция
 * меняют кадр), мышь-ввод (drag крутит орбиту, колесо — дистанция),
 * следование за героем во всех режимах, туман в перспективе, окно частиц
 * без NaN, возврат в изо ПОПИКСЕЛЬНО равен эталону (главный замок
 * обратимости), кнопки дев-панели переключают режим. Гейт начинает и
 * заканчивает в изо; режим в сейв не пишется. Скриншоты: /tmp/v2_camera_*.png.
 *
 * Грабля детерминизма: атмосфера глушится (setAsh(false) — осадки/пепел/
 * дымка/блики/листья с общим elapsed уходят), фауна выключается, часы
 * перематываются setClock(720) ПЕРЕД каждым снимком + stepTicks(1) —
 * тогда кадр при прочих равных воспроизводится бит-в-бит.
 *
 * Запуск: node apps/game/tools/checks/camera.mjs [--port 5336]
 */
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 ?? 5336);

const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'camera', ok: false, checks: [] };
const check = (title, ok, info = null) => {
    report.checks.push({ title, ok, info });
    console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};

/** Число различающихся пикселей двух кадров (порог 8 на канал). */
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;
}

const finiteBox = (w) => [w.minX, w.maxX, w.minZ, w.maxZ].every(Number.isFinite);

async function shot(client, path) {
    await client.screenshot(path);
    return decodePng(readFileSync(path)).rgba;
}

/** Кадр в детерминированном состоянии: часы перемотаны, один тик логики
 *  (грабля ручного режима: скриншот — последний отрисованный кадр). */
async function stableShot(client, path) {
    await client.call('setClock', 720);
    await client.call('stepTicks', 1);
    return shot(client, path);
}

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 при загрузке', errs.length === 0, errs.slice(0, 3));

        await client.call('setManual', true);
        await client.call('setAsh', false); // детерминизм кадра: без атмосферы
        await client.call('setFauna', false);
        await client.call('setFollow', false); // цель — float-центр спавна
        // DOM-оверлей fps обновляется каждый кадр — в кадр-равенства не входит
        await client.evaluate(() => { document.getElementById('fps').hidden = true; });
        const hero0 = await client.call('heroInfo'); // спавн (герой вернётся сюда)

        // ---- Эталон изо-кадра (и его повторяемость) ----
        let ci = await client.call('cameraInfo');
        check('старт в изо (дефолт режима, в сейв не пишется)', ci.mode === 'iso', ci.mode);
        const ref = await stableShot(client, '/tmp/v2_camera_iso.png');
        const ref2 = await stableShot(client, '/tmp/v2_camera_iso_ref2.png');
        check('эталон повторяем бит-в-бит (детерминизм ручного тика)',
            countDiff(ref, ref2) === 0, { diff: countDiff(ref, ref2) });

        // ---- Third person: перспектива, поворот, дистанция ----
        await client.call('setCameraMode', 'third');
        await client.call('stepTicks', 1);
        ci = await client.call('cameraInfo');
        const third = await shot(client, '/tmp/v2_camera_third.png');
        check('→ third: режим активен, кадр отличается от изо',
            ci.mode === 'third' && countDiff(third, ref) > 1000,
            { mode: ci.mode, diff: countDiff(third, ref) });

        const yaw0 = ci.yaw;
        await client.call('setOrbit', { yaw: yaw0 + 1.2 });
        await client.call('stepTicks', 1);
        const yawed = await shot(client, '/tmp/v2_camera_third_yaw.png');
        check('поворот орбиты меняет кадр', countDiff(yawed, third) > 500,
            { diff: countDiff(yawed, third) });

        const ci2 = await client.call('cameraInfo');
        await client.call('setOrbit', { dist: ci2.dist * 0.5 });
        await client.call('stepTicks', 1);
        const close = await shot(client, '/tmp/v2_camera_third_dist.png');
        check('дистанция орбиты меняет кадр', countDiff(close, yawed) > 500,
            { diff: countDiff(close, yawed) });

        // ---- Мышь-ввод: drag крутит yaw, колесо в third — дистанция ----
        const before = (await client.call('cameraInfo')).yaw;
        await client.evaluate(() => {
            const c = document.getElementById('scene');
            const opts = (x, y) => ({ bubbles: true, button: 0, pointerId: 7, clientX: x, clientY: y });
            c.dispatchEvent(new PointerEvent('pointerdown', opts(200, 200)));
            c.dispatchEvent(new PointerEvent('pointermove', opts(320, 200)));
            c.dispatchEvent(new PointerEvent('pointerup', opts(320, 200)));
        });
        await client.call('stepTicks', 1);
        const after = (await client.call('cameraInfo')).yaw;
        check('drag ЛКМ по канвасу крутит орбиту', Math.abs(after - before) > 0.1,
            { before, after });

        const d0 = (await client.call('cameraInfo')).dist;
        await client.evaluate(() => {
            document.getElementById('scene').dispatchEvent(
                new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }),
            );
        });
        await client.call('stepTicks', 1);
        const d1 = (await client.call('cameraInfo')).dist;
        check('колесо в third отдаляет камеру', d1 > d0 * 1.05, { d0, d1 });

        // ---- Следование в third ----
        await client.call('heroTeleport', 30, 30);
        await client.call('stepTicks', 1);
        const hi = await client.call('heroInfo');
        const followDx = Math.hypot(hi.camTarget[0] - hi.pos[0], hi.camTarget[2] - hi.pos[2]);
        check('следование в third: цель камеры едет за героем', followDx < 2, { followDx });

        // ---- First person: глаза героя, герой скрыт, цель едет ----
        await client.call('setCameraMode', 'first');
        await client.call('stepTicks', 1);
        ci = await client.call('cameraInfo');
        const first = await shot(client, '/tmp/v2_camera_first.png');
        const eyeH = ci.pos[1] - hi.camTarget[1];
        check('→ first: режим активен, глаза над целью (~10), кадр другой',
            ci.mode === 'first' && Math.abs(eyeH - 10) < 0.5 && countDiff(first, third) > 1000,
            { mode: ci.mode, eyeH, diff: countDiff(first, third) });

        await client.call('heroTeleport', 36, 34);
        await client.call('stepTicks', 1);
        const ci3 = await client.call('cameraInfo');
        const movedDx = Math.hypot(ci3.pos[0] - 36, ci3.pos[2] - 34);
        check('first: камера (глаза) поехала за героем', movedDx < 2, { movedDx });

        // ---- Туман в перспективе: конечные числа, кадр меняется ----
        await client.call('setWeather', { fog: 0.8 });
        await client.call('stepTicks', 3);
        const fi = await client.call('fogInfo');
        const fogged = await shot(client, '/tmp/v2_camera_first_fog.png');
        const fogFinite = Number.isFinite(fi.strength) && (fi.bands === undefined || Number.isFinite(fi.bands));
        check('туман в перспективе: сила конечна, кадр меняется',
            fogFinite && fi.strength > 0.5 && countDiff(fogged, first) > 500,
            { fi, diff: countDiff(fogged, first) });
        await client.call('setWeather', null); // правка тумана не тащится дальше
        await client.call('stepTicks', 1);

        // ---- Окно частиц без NaN (перспектива) ----
        const w = await client.call('windowInfo', 0, 40);
        check('windowInfo в перспективе: окно и камера — конечные числа',
            finiteBox(w) && Number.isFinite(w.cam.pos[1]), w.cam);

        // ---- Возврат в изо: кадр попиксельно равен эталону ----
        await client.call('heroTeleport', hero0.pos[0], hero0.pos[2]); // герой на спавне
        await client.call('setAsh', false); // состояние мира = эталонное (погода/частицы)
        await client.call('setCameraMode', 'iso');
        await client.call('setFollow', false); // та же float-цель спавна, что у эталона
        const heroR = await client.call('heroInfo');
        const ret = await stableShot(client, '/tmp/v2_camera_iso_return.png');
        const ret2 = await stableShot(client, '/tmp/v2_camera_iso_return2.png');
        check('возврат в изо: кадр попиксельно равен эталону (обратимость)',
            countDiff(ret, ref) === 0, {
                diff: countDiff(ret, ref),
                rep: countDiff(ret, ret2),
                heroYaw: [hero0.yaw, heroR.yaw], phase: [hero0.phase, heroR.phase],
            });

        // ---- Кнопки дев-панели ----
        await client.call('devOpen');
        await client.evaluate(() => document.getElementById('dev-cam-third').click());
        const mThird = (await client.call('cameraInfo')).mode;
        await client.evaluate(() => document.getElementById('dev-cam-iso').click());
        const mIso = (await client.call('cameraInfo')).mode;
        check('дев-панель: кнопки переключают режим (third → iso)',
            mThird === 'third' && mIso === 'iso', { mThird, mIso });

        // сброс орбиты возвращает дефолт после ручного поворота
        await client.call('setCameraMode', 'third');
        await client.call('setOrbit', { yaw: 2.5, pitch: 1.0, dist: 200 });
        await client.evaluate(() => document.getElementById('dev-cam-reset').click());
        const rs = await client.call('cameraInfo');
        check('дев-панель: сброс орбиты (yaw/pitch/dist по умолчанию)',
            Math.abs(rs.yaw - yaw0) < 1e-6 && Math.abs(rs.pitch - 0.5) < 1e-6
            && Math.abs(rs.dist - rs.viewSize) < 1e-6, rs);
        await client.call('setCameraMode', 'iso'); // гейт заканчивает в изо
        await client.call('devClose');
        await client.call('stepTicks', 1);
    } finally {
        await browser.close();
    }
} catch (e) {
    console.error(e);
    report.fatal = String(e);
}

report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));
process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно