Newer
Older
rpg / v2 / packages / engine / src / render / weather.ts
/**
 * Погода как часть движка: дождь, снег, ветер, облачность. Слои дождя/снега —
 * те же спрайты-билборды (particles.ts): живут в мировых координатах, окно
 * видимости камеры заворачивает их (маржа за экраном — без «появления из
 * воздуха»), ambient-тинт и точечные источники применяются слоем автоматически.
 * Ветер — глобальный горизонтальный дрейф (вектор из направления и силы),
 * облачность — гашение солнца/ambient рендера (VoxelRenderer.setCloud).
 * Счётчик капель — плотность на площадь окна: окно (след кадра по высоте
 * слоя) уходит в глубину до дальней плоскости и растёт с зумом камеры —
 * фиксированный список капель размазывается по нему в субпиксельную пыль.
 * Дождь рисуется штрихами — цепочкой точек над головой капли. Глубина
 * (план даль/близь) симулируется расхождением параметров частицы — камера
 * ортографическая, параллакса нет: далёкие капли/хлопья меньше, медленнее
 * и бледнее (аэроперспектива), ближние крупнее, быстрее; у дождя дальние
 * штрихи короче, у снега ближние качаются шире. Осадки МНОГОСЛОЙНЫЕ
 * (фидбек «сильно гуще, тоже многослойным, как туман»): RAIN_LAYERS/SNOW_LAYERS
 * слоёв частиц, каждый живёт в своей узкой полосе глубины — ближний слой
 * редкий и крупный, дальний частый и мелкий (стена дождя в глубине);
 * суммарная плотность в ~3× выше одиночного слоя. Респавн
 * наверху — ПОЛНЫЙ реролл колонки (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;
    /** Многослойный туман 0..1 (слои по дальности от камеры, render/fog.ts). */
    fog: number;
}

/** Дождь без ветра, ясно — нейтральная погода по умолчанию. */
export const DEFAULT_WEATHER: WeatherSpec = {
    windDir: 0.7, windStrength: 0, cloud: 0, rain: 0, snow: 0, fog: 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)),
        fog: clamp01(num(w.fog, DEFAULT_WEATHER.fog)),
    };
}

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_MAX = 9000; // потолок счёта (страховка от гигантских окон)
const RAIN_TOP = 40; // высота слоя дождя (вертикальный цикл капли)
const RAIN_SPEED = 31; // падение, юнит/с (×1.4 — фидбек «медленно»)
const RAIN_SIZE = 0.55; // точка-голова штриха ближнего плана (~2 px)
const RAIN_COLOR = '#a8bacb';
const RAIN_ALPHA = 0.75;
const RAIN_CHAIN = 3; // точек в штрихе (хвост выше головы)
const RAIN_STEP = 0.7; // шаг цепочки ближнего плана (штрих ~1.4 юнита)
const RAIN_SPAN = 731; // период переброса колонки при респавне (полный реролл)

const SNOW_MAX = 4000;
const SNOW_TOP = 36;
const SNOW_SPEED = 1.7; // падение, юнит/с (×1.4 от 1.2 — фидбек)
const SNOW_SWAY = 1.2; // размах покачивания ближнего плана
const SNOW_SPAN = 613; // период переброса колонки при респавне (полный реролл)
const SNOW_SIZE = 0.7;
const SNOW_COLOR = '#eef3f6';
const SNOW_ALPHA = 0.85;

// ---- Многослойность осадков (фидбек «сильно гуще, как туман») ----
// Каждый слой живёт в своей полосе глубины [from..to] (0 — дальний план,
// 1 — ближний) со своей плотностью: ближний слой редкий и крупный,
// дальний частый и мелкий — стена дождя читается в глубине, отдельные
// капли вблизи. Полосы перекрываются — слоистость не видна как линии.

/** Число слоёв дождя. */
export const RAIN_LAYERS = 3;
/** Число слоёв снега. */
export const SNOW_LAYERS = 3;

/** Полосы глубины слоёв (от дальнего к ближнему) и их плотность
 *  (капель на кв. юнит окна при rain = 1). Сумма 0.044 против 0.016
 *  одиночного слоя — «сильно гуще». */
const RAIN_BANDS: readonly { from: number; to: number; density: number }[] = [
    { from: 0.0, to: 0.34, density: 0.020 }, // дальний: стена мелкой мороси
    { from: 0.24, to: 0.62, density: 0.014 }, // средний
    { from: 0.5, to: 1.0, density: 0.010 }, // ближний: редкие крупные капли
];

/** Полосы глубины слоёв снега и плотности (сумма 0.022 против 0.007). */
const SNOW_BANDS: readonly { from: number; to: number; density: number }[] = [
    { from: 0.0, to: 0.34, density: 0.010 },
    { from: 0.24, to: 0.62, density: 0.007 },
    { from: 0.5, to: 1.0, density: 0.005 },
];

const MARGIN = 16; // перекрытие за краем кадра (как у пепла)

// ---- Глубина (план даль/близь) ----
// Камера ортографическая — параллакса от глубины нет, поэтому глубина
// частицы симулируется расхождением параметров (стандартный приём 2.5D):
// далёкое — меньше, медленнее и бледнее (аэроперспектива), ближнее —
// крупнее, быстрее и насыщеннее; у дождя дальние штрихи короче, у снега
// ближние хлопья качаются шире. Глубина стабильна на частицу (это её
// «план»), респавн перебрасывает только колонку. Лёгкий уклон в дальний
// план — масса осадков читается вдали, отдельные капли вблизи.

/** Глубина частицы 0..1 (0 — дальний план, 1 — ближний), по номеру. */
function depth01(i: number): number {
    return Math.pow(hash01(i, 91.7), 1.3);
}

/** Масштаб глубины: [дальний, ближний] × глубина. */
function dScale(near: number, far: number, d: number): number {
    return far + (near - far) * d;
}

/** Плотность → счётчик: площадь окна × интенсивность, с потолком. */
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).
 *  Слои (layer undefined — все разом, для счётчиков/тестов): слой k живёт
 *  в своей полосе глубины, глобальный номер gi = i + k·7919 разводит колонки
 *  слоёв (без них слои легли бы в одни и те же x). Респавн наверху случается
 *  в НОВОЙ колонке: к якорю добавляется sin-хэш от номера цикла падения,
 *  растянутый на большой период RAIN_SPAN. Сдвиг в узкой окрестности якоря
 *  оставлял каплю в одной полосе — трек «горел» на экране вечно. Капля
 *  рисуется штрихом — цепочкой точек над головой; глубина частицы
 *  масштабирует скорость/размер/альфу и длину штриха. */
export function rainPoints(
    spec: WeatherSpec, t: number,
    win: { minX: number; maxX: number; minZ: number; maxZ: number },
    layer?: number,
): { pos: [number, number, number]; alpha: number; size?: number }[] {
    const [wx, wz] = windVector(spec);
    const out: { pos: [number, number, number]; alpha: number; size?: number }[] = [];
    const k0 = layer ?? 0;
    const k1 = layer ?? RAIN_BANDS.length - 1;
    for (let k = k0; k <= k1; k++) {
        const band = RAIN_BANDS[k]!;
        const n = densityCount(band.density, spec.rain, win, RAIN_MAX);
        for (let i = 0; i < n; i++) {
            const gi = i + k * 7919; // колонки слоёв не совпадают
            const d = band.from + (band.to - band.from) * depth01(gi);
            const speed = (RAIN_SPEED + (gi % 7) * 1.3) * dScale(1, 0.55, d);
            const ph = gi * 5.3;
            const cyc = Math.floor((t * speed + ph) / RAIN_TOP); // номер цикла падения
            const fall = RAIN_TOP - ((t * speed + ph) % RAIN_TOP);
            const x = wrapV(gi * 37.7 + hash01(gi, cyc) * RAIN_SPAN + t * wx * 0.6, win.minX, win.maxX);
            const z = wrapV(gi * 53.9 + hash01(gi * 3.7, cyc) * RAIN_SPAN + t * wz * 0.6, win.minZ, win.maxZ);
            const step = RAIN_STEP * dScale(1.2, 0.5, d); // ближний штрих длиннее
            const sz = RAIN_SIZE * dScale(1.15, 0.45, d);
            const a = edge(win, x, z) * RAIN_ALPHA * dScale(1, 0.4, d);
            for (let c = 0; c < RAIN_CHAIN; c++) {
                out.push({
                    pos: [x, fall + c * step, z],
                    alpha: a * (1 - c * 0.35),
                    size: sz * (1 - c * 0.1),
                });
            }
        }
    }
    return out;
}

/** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра;
 *  слои — как у дождя (SNOW_BANDS, gi = i + k·6271). Глубина масштабирует
 *  скорость/качание (ближние хлопья качаются шире)/размер/альфу. */
export function snowPoints(
    spec: WeatherSpec, t: number,
    win: { minX: number; maxX: number; minZ: number; maxZ: number },
    layer?: number,
): { pos: [number, number, number]; alpha: number; size?: number }[] {
    const [wx, wz] = windVector(spec);
    const out: { pos: [number, number, number]; alpha: number; size?: number }[] = [];
    const k0 = layer ?? 0;
    const k1 = layer ?? SNOW_BANDS.length - 1;
    for (let k = k0; k <= k1; k++) {
        const band = SNOW_BANDS[k]!;
        const n = densityCount(band.density, spec.snow, win, SNOW_MAX);
        for (let i = 0; i < n; i++) {
            const gi = i + k * 6271;
            const d = band.from + (band.to - band.from) * depth01(gi);
            const speed = (SNOW_SPEED + (gi % 5) * 0.42) * dScale(1, 0.5, d);
            const ph = (gi * 4.1) % SNOW_TOP;
            const cyc = Math.floor((t * speed + ph) / SNOW_TOP);
            const p = gi * 2.399; // фазы покачивания — золотое сечение
            const fall = SNOW_TOP - ((t * speed + ph) % SNOW_TOP);
            const swayAmp = SNOW_SWAY * dScale(1.3, 0.4, d);
            const sway = Math.sin(t * 0.8 + p) * swayAmp;
            const x = wrapV(gi * 61.7 + hash01(gi, cyc) * SNOW_SPAN + t * wx + sway, win.minX, win.maxX);
            const z = wrapV(gi * 47.3 + hash01(gi * 1.7, cyc) * SNOW_SPAN + t * wz
                + Math.cos(t * 0.6 + p) * swayAmp * 0.6, win.minZ, win.maxZ);
            out.push({
                pos: [x, fall, z],
                alpha: edge(win, x, z) * SNOW_ALPHA * dScale(1, 0.45, d),
                size: SNOW_SIZE * dScale(1.2, 0.5, d),
            });
        }
    }
    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 };
}

/** Погода: многослойные осадки (RAIN_LAYERS/SNOW_LAYERS слоёв частиц,
 *  каждый — своя полоса глубины) + ветер внутри их дрейфа. Облачность
 *  уводит в 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 = Array.from({ length: RAIN_LAYERS }, () => renderer.addParticles({ lit: true }));
        this.snow = Array.from({ length: SNOW_LAYERS }, () => renderer.addParticles({ lit: true }));
    }

    /** Применить погоду (клампится). Облачность — сразу в рендер; дождь/снег
     *  отрисуются на ближайшем 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;
    }

    /** Тик: позы слоёв от времени цикла (детерминированно). Туман уходит в
     *  рендер (setFog) — слои по дальности композитятся в кадре рендера. */
    update(t: number): void {
        const win = weatherWindow(this.renderer, RAIN_TOP);
        let drops = 0;
        for (let k = 0; k < this.rain.length; k++) {
            const pts = rainPoints(this.spec, t, win, k);
            this.rain[k]!.update(pts.map((d) => ({ pos: d.pos, color: RAIN_COLOR, alpha: d.alpha, size: d.size })));
            drops += pts.length / RAIN_CHAIN; // штрих = одна капля
        }
        let flakes = 0;
        for (let k = 0; k < this.snow.length; k++) {
            const pts = snowPoints(this.spec, t, win, k);
            this.snow[k]!.update(pts.map((f) => ({ pos: f.pos, color: SNOW_COLOR, alpha: f.alpha, size: f.size ?? SNOW_SIZE })));
            flakes += pts.length;
        }
        this.renderer.setFog(this.spec.fog, t, windVector(this.spec));
        this.lastDrops = drops;
        this.lastFlakes = flakes;
    }

    /** Скрыть осадки (погода выключена). */
    hide(): void {
        for (const l of this.rain) l.update([]);
        for (const l of this.snow) l.update([]);
        this.lastDrops = this.lastFlakes = 0;
    }

    /** Счётчики для снапшота (фактически рисуемые капли/снежинки). */
    info(): { rainDrops: number; snowFlakes: number } {
        return { rainDrops: this.lastDrops, snowFlakes: this.lastFlakes };
    }

    dispose(): void {
        for (const l of this.rain) l.dispose();
        for (const l of this.snow) l.dispose();
    }
}