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,