/**
* Скриншот-гейт боя v2 (трек «бой»): сгустки на лугу (спящие кучки и
* патруль), пробуждение от гулкого звука и погоня, удар конусом убивает
* (труп исчезает), смерть героя — респаун на старте с полным hp, плевун
* наносит урон герою, резонанс усыпляет. Ручной режим цикла — детерминизм.
* Скриншоты: /tmp/v2_combat_*.png.
*
* Запуск: node apps/demo/tools/checks/combat.mjs [--port 5312]
*/
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
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 ?? 5312);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));
const server = startDevServer({ port, cwd: DEMO_DIR });
const report = { name: 'combat', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
/** Чистая поляна у точки старта: герой (40,60) вокс = (13.33,20) юнит,
* ряд по z=60 пуст — проверено прожектором (равновесие погони ровно 0.55). */
const H = { x: 40, z: 60 };
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));
await client.call('setManual', true);
// Демка стартует в меню — входим в игру и проходим фейд (48 тиков) + запас
await client.call('startGame', false);
await client.call('stepTicks', 60);
const si = await client.call('sceneInfo');
check('вошли в игру', si.current === 'game' && !si.transitioning, si);
// Луг населён: патрульный ползун бодр, плевун и тяжёлый спят в пепле
const stats0 = await client.call('combatStats');
check('на лугу 3 сгустка, двое спят',
stats0.enemies === 3 && stats0.asleep === 2 && stats0.dead === 0, stats0);
// Кучка спящего рисуется: кадр с врагом vs чистый кадр. Герой (манекен
// v3) 14 вокселей шириной — кучка в 3.5 вокселя от его центра скрыта
// телом; для кадра спавним в 5 юнитах (18.5, 20), дальше по гейту
// боевые проверки идут на своих местах
await client.call('combatClear');
await client.call('heroTeleport', H.x, H.z);
await client.call('stepTicks', 2);
const clearShot = await client.screenshot('/tmp/v2_combat_clear.png');
await client.call('combatSpawn', 'crawler', 18.5, 20);
await client.call('stepTicks', 2);
const pileShot = await client.screenshot('/tmp/v2_combat_pile.png');
const pileDiff = countDiff(png(clearShot), png(pileShot));
check('кучка спящего сгустка рисуется', pileDiff > 100, { pileDiff });
// Гулкий удар будит спящего БЕЗ урона: сгусток за краем конуса
// (1.37 > 1.0), но в радиусе звука (4.5) → rise. Лёгкий шаг ставит
// взгляд героя на +x (для конуса ниже); затем погоня — равновесие
// сепарации 0.55 (радиусы 0.35+0.2)
await client.call('combatClear');
await client.call('heroTeleport', H.x, H.z);
await client.call('stepTicks', 2);
await client.call('combatSpawn', 'crawler', 14.7, 20);
await client.call('setInput', 1, 1); // экранная диагональ = +x мира
await client.call('stepTicks', 2);
await client.call('setInput', null, null);
await client.call('attackInput', true);
await client.call('attackInput', false);
await client.call('stepTicks', 5);
let info = await client.call('combatInfo');
check('звон будит спящего без урона (rise)',
info.enemies[0].state === 'rise' && info.enemies[0].hp === 3, info.enemies[0]);
await client.call('stepTicks', 150);
info = await client.call('combatInfo');
const heroNow = await client.call('heroInfo');
const en = info.enemies[0];
const dist = Math.hypot(en.pos.x - heroNow.pos[0] / 3, en.pos.z - heroNow.pos[2] / 3);
check('погоня: догнал и «липнет» рядом (0.55)', en.state === 'chase' && dist < 0.75, { state: en.state, dist });
// Удар конусом: 3 → 1 → труп; труп исчезает через 0.5 с
await client.call('attackInput', true);
await client.call('attackInput', false);
await client.call('stepTicks', 5);
info = await client.call('combatInfo');
check('конус ранил: hp 3 → 1', info.enemies[0].hp === 1, info.enemies[0]);
await client.call('stepTicks', 30); // кулдаун удара 0.45 с = 27 тиков
await client.call('attackInput', true);
await client.call('attackInput', false);
await client.call('stepTicks', 5);
info = await client.call('combatInfo');
check('второй удар добил: труп', info.enemies[0].dead === true, info.enemies[0]);
await client.call('stepTicks', 45); // corpseTimer 0.5 с
const after = await client.call('combatStats');
check('труп исчез', after.enemies === 0, after);
// Смерть героя: контактные удары в упор — респаун на старте с полным
// hp. Спящий вплотную НЕ просыпается (dormant глух к близости), поэтому
// сначала будим звоном издали и даём дойти до 0.55; ползун в равновесии
// дальше attackRange (0.4) не бьёт, поэтому каждый круг телепортируем
// героя прямо на врага (вокс-сетка даёт остаток ≤ ~0.24 < 0.4).
// Секция ДО плевуна: у героя ровно 5 сердец.
await client.call('combatClear');
await client.call('heroTeleport', H.x, H.z);
await client.call('stepTicks', 2);
await client.call('combatSpawn', 'crawler', 14.5, 20);
await client.call('attackInput', true);
await client.call('attackInput', false);
await client.call('stepTicks', 150);
const hpSeries = [];
let respawnPos = null;
let prevHp = 5;
for (let i = 0; i < 5; i++) {
const ci = await client.call('combatInfo');
const foe = ci.enemies.find((e) => !e.dead && !e.asleep);
if (!foe) { check(`круг ${i + 1}: сгусток жив и бодр`, false, ci.enemies); break; }
await client.call('heroTeleport', Math.round(foe.pos.x * 3), Math.round(foe.pos.z * 3));
await client.call('stepTicks', 3);
const c = await client.call('combatInfo');
hpSeries.push(c.hp);
if (respawnPos === null && c.hp > prevHp) {
// hp подскочил — герой пал и возродился; снимаем точку респауна
const h = await client.call('heroInfo');
respawnPos = h.pos;
}
prevHp = c.hp;
await client.call('stepTicks', 70); // неуязвимость 0.8 с + кулдаун ползуна 0.8 с
}
const hero = await client.call('heroInfo');
const finalInfo = await client.call('combatInfo');
const fell = hpSeries.join(',') === '4,3,2,1,5'; // 5-е сердце → смерть и респаун
check('смерть героя: 5 ударов, респаун с полным hp', fell && finalInfo.hp === 5, { hpSeries, finalInfo });
const rp = respawnPos ?? hero.pos;
check('респаун на точке старта (40,60)',
Math.abs(rp[0] - 40) < 0.01 && Math.abs(rp[2] - 60) < 0.01, rp);
// Плевун: будим издали, держит дистанцию и плюёт — герой теряет сердце
await client.call('combatClear');
await client.call('heroTeleport', H.x, H.z);
await client.call('stepTicks', 2);
await client.call('combatSpawn', 'spitter', 15.5, 20);
await client.call('setFollow', false); // камера фиксирована — зона HUD чистая
await client.call('stepTicks', 2);
const hearts5Shot = await client.screenshot('/tmp/v2_combat_hearts5.png');
await client.call('attackInput', true); // звон будит (конус не достаёт: 2.17 > 1.0)
await client.call('attackInput', false);
await client.call('stepTicks', 240); // замах 0.35 + плевок + полёт ~1 с
info = await client.call('combatInfo');
check('плевун нанёс урон: hp 5 → 4', info.hp === 4, { hp: info.hp, enemies: info.enemies });
const hearts4Shot = await client.screenshot('/tmp/v2_combat_hearts4.png');
const hudDiff = countDiffZone(png(hearts5Shot), png(hearts4Shot), 0, 60, 700, 960);
check('HUD сердец перерисовался (правый верх)', hudDiff > 100, { hudDiff });
await client.call('setFollow', true);
// Резонанс: заряд ≥ 0.6 с усыпляет обоих в радиусе 3 (сгустки за краем
// конуса — проснулись от звона без урона; спавн с одной стороны, чтобы
// толкучка не втолкнула кого-то в strike-радиус героя)
await client.call('combatClear');
await client.call('heroTeleport', H.x, H.z);
await client.call('stepTicks', 2);
await client.call('combatSpawn', 'crawler', 15.0, 20);
await client.call('combatSpawn', 'crawler', 15.3, 20);
await client.call('attackInput', true);
await client.call('attackInput', false);
await client.call('stepTicks', 10);
await client.call('attackInput', true);
await client.call('stepTicks', 40); // заряд 0.83 с ≥ 0.6
await client.call('attackInput', false);
info = await client.call('combatInfo');
check('резонанс усыпил обоих (sleeping)',
info.enemies.length === 2 && info.enemies.every((e) => e.state === 'sleeping'), info.enemies);
await client.call('combatClear');
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);
/** PNG со скриншота. */
function png(path) {
return decodePng(readFileSync(path));
}
/** Число различающихся пикселей двух кадров одного размера. */
function countDiff(a, b) {
let n = 0;
for (let i = 0; i < Math.min(a.rgba.length, b.rgba.length); i += 4) {
if (Math.abs(a.rgba[i] - b.rgba[i]) > 8 || Math.abs(a.rgba[i + 1] - b.rgba[i + 1]) > 8
|| Math.abs(a.rgba[i + 2] - b.rgba[i + 2]) > 8) n++;
}
return n;
}
/** Различия зоны [y0..y1)×[x0..x1) — HUD-зона без шума сцены. */
function countDiffZone(a, b, y0, y1, x0, x1) {
const stride = a.width * 4;
let n = 0;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const i = y * stride + x * 4;
if (Math.abs(a.rgba[i] - b.rgba[i]) > 8 || Math.abs(a.rgba[i + 1] - b.rgba[i + 1]) > 8
|| Math.abs(a.rgba[i + 2] - b.rgba[i + 2]) > 8) n++;
}
}
return n;
}