/**
* Молния: разряд + вспышка с тенями. Разряд — излучающий слой частиц
* (lit: false): зигзаг-канал от земли вверх с парой ветвей; форма
* детерминирована номером удара (кадр-равенства гейтов). Вспышка —
* отдельный направленный свет рендера (VoxelRenderer.setLightning): свет
* бьёт из разряда вниз с лёгким наклоном — тени от кастеров качаются
* (та же shadow map-механика, что у солнца); ночью вспышка читается ярче —
* днём она тонет в солнечном свете. Lit-частицы (дождь/снег) и туман
* подсвечиваются вспышкой через env-свет (lightningEnvLight — вклад в
* uEnv/uFogColor в setSun). Огибающая мерцания и геометрия разряда
* — чистые функции (тесты без WebGL). Пока удар по кнопке дев-панели;
* позже из этого собирается гроза (серия ударов) при необходимости в игре.
*/
import type { ParticleLayer } from './particles';
import type { VoxelRenderer } from './voxelRenderer';
/** Высота разряда над точкой удара (юниты): полукадр по вертикали мира
* ~viewSize/cos(elev) ≈ 176 — разряд уходит за верхний край кадра. */
export const LIGHTNING_TOP = 176;
/** Длительность вспышки с мерцанием (с). */
export const LIGHTNING_DURATION = 0.55;
/** Сила вспышки-света при пике огибающей (множитель к направленному свету). */
export const LIGHTNING_FLASH = 5;
/** Цвет канала разряда (холодный бело-голубой). */
const BOLT_COLOR = '#eef3ff';
/** Цвет света вспышки (совпадает со светом рендера, 0xdfe8ff). */
export const LIGHTNING_COLOR: readonly [number, number, number] = [0.874, 0.91, 1.0];
/** Доля вспышки в env-свете частиц/тумана: билборд ловит свет со всех
* сторон, а вспышка направленная — берём часть её силы. */
export const LIGHTNING_ENV_SHARE = 0.2;
/** Вклад вспышки в env-свет частиц и тумана: дождь/снег (lit-слои) и
* туман подсвечиваются разрядом вместе с миром. Чистая функция. */
export function lightningEnvLight(k: number, nightK: number): [number, number, number] {
const f = Math.max(0, k) * LIGHTNING_FLASH * (1 + nightK) * LIGHTNING_ENV_SHARE;
return [LIGHTNING_COLOR[0] * f, LIGHTNING_COLOR[1] * f, LIGHTNING_COLOR[2] * f];
}
/** Псевдослучай 0..1 от пары чисел (sin-хэш): детерминирован, без состояния. */
function boltHash(a: number, b: number): number {
const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
return s - Math.floor(s);
}
/** Огибающая мерцания вспышки 0..1: резкий пик в момент удара и два
* рестрайка (настоящие молнии бьют 2–3 раза), затухание к нулю.
* Чистая функция — тесты без WebGL. */
export function lightningEnvelope(dt: number): number {
if (dt < 0 || dt >= LIGHTNING_DURATION) return 0;
const main = Math.exp(-dt * 9); // первый (главный) разряд
const re1 = 0.7 * Math.exp(-Math.pow((dt - 0.18) * 14, 2)); // рестрайк
const re2 = 0.5 * Math.exp(-Math.pow((dt - 0.34) * 16, 2)); // второй
return Math.min(1, main + re1 + re2);
}
/** Точка геометрии разряда (для слоя частиц): позиция, размер, доля альфы. */
export interface BoltPoint {
pos: [number, number, number];
size: number;
alpha: number;
}
/** Геометрия разряда: непрерывный зигзаг-канал от земли вверх (узлы каждые
* 3 юнита + ореол вокруг каждого) и две ветви вбок-вниз. Зигзаг — сумма
* двух синусов (ограничен, канал не уходит от точки удара), фазы/частоты
* от seed (номер удара) — чистая функция, детерминированная. */
export function boltPoints(seed: number, at: readonly [number, number, number]): BoltPoint[] {
const pts: BoltPoint[] = [];
const steps = Math.round(LIGHTNING_TOP / 3); // 59 узлов канала (TOP 176)
const p1 = boltHash(seed, 1.3) * Math.PI * 2; // фазы и частоты зигзага
const p2 = boltHash(seed, 2.9) * Math.PI * 2;
const f1 = 0.55 + boltHash(seed, 4.1) * 0.3;
const f2 = 0.19 + boltHash(seed, 5.7) * 0.1;
const b1 = 6 + Math.floor(boltHash(seed, 6.1) * 14); // узлы ветвления
const b2 = 28 + Math.floor(boltHash(seed, 7.3) * 20);
for (let i = 0; i < steps; i++) {
const y = at[1] + i * 3;
const x = at[0] + Math.sin(i * f1 + p1) * 2.6 + Math.sin(i * f2 + p2) * 1.9;
const z = at[2] + Math.cos(i * f1 * 0.9 + p2) * 2.6 + Math.sin(i * f2 * 1.1 + p1) * 1.9;
pts.push({ pos: [x, y, z], size: 1.35 - i * 0.008, alpha: 1 });
pts.push({ pos: [x, y, z], size: 3.6, alpha: 0.22 }); // мягкий ореол
if (i === b1 || i === b2) {
// ветвь: 5 сегментов вбок и вниз от узла канала
const ang = boltHash(seed, 10 + i) * Math.PI * 2;
const dx = Math.cos(ang);
const dz = Math.sin(ang);
let bx = x;
let by = y - 1;
let bz = z;
for (let j = 0; j < 5; j++) {
bx += dx * 2.4;
by -= 2.2;
bz += dz * 2.4;
pts.push({ pos: [bx, by, bz], size: 0.6, alpha: 0.7 });
}
}
}
return pts;
}
/** Снапшот молнии (мост/гейты): активна ли вспышка, счётчик ударов,
* текущая огибающая и точка удара. */
export interface LightningInfo {
active: boolean;
strikes: number;
flash: number;
at: [number, number, number] | null;
}
/** Молния: слой-разряд + свет вспышки. `strike()` бьёт в заданную точку
* земли [x, y, z]; без аргумента — в случайную точку окна видимости
* (детерминированно от номера удара, y — фокус камеры). `update(t)` гонит
* огибающую: точки разряда и setLightning рендера. */
export class LightningSystem {
private readonly renderer: VoxelRenderer;
private readonly layer: ParticleLayer;
private strikeT = -1; // время начала вспышки (-1 — нет)
private seed = 0;
private at: [number, number, number] | null = null;
private strikes = 0;
private flashK = 0;
private lastT = 0;
constructor(renderer: VoxelRenderer) {
this.renderer = renderer;
// разряд излучает сам — не освещаемый слой (вспышка и есть свет)
this.layer = renderer.addParticles({ lit: false });
}
/** Удар молнии: at — точка земли [x, y, z]; без аргумента — случайная
* точка окна видимости (детерминированно от номера удара). */
strike(at?: readonly [number, number, number]): void {
this.strikes++;
this.seed = this.strikes * 7919 + 17;
if (at) {
this.at = [at[0], at[1], at[2]];
} else {
const w = this.renderer.visibleWindow(0, LIGHTNING_TOP);
const f = this.renderer.focusPoint();
const rx = boltHash(this.seed, 3.1);
const rz = boltHash(this.seed, 7.7);
this.at = [w.minX + (w.maxX - w.minX) * rx, f[1], w.minZ + (w.maxZ - w.minZ) * rz];
}
this.strikeT = this.lastT;
this.update(this.lastT);
}
/** Тик: огибающая мерцания → точки разряда + свет вспышки; по истечении
* длительности — погасить слой и свет. */
update(t: number): void {
this.lastT = t;
if (this.strikeT < 0 || !this.at) return;
const dt = t - this.strikeT;
const e = lightningEnvelope(dt);
if (e <= 0) {
this.layer.update([]);
this.renderer.setLightning(null, 0);
this.strikeT = -1;
this.flashK = 0;
return;
}
this.flashK = e;
this.layer.update(boltPoints(this.seed, this.at).map((p) => ({
pos: p.pos, color: BOLT_COLOR, alpha: p.alpha * e, size: p.size,
})));
this.renderer.setLightning(this.at, e);
}
/** Снапшот для моста/гейтов. */
info(): LightningInfo {
return {
active: this.strikeT >= 0,
strikes: this.strikes,
flash: this.flashK,
at: this.at ? [...this.at] : null,
};
}
/** Погасить активную вспышку немедленно (переходы/гейты: мир замер —
* огибающей больше некогда догорать до нуля). Неактивной безвредна. */
cancel(): void {
this.strikeT = -1;
this.flashK = 0;
this.layer.update([]);
this.renderer.setLightning(null, 0);
}
dispose(): void {
// гасим всё, что могло остаться от незавершённой вспышки: слой и
// свет рендера иначе замирают навсегда (update уже не догонит)
this.layer.dispose();
this.renderer.setLightning(null, 0);
this.strikeT = -1;
this.flashK = 0;
}
}