diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md
index f118ef7..4cc0379 100644
--- a/v2/CLAUDE.md
+++ b/v2/CLAUDE.md
@@ -1398,6 +1398,50 @@
Визуально: хлопок пепла у колокольчика ночью ярко-тёплый, пыль разъезда
в конусе фонарика подсвечена.
+## Погода (2026-09-12): дождь, снег, ветер, облачность — часть движка
+
+Запрос: «по погодным эффектам запариться... это должно быть частью движка,
+и в дев панели управляться». Три слоя движка + проводка игры:
+
+- **Движок** (`render/weather.ts`): `WeatherSpec {windDir, windStrength,
+ cloud, rain, snow}` (0..1), `DEFAULT_WEATHER`, `clampWeather` (частичный
+ ввод моста → полная валидная), `windVector` (сила 1 — 6 юнит/с),
+ `rainPoints`/`snowPoints(spec, t, win)` — детерминированные позы слоёв от
+ времени цикла (якоря i·константа + падение/дрейф от вектора ветра; капля
+ тяжёлая — дрейф ×0.6, снежинка качается и дрейфует полностью). Окно
+ `weatherWindow(renderer, top)` = видимый след кадра + маржа 16 — wrap и
+ edgeAlpha гасят частицы у края (как у пепла: без «появления из воздуха»).
+ `WeatherSystem(renderer)` — два слоя через `renderer.addParticles()`
+ (спрайты-билборды: ambient-тинт и свет источников применяются слоями
+ сами); `set(spec)` клампит и сразу гонит облачность в рендер, `update(t)`
+ переставляет позы, `info()` — счётчики для снапшота, `setTint` — ночь
+ гасит осадки.
+- **Облачность в рендере** (`voxelRenderer.ts` + `daynight.ts`): `setCloud(k)`
+ — солнце ×(1−0.6k), ambient/фон обесцвечиваются к серому и темнеют
+ (`cloudDim(color, k, sat, dim)` — чистая, тестируется). Метод применяется
+ и сам (панель/мост), и внутри `setNight` — тик игры каждый тик
+ перезаписывает ambient, облачность не теряется.
+- **Игра** (`data/weather.ts` + `main.ts`): база погоды по областям (луга —
+ ветерок 0.15, пруды — штиль/облачность/снег, роща — морось, разъезд —
+ продувает с дождём, интерьеры — пусто). `activeWeatherSpec()` = база
+ области + ручные правки панели; `weather.set` на входе в область и в
+ мостах. В тике погода анимирует (в т.ч. при открытой дев-панели — мир
+ стоит, осадки видны: слайдеры в деле). `setAsh(false)` (гейт-режим)
+ глушит и погоду — кадр-равенства старых гейтов не ломаются.
+- **Мост**: `weatherInfo()` (активный spec + rainDrops/snowFlakes + manual),
+ `setWeather(spec | null)` (частичная правка поверх базы; null — база
+ области; правка переживает смену области), `devSetWeather` (панель
+ агентом + синхронизация слайдеров). Дев-панель: секция «ПОГОДА» — 5
+ слайдеров (облачность/дождь/снег/ветер/направление в градусах) + сброс,
+ инфо-строка с счётчиками слоёв и режимом (база/правка).
+- **Гейт** `weather.mjs` (14): база лугов/разъезда/рощи, ливень 260 капель
+ и снег 200 снежинок рисуются и падают (кадр-диффы), облачность темнит
+ кадр ×0.8, setAsh(false) глушит погоду, сброс к базе, слайдер дев-панели
+ управляет погодой. Скриншоты /tmp/v2_weather_*.png. Грабли: после
+ `gotoArea` шаг ≥60 тиков (фейд 0.4 с затемняет кадр — снимки в фейде
+ выглядят «ночью»); правка панели переживает смену области — в гейте
+ снимать `setWeather(null)` перед проверкой базы. `check:fast` — 343 ✓.
+
## Рабочие привычки
- Для задач из 3+ шагов веди список задач и иди автономно.
diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html
index 0b38bd8..166211e 100644
--- a/v2/apps/game/index.html
+++ b/v2/apps/game/index.html
@@ -187,6 +187,17 @@
font: 12px monospace; padding: 2px 6px; cursor: pointer;
}
#dev-presets button:hover { border-color: #d99a32; color: #fff; }
+ /* Погода: слайдеры поверх базы области (ручной режим панели) */
+ #dev-weather { display: flex; flex-direction: column; gap: 4px; }
+ #dev-weather label { color: #c9c9d2; font: 13px monospace; display: flex; justify-content: space-between; }
+ #dev-weather input[type="range"] { width: 100%; accent-color: #d99a32; }
+ #dev-w-row { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
+ #dev-w-reset {
+ background: none; border: 1px solid #44454e; color: #c9c9d2;
+ font: 12px monospace; padding: 2px 6px; cursor: pointer;
+ }
+ #dev-w-reset:hover { border-color: #d99a32; color: #fff; }
+ #dev-w-info { color: #8a8b96; font: 11px monospace; }
#devview {
position: fixed;
inset: 0;
@@ -325,6 +336,23 @@
+
ПОГОДА
+
ОБЪЕКТЫ · F3
diff --git a/v2/apps/game/src/data/weather.ts b/v2/apps/game/src/data/weather.ts
new file mode 100644
index 0000000..37cc820
--- /dev/null
+++ b/v2/apps/game/src/data/weather.ts
@@ -0,0 +1,24 @@
+/**
+ * Базовая погода областей (демо-полигон движковой WeatherSystem): ветер,
+ * облачность, дождь, снег. Интерьеры — без осадков и без облачности
+ * (дефолт WeatherSpec). Ручной режим дев-панели ложится поверх этой базы.
+ */
+import type { WeatherSpec } from '@rpg/engine';
+
+/** База по id области (частичная — недостающее берётся из DEFAULT_WEATHER). */
+export const AREA_WEATHER: Record> = {
+ // Луга: лёгкий ветерок, почти ясно.
+ meadows: { windDir: 0.7, windStrength: 0.15, cloud: 0.15 },
+ // Пруды: штиль, облачно, редкий снег в дымке.
+ ponds: { windDir: 0.3, windStrength: 0.05, cloud: 0.4, snow: 0.25 },
+ // Деревня: заметный ветер, облачно.
+ zvenets: { windDir: 2.1, windStrength: 0.3, cloud: 0.2 },
+ // Роща: морось в кронах.
+ rust_grove: { windDir: 1.2, windStrength: 0.2, cloud: 0.5, rain: 0.3 },
+ // Разъезд: продувает, срывается дождь.
+ old_siding: { windDir: 2.8, windStrength: 0.6, cloud: 0.35, rain: 0.15 },
+ // Подземелье и интерьеры: погода не играет.
+ machine_hall: {},
+ house_elder: {},
+ shop: {},
+};
\ No newline at end of file
diff --git a/v2/apps/game/src/devview.ts b/v2/apps/game/src/devview.ts
index 8e14b14..336de4c 100644
--- a/v2/apps/game/src/devview.ts
+++ b/v2/apps/game/src/devview.ts
@@ -6,7 +6,15 @@
* F3 (или «) — открыть/закрыть; мир стоит, пока открыта панель/вьювер.
*/
import { ModelViewer, mannequin, resolveTiles } from '@rpg/engine';
-import type { MannequinPalette, ModelStats } from '@rpg/engine';
+import type { MannequinPalette, ModelStats, WeatherSpec } from '@rpg/engine';
+
+/** Снапшот погоды панели: активный spec + счётчики слоёв + режим. */
+export interface WeatherPanelInfo extends WeatherSpec {
+ rainDrops?: number;
+ snowFlakes?: number;
+ /** Правки панели активны (null/undefined — база области). */
+ manual?: Partial | null;
+}
import { registryAll, registryGet, type RegistryBuild, type RegistryEntry } from './registry';
/** Каталог дев-панели (мосту тоже нужен): id + подпись в порядке регистрации. */
@@ -35,6 +43,8 @@
private readonly onOpenChange: (open: boolean) => void,
private readonly getMinutes: () => number,
private readonly setMinutes: (m: number) => void,
+ private readonly getWeather: () => WeatherPanelInfo,
+ private readonly setWeather: (spec: Partial | null) => void,
) {
document.getElementById('dev-open')!.addEventListener('click', () => this.closeViewer());
const playBtn = document.getElementById('dev-play')!;
@@ -63,6 +73,24 @@
this.refreshClock();
});
}
+ // Погода: слайдер тянет правку сразу (поверх базы области), сброс —
+ // вернуть погоду области; осадки анимируют, пока панель открыта
+ const bind = (id: string, patch: (v: number) => Partial): void => {
+ const el = document.getElementById(id) as HTMLInputElement;
+ el.addEventListener('input', () => {
+ this.setWeather(patch(Number(el.value)));
+ this.refreshWeather();
+ });
+ };
+ bind('dev-w-cloud', (v) => ({ cloud: v }));
+ bind('dev-w-rain', (v) => ({ rain: v }));
+ bind('dev-w-snow', (v) => ({ snow: v }));
+ bind('dev-w-wind', (v) => ({ windStrength: v }));
+ bind('dev-w-dir', (deg) => ({ windDir: (deg * Math.PI) / 180 })); // слайдер в градусах
+ document.getElementById('dev-w-reset')!.addEventListener('click', () => {
+ this.setWeather(null);
+ this.refreshWeather();
+ });
}
private playing = true;
@@ -87,6 +115,7 @@
list.appendChild(btn);
}
this.refreshClock();
+ this.refreshWeather();
this.onOpenChange(true);
}
@@ -99,6 +128,29 @@
document.getElementById('dev-clock')!.textContent = `${h}:${mm < 10 ? '0' : ''}${mm}`;
}
+ /** Слайдеры/счётчики из активной погоды (база области + правки панели). */
+ refreshWeather(): void {
+ const w = this.getWeather();
+ const set = (id: string, v: string): void => {
+ document.getElementById(id)!.textContent = v;
+ };
+ const input = (id: string, v: number): void => {
+ (document.getElementById(id) as HTMLInputElement).value = String(v);
+ };
+ input('dev-w-cloud', w.cloud);
+ set('dev-w-cloud-v', w.cloud.toFixed(2));
+ input('dev-w-rain', w.rain);
+ set('dev-w-rain-v', w.rain.toFixed(2));
+ input('dev-w-snow', w.snow);
+ set('dev-w-snow-v', w.snow.toFixed(2));
+ input('dev-w-wind', w.windStrength);
+ set('dev-w-wind-v', w.windStrength.toFixed(2));
+ input('dev-w-dir', Math.round((w.windDir * 180) / Math.PI));
+ set('dev-w-dir-v', `${Math.round((w.windDir * 180) / Math.PI / 5) * 5}°`);
+ const counts = `дождь: ${w.rainDrops ?? 0} · снег: ${w.snowFlakes ?? 0}`;
+ set('dev-w-info', w.manual ? `${counts} · правка панели` : `${counts} · база области`);
+ }
+
/** Закрыть всё (панель и вьювер). */
close(): void {
this.closeViewer();
diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts
index 5f406a6..ba1748a 100644
--- a/v2/apps/game/src/main.ts
+++ b/v2/apps/game/src/main.ts
@@ -11,6 +11,7 @@
mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
DialogueRunner, Inventory, CutsceneRunner, validateCutscenes,
resolveTiles, collides, worldTileAtlas, backpackModel, torchModel, flashlightModel,
+ WeatherSystem, type WeatherSpec,
} from '@rpg/engine';
import type {
VoxelCloud, DialogueGraph, DialogueView,
@@ -33,6 +34,7 @@
import { FlowerQuest } from './flowers';
import { GameClock } from './clock';
import { LIGHTING, ATMOSPHERE, AIRFX, type Atmosphere, type LightingDef } from './data/lighting';
+import { AREA_WEATHER } from './data/weather';
import { GameLighting, type HeroLampDef, type FlashlightDef } from './lighting';
import { AshParticles } from './ash';
import { AmbientParticles, type AmbientKind } from './ambientParticles';
@@ -241,6 +243,14 @@
/** Глобальный вкл/выкл пепла (мост: гейт кадр-равенства). */
let ashEnabled = true;
let areaAsh = false; // играет ли пепел в текущей области
+/** Погода движка (дождь/снег/ветер) + облачность в рендере. */
+const weather = new WeatherSystem(renderer);
+/** Ручная погода дев-панели: поверх базы области (null — только база). */
+let weatherManual: Partial | null = null;
+/** Активная погода: база области + ручные правки панели. */
+function activeWeatherSpec(): Partial {
+ return { ...AREA_WEATHER[currentArea.id], ...weatherManual };
+}
/** Мотыльки лугов / дымка прудов (v1 Atmosphere): частицы области. */
let ambient: AmbientParticles | null = null;
/** Шиммер воды (блики на поверхности прудов). */
@@ -509,6 +519,7 @@
airFx?.setTint(c);
ambient?.setTint(c);
shimmer?.setTint(c);
+ weather.setTint(c); // ночь гасит дождь/снег вместе со сценой
},
// частицы подсвечиваются источниками в шейдере (общий блок юниформ
// рендера — пепел у очага/колокольчика, луч фонарика на пыли)
@@ -517,6 +528,7 @@
areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash';
if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется
rebuildAmbient(); // мотыльки/дымка + шиммер воды (v1 Atmosphere)
+ weather.set(activeWeatherSpec()); // погода новой области (+ облачность в рендер)
// пост-проход пересобирается по области: дисторсия жары — только в зале
postFx?.dispose();
postFx = new PostFx(renderer, currentArea.id === 'machine_hall');
@@ -794,6 +806,13 @@
lighting?.update(); // свет пересчитать сразу, не дожидаясь тика
updateHud();
},
+ () => agent.weatherInfo(),
+ (spec) => {
+ // ручной режим панели поверх базы области (null — вернуться к базе);
+ // облачность применяется сразу, осадки — на ближайшем тике панели
+ agent.setWeather(spec);
+ updateHud();
+ },
);
/** Тост: всплывашка эффекта, уходит сама (реальное время). */
@@ -1050,7 +1069,12 @@
/** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */
function tick(dt: number): void {
if (menuOpen) return; // меню-пауза: мир стоит (автосейв снимается при открытии)
- if (dev.open) return; // дев-панель/вьювер: мир стоит (просмотр моделей)
+ if (dev.open) {
+ // дев-панель: мир стоит, но погода анимирует — слайдеры видно в деле
+ elapsed += dt;
+ weather.update(elapsed);
+ return;
+ }
// время суток и свет идут всегда (в фейде/катсцене — тоже, как в v1)
clock.advance(dt);
elapsed += dt;
@@ -1059,6 +1083,7 @@
ambient?.update(elapsed); // мотыльки/дымка области
shimmer?.update(elapsed); // блики на воде прудов
airFx?.update(elapsed); // листья рощи / пыль разъезда
+ weather.update(elapsed); // дождь/снег поверх амбиента области
postFx?.update(lighting?.nightFactor ?? 0, elapsed, ashEnabled); // виньетка/жара
fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер)
if (fade.active) {
@@ -1301,12 +1326,31 @@
played: audio.log,
};
},
+ /** Снапшот погоды: активный spec, счётчики слоёв, ручные правки панели. */
+ weatherInfo() {
+ const info = weather.info();
+ return {
+ ...weather.current,
+ rainDrops: info.rainDrops,
+ snowFlakes: info.snowFlakes,
+ manual: weatherManual,
+ };
+ },
+ /** Погода вручную (дев-панель/гейт): частичный spec поверх базы области;
+ * null — вернуть погоду области. Облачность применяется сразу. */
+ setWeather(spec: Partial | null): void {
+ weatherManual = spec ? { ...weatherManual, ...spec } : null;
+ weather.set(activeWeatherSpec());
+ },
/** Пепел вкл/выкл (гейт: кадр-равенство сборки — мир без атмосферы). */
setAsh(on: boolean): void {
ashEnabled = on;
areaAsh = on && ATMOSPHERE[currentArea.id] === 'ash';
if (!areaAsh) ash.hide();
rebuildAmbient(); // вкл: пересобрать частицы; выкл: null (тик их не рисует)
+ // гейт-режим «без атмосферы» глушит и осадки с облачностью
+ weather.set(on ? activeWeatherSpec() : {});
+ if (!on) weatherManual = null;
},
/** Фауна вкл/выкл (гейт: кадр-равенство — олени бродят в реальном времени). */
setFauna(on: boolean): void {
@@ -1493,6 +1537,12 @@
dev.refreshClock();
updateHud();
},
+ /** Погода из дев-панели агентом: ручная правка/сброс + слайдеры сразу. */
+ devSetWeather(spec: Partial | null): void {
+ agent.setWeather(spec);
+ dev.refreshWeather();
+ updateHud();
+ },
/** Дев-панель агентом: открыть список / вьювер / закрыть. */
devOpen(): void {
if (!dev.open) dev.openPanel();
diff --git a/v2/apps/game/tools/checks/weather.mjs b/v2/apps/game/tools/checks/weather.mjs
new file mode 100644
index 0000000..b3d2f98
--- /dev/null
+++ b/v2/apps/game/tools/checks/weather.mjs
@@ -0,0 +1,155 @@
+/**
+ * Гейт погоды 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 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('ручной ливень: 260 капель, снег 0, правка панели активна',
+ wi.rainDrops === 260 && wi.snowFlakes === 0 && wi.manual !== null, wi);
+ const rainA = await shot(client, '/tmp/v2_weather_rain.png');
+ await client.call('stepTicks', 30); // стена дождя падает (22 юнит/с)
+ 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('ручной снег: 200 снежинок, дождь 0', wi.snowFlakes === 200 && 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'));
+ check('облачность темнит кадр (полная — заметно темнее)',
+ cloudBright < clearBright * 0.8, { clearBright, cloudBright });
+
+ // ---- 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']
+ .every((id) => document.getElementById(id) !== null));
+ check('дев-панель: секция погоды с 5 слайдерами', hasSliders === true);
+ // слайдер дождя → 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);
+ 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 — выходим явно
\ No newline at end of file
diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts
index bd1f341..c81ba36 100644
--- a/v2/packages/engine/src/index.ts
+++ b/v2/packages/engine/src/index.ts
@@ -106,5 +106,7 @@
dayNightAmbient, dayNightFactor, dimColor, flickerFactor, lerpAmbient, lerpColor,
} from './render/daynight';
export type { DayNightSpec } from './render/daynight';
+export { DEFAULT_WEATHER, WeatherSystem, clampWeather, snowPoints, windVector, rainPoints } from './render/weather';
+export type { WeatherSpec } from './render/weather';
export type { PointLightOptions, PointLightHandle, SpotLightOptions, SpotLightHandle } from './render/voxelRenderer';
// setDayAmbient/setNight/addPointLight/addSpotLight — методы класса VoxelRenderer
diff --git a/v2/packages/engine/src/render/__tests__/weather.test.ts b/v2/packages/engine/src/render/__tests__/weather.test.ts
new file mode 100644
index 0000000..b6e259a
--- /dev/null
+++ b/v2/packages/engine/src/render/__tests__/weather.test.ts
@@ -0,0 +1,107 @@
+/** Тесты чистой части погоды: кламп spec'а, вектор ветра, детерминизм и
+ * заворачивание в окно у слоёв дождя/снега, обесцвечивание облачности. */
+import { describe, expect, it } from 'vitest';
+import { cloudDim } from '../daynight';
+import {
+ DEFAULT_WEATHER, WeatherSystem, clampWeather, rainPoints, snowPoints, windVector,
+} from '../weather';
+
+const WIN = { minX: 0, maxX: 48, minZ: 0, maxZ: 32 };
+
+describe('clampWeather', () => {
+ it('частичный ввод дополняется дефолтом', () => {
+ const w = clampWeather({ rain: 0.3 });
+ expect(w.rain).toBe(0.3);
+ expect(w.snow).toBe(0);
+ expect(w.windStrength).toBe(0);
+ expect(w.windDir).toBe(DEFAULT_WEATHER.windDir);
+ expect(w.cloud).toBe(0);
+ });
+ it('выходит за 0..1 — клампится; NaN/не-число — дефолт', () => {
+ expect(clampWeather({ rain: 5 }).rain).toBe(1);
+ expect(clampWeather({ snow: -2 }).snow).toBe(0);
+ expect(clampWeather({ windStrength: NaN }).windStrength).toBe(0);
+ expect(clampWeather({ cloud: 'x' as unknown as number }).cloud).toBe(0);
+ });
+});
+
+describe('windVector', () => {
+ it('ноль силы — ноль дрейфа; сила 1 — 6 юнит/с по направлению', () => {
+ expect(windVector({ ...DEFAULT_WEATHER, windStrength: 0 })).toEqual([0, 0]);
+ const [x, z] = windVector({ ...DEFAULT_WEATHER, windStrength: 1, windDir: Math.PI / 2 });
+ expect(x).toBeCloseTo(6);
+ expect(z).toBeCloseTo(0);
+ const [x2, z2] = windVector({ ...DEFAULT_WEATHER, windStrength: 0.5, windDir: 0 });
+ expect(x2).toBeCloseTo(0);
+ expect(z2).toBeCloseTo(3);
+ });
+});
+
+describe('rain/snow points', () => {
+ it('число точек = ёмкость × интенсивность; ноль — пусто', () => {
+ const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 0 };
+ expect(rainPoints(spec, 0, WIN).length).toBe(260);
+ expect(snowPoints({ ...spec, snow: 1 }, 0, WIN).length).toBe(200);
+ expect(rainPoints({ ...spec, rain: 0 }, 0, WIN).length).toBe(0);
+ expect(snowPoints({ ...spec, snow: 0 }, 0, WIN).length).toBe(0);
+ });
+ it('детерминизм: тот же t — те же позы', () => {
+ const spec = { ...DEFAULT_WEATHER, rain: 0.4, snow: 0.6, windStrength: 0.3 };
+ const a = rainPoints(spec, 3.7, WIN);
+ const b = rainPoints(spec, 3.7, WIN);
+ expect(a).toEqual(b);
+ expect(snowPoints(spec, 1.2, WIN)).toEqual(snowPoints(spec, 1.2, WIN));
+ });
+ it('время идёт — капли падают (y убывает, потом цикл)', () => {
+ const spec = { ...DEFAULT_WEATHER, rain: 1 };
+ const t0 = rainPoints(spec, 0, WIN)[0]!.pos[1];
+ const t1 = rainPoints(spec, 0.5, WIN)[0]!.pos[1];
+ expect(t1).toBeLessThan(t0);
+ });
+ it('позы внутри окна (wrap), альфа в 0..1', () => {
+ const spec = { ...DEFAULT_WEATHER, snow: 1, windStrength: 1 };
+ for (const p of snowPoints(spec, 17, WIN)) {
+ expect(p.pos[0]).toBeGreaterThanOrEqual(WIN.minX);
+ expect(p.pos[0]).toBeLessThanOrEqual(WIN.maxX);
+ expect(p.pos[2]).toBeGreaterThanOrEqual(WIN.minZ);
+ expect(p.pos[2]).toBeLessThanOrEqual(WIN.maxZ);
+ expect(p.alpha).toBeGreaterThanOrEqual(0);
+ expect(p.alpha).toBeLessThanOrEqual(1);
+ }
+ });
+ it('высота слоя: капли и снег не ниже земли цикла (0..TOP)', () => {
+ const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 1 };
+ for (const p of rainPoints(spec, 13, WIN)) expect(p.pos[1]).toBeGreaterThanOrEqual(0);
+ for (const p of snowPoints(spec, 13, WIN)) expect(p.pos[1]).toBeGreaterThanOrEqual(0);
+ });
+});
+
+describe('cloudDim (облачность)', () => {
+ it('k=0 — цвет без изменений, k=1 — затемнение на долю dim', () => {
+ expect(cloudDim(0xf7f4ea, 0, 0.7, 0.25)).toBe(0xf7f4ea);
+ const c = cloudDim(0xf7f4ea, 1, 0.7, 0.25);
+ expect(c).toBeLessThan(0xf7f4ea);
+ });
+ it('обесцвечивание: каналы сходятся к серому при полной облачности', () => {
+ // насыщенный красный при sat=1 и dim=0 становится серым той же яркости
+ const c = cloudDim(0xff0000, 1, 1, 0);
+ const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
+ expect(r).toBe(g);
+ expect(g).toBe(b);
+ });
+});
+
+describe('WeatherSystem (без WebGL — только спецификация)', () => {
+ it('set клампит и current отдаёт нормализованную погоду', () => {
+ const sys = new WeatherSystem({
+ addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
+ setCloud: () => {},
+ visibleWindow: () => ({ minX: 0, maxX: 0, minZ: 0, maxZ: 0 }),
+ } as never);
+ sys.set({ rain: 5, windStrength: -1, cloud: 0.5 });
+ expect(sys.current.rain).toBe(1);
+ expect(sys.current.windStrength).toBe(0);
+ expect(sys.current.cloud).toBe(0.5);
+ expect(sys.info()).toEqual({ rainDrops: 260, snowFlakes: 0 });
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/render/daynight.ts b/v2/packages/engine/src/render/daynight.ts
index acf523c..e65b14a 100644
--- a/v2/packages/engine/src/render/daynight.ts
+++ b/v2/packages/engine/src/render/daynight.ts
@@ -31,6 +31,19 @@
return (r << 16) | (g << 8) | b;
}
+/** Облачность: цвет обесцвечивается к серому (sat — доля к серому при
+ * k=1) и темнеет (dim — доля затемнения при k=1). Чистая — для тестов. */
+export function cloudDim(color: number, k: number, sat: number, dim: number): number {
+ const kk = Math.max(0, Math.min(1, k));
+ const r = (color >> 16) & 0xff;
+ const g = (color >> 8) & 0xff;
+ const b = color & 0xff;
+ const gray = (r * 0.3 + g * 0.6 + b * 0.1) / 255; // 0..1, «яркость» цвета
+ const mix = (c: number): number =>
+ Math.round(((c / 255) * (1 - sat * kk) + gray * sat * kk) * 255 * (1 - dim * kk));
+ return (mix(r) << 16) | (mix(g) << 8) | mix(b);
+}
+
/** Границы суток в часах: рассвет dawn..day, закат dusk..night. */
export interface DayNightSpec {
dawn: number;
diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts
index 93a99a5..cdb5464 100644
--- a/v2/packages/engine/src/render/voxelRenderer.ts
+++ b/v2/packages/engine/src/render/voxelRenderer.ts
@@ -11,7 +11,7 @@
import type { VoxelMesh } from '../voxel/mesher';
import type { PosedVoxel } from '../animation/rig';
import type { ResolvedTile } from '../models/format';
-import { lerpColor } from './daynight';
+import { cloudDim, lerpColor } from './daynight';
import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights } from './particles';
import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles';
import { particleWindow } from './polyWindow';
@@ -121,6 +121,10 @@
private readonly particleLightsU: ParticleLightUniforms = makeParticleLightUniforms();
private sunK = 0.5;
private nightK = 0;
+ /** Облачность 0..1: гасит солнце и ambient, фон — к пасмурному серому. */
+ private cloudK = 0;
+ /** Последний ночной tint setNight — чтобы setCloud пересчитывал сразу. */
+ private lastNightTint = 0x94949e;
/** Дневной базовый ambient области (интерьеры темнее улиц — как v1 setAmbient). */
private dayHemisphere = 0x94949e; // P5
/** Дневной множитель солнца (тёмный интерьер гасит и прямое солнце). */
@@ -199,10 +203,28 @@
*/
setNight(k: number, tint: number): void {
this.nightK = Math.min(1, Math.max(0, k));
- this.ambient.color.set(lerpColor(this.dayHemisphere, tint & 0xffffff, this.nightK));
+ this.lastNightTint = tint & 0xffffff;
+ this.applyCloud(); // ambient/фон с учётом облачности
this.setSun(this.sunK); // солнце пересчитает себя с учётом nightK
if (!this.scene.background) this.scene.background = new THREE.Color();
- (this.scene.background as THREE.Color).set(tint & 0xffffff);
+ }
+
+ /** Облачность k 0..1: солнце гаснет (~×0.4 в полную), ambient темнее
+ * и серее, фон — к пасмурному серому. Вызывается и сама (панель/мост),
+ * и внутри setNight (тик игры перезаписывает ambient каждый тик). */
+ setCloud(k: number): void {
+ this.cloudK = Math.min(1, Math.max(0, k));
+ this.applyCloud();
+ this.setSun(this.sunK);
+ }
+
+ /** Ambient и фон: базовый микс день/ночь, затем облачность — к серому
+ * (обесцвечивание) и вниз по яркости. */
+ private applyCloud(): void {
+ const base = lerpColor(this.dayHemisphere, this.lastNightTint, this.nightK);
+ this.ambient.color.set(cloudDim(base, this.cloudK, 0.7, 0.25));
+ if (!this.scene.background) this.scene.background = new THREE.Color();
+ (this.scene.background as THREE.Color).set(cloudDim(this.lastNightTint, this.cloudK, 0.7, 0.3));
}
/** Точечный источник (не бросает тень): мерцание — через setIntensity.
@@ -377,9 +399,10 @@
cam.far = dist + r + SHADOW_CASTER_H + this.viewSize;
cam.updateProjectionMatrix();
}
- // ночь гасит солнце до остаточной засветки (луна/небо), тёмный интерьер — и днём
- this.sun.intensity =
- (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK) * this.daySunScale;
+ // ночь гасит солнце до остаточной засветки (луна/небо), тёмный
+ // интерьер — и днём, облачность — в пасмурный остаток
+ this.sun.intensity = (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK)
+ * this.daySunScale * (1 - 0.6 * this.cloudK);
// тёплый свет у горизонта (F1), нейтральный в полдень
const warm = 1 - Math.sin(kk * Math.PI);
this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45);
diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts
new file mode 100644
index 0000000..6f19dc6
--- /dev/null
+++ b/v2/packages/engine/src/render/weather.ts
@@ -0,0 +1,193 @@
+/**
+ * Погода как часть движка: дождь, снег, ветер, облачность. Слои дождя/снега —
+ * те же спрайты-билборды (particles.ts): живут в мировых координатах, окно
+ * видимости камеры заворачивает их (маржа за экраном — без «появления из
+ * воздуха»), ambient-тинт и точечные источники применяются слоем автоматически.
+ * Ветер — глобальный горизонтальный дрейф (вектор из направления и силы),
+ * облачность — гашение солнца/ambient рендера (VoxelRenderer.setCloud).
+ * Позы детерминированы временем цикла — кадр-равенства гейтов работают.
+ */
+import type { ParticleLayer } from './particles';
+import type { VoxelRenderer } from './voxelRenderer';
+
+/** Спецификация погоды: всё в диапазонах 0..1, ветер — 0..1 + направление. */
+export interface WeatherSpec {
+ /** Направление ветра, рад (0 — дрейф к +Z, π/2 — к +X). */
+ windDir: number;
+ /** Сила ветра 0..1 (полная сила — 6 юнит/с дрейфа). */
+ windStrength: number;
+ /** Облачность 0..1 (гасит солнце и ambient, серее фон). */
+ cloud: number;
+ /** Интенсивность дождя 0..1. */
+ rain: number;
+ /** Интенсивность снега 0..1. */
+ snow: number;
+}
+
+/** Дождь без ветра, ясно — нейтральная погода по умолчанию. */
+export const DEFAULT_WEATHER: WeatherSpec = {
+ windDir: 0.7, windStrength: 0, cloud: 0, rain: 0, snow: 0,
+};
+
+/** Кламп спецификации: частичный ввод (мост/панель) → полная валидная. */
+export function clampWeather(w: Partial): WeatherSpec {
+ const num = (v: unknown, fallback: number): number =>
+ typeof v === 'number' && Number.isFinite(v) ? v : fallback;
+ return {
+ windDir: num(w.windDir, DEFAULT_WEATHER.windDir),
+ windStrength: clamp01(num(w.windStrength, DEFAULT_WEATHER.windStrength)),
+ cloud: clamp01(num(w.cloud, DEFAULT_WEATHER.cloud)),
+ rain: clamp01(num(w.rain, DEFAULT_WEATHER.rain)),
+ snow: clamp01(num(w.snow, DEFAULT_WEATHER.snow)),
+ };
+}
+
+function clamp01(v: number): number {
+ return Math.min(1, Math.max(0, v));
+}
+
+/** Вектор ветра в мировых единицах/сек: сила 1 — 6 юнит/с. */
+export function windVector(w: WeatherSpec): [number, number] {
+ const s = w.windStrength * 6;
+ return [Math.sin(w.windDir) * s, Math.cos(w.windDir) * s];
+}
+
+// ---- Параметры слоёв (юниты мира; окно — след кадра + маржа) ----
+
+const RAIN_COUNT = 260; // капель при rain = 1
+const RAIN_TOP = 40; // высота слоя дождя (вертикальный цикл капли)
+const RAIN_SPEED = 22; // падение, юнит/с (ливень читается на 480×270)
+const RAIN_SIZE = 0.5;
+const RAIN_COLOR = '#a8bacb';
+const RAIN_ALPHA = 0.7;
+
+const SNOW_COUNT = 200; // хлопьев при snow = 1
+const SNOW_TOP = 36;
+const SNOW_SWAY = 1.2; // размах покачивания при падении
+const SNOW_SIZE = 0.55;
+const SNOW_COLOR = '#eef3f6';
+const SNOW_ALPHA = 0.85;
+
+const MARGIN = 16; // перекрытие за краем кадра (как у пепла)
+
+/** Позы капель: якорь + падение + дрейф ветра (капля тяжёлая — дрейф ×0.6). */
+export function rainPoints(
+ spec: WeatherSpec, t: number,
+ win: { minX: number; maxX: number; minZ: number; maxZ: number },
+): { pos: [number, number, number]; alpha: number }[] {
+ const n = Math.round(RAIN_COUNT * spec.rain);
+ const [wx, wz] = windVector(spec);
+ const out: { pos: [number, number, number]; alpha: number }[] = [];
+ for (let i = 0; i < n; i++) {
+ const speed = RAIN_SPEED + (i % 7) * 1.3; // разлёт скоростей — стена дождя
+ const fall = RAIN_TOP - ((t * speed + i * 5.3) % RAIN_TOP);
+ const x = wrapV(i * 37.7 + t * wx * 0.6, win.minX, win.maxX);
+ const z = wrapV(i * 53.9 + t * wz * 0.6, win.minZ, win.maxZ);
+ out.push({ pos: [x, fall, z], alpha: edge(win, x, z) * RAIN_ALPHA });
+ }
+ return out;
+}
+
+/** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра. */
+export function snowPoints(
+ spec: WeatherSpec, t: number,
+ win: { minX: number; maxX: number; minZ: number; maxZ: number },
+): { pos: [number, number, number]; alpha: number }[] {
+ const n = Math.round(SNOW_COUNT * spec.snow);
+ const [wx, wz] = windVector(spec);
+ const out: { pos: [number, number, number]; alpha: number }[] = [];
+ for (let i = 0; i < n; i++) {
+ const speed = 1.2 + (i % 5) * 0.3;
+ const p = i * 2.399; // фазы покачивания — золотое сечение
+ const fall = SNOW_TOP - ((t * speed + (i * 4.1) % SNOW_TOP) % SNOW_TOP);
+ const sway = Math.sin(t * 0.8 + p) * SNOW_SWAY;
+ const x = wrapV(i * 61.7 + t * wx + sway, win.minX, win.maxX);
+ const z = wrapV(i * 47.3 + t * wz + Math.cos(t * 0.6 + p) * SNOW_SWAY * 0.6, win.minZ, win.maxZ);
+ out.push({ pos: [x, fall, z], alpha: edge(win, x, z) * SNOW_ALPHA });
+ }
+ return out;
+}
+
+/** Заворачивание в окно (по одной оси). */
+function wrapV(v: number, min: number, max: number): number {
+ const size = max - min;
+ let r = (v - min) % size;
+ if (r < 0) r += size;
+ return min + r;
+}
+
+/** Альфа у края окна: 0 в марже, 1 внутри кадра. */
+function edge(win: { minX: number; maxX: number; minZ: number; maxZ: number }, x: number, z: number): number {
+ const d = Math.min(x - win.minX, win.maxX - x, z - win.minZ, win.maxZ - z);
+ return Math.max(0, Math.min(1, d / MARGIN));
+}
+
+/** Окно осадков: след кадра камеры по высоте слоя + маржа (едет за камерой). */
+export function weatherWindow(
+ renderer: Pick, top: number,
+): { minX: number; maxX: number; minZ: number; maxZ: number } {
+ const b = renderer.visibleWindow(0, top);
+ return { minX: b.minX - MARGIN, maxX: b.maxX + MARGIN, minZ: b.minZ - MARGIN, maxZ: b.maxZ + MARGIN };
+}
+
+/** Погода: два слоя частиц (дождь/снег) + ветер внутри их дрейфа.
+ * Облачность уводит в VoxelRenderer.setCloud — система её не дублирует. */
+export class WeatherSystem {
+ private readonly renderer: VoxelRenderer;
+ private readonly rain: ParticleLayer;
+ private readonly snow: ParticleLayer;
+ private spec: WeatherSpec = { ...DEFAULT_WEATHER };
+
+ constructor(renderer: VoxelRenderer) {
+ this.renderer = renderer;
+ this.rain = renderer.addParticles();
+ this.snow = renderer.addParticles();
+ }
+
+ /** Применить погоду (клампится). Облачность — сразу в рендер; дождь/снег
+ * отрисуются на ближайшем update(t). */
+ set(spec: Partial): void {
+ this.spec = clampWeather(spec);
+ this.renderer.setCloud(this.spec.cloud);
+ if (this.spec.rain === 0 && this.spec.snow === 0) this.hide();
+ }
+
+ /** Активная спецификация (снапшот/мост). */
+ get current(): WeatherSpec {
+ return this.spec;
+ }
+
+ /** Тик: позы слоёв от времени цикла (детерминированно). */
+ update(t: number): void {
+ const win = weatherWindow(this.renderer, RAIN_TOP);
+ const drops = rainPoints(this.spec, t, win);
+ this.rain.update(drops.map((d) => ({ pos: d.pos, color: RAIN_COLOR, alpha: d.alpha, size: RAIN_SIZE })));
+ const flakes = snowPoints(this.spec, t, win);
+ this.snow.update(flakes.map((f) => ({ pos: f.pos, color: SNOW_COLOR, alpha: f.alpha, size: SNOW_SIZE })));
+ }
+
+ /** Тинт освещения: ночь гасит осадки вместе со сценой (как пепел/листья). */
+ setTint(color: number): void {
+ this.rain.setTint(color);
+ this.snow.setTint(color);
+ }
+
+ /** Скрыть осадки (погода выключена). */
+ hide(): void {
+ this.rain.update([]);
+ this.snow.update([]);
+ }
+
+ /** Счётчики для снапшота (фактически рисуемые капли/снежинки). */
+ info(): { rainDrops: number; snowFlakes: number } {
+ return {
+ rainDrops: Math.round(RAIN_COUNT * this.spec.rain),
+ snowFlakes: Math.round(SNOW_COUNT * this.spec.snow),
+ };
+ }
+
+ dispose(): void {
+ this.rain.dispose();
+ this.snow.dispose();
+ }
+}
\ No newline at end of file
diff --git a/v2/packages/engine/tools/agent-lib.mjs b/v2/packages/engine/tools/agent-lib.mjs
index ab57c69..61931bb 100644
--- a/v2/packages/engine/tools/agent-lib.mjs
+++ b/v2/packages/engine/tools/agent-lib.mjs
@@ -44,10 +44,15 @@
});
const waitReady = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`vite не поднялся за 30 с (${cwd})`)), 30000);
+ let errTail = '';
child.stdout.on('data', (d) => {
if (String(d).includes('Local:')) { clearTimeout(timer); resolve(); }
});
- child.on('exit', (code) => { clearTimeout(timer); reject(new Error(`vite упал (code ${code})`)); });
+ child.stderr.on('data', (d) => { errTail = (errTail + String(d)).slice(-400); });
+ child.on('exit', (code) => {
+ clearTimeout(timer);
+ reject(new Error(`vite упал (code ${code})${errTail ? ' — ' + errTail.trim().slice(0, 200) : ''}`));
+ });
});
return {
child,