/**
* Гейт неба (трек «Небо: солнце, луна, звёзды, градиент»): на улицах
* skyInfo включён (солнце+луна), в изо небо не видно (кадр бит-в-бит при
* setSky(false) — регрессия изо-гейтов), first — градиент зенит/горизонт,
* диск солнца в полдень, звёзды ночью (яркие точки в тёмном небе), луна
* ночью с фазой (skyInfo.phase = формула), день/ночь различаются,
* облачность темнит день и гасит звёзды, интерьер без неба.
* Скриншоты: /tmp/v2_sky_*.png.
*
* Грабли детерминизма (как в camera.mjs): setAsh(false) + setClock ПЕРЕД
* КАЖДЫМ кадром (firstShot пинит сам — иначе часы стоят и светила не там)
* + stepTicks(1); мерцание звёзд идёт от времени цикла (fogTime), которое
* пинится тем же тиком.
*
* Грабли камеры: градиент живёт на высотах 0..33° — на pitch −1.2 весь кадр
* зенит; проверка градиента — pitch −0.5 (верх кадра ~зенит, низ ~горизонт).
* Звёзды/облачность считаем на камере yaw 1.6 — от обоих светил (в полдень
* солнце в +Z, в полночь там же луна; тост-оверлей занимает x<430, y<52 —
* все замеры неба правее x=480).
*
* Запуск: node apps/game/tools/checks/sky.mjs [--port 5338]
*/
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 ?? 5338);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'sky', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
/** Число различающихся пикселей двух кадров (порог 8 на канал). */
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;
}
const W = 960, H = 540;
/** Средняя яркость прямоугольника кадра (в пикселях канваса). */
function meanLuma(img, x0, y0, x1, y1) {
let s = 0, n = 0;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const i = (y * W + x) * 4;
s += (img[i] * 0.3 + img[i + 1] * 0.6 + img[i + 2] * 0.1);
n++;
}
}
return s / n;
}
/** Число ярких пикселей (все каналы > thr) в прямоугольнике — звёзды/диски.
* Порог 180: после пост-цепочки максимум белого диска ~191 (не 255). */
function countBright(img, x0, y0, x1, y1, thr = 180) {
let n = 0;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const i = (y * W + x) * 4;
if (img[i] > thr && img[i + 1] > thr && img[i + 2] > thr) n++;
}
}
return n;
}
/** Правая половина неба без тост-оверлея (x<430, y<52). */
const SKY_X0 = 480, SKY_X1 = 920, SKY_Y0 = 20, SKY_Y1 = 320;
async function shot(client, path) {
await client.screenshot(path);
return decodePng(readFileSync(path)).rgba;
}
/** Взгляд из первого лица (pitch < 0 — вверх) + кадр: часы пинятся всегда. */
async function firstShot(client, path, yaw, pitch, minutes = 720) {
await client.call('setCameraMode', 'first', { yaw, pitch });
await client.call('setClock', minutes);
await client.call('stepTicks', 1);
return shot(client, path);
}
// Фаза луны по часам — та же формула, что движка (moonPhase(hours, 8))
const phaseAt = (hours) => (1 - Math.cos(2 * Math.PI * ((hours / 24) % 8) / 8)) / 2;
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('setFauna', false);
await client.call('setFollow', false);
await client.evaluate(() => { document.getElementById('fps').hidden = true; });
// ---- Небо на улице: включено, солнце+луна ----
await client.call('setClock', 720);
await client.call('stepTicks', 1);
const si = await client.call('skyInfo');
check('луга: небо включено, 2 тела (солнце+луна)',
si !== null && si.enabled === true && si.bodyCount === 2
&& si.stars === true && Math.abs(si.hours - 12) < 0.01, si);
// ---- Градиент: зенит ≠ горизонт (first, pitch −0.5: верх кадра
// ~зенит, низ ещё небо у горизонта; камера от светил) ----
const grad = await firstShot(client, '/tmp/v2_sky_gradient.png', 1.6, -0.5);
const top = meanLuma(grad, SKY_X0, SKY_Y0, SKY_X1, 110);
const mid = meanLuma(grad, SKY_X0, 230, SKY_X1, 330);
check('градиент неба: зенит (верх) темнее горизонта (середина)',
mid - top > 8, { top: Math.round(top), mid: Math.round(mid) });
// ---- Диск солнца в полдень (yaw на светило, elev ≈ 1.167) ----
const sun = await firstShot(client, '/tmp/v2_sky_sun.png', -Math.PI / 2, -1.167);
const sunPx = countBright(sun, 280, 90, 680, 450);
check('диск солнца: яркие пиксели при взгляде на светило',
sunPx > 100, { sunPx });
check('солнце меняет кадр против градиента без светила',
countDiff(sun, grad) > 100, { diff: countDiff(sun, grad) });
// ---- Ночь: темнее дня, звёзды — яркие точки (камера от светил:
// в полночь луна стоит ровно в направлении полуденного солнца) ----
const night = await firstShot(client, '/tmp/v2_sky_night.png', 1.6, -0.5, 0);
check('ночь темнее дня (та же камера)', countDiff(night, grad) > 5000,
{ diff: countDiff(night, grad) });
const dayStars = countBright(grad, SKY_X0, SKY_Y0, SKY_X1, 320);
const nightStars = countBright(night, SKY_X0, SKY_Y0, SKY_X1, 320);
check('звёзды ночью: яркие точки в тёмном небе (днём их нет)',
nightStars > 30 && dayStars < 5, { nightStars, dayStars });
// ---- Луна ночью: фаза по формуле, диск виден ----
// 22:00: moonK = 1/3 (az 2π/3, elev ≈ 1.027), фаза ≈ 0.124
const moon = await firstShot(client, '/tmp/v2_sky_moon.png', -Math.PI / 3, -1.027, 1320);
const si2 = await client.call('skyInfo');
const expPhase = phaseAt(22);
const gotPhase = si2.bodies[1].phase;
check('луна: фаза из формулы (22:00 ≈ 0.124), интенсивность ночи',
Math.abs(gotPhase - expPhase) < 0.005 && si2.bodies[1].intensity === 1,
{ gotPhase, expPhase, intensity: si2.bodies[1].intensity });
const moonPx = countBright(moon, 280, 90, 680, 450);
check('луна: диск рисуется (яркие пиксели в направлении тела)',
moonPx > 50, { moonPx });
// ---- Облачность: темнит день (та же камера) и гасит звёзды ночью ----
await client.call('setWeather', { cloud: 1 });
await client.call('stepTicks', 30); // разгон cloudK к цели
const dayCloudy = await firstShot(client, '/tmp/v2_sky_daycloudy.png', 1.6, -0.5);
const cloudyNight = await firstShot(client, '/tmp/v2_sky_cloudy.png', 1.6, -0.5, 0);
const cloudyStars = countBright(cloudyNight, SKY_X0, SKY_Y0, SKY_X1, 320);
const dayLuma = meanLuma(grad, SKY_X0, SKY_Y0, SKY_X1, 320);
const cloudyLuma = meanLuma(dayCloudy, SKY_X0, SKY_Y0, SKY_X1, 320);
check('облачность темнит день и гасит звёзды',
cloudyLuma < dayLuma && cloudyStars * 2 < nightStars,
{ dayLuma: Math.round(dayLuma), cloudyLuma: Math.round(cloudyLuma),
nightStars, cloudyStars });
await client.call('setWeather', null); // база области дальше
await client.call('stepTicks', 1);
// ---- Изо-регрессия: небо в изо не видно — кадр бит-в-бит ----
await client.call('setCameraMode', 'iso');
await client.call('setFollow', false);
const isoRef = await stableShotRef(client, '/tmp/v2_sky_iso.png');
await client.call('setSky', false);
const isoNoSky = await stableShotRef(client, '/tmp/v2_sky_iso_nosky.png');
check('изо: кадр с небом и без попиксельно равен (небо в изо не видно)',
countDiff(isoRef, isoNoSky) === 0, { diff: countDiff(isoRef, isoNoSky) });
await client.call('setSky', true);
await client.call('stepTicks', 1);
// ---- Интерьер без неба ----
await client.call('gotoArea', 'house_elder');
await client.call('stepTicks', 60); // полный фейд out+in (0.4 с × 2)
const siIn = await client.call('skyInfo');
check('интерьер (дом старейшины): неба нет', siIn === null, siIn);
} finally {
await browser.close();
}
} catch (e) {
console.error(e);
report.fatal = String(e);
}
/** Кадр в изо в детерминированном состоянии (полдень, один тик). */
async function stableShotRef(client, path) {
await client.call('setClock', 720);
await client.call('stepTicks', 1);
return shot(client, path);
}
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 — выходим явно