diff --git a/v2/packages/engine/src/render/__tests__/weather.test.ts b/v2/packages/engine/src/render/__tests__/weather.test.ts index 3b0686d..2daa5fa 100644 --- a/v2/packages/engine/src/render/__tests__/weather.test.ts +++ b/v2/packages/engine/src/render/__tests__/weather.test.ts @@ -7,6 +7,7 @@ } from '../weather'; const WIN = { minX: 0, maxX: 48, minZ: 0, maxZ: 32 }; +const RAIN_SPEED_TEST = 22; // базовая скорость капли (i=0 — без надбавки) describe('clampWeather', () => { it('частичный ввод дополняется дефолтом', () => { @@ -76,6 +77,15 @@ 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); }); + it('респавн меняет колонку: траектория не повторяется по циклам', () => { + // без ветра капля i=0 на цикле 0 и цикле+1 — на том же y (начало + // падения), но на другой колонке (sin-хэш от номера цикла) + const spec = { ...DEFAULT_WEATHER, rain: 1 }; + const a = rainPoints(spec, 0, WIN)[0]!.pos; + const b = rainPoints(spec, 40 / RAIN_SPEED_TEST, WIN)[0]!.pos; // ровно один цикл + expect(b[1]).toBeCloseTo(a[1]); + expect(b[0]).not.toBeCloseTo(a[0], 3); + }); }); describe('cloudDim (облачность)', () => { diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts index 7df15bb..47db7c1 100644 --- a/v2/packages/engine/src/render/weather.ts +++ b/v2/packages/engine/src/render/weather.ts @@ -8,7 +8,9 @@ * Счётчик капель — плотность на площадь окна: окно (след кадра по высоте * слоя) уходит в глубину до дальней плоскости и растёт с зумом камеры — * фиксированный список капель размазывается по нему в субпиксельную пыль. - * Дождь рисуется штрихами — цепочкой точек над головой капли. Позы + * Дождь рисуется штрихами — цепочкой точек над головой капли. Респавн + * наверху — в новой колонке (sin-хэш от номера цикла падения): без этого + * капля вечно повторяет одну траекторию, рисунок дождя зациклен. Позы * детерминированы временем цикла — кадр-равенства гейтов работают. */ import type { ParticleLayer } from './particles'; @@ -70,11 +72,13 @@ const RAIN_ALPHA = 0.75; const RAIN_CHAIN = 3; // точек в штрихе (хвост выше головы) const RAIN_STEP = 0.7; // шаг цепочки, юниты (штрих ~1.4 юнита) +const RAIN_RESPAWN = 41; // разброс колонки при респавне (ломает повтор траектории) const SNOW_DENSITY = 0.007; // хлопьев на кв. юнит окна при snow = 1 const SNOW_MAX = 2000; const SNOW_TOP = 36; const SNOW_SWAY = 1.2; // размах покачивания при падении +const SNOW_RESPAWN = 31; // разброс колонки при респавне const SNOW_SIZE = 0.7; const SNOW_COLOR = '#eef3f6'; const SNOW_ALPHA = 0.85; @@ -87,7 +91,15 @@ return Math.min(max, Math.round(area * density * k)); } +/** Псевдослучай 0..1 от пары целых (sin-хэш): детерминирован, без состояния. */ +function hash01(a: number, b: number): number { + const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453; + return s - Math.floor(s); +} + /** Позы капель: якорь + падение + дрейф ветра (капля тяжёлая — дрейф ×0.6). + * Респавн наверху случается в НОВОЙ колонке — к якорю добавляется sin-хэш + * от номера цикла падения, иначе капля вечно падает по одной траектории. * Капля рисуется штрихом — цепочкой точек над головой (хвост бледнее). */ export function rainPoints( spec: WeatherSpec, t: number, @@ -98,9 +110,11 @@ 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); + const ph = i * 5.3; + const cyc = Math.floor((t * speed + ph) / RAIN_TOP); // номер цикла падения + const fall = RAIN_TOP - ((t * speed + ph) % RAIN_TOP); + const x = wrapV(i * 37.7 + (hash01(i, cyc) - 0.5) * RAIN_RESPAWN + t * wx * 0.6, win.minX, win.maxX); + const z = wrapV(i * 53.9 + (hash01(i * 3.7, cyc) - 0.5) * RAIN_RESPAWN + t * wz * 0.6, win.minZ, win.maxZ); const a = edge(win, x, z) * RAIN_ALPHA; for (let k = 0; k < RAIN_CHAIN; k++) { out.push({ @@ -113,7 +127,8 @@ return out; } -/** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра. */ +/** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра; + * колонка респавна — sin-хэш от номера цикла (как у дождя). */ export function snowPoints( spec: WeatherSpec, t: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, @@ -123,11 +138,14 @@ const out: { pos: [number, number, number]; alpha: number }[] = []; for (let i = 0; i < n; i++) { const speed = 1.2 + (i % 5) * 0.3; + const ph = (i * 4.1) % SNOW_TOP; + const cyc = Math.floor((t * speed + ph) / SNOW_TOP); const p = i * 2.399; // фазы покачивания — золотое сечение - const fall = SNOW_TOP - ((t * speed + (i * 4.1) % SNOW_TOP) % SNOW_TOP); + const fall = SNOW_TOP - ((t * speed + ph) % 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); + const x = wrapV(i * 61.7 + (hash01(i, cyc) - 0.5) * SNOW_RESPAWN + t * wx + sway, win.minX, win.maxX); + const z = wrapV(i * 47.3 + (hash01(i * 1.7, cyc) - 0.5) * SNOW_RESPAWN + 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;