Newer
Older
rpg / v2 / apps / demo / tools / checks / anim.mjs
/**
 * Гейт аним-листа: превью walk-цикла манекена (8 кадров, 4×2) — визуальная
 * и числовая проверка анимации (docs/plan.md, трек 6, «анимация = данные»).
 * Картинка: ячейки видны, соседние фазы различаются (та же проекция, что
 * камера, референсный кадр без моделей). Числа через мост: клип замкнут
 * (validateClip), противофазы ног/рук, контакт стоп, качание корня.
 * Скриншоты: /tmp/v2_anim.png, /tmp/v2_anim_pads.png.
 *
 * Запуск: node apps/demo/tools/checks/anim.mjs [--port 5299]
 */
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 server = startDevServer({ port, cwd: new URL('../../', import.meta.url).pathname });

const report = { name: 'anim', 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;
    // страница 960×720 — вьюпорт с запасом, иначе низ листа обрезается кадром
    const browser = await launchBrowser({ viewport: { width: 960, height: 760 } });
    try {
        const client = await openGame(browser, { url: `http://localhost:${port}/anim.html` });

        const errs = client.errors();
        check('нет pageerror при загрузке листа', errs.length === 0, errs.slice(0, 3));

        const info = await client.call('sheetInfo');
        const stats = await client.call('stats');
        check('лист: 8 кадров манекена в облаках', stats.clouds === 8 && stats.voxels > 100, stats);

        // Числовая валидация клипа через мост (как агент валидирует текстом)
        const problems = await client.call('clipCheck');
        check('клип замкнут и проходит валидацию', problems.length === 0, problems);

        const full = decodePng(readFileSync(await client.screenshot('/tmp/v2_anim.png')));
        await client.call('setModels', false);
        await sleep(300);
        const pads = decodePng(readFileSync(await client.screenshot('/tmp/v2_anim_pads.png')));
        await client.call('setModels', true);

        // Ячейки: проекция мирового центра ячейки той же матрицей, что камера
        const cells = [];
        for (let col = 0; col < info.cols; col++)
            for (let row = 0; row < info.rows; row++) {
                const wx = col * info.pitch + Math.floor(info.pitch / 2);
                const wz = row * info.pitch + Math.floor(info.pitch / 2);
                const [px, py] = worldToPx([wx, 3, wz], info);
                cells.push({ col, row, px, py, diff: cellDiff(full, pads, px, py) });
            }
        const empty = cells.filter((c) => c.diff <= 150);
        check('во всех 8 ячейках манекен виден', empty.length === 0,
            empty.length ? empty : { minDiff: Math.min(...cells.map((c) => c.diff)) });

        // Соседние фазы различаются (внутри ряда; шаг фазы 45°: у манекена
        // 9×16×4 даёт ~50–110 px — порог 40; фазы 0 и 4 цикла совпадают —
        // норма двухшагового цикла)
        const pairs = [];
        for (const row of [0, 1])
            for (let col = 0; col + 1 < info.cols; col++) {
                const a = cells[row * info.cols + col], b = cells[row * info.cols + col + 1];
                pairs.push(cellDiff(full, full, a.px, a.py, b.px, b.py));
            }
        const same = pairs.filter((d) => d <= 40);
        check('соседние фазы различаются (6 пар)', same.length === 0,
            same.length ? { same } : { minDiff: Math.min(...pairs) });

        // Числовые снапшоты поз: противофазы, контакт стоп, качание корня
        const ps = [];
        for (let i = 0; i < info.frames; i++) ps.push(await client.call('poseStats', i));
        const mid = ps[2], zero = ps[0];
        check('ноги и руки в противофазе (i=2)', mid.legR === -mid.legL && mid.armR === -mid.armL, { legL: mid.legL, armL: mid.armL });
        // Подъём стопы в махе: у манекена 9×16×4 ноги 6 вокселей, при swing
        // 0.55 рад стопа поднимается на ~0.8 вокселя (у 11×21×4 было >1)
        check('контакт стоп в i=0/i=4, мах в i=2 поднимает стопы',
            Math.abs(ps[0].footMinL - 0.5) < 0.05 && Math.abs(ps[4].footMinL - 0.5) < 0.05 &&
            mid.footMinL > 0.7 && mid.footMinR > 0.7,
            { contact: ps[0].footMinL, midL: mid.footMinL, midR: mid.footMinR });
        check('корень: в контакте y=0, в шаге приседает и качается вбок',
            Math.abs(zero.rootPos[1]) < 0.01 && mid.rootPos[1] < -0.2 &&
            Math.abs(mid.rootPos[0]) > 0.1 && Math.abs(ps[6].rootPos[0] + mid.rootPos[0]) < 0.01,
            { zeroY: zero.rootPos[1], midY: mid.rootPos[1], swayL: mid.rootPos[0], swayR: ps[6].rootPos[0] });

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

/** Экранные координаты мировой точки — зеркалим VoxelRenderer (см. turntable.mjs). */
function worldToPx(p, info) {
    const norm = (v) => { const l = Math.hypot(...v); return v.map((c) => c / l); };
    const cross = (a, b) => [
        a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0],
    ];
    const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    const dir = norm([Math.cos(Math.PI / 4) * 0.5, 1, Math.sin(Math.PI / 4) * 0.5]);
    const xAxis = norm(cross([0, 1, 0], dir));
    const yAxis = cross(dir, xAxis);
    const eye = info.target.map((t, i) => t + dir[i] * info.viewSize * 4);
    const rel = p.map((c, i) => c - eye[i]);
    const aspect = info.width / info.height;
    const ndcX = dot(rel, xAxis) / (info.viewSize * aspect);
    const ndcY = dot(rel, yAxis) / info.viewSize;
    return [
        ((ndcX + 1) / 2) * info.width * info.scale,
        (1 - (ndcY + 1) / 2) * info.height * info.scale,
    ];
}

/** Число различающихся пикселей в эллипсе вокруг центров ячеек (см. turntable.mjs). */
function cellDiff(a, b, pxA, pyA, pxB = pxA, pyB = pyA) {
    const RX = 24, RY = 21;
    let diff = 0;
    for (let dy = -RY; dy <= RY; dy++)
        for (let dx = -RX; dx <= RX; dx++) {
            if ((dx / RX) ** 2 + (dy / RY) ** 2 > 1) continue;
            const ia = pixelIndex(a, pxA + dx, pyA + dy);
            const ib = pixelIndex(b, pxB + dx, pyB + dy);
            for (let k = 0; k < 3; k++) if (Math.abs(a.rgba[ia + k] - b.rgba[ib + k]) > 8) { diff++; break; }
        }
    return diff;
}

function pixelIndex(png, x, y) {
    const cx = Math.min(png.width - 1, Math.max(0, Math.round(x)));
    const cy = Math.min(png.height - 1, Math.max(0, Math.round(y)));
    return (cy * png.width + cx) * 4;
}