/**
* Гейт аним-листа: превью 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;
}