/**
* Гейт погоды v2 (трек «погода в движке»): база погоды по областям, ручной
* режим (дождь/снег рисуются и падают, облачность темнит кадр, туман — слои
* по дальности с дрейфом и глубинным градиентом), кадр без осадков, сброс к
* погоде области, управление из дев-панели. Скриншоты: /tmp/v2_weather_*.png.
*
* Запуск: node apps/game/tools/checks/weather.mjs [--port 5330]
*/
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 ?? 5330);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'weather', 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 bandBrightness(rgba, width, y0, y1) {
let sum = 0, n = 0;
for (let y = y0; y < y1; y++) for (let x = 0; x < width; x++) {
const i = (y * width + x) * 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;
}
async function shot(client, path) {
await client.screenshot(path);
return decodePng(readFileSync(path)).rgba;
}
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('setClock', 720); // полдень — облачность мерить на ровном дне
await client.call('stepTicks', 5);
// ---- База погоды области ----
let wi = await client.call('weatherInfo');
check('луга: база — лёгкий ветер, облачность 0.15, без осадков',
wi.windStrength === 0.15 && wi.cloud === 0.15 && wi.rain === 0 && wi.snow === 0 && wi.manual === null, wi);
await client.call('gotoArea', 'old_siding');
await client.call('stepTicks', 60); // фейд перехода 0.4с — дождаться конца
wi = await client.call('weatherInfo');
check('разъезд: база — продувает (ветер 0.6, дождь 0.15)',
wi.windStrength === 0.6 && wi.cloud === 0.35 && wi.rain === 0.15, wi);
// ---- Ручной ливень: рисуется и падает ----
await client.call('setWeather', { rain: 1, snow: 0, cloud: 0, windStrength: 0 });
await client.call('stepTicks', 3);
wi = await client.call('weatherInfo');
check('ручной ливень: стена капель (плотность на окно), снег 0, правка активна',
wi.rainDrops > 1000 && wi.snowFlakes === 0 && wi.manual !== null, wi);
const rainA = await shot(client, '/tmp/v2_weather_rain.png');
await client.call('stepTicks', 30); // стена дождя падает (93 юнит/с)
const rainB = await shot(client, '/tmp/v2_weather_rain2.png');
const rainDiff = countDiff(rainA, rainB);
check('дождь падает: кадры различаются', rainDiff > 100, { rainDiff });
// ---- Снег: медленный, рисуется и падает ----
await client.call('setWeather', { rain: 0, snow: 1, cloud: 0, windStrength: 0 });
await client.call('stepTicks', 3);
wi = await client.call('weatherInfo');
check('ручной снег: снежинки плотностью на окно, дождь 0',
wi.snowFlakes > 500 && wi.rainDrops === 0, wi);
const snowA = await shot(client, '/tmp/v2_weather_snow.png');
await client.call('stepTicks', 40); // снег медленный — шаг подлиннее
const snowB = await shot(client, '/tmp/v2_weather_snow2.png');
const snowDiff = countDiff(snowA, snowB);
check('снег падает: кадры различаются', snowDiff > 100, { snowDiff });
// ---- Облачность темнит кадр (день) ----
await client.call('gotoArea', 'meadows');
await client.call('stepTicks', 60); // фейд перехода — дождаться конца
await client.call('setWeather', { rain: 0, snow: 0, cloud: 0, windStrength: 0 });
await client.call('stepTicks', 2);
const clearBright = brightness(await shot(client, '/tmp/v2_weather_clear.png'));
await client.call('setWeather', { cloud: 1 });
await client.call('stepTicks', 3);
const cloudBright = brightness(await shot(client, '/tmp/v2_weather_cloud.png'));
// порог 0.93: зенитный свет пасмурного дня (OVERCAST_TOP_LIGHT —
// «свет переотражается, словно есть доп. свет прямо сверху») добавляет
// освещённости, и кадр темнит слабее, чем до зенита (~0.89 против ~0.75)
check('облачность темнит кадр (полная — заметно темнее)',
cloudBright < clearBright * 0.93, { clearBright, cloudBright });
// ---- Туман: слои по дальности, дрейф, глубинный градиент ----
await client.call('setWeather', { rain: 0, snow: 0, cloud: 0, fog: 0, windStrength: 0.6 });
await client.call('stepTicks', 2);
const clear = await shot(client, '/tmp/v2_weather_fog0.png');
await client.call('setWeather', { fog: 1 });
await client.call('stepTicks', 3);
const foggy = await shot(client, '/tmp/v2_weather_fog1.png');
const fogDiff = countDiff(clear, foggy);
check('туман рисуется: кадры без/с туманом различаются', fogDiff > 20000, { fogDiff });
const W = decodePng(readFileSync('/tmp/v2_weather_fog0.png')).width;
// разница «с туманом − без» по строкам: даль (верх) должен посветлеть
// сильнее близи (низ) — слои копятся с глубиной
const gradTop = bandBrightness(foggy, W, 0, 150) - bandBrightness(clear, W, 0, 150);
const gradBottom = bandBrightness(foggy, W, 400, 540) - bandBrightness(clear, W, 400, 540);
check('туман гуще вдали: верх кадра светлее низа на разнице',
gradTop > gradBottom + 5, { gradTop, gradBottom });
await client.call('stepTicks', 60); // ветер 0.6 гонит клубы
const foggy2 = await shot(client, '/tmp/v2_weather_fog1b.png');
const drift = countDiff(foggy, foggy2);
check('туман дрейфует с ветром: кадры различаются', drift > 5000, { drift });
await client.call('setClock', 0); // ночь — туман красится env-светом
await client.call('stepTicks', 5);
const nightFog = brightness(await shot(client, '/tmp/v2_weather_fog_night.png'));
check('ночь: туман тёмный (env-свет), кадр не выбелен',
nightFog < 90 && nightFog < brightness(foggy), { nightFog });
await client.call('setClock', 720);
await client.call('setWeather', null); // снять ручную правку перед молнией
// ---- Молния: разряд + вспышка с тенями, гаснет ----
await client.call('setWeather', { rain: 0.6, cloud: 0.6, fog: 0, windStrength: 0.6 });
await client.call('stepTicks', 2);
const lBefore = brightness(await shot(client, '/tmp/v2_lightning_before.png'));
await client.call('makeLightning');
await client.call('stepTicks', 2); // внутри окна вспышки (0.55 с)
let li = await client.call('lightningInfo');
check('молния: вспышка активна (свет + разряд)', li.active === true && li.flash > 0.3, li);
const lFlash = brightness(await shot(client, '/tmp/v2_lightning_flash.png'));
check('молния: кадр светлее во вспышку', lFlash > lBefore * 1.08, { lBefore, lFlash });
await client.call('stepTicks', 60); // вспышка 0.55 с — погасла
li = await client.call('lightningInfo');
const lAfter = brightness(await shot(client, '/tmp/v2_lightning_after.png'));
check('молния: погасла, кадр вернулся', !li.active && li.flash === 0 && lAfter < lFlash * 0.95,
{ lAfter, lFlash });
// ночь: вспышка — главный свет кадра, читается драматичнее
await client.call('setClock', 0);
await client.call('stepTicks', 5);
const nBefore = brightness(await shot(client, '/tmp/v2_lightning_night0.png'));
await client.call('makeLightning');
await client.call('stepTicks', 2);
const nFlash = brightness(await shot(client, '/tmp/v2_lightning_night1.png'));
check('молния ночью: вспышка освещает кадр', nFlash > nBefore * 1.4, { nBefore, nFlash });
// тени вспышки: направление света постоянно (наклон от точки удара
// не зависит), так что честный чек — сравнение кадра вспышки с
// тенями и без (setLightningShadows(false)): тёмные пиксели (тень =
// один ambient) в земной трети кадра исчезают вместе с shadow map
await client.call('setWeather', { rain: 0, snow: 0, cloud: 0.4, fog: 0, windStrength: 0.2 });
await client.call('stepTicks', 3);
const sw = await client.call('windowInfo', 0, 40);
const scx = (sw.minX + sw.maxX) / 2, scz = (sw.minZ + sw.maxZ) / 2;
// тёмные пиксели нижней (земной) трети кадра
const groundDarks = (rgba) => {
let n = 0;
for (let y = 360; y < 540; y++) for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
if (rgba[i] * 0.3 + rgba[i + 1] * 0.6 + rgba[i + 2] * 0.1 < 40) n++;
}
return n;
};
await client.call('makeLightning', [scx, 0, scz]);
await client.call('stepTicks', 2);
const darksOn = groundDarks(await shot(client, '/tmp/v2_lightning_shadow_on.png'));
await client.call('setLightningShadows', false);
await client.call('stepTicks', 60); // вспышка догорела
await client.call('makeLightning', [scx, 0, scz]);
await client.call('stepTicks', 2);
const darksOff = groundDarks(await shot(client, '/tmp/v2_lightning_shadow_off.png'));
await client.call('setLightningShadows', true); // вернуть для следующих чеков
check('молния: тени кастеров реально рисуются (гаснут с castShadow)',
darksOn > darksOff + 150, { darksOn, darksOff });
await client.call('setClock', 720);
await client.call('setWeather', null); // снять ручную правку перед рощей
// ---- setAsh(false): гейт-режим глушит и осадки с облачностью ----
await client.call('setWeather', null); // ручная правка переживает смену области — снять
await client.call('gotoArea', 'rust_grove'); // база рощи — морось 0.3
await client.call('stepTicks', 60); // фейд перехода — дождаться конца
wi = await client.call('weatherInfo');
check('роща: база — морось (rain 0.3, облачность 0.5)',
wi.rain === 0.3 && wi.cloud === 0.5 && wi.manual === null, wi);
await client.call('setAsh', false);
wi = await client.call('weatherInfo');
check('setAsh(false): осадки и облачность глушатся (кадр-равенства)',
wi.rainDrops === 0 && wi.snowFlakes === 0 && wi.cloud === 0, wi);
// ---- Сброс возвращает базу области ----
await client.call('setWeather', { rain: 0.9, windStrength: 1 });
await client.call('setWeather', null);
wi = await client.call('weatherInfo');
check('сброс: вернулась база рощи, правок панели нет',
wi.rain === 0.3 && wi.cloud === 0.5 && wi.manual === null, wi);
// ---- Дев-панель: слайдер дождя реально управляет погодой ----
await client.call('setAsh', true); // вернуть атмосферу после гейт-режима
await client.call('devOpen');
const hasSliders = await client.evaluate(() =>
['dev-w-cloud', 'dev-w-rain', 'dev-w-snow', 'dev-w-wind', 'dev-w-dir', 'dev-w-fog']
.every((id) => document.getElementById(id) !== null));
check('дев-панель: секция погоды с 6 слайдерами (включая туман)', hasSliders === true);
// слайдер тумана → input-событие → ручная правка активна
await client.evaluate(() => {
const el = document.getElementById('dev-w-fog');
el.value = '0.7';
el.dispatchEvent(new Event('input'));
});
wi = await client.call('weatherInfo');
check('дев-панель: слайдер тумана включает ручной режим', wi.manual?.fog === 0.7, wi);
await client.call('devSetWeather', null); // сброс + синхронизация слайдеров
// слайдер дождя → input-событие → ручная правка активна
await client.evaluate(() => {
const el = document.getElementById('dev-w-rain');
el.value = '1';
el.dispatchEvent(new Event('input'));
});
wi = await client.call('weatherInfo');
check('дев-панель: слайдер дождя включает ручной режим', wi.manual?.rain === 1, wi);
await client.call('devSetWeather', null); // сброс + синхронизация слайдеров
wi = await client.call('weatherInfo');
check('дев-панель: сброс вернул базу области', wi.manual === null && wi.windStrength === 0.2, wi);
// кнопка молнии: клик → счётчик ударов растёт
const hasBolt = await client.evaluate(() => document.getElementById('dev-w-lightning') !== null);
check('дев-панель: кнопка молнии', hasBolt === true);
const s0 = (await client.call('lightningInfo')).strikes;
await client.evaluate(() => document.getElementById('dev-w-lightning').click());
const s1 = (await client.call('lightningInfo')).strikes;
check('дев-панель: кнопка молнии бьёт (счётчик +1)', s1 === s0 + 1, { s0, s1 });
await client.call('devClose');
} 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 — выходим явно