Newer
Older
rpg / v2 / apps / demo / tools / checks / render.mjs
/**
 * Скриншот-гейт рендера v2 (урок №2: визуальная подсистема готова только с ним).
 * Проверяет в реальном Chromium (swiftshader): страница без ошибок, меш собран,
 * солнце едет (тени ползут — кадры различаются), пиксель-арт апскейл кратен 2×2,
 * картинка многокрасочная. Скриншоты: /tmp/v2_render_*.png.
 *
 * Запуск: node apps/demo/tools/checks/render.mjs [--port 5299]
 */
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 ?? 5299);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));

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

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

        const stats = await client.call('stats');
        check('меш собран', !!stats && stats.triangles > 1000 && stats.vertices > 1000, stats);

        // День и вечер: солнце движется → тени ползут
        await client.call('setSun', 0.08);
        await sleep(500);
        const morningShot = await client.screenshot('/tmp/v2_render_morning.png');
        await client.call('setSun', 0.9);
        await sleep(500);
        const eveningShot = await client.screenshot('/tmp/v2_render_evening.png');

        const morning = decodePng(readFileSync(morningShot));
        const evening = decodePng(readFileSync(eveningShot));
        const diff = countDiff(morning.rgba, evening.rgba);
        check('тени ползут: кадры утро/вечер различаются', diff > 20000, { diffPixels: diff });

        const block = pixelBlockViolations(morning);
        check('апскейл кратен 2×2 (CSS целый масштаб)', block.violations / block.samples < 0.01, block);

        const colors = distinctColors(morning.rgba);
        check('картинка многокрасочная (палитра × AO × свет)', colors > 24, { colors });

        // Мир чанковый: земля 96×96 заполняет 6×6 чанков; мешинг быстрых масштабов
        const cs = await client.call('chunkStats');
        check('мир из 36 чанков, меш у всех', cs.chunks === 36 && cs.meshes === 36, cs);
        const perf = await client.call('perf');
        check('постройка 36 чанков + мешинг < 2 с', perf.buildMs < 2000, perf);

        // Правка мира на шве чанков x=16: столбик в мир → кадр изменился
        await client.call('setSun', 0.5);
        const before = decodePng(readFileSync(await client.screenshot('/tmp/v2_render_before_edit.png')));
        await client.call('setVoxel', 47, 2, 30, 6);
        await client.call('setVoxel', 48, 2, 30, 6);
        await client.call('setVoxel', 48, 3, 30, 6);
        const after = decodePng(readFileSync(await client.screenshot('/tmp/v2_render_after_edit.png')));
        const editDiff = countDiff(before.rgba, after.rgba);
        check('setVoxel на шве чанков меняет кадр', editDiff > 200, { editDiff });
        const cs2 = await client.call('chunkStats');
        check('dirty-чанки после правки схлынули', cs2.chunks === 36 && cs2.meshes === 36, cs2);

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

/** Нарушения кратности 2×2: соседние пиксели блока должны совпадать. */
function pixelBlockViolations(png) {
    const { width, height, rgba } = png;
    let violations = 0, samples = 0;
    const eq = (x1, y1, x2, y2) => {
        const i1 = (y1 * width + x1) * 4, i2 = (y2 * width + x2) * 4;
        return Math.abs(rgba[i1] - rgba[i2]) <= 2 && Math.abs(rgba[i1 + 1] - rgba[i2 + 1]) <= 2 &&
            Math.abs(rgba[i1 + 2] - rgba[i2 + 2]) <= 2;
    };
    for (let y = 0; y + 1 < height; y += 2)
        for (let x = 0; x + 1 < width; x += 2) {
            samples++;
            if (!eq(x, y, x + 1, y) || !eq(x, y, x, y + 1)) violations++;
        }
    return { violations, samples };
}

/** Число уникальных RGB-цветов (квант 8). */
function distinctColors(rgba) {
    const set = new Set();
    for (let i = 0; i < rgba.length; i += 4) set.add((rgba[i] >> 3) + ',' + (rgba[i + 1] >> 3) + ',' + (rgba[i + 2] >> 3));
    return set.size;
}