/**
* Гейт атмосферы 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;
}
/** Средняя яркость прямоугольной зоны кадра (виньетка: углы против центра). */
function zoneBrightness(px, x0, y0, x1, y1) {
let sum = 0, n = 0;
for (let y = Math.max(0, y0); y < Math.min(px.height, y1); y++) {
for (let x = Math.max(0, x0); x < Math.min(px.width, x1); x++) {
const i = (y * px.width + x) * 4;
sum += px.rgba[i] * 0.3 + px.rgba[i + 1] * 0.6 + px.rgba[i + 2] * 0.1;
n++;
}
}
return n ? sum / n : 0;
}
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 });
// ---- Виньетка пост-прохода: углы кадра темнее центра ----
const dayPx = decodePng(readFileSync(dayShot));
const w = dayPx.width, h = dayPx.height, q = 24; // угол 24×24, центр 48×48
const corners = [
zoneBrightness(dayPx, 0, 0, q, q),
zoneBrightness(dayPx, w - q, 0, w, q),
zoneBrightness(dayPx, 0, h - q, q, h),
zoneBrightness(dayPx, w - q, h - q, w, h),
];
const center = zoneBrightness(dayPx, w / 2 - q, h / 2 - q, w / 2 + q, h / 2 + q);
const cornerMean = corners.reduce((s, v) => s + v, 0) / 4;
// зона углов несёт и контент (небо ярче земли) — порог мягкий: эффект
// ~-25% на углу плюс разброс содержимого
check('виньетка: углы в среднем темнее центра кадра', cornerMean < center * 0.92, { center, cornerMean, corners });
// ---- Пепел: живой слой кадра ----
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 });
// ---- Воздух области (трек 31): листопад рощи, пыль разъезда ----
await client.call('gotoArea', 'rust_grove');
await client.call('stepTicks', 1);
const airL = (await client.call('ambientInfo')).air;
check('роща: листья в воздухе (leaves ×26)', airL?.kind === 'leaves' && airL.count === 26, airL);
const shotLeaves0 = await client.screenshot('/tmp/v2_atmosphere_leaves0.png');
await client.call('stepTicks', 40); // листья падают и качаются
const shotLeaves1 = await client.screenshot('/tmp/v2_atmosphere_leaves1.png');
const leavesDiff = countDiff(decodePng(readFileSync(shotLeaves0)).rgba, decodePng(readFileSync(shotLeaves1)).rgba);
check('листья дрейфуют: кадры различаются', leavesDiff > 100, { leavesDiff });
await client.call('gotoArea', 'old_siding');
await client.call('stepTicks', 1);
const airD = (await client.call('ambientInfo')).air;
check('разъезд: пыль в воздухе (dust ×40)', airD?.kind === 'dust' && airD.count === 40, airD);
const shotDust0 = await client.screenshot('/tmp/v2_atmosphere_dust0.png');
await client.call('stepTicks', 400); // пыль ползёт медленно — нужен долгий шаг
const shotDust1 = await client.screenshot('/tmp/v2_atmosphere_dust1.png');
const dustDiff = countDiff(decodePng(readFileSync(shotDust0)).rgba, decodePng(readFileSync(shotDust1)).rgba);
check('пыль дрейфует: кадры различаются', dustDiff > 100, { dustDiff });
// ---- Источники по флагам: очаг, башня ----
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 — выходим явно