/**
* Погода как часть движка: дождь, снег, ветер, облачность. Слои дождя/снега —
* те же спрайты-билборды (particles.ts): живут в мировых координатах, окно
* видимости камеры заворачивает их (маржа за экраном — без «появления из
* воздуха»), ambient-тинт и точечные источники применяются слоем автоматически.
* Ветер — глобальный горизонтальный дрейф (вектор из направления и силы),
* облачность — гашение солнца/ambient рендера (VoxelRenderer.setCloud).
* Счётчик капель — плотность на площадь окна: окно (след кадра по высоте
* слоя) уходит в глубину до дальней плоскости и растёт с зумом камеры —
* фиксированный список капель размазывается по нему в субпиксельную пыль.
* Дождь рисуется штрихами — цепочкой точек над головой капли. Респавн
* наверху — в новой колонке (sin-хэш от номера цикла падения): без этого
* капля вечно повторяет одну траекторию, рисунок дождя зациклен. Позы
* детерминированы временем цикла — кадр-равенства гейтов работают.
*/
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>): 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_DENSITY = 0.016; // капель на кв. юнит окна при rain = 1
const RAIN_MAX = 4000; // потолок счёта (страховка от гигантских окон)
const RAIN_TOP = 40; // высота слоя дождя (вертикальный цикл капли)
const RAIN_SPEED = 22; // падение, юнит/с
const RAIN_SIZE = 0.55; // точка-голова штриха (~2 px при 3.3 px/юнит)
const RAIN_COLOR = '#a8bacb';
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;
const MARGIN = 16; // перекрытие за краем кадра (как у пепла)
/** Плотность → счётчик: площадь окна × интенсивность, с потолком. */
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..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,
win: { minX: number; maxX: number; minZ: number; maxZ: number },
): { 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; size?: number }[] = [];
for (let i = 0; i < n; i++) {
const speed = RAIN_SPEED + (i % 7) * 1.3; // разлёт скоростей — стена дождя
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({
pos: [x, fall + k * RAIN_STEP, z],
alpha: a * (1 - k * 0.35),
size: RAIN_SIZE - k * 0.1,
});
}
}
return out;
}
/** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра;
* колонка респавна — sin-хэш от номера цикла (как у дождя). */
export function snowPoints(
spec: WeatherSpec, t: number,
win: { minX: number; maxX: number; minZ: number; maxZ: number },
): { pos: [number, number, number]; alpha: number }[] {
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++) {
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 + ph) % SNOW_TOP);
const sway = Math.sin(t * 0.8 + p) * SNOW_SWAY;
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;
}
/** Заворачивание в окно (по одной оси). */
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<VoxelRenderer, 'visibleWindow'>, 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 };
private lastDrops = 0; // фактически рисуемые капли (после update)
private lastFlakes = 0;
constructor(renderer: VoxelRenderer) {
this.renderer = renderer;
this.rain = renderer.addParticles();
this.snow = renderer.addParticles();
}
/** Применить погоду (клампится). Облачность — сразу в рендер; дождь/снег
* отрисуются на ближайшем update(t). */
set(spec: Partial<WeatherSpec>): 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: 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;
}
/** Тинт освещения: ночь гасит осадки вместе со сценой (как пепел/листья). */
setTint(color: number): void {
this.rain.setTint(color);
this.snow.setTint(color);
}
/** Скрыть осадки (погода выключена). */
hide(): void {
this.rain.update([]);
this.snow.update([]);
this.lastDrops = this.lastFlakes = 0;
}
/** Счётчики для снапшота (фактически рисуемые капли/снежинки). */
info(): { rainDrops: number; snowFlakes: number } {
return { rainDrops: this.lastDrops, snowFlakes: this.lastFlakes };
}
dispose(): void {
this.rain.dispose();
this.snow.dispose();
}
}