Newer
Older
rpg / v2 / apps / demo / tools / checks / physics.mjs
/**
 * Скриншот-гейт физики v2 (трек 28: AABB-тело vs воксели + гравитация).
 * Проверяет в реальном Chromium (swiftshader) и через мост: тело падает и
 * встаёт ровно на землю, идёт walk-циклом, стена дома останавливает по грани,
 * кадр меняется от движения, манекен реально рисуется. Скриншоты:
 * /tmp/v2_physics_*.png.
 *
 * Запуск: node apps/demo/tools/checks/physics.mjs [--port 5301]
 */
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 ?? 5301);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));

const server = startDevServer({ port, cwd: DEMO_DIR });
const report = { name: 'physics', 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, земля — грань y=2.
const HOUSE_WALL_X = 44;
const GROUND_Y = 2;
const SPAWN = [38, 46]; // перед южной стеной дома, путь чист

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

        // Камера фиксированная: слежение за героем off (герой проверяет hero.mjs)
        await client.call('setFollow', false);

        // Спавн в воздухе (y=6): гейт сразу видит тело до шагов физики
        const spawnInfo = await client.call('bodySpawn', SPAWN[0], SPAWN[1], 0, 0);
        check('тело спавнится в воздухе (y=6, не grounded)',
            spawnInfo && spawnInfo.pos[1] === 6 && spawnInfo.grounded === false, spawnInfo);

        // Гравитация: падение до земли, встаёт ровно на грань y=2
        const landed = await client.call('bodyStep', 180);
        check('гравитация: падает и встаёт на землю y=2, grounded',
            landed && landed.grounded === true && Math.abs(landed.pos[1] - GROUND_Y) < 0.02, landed);

        // Референсный кадр: манекен стоит на старте
        await client.call('setSun', 0.5);
        await sleep(300);
        const startShot = await client.screenshot('/tmp/v2_physics_start.png');
        const start = decodePng(readFileSync(startShot));

        // Ходьба на восток к стене дома: тело движется, фаза walk-цикла растёт
        const walker = await client.call('bodySpawn', SPAWN[0], SPAWN[1], 2, 0);
        await client.call('bodyStep', 240);
        const atWall = await client.call('bodyInfo');
        check('ходьба: фаза walk-цикла растёт в движении', atWall && atWall.walkT > 1, atWall);

        // Стена останавливает по своей западной грани: pos.x ≈ 44 − w/2 − eps
        const expectedX = HOUSE_WALL_X - atWall.size[0] / 2 - 1e-4;
        check('стена дома останавливает тело по грани (vel=0)',
            atWall && atWall.vel[0] === 0 && Math.abs(atWall.pos[0] - expectedX) < 0.01,
            { pos: atWall.pos[0], expectedX });

        // Кадр изменился: манекен сместился к стене и в другой фазе цикла
        const wallShot = await client.screenshot('/tmp/v2_physics_wall.png');
        const wall = decodePng(readFileSync(wallShot));
        const walkDiff = countDiff(start.rgba, wall.rgba);
        check('кадр изменился: тело дошло до стены', walkDiff > 150, { walkDiff });

        // Манекен реально рисуется: без тела кадр возвращается к пустому месту
        await client.call('bodyRemove');
        await sleep(300);
        const emptyShot = await client.screenshot('/tmp/v2_physics_empty.png');
        const empty = decodePng(readFileSync(emptyShot));
        const bodyDiff = countDiff(wall.rgba, empty.rgba);
        check('тело-манекен рисуется в сцене', bodyDiff > 150, { bodyDiff });

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