diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 4cc0379..7796ab0 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1408,9 +1408,15 @@ ввод моста → полная валидная), `windVector` (сила 1 — 6 юнит/с), `rainPoints`/`snowPoints(spec, t, win)` — детерминированные позы слоёв от времени цикла (якоря i·константа + падение/дрейф от вектора ветра; капля - тяжёлая — дрейф ×0.6, снежинка качается и дрейфует полностью). Окно - `weatherWindow(renderer, top)` = видимый след кадра + маржа 16 — wrap и - edgeAlpha гасят частицы у края (как у пепла: без «появления из воздуха»). + тяжёлая — дрейф ×0.6, снежинка качается и дрейфует полностью; капля + рисуется штрихом — цепочкой из 3 точек над головой). Счётчик — ПЛОТНОСТЬ + на площадь окна (`RAIN_DENSITY`/`SNOW_DENSITY` + потолок `RAIN_MAX`/ + `SNOW_MAX`): окно `weatherWindow(renderer, top)` = видимый след кадра + + маржа 16 — но след по высоте слоя уходит в глубину до дальней плоскости + и растёт с зумом камеры (у игры viewSize 80, far 800 — окно 342×342 + юнита): фиксированный список капель размазывался по нему в субпиксельную + пыль (капля 0.5 юнита при 3.3 px/юнит — 1.7 px). Wrap и edgeAlpha гасят + частицы у края (как у пепла: без «появления из воздуха»). `WeatherSystem(renderer)` — два слоя через `renderer.addParticles()` (спрайты-билборды: ambient-тинт и свет источников применяются слоями сами); `set(spec)` клампит и сразу гонит облачность в рендер, `update(t)` @@ -1434,13 +1440,14 @@ агентом + синхронизация слайдеров). Дев-панель: секция «ПОГОДА» — 5 слайдеров (облачность/дождь/снег/ветер/направление в градусах) + сброс, инфо-строка с счётчиками слоёв и режимом (база/правка). -- **Гейт** `weather.mjs` (14): база лугов/разъезда/рощи, ливень 260 капель - и снег 200 снежинок рисуются и падают (кадр-диффы), облачность темнит - кадр ×0.8, setAsh(false) глушит погоду, сброс к базе, слайдер дев-панели - управляет погодой. Скриншоты /tmp/v2_weather_*.png. Грабли: после - `gotoArea` шаг ≥60 тиков (фейд 0.4 с затемняет кадр — снимки в фейде - выглядят «ночью»); правка панели переживает смену области — в гейте - снимать `setWeather(null)` перед проверкой базы. `check:fast` — 343 ✓. +- **Гейт** `weather.mjs` (14): база лугов/разъезда/рощи, ливень — стена + капель (счётчики >1000 — плотность от окна, не фиксированный список) и + снег рисуются и падают (кадр-диффы), облачность темнит кадр ×0.8, + setAsh(false) глушит погоду, сброс к базе, слайдер дев-панели управляет + погодой. Скриншоты /tmp/v2_weather_*.png. Грабли: после `gotoArea` шаг + ≥60 тиков (фейд 0.4 с затемняет кадр — снимки в фейде выглядят «ночью»); + правка панели переживает смену области — в гейте снимать + `setWeather(null)` перед проверкой базы. `check:fast` — 344 ✓. ## Рабочие привычки diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index ba1748a..5ca2fbb 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -1336,6 +1336,15 @@ manual: weatherManual, }; }, + /** Окно видимости частиц (диагностика плотности осадков/пепла): AABB + * следа кадра для слоя [y0,y1] + параметры камеры. */ + windowInfo(y0 = 0, y1 = 40) { + const cam = renderer.camera; + return { + ...renderer.visibleWindow(y0, y1), + cam: { right: cam.right, top: cam.top, near: cam.near, far: cam.far }, + }; + }, /** Погода вручную (дев-панель/гейт): частичный spec поверх базы области; * null — вернуть погоду области. Облачность применяется сразу. */ setWeather(spec: Partial | null): void { diff --git a/v2/apps/game/tools/checks/weather.mjs b/v2/apps/game/tools/checks/weather.mjs index b3d2f98..768c886 100644 --- a/v2/apps/game/tools/checks/weather.mjs +++ b/v2/apps/game/tools/checks/weather.mjs @@ -73,8 +73,8 @@ 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); + 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); // стена дождя падает (22 юнит/с) const rainB = await shot(client, '/tmp/v2_weather_rain2.png'); @@ -85,7 +85,8 @@ 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); + 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'); diff --git a/v2/packages/engine/src/render/__tests__/weather.test.ts b/v2/packages/engine/src/render/__tests__/weather.test.ts index b6e259a..3b0686d 100644 --- a/v2/packages/engine/src/render/__tests__/weather.test.ts +++ b/v2/packages/engine/src/render/__tests__/weather.test.ts @@ -38,10 +38,12 @@ }); describe('rain/snow points', () => { - it('число точек = ёмкость × интенсивность; ноль — пусто', () => { + // окно 48×32 = 1536 кв. юнитов; плотность дождя 0.016 → 25 капель, + // каждая — штрих из 3 точек (75); снег 0.007 → 11 хлопьев + 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, 0, WIN).length).toBe(75); + expect(snowPoints({ ...spec, snow: 1 }, 0, WIN).length).toBe(11); expect(rainPoints({ ...spec, rain: 0 }, 0, WIN).length).toBe(0); expect(snowPoints({ ...spec, snow: 0 }, 0, WIN).length).toBe(0); }); @@ -102,6 +104,19 @@ 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 }); + expect(sys.info()).toEqual({ rainDrops: 0, snowFlakes: 0 }); // счётчики — после update + }); + it('update считает фактические капли/снежинки от окна', () => { + const sys = new WeatherSystem({ + addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }), + setCloud: () => {}, + visibleWindow: () => ({ minX: 0, maxX: 48, minZ: 0, maxZ: 32 }), + } as never); + sys.set({ rain: 1 }); + sys.update(1); + // окно погоды — след кадра + маржа 16: (48+32)×(32+32) = 5120 кв. юнитов + expect(sys.info()).toEqual({ rainDrops: 82, snowFlakes: 0 }); + sys.set({ rain: 0, snow: 0 }); // пустая погода прячет слои и счётчики + expect(sys.info()).toEqual({ rainDrops: 0, snowFlakes: 0 }); }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts index 6f19dc6..7df15bb 100644 --- a/v2/packages/engine/src/render/weather.ts +++ b/v2/packages/engine/src/render/weather.ts @@ -5,7 +5,11 @@ * воздуха»), ambient-тинт и точечные источники применяются слоем автоматически. * Ветер — глобальный горизонтальный дрейф (вектор из направления и силы), * облачность — гашение солнца/ambient рендера (VoxelRenderer.setCloud). - * Позы детерминированы временем цикла — кадр-равенства гейтов работают. + * Счётчик капель — плотность на площадь окна: окно (след кадра по высоте + * слоя) уходит в глубину до дальней плоскости и растёт с зумом камеры — + * фиксированный список капель размазывается по нему в субпиксельную пыль. + * Дождь рисуется штрихами — цепочкой точек над головой капли. Позы + * детерминированы временем цикла — кадр-равенства гейтов работают. */ import type { ParticleLayer } from './particles'; import type { VoxelRenderer } from './voxelRenderer'; @@ -53,37 +57,58 @@ } // ---- Параметры слоёв (юниты мира; окно — след кадра + маржа) ---- +// Счётчики — ПЛОТНОСТЬ на площадь окна: окно растёт с зумом камеры +// (след кадра уходит в глубину до дальней плоскости), фиксированный +// список капель размазывается по нему в субпиксельную пыль. -const RAIN_COUNT = 260; // капель при rain = 1 +const RAIN_DENSITY = 0.016; // капель на кв. юнит окна при rain = 1 +const RAIN_MAX = 4000; // потолок счёта (страховка от гигантских окон) const RAIN_TOP = 40; // высота слоя дождя (вертикальный цикл капли) -const RAIN_SPEED = 22; // падение, юнит/с (ливень читается на 480×270) -const RAIN_SIZE = 0.5; +const RAIN_SPEED = 22; // падение, юнит/с +const RAIN_SIZE = 0.55; // точка-голова штриха (~2 px при 3.3 px/юнит) const RAIN_COLOR = '#a8bacb'; -const RAIN_ALPHA = 0.7; +const RAIN_ALPHA = 0.75; +const RAIN_CHAIN = 3; // точек в штрихе (хвост выше головы) +const RAIN_STEP = 0.7; // шаг цепочки, юниты (штрих ~1.4 юнита) -const SNOW_COUNT = 200; // хлопьев при snow = 1 +const SNOW_DENSITY = 0.007; // хлопьев на кв. юнит окна при snow = 1 +const SNOW_MAX = 2000; const SNOW_TOP = 36; const SNOW_SWAY = 1.2; // размах покачивания при падении -const SNOW_SIZE = 0.55; +const SNOW_SIZE = 0.7; const SNOW_COLOR = '#eef3f6'; const SNOW_ALPHA = 0.85; const MARGIN = 16; // перекрытие за краем кадра (как у пепла) -/** Позы капель: якорь + падение + дрейф ветра (капля тяжёлая — дрейф ×0.6). */ +/** Плотность → счётчик: площадь окна × интенсивность, с потолком. */ +function densityCount(density: number, k: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, max: number): number { + const area = (win.maxX - win.minX) * (win.maxZ - win.minZ); + return Math.min(max, Math.round(area * density * k)); +} + +/** Позы капель: якорь + падение + дрейф ветра (капля тяжёлая — дрейф ×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); +): { pos: [number, number, number]; alpha: number; size?: number }[] { + const n = densityCount(RAIN_DENSITY, spec.rain, win, RAIN_MAX); const [wx, wz] = windVector(spec); - const out: { pos: [number, number, number]; alpha: number }[] = []; + const out: { pos: [number, number, number]; alpha: number; size?: 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 }); + const a = edge(win, x, z) * RAIN_ALPHA; + for (let k = 0; k < RAIN_CHAIN; k++) { + out.push({ + pos: [x, fall + k * RAIN_STEP, z], + alpha: a * (1 - k * 0.35), + size: RAIN_SIZE - k * 0.1, + }); + } } return out; } @@ -93,7 +118,7 @@ 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 n = densityCount(SNOW_DENSITY, spec.snow, win, SNOW_MAX); const [wx, wz] = windVector(spec); const out: { pos: [number, number, number]; alpha: number }[] = []; for (let i = 0; i < n; i++) { @@ -137,6 +162,8 @@ private readonly rain: ParticleLayer; private readonly snow: ParticleLayer; private spec: WeatherSpec = { ...DEFAULT_WEATHER }; + private lastDrops = 0; // фактически рисуемые капли (после update) + private lastFlakes = 0; constructor(renderer: VoxelRenderer) { this.renderer = renderer; @@ -161,9 +188,11 @@ 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 }))); + this.rain.update(drops.map((d) => ({ pos: d.pos, color: RAIN_COLOR, alpha: d.alpha, size: d.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 }))); + this.lastDrops = drops.length / RAIN_CHAIN; // штрих = одна капля + this.lastFlakes = flakes.length; } /** Тинт освещения: ночь гасит осадки вместе со сценой (как пепел/листья). */ @@ -176,14 +205,12 @@ hide(): void { this.rain.update([]); this.snow.update([]); + this.lastDrops = this.lastFlakes = 0; } /** Счётчики для снапшота (фактически рисуемые капли/снежинки). */ info(): { rainDrops: number; snowFlakes: number } { - return { - rainDrops: Math.round(RAIN_COUNT * this.spec.rain), - snowFlakes: Math.round(SNOW_COUNT * this.spec.snow), - }; + return { rainDrops: this.lastDrops, snowFlakes: this.lastFlakes }; } dispose(): void {