/**
* Скриншот-гейт областей v2 (трек 15 «карты и области»): конвертация v1 →
* мир детерминирована (пересбор той же области — тот же кадр), спавн лугов,
* blocked-тайлы (вода/дерево сплошные, тропа нет; тело зажимается гранью),
* step-переход входом на тайл-триггер (фейд out→swap→in), возврат 'return'
* по стеку, click-переход через «E» с подсказкой. Скриншоты: /tmp/v2_areas_*.png.
*
* Запуск: node apps/game/tools/checks/areas.mjs [--port 5314]
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
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 ?? 5314);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'areas', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
/** Тиков на полный переход (out+in) с запасом. */
const PASS = 70;
/** Тиков на проход через один тайл с запасом (4 вокс/с, тайл = 3 вокс). */
const CROSS = 120;
// ---- Раскладка v1 (maps.json — чистые данные, движок не импортируется) ----
const maps = JSON.parse(readFileSync(new URL('../../src/data/maps.json', import.meta.url), 'utf8'));
/** RLE → плоский массив тайлов (как в areas.ts). */
function tilesFlat(id) {
const m = maps[id];
const out = [];
for (const [tid, run] of m.tiles) for (let k = 0; k < run; k++) out.push(tid);
return out;
}
/** Первый тайл области с данным id → {x, y} (в тайлах v1). */
function findTile(id, tileId) {
const m = maps[id];
const flat = tilesFlat(id);
for (let i = 0; i < flat.length; i++) {
if (flat[i] === tileId) return { x: i % m.width, y: Math.floor(i / m.width) };
}
throw new Error(`${id}: тайл ${tileId} не найден`);
}
/** Воксельный центр тайла (как tileCenter в игре: tile*UNIT + 1.5). */
const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 });
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;
}
function isBlack(rgba) {
let bright = 0, n = 0;
for (let i = 0; i < rgba.length; i += 4) {
n++;
if (rgba[i] + rgba[i + 1] + rgba[i + 2] > 30) bright++;
}
return bright / n < 0.01;
}
/** Число различимых цветов (квантование 5 бит/канал). */
function colorCount(rgba) {
const seen = new Set();
for (let i = 0; i < rgba.length; i += 4) {
seen.add((rgba[i] >> 3) + (rgba[i + 1] >> 3) * 32 + (rgba[i + 2] >> 3) * 1024);
}
return seen.size;
}
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);
await client.call('stepTicks', 10);
// Старт: луга, герой на спавне, меши собраны
let ai = await client.call('areaInfo');
check('старт: луга, герой на спавне (16,28), меши собраны',
ai.id === 'meadows' && ai.heroTile.x === 16 && ai.heroTile.y === 28 && ai.meshes > 0,
{ id: ai.id, tile: ai.heroTile, meshes: ai.meshes });
// Детерминизм сборки: пересбор той же области — тот же кадр
const spawnC = center({ x: 16, y: 28 });
await client.call('setFollow', false);
const shotA = await client.screenshot('/tmp/v2_areas_meadows.png');
await client.call('gotoArea', 'meadows');
await client.call('stepTicks', 5);
const shotA2 = await client.screenshot('/tmp/v2_areas_meadows2.png');
const detDiff = countDiff(decodePng(readFileSync(shotA)).rgba, decodePng(readFileSync(shotA2)).rgba);
check('пересборка области детерминирована (кадры равны)', detDiff === 0, { detDiff });
// Чистая мировая ось — комбинированный стик: при yaw π/4 экранное
// (1,1) = +x мира (hero-гейт демки), (1,-1) = −z, (-1,1) = +z
await client.call('heroTeleport', spawnC.x, spawnC.z);
await client.call('setInput', 1, 1);
await client.call('stepTicks', 10);
const hiProbe = await client.call('heroInfo');
await client.call('setInput', null, null);
check('стик (1,1) — чистый +x мира (без дрейфа по z)',
hiProbe.pos[0] - spawnC.x > 0.3 && Math.abs(hiProbe.pos[2] - spawnC.z) < 0.2,
{ dx: Math.round((hiProbe.pos[0] - spawnC.x) * 100) / 100,
dz: Math.round((hiProbe.pos[2] - spawnC.z) * 100) / 100 });
// blocked: дерево и вода сплошные (хоть одна под-колонка тайла),
// тропа — ни одной сплошной под-колонки
const solidTile = async (c) => {
let n = 0;
for (const dx of [-1, 0, 1]) for (const dz of [-1, 0, 1]) {
if (await client.call('solidAt', c.x + dx, c.z + dz)) n++;
}
return n;
};
const tree = center(findTile('meadows', 3));
const water = center(findTile('meadows', 2));
const path = center(findTile('meadows', 1));
const sTree = await solidTile(tree);
const sWater = await solidTile(water);
const sPath = await solidTile(path);
check('blocked: дерево/вода сплошные, тропа проходима', sTree > 0 && sWater === 9 && sPath === 0,
{ tree: sTree, water: sWater, path: sPath });
// Тело зажимается гранью воды (вода — сплошной слой на уровне ног)
await client.call('heroTeleport', water.x - 3.6, water.z);
await client.call('setInput', 1, 1);
await client.call('stepTicks', CROSS);
await client.call('setInput', null, null);
const hiStop = await client.call('heroInfo');
const gap = water.x - hiStop.pos[0]; // центр тела до центра воды
check('тело героя останавливается у грани воды', gap > 2.6 && gap < 4.5,
{ gap: Math.round(gap * 10) / 10 });
// Скриншоты областей: различаются и не пустые
const areas = ['meadows', 'ponds', 'zvenets', 'rust_grove', 'old_siding', 'machine_hall', 'house_elder'];
const shots = {};
let minDiff = Infinity;
let allColorful = true;
for (const id of areas) {
await client.call('gotoArea', id);
await client.call('stepTicks', 5);
const p = `/tmp/v2_areas_${id}.png`;
const img = decodePng(readFileSync(await client.screenshot(p)));
shots[id] = img.rgba;
if (isBlack(img.rgba) || colorCount(img.rgba) < 8) allColorful = false;
}
for (let i = 1; i < areas.length; i++) {
minDiff = Math.min(minDiff, countDiff(shots[areas[i - 1]], shots[areas[i]]));
}
check('скриншоты 7 областей: не пустые, различаются', allColorful && minDiff > 5000,
{ minDiff, allColorful });
// step-переход: телепорт у триггера (54,28) → идти на него → Звенец
await client.call('gotoArea', 'meadows');
await client.call('stepTicks', 5);
const trig = center({ x: 54, y: 28 });
await client.call('heroTeleport', trig.x - 3, trig.z);
await client.call('setInput', 1, 1);
await client.call('stepTicks', CROSS);
await client.call('setInput', null, null);
await client.call('stepTicks', PASS);
ai = await client.call('areaInfo');
check('step-переход луга→Звенец (фейд, возврат запомнен)',
ai.id === 'zvenets' && ai.returns === 1 && !ai.fade.active, { id: ai.id, returns: ai.returns });
// Вторая пара step-переходов: луга (2,2) → пруды → обратно в луга
await client.call('gotoArea', 'meadows');
await client.call('stepTicks', 5);
const pTrig = center({ x: 2, y: 2 });
await client.call('heroTeleport', pTrig.x + 3, pTrig.z);
await client.call('setInput', -1, -1);
await client.call('stepTicks', CROSS);
await client.call('setInput', null, null);
await client.call('stepTicks', PASS);
ai = await client.call('areaInfo');
check('второй step-переход: луга→пруды', ai.id === 'ponds', ai.id);
const pBack = center({ x: 3, y: 2 });
await client.call('heroTeleport', pBack.x, pBack.z);
await client.call('setInput', -1, -1);
await client.call('stepTicks', CROSS);
await client.call('setInput', null, null);
await client.call('stepTicks', PASS);
ai = await client.call('areaInfo');
check('обратный переход пруды→луга', ai.id === 'meadows', ai.id);
// return-переход: люк подземелья → зал → люк обратно
await client.call('gotoArea', 'old_siding');
await client.call('stepTicks', 5);
const hatch = center({ x: 28, y: 31 });
await client.call('heroTeleport', hatch.x - 3, hatch.z);
await client.call('setInput', 1, 1);
await client.call('stepTicks', CROSS);
await client.call('setInput', null, null);
await client.call('stepTicks', PASS);
ai = await client.call('areaInfo');
const wentDown = ai.id === 'machine_hall' && ai.returns === 1;
check('переход в подземелье через люк (step, 0.6 с)', wentDown, { id: ai.id, returns: ai.returns });
if (wentDown) {
// запад/восток триггера (13,21) — стены; идём с севера (13,20), +z
const hall = center({ x: 13, y: 20 });
await client.call('heroTeleport', hall.x, hall.z);
await client.call('setInput', -1, 1);
await client.call('stepTicks', CROSS);
await client.call('setInput', null, null);
await client.call('stepTicks', PASS);
ai = await client.call('areaInfo');
check('возврат из подземелья по стеку (на тайле входа)',
ai.id === 'old_siding' && ai.returns === 0, { id: ai.id, returns: ai.returns });
}
// click-переход: дверь дома Ирвина в Звенце — подсказка и «E»
await client.call('gotoArea', 'zvenets');
await client.call('stepTicks', 5);
const door = center({ x: 13, y: 12 });
await client.call('heroTeleport', door.x + 3, door.z);
await client.call('stepTicks', 5);
let ii = await client.call('interactInfo');
check('подсказка у двери: «E — Дом Ирвина»', ii.label === 'Дом Ирвина' && ii.hint.includes('E'), ii);
await client.call('heroTeleport', door.x + 30, door.z); // ~10 тайлов в стороне
await client.call('stepTicks', 5);
ii = await client.call('interactInfo');
check('вдали от двери подсказки нет', ii.label === null && ii.hint === '', ii);
await client.call('heroTeleport', door.x + 3, door.z);
await client.call('stepTicks', 5);
await client.call('interact');
await client.call('stepTicks', PASS);
ai = await client.call('areaInfo');
check('click-переход «E» → дом Ирвина', ai.id === 'house_elder', ai.id);
const inside = await client.screenshot('/tmp/v2_areas_inside.png');
check('кадр интерьера рисуется (не пустой)', colorCount(decodePng(readFileSync(inside)).rgba) >= 8, {});
} finally {
await browser.close();
}
} catch (e) {
report.error = String(e && e.stack ? e.stack : e);
console.error(report.error);
} finally {
server.stop();
}
report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));