/**
* Гейт атмосферы v2 (трек 18 «время суток, освещение, музыка»): часы суток
* (1 сек = 1 игровая минута), кривая ночи по часам, точечные источники
* по флагам/времени/области (bells, окна, очаг, лампа героя), мерцание,
* пепел как живой слой кадра, тема области с кроссфейдом. Скриншоты:
* /tmp/v2_atmosphere_*.png.
*
* Запуск: node apps/game/tools/checks/atmosphere.mjs [--port 5320]
*/
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 ?? 5320);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'atmosphere', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
/** Средняя яркость кадра 0..255 (день/ночь сравнение). */
function brightness(rgba) {
let sum = 0, n = 0;
for (let i = 0; i < rgba.length; i += 4) {
sum += rgba[i] * 0.3 + rgba[i + 1] * 0.6 + rgba[i + 2] * 0.1;
n++;
}
return sum / n;
}
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;
}
try {
await server.waitReady;
const browser = await launchBrowser();
try {
const client = await openGame(browser, { url: `http://localhost:${port}/` });
await client.call('startGame', false); // страница стартует в меню (меню/автосейв)
const errs = client.errors();
check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3));
await client.call('setManual', true);
await client.call('setAsh', false); // кадр-равенства — мир без пепла
await client.call('setClock', 480); // страница грузилась в реальном времени
await client.call('stepTicks', 5);
// ---- Часы суток ----
let ci = await client.call('clockInfo');
check('старт: 8:00, ночь 0 (день)', ci.label === '8:00' && ci.minutes === 480 && ci.nightFactor === 0, ci);
await client.call('setClock', 1140); // 19:00 — половина заката
ci = await client.call('clockInfo');
check('19:00 — фактор ночи 0.5 (золотой час)', ci.label === '19:00' && Math.abs(ci.nightFactor - 0.5) < 0.01, ci);
await client.call('setClock', 1134); // 18:54 → пик золотого часа (0.45)
ci = await client.call('clockInfo');
check('18:54 — пик золотого часа 0.45', Math.abs(ci.nightFactor - 0.45) < 0.01, ci);
await client.call('setClock', 1200); // 20:00 — ночь
ci = await client.call('clockInfo');
check('20:00 — ночь (фактор 1)', ci.label === '20:00' && ci.nightFactor === 1, ci);
await client.call('setClock', 1495); // > суток: mod 1440 → 0:55
ci = await client.call('clockInfo');
check('перемотка mod 1440: 0:55', ci.label === '0:55' && ci.minutes === 55, ci);
// ---- Ночные источники лугов (bells) + лампа героя ----
await client.call('setClock', 480); // назад в день
let li = await client.call('lightingInfo');
check('день на лугах: источников нет (bells whenNight)', li.sources.length === 0, li);
await client.call('setClock', 1200); // ночь
li = await client.call('lightingInfo');
check('ночь на лугах: колокольчики светятся (bells@тайлы)',
li.sources.some((s) => s.id === 'bells' && s.intensity > 0), li);
check('ночь: лампа героя у героя', li.sources.some((s) => s.id === 'hero_lamp'), li);
// Мерцание: интенсивность bells меняется со временем (flicker 0.15)
const i0 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1;
let i1 = i0;
for (let k = 0; k < 4 && i1 === i0; k++) {
await client.call('stepTicks', 9);
li = await client.call('lightingInfo');
i1 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1;
}
check('мерцание: интенсивность пульсирует', i1 !== i0, { i0, i1 });
// ---- Кадр дня против ночи (фон и свет гаснут) ----
await client.call('setFollow', false);
await client.call('setClock', 720); // полдень
await client.call('stepTicks', 1);
const dayShot = await client.screenshot('/tmp/v2_atmosphere_day.png');
await client.call('setClock', 1320); // 22:00 — глухая ночь
await client.call('stepTicks', 1);
const nightShot = await client.screenshot('/tmp/v2_atmosphere_night.png');
const dayBright = brightness(decodePng(readFileSync(dayShot)).rgba);
const nightBright = brightness(decodePng(readFileSync(nightShot)).rgba);
check('кадр ночи темнее кадра дня', nightBright < dayBright * 0.85, { dayBright, nightBright });
// ---- Пепел: живой слой кадра ----
await client.call('setClock', 720);
await client.call('setAsh', true);
await client.call('stepTicks', 1);
const shotNoAsh = await client.screenshot('/tmp/v2_atmosphere_ash0.png');
await client.call('setAsh', true);
await client.call('stepTicks', 40); // пепел дрейфует с тиками
const shotAsh = await client.screenshot('/tmp/v2_atmosphere_ash1.png');
const ashDiff = countDiff(decodePng(readFileSync(shotNoAsh)).rgba, decodePng(readFileSync(shotAsh)).rgba);
check('пепел дрейфует: кадры различаются', ashDiff > 100, { ashDiff });
// ---- Источники по флагам: очаг, башня ----
await client.call('setAsh', false);
await client.call('gotoArea', 'house_elder');
li = await client.call('lightingInfo');
check('очаг до сдачи: hearth_low тлеет', li.sources.some((s) => s.id === 'hearth_low'), li);
await client.call('setState', { flags: ['quest_bells_done'] });
await client.call('stepTicks', 1);
li = await client.call('lightingInfo');
check('после сдачи: hearth_high горит, hearth_low погас',
li.sources.some((s) => s.id === 'hearth_high') && !li.sources.some((s) => s.id === 'hearth_low'), li);
await client.call('gotoArea', 'zvenets');
await client.call('stepTicks', 1);
li = await client.call('lightingInfo');
check('башня без звонка: отсвета нет', !li.sources.some((s) => s.id === 'tower_resonance'), li);
await client.call('setState', { flags: ['rang_tower'] });
await client.call('stepTicks', 1);
li = await client.call('lightingInfo');
check('после звонка: холодный отсвет башни', li.sources.some((s) => s.id === 'tower_resonance'), li);
// ---- Тема области ----
await client.call('gotoArea', 'meadows'); // назад на луга (тема области)
await client.call('stepTicks', 1);
let ai = await client.call('audioInfo');
check('луга: тема meadows играет', ai.theme === 'meadows' && ai.played.some((p) => p.key === 'theme/meadows'), ai);
await client.call('gotoArea', 'house_elder');
await client.call('stepTicks', 1);
ai = await client.call('audioInfo');
check('интерьер: темы нет (тишина, как в v1)', ai.theme === null, ai);
} finally {
await browser.close();
}
} catch (e) {
console.error(e);
report.fatal = String(e);
}
report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));
process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно