diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md
index 0b3a145..13b4424 100644
--- a/v2/CLAUDE.md
+++ b/v2/CLAUDE.md
@@ -1620,12 +1620,30 @@
силу/время/ветер в рендер каждый тик. База погоды: пруды fog 0.3,
остальные 0. Дев-панель: 6-й слайдер «туман», мост `setWeather`/
`devSetWeather` — как остальные поля.
-- **Гейт weather.mjs** (+6 чеков, 19 всего): кадры вкл/выкл различаются,
- глубинный градиент (верх кадра = даль светлее низа на разнице),
- дрейф с ветром, ночь не выбелена, 6 слайдеров панели, слайдер тумана
- включает ручной режим. Грабля замера: нижняя полоса строк — 400..540
- (высота кадра 540; выход за границы декодера даёт NaN без падения).
- `check:fast` — 377 ✓ (включая фикс pow-грабли lit-шейдера: степень
+- **Молния** (2026-09-12, «добавим молнию с тенями»; пока удар по дебаг-
+ кнопке — позже из этого гроза): `render/lightning.ts` —
+ `LightningSystem(renderer)`: `strike(at?)` (без точки — случайная точка
+ окна видимости, y — фокус камеры; детерминировано номером удара),
+ `update(t)` гонит огибающую мерцания `lightningEnvelope(dt)` (пик + два
+ рестрайка, LIGHTNING_DURATION 0.55 с), `info()` — снапшот моста.
+ Разряд — излучающий слой частиц (lit: false): `boltPoints(seed, at)` —
+ непрерывный зигзаг (сумма двух синусов — ограничен) 58 узлов каждые
+ 3 юнита на высоту LIGHTNING_TOP 176 (полукадр по вертикали мира — меньше
+ не дотягивается до верха кадра; первая проба с 64 и 16 узлами дала
+ «жидкий» пунктир у земли) + ореол (size 3.6, alpha 0.22) и две ветви.
+ Вспышка с тенями — отдельный DirectionalLight рендера
+ (`setLightning(at, k)`, конструктор): свет из точки разряда вниз с
+ наклоном (строго вертикальный свет прячет тени под объектом), фрустум
+ как у зенитного, `k·LIGHTNING_FLASH(5)·(1+nightK)` — днём вспышка тонет
+ в солнце, ночью главная (ночной кадр 13 → 153 яркости). НЕ трогает
+ setSun (тот зовётся каждым тиком света). Мост: `makeLightning(at?)`,
+ `lightningInfo()`; кнопка «⚡ Молния» в секции погоды панели.
+- **Гейт weather.mjs** (+6 чеков молнии, 25 всего): вспышка активна и
+ ярче (кадр ×1.08), гаснет за 0.55 с (кадр вернулся), ночью вспышка
+ освещает кадр (×1.4), кнопка панели бьёт (счётчик +1). Грабля замера:
+ нижняя полоса строк — 400..540 (высота кадра 540; выход за границы
+ декодера даёт NaN без падения).
+ `check:fast` — 387 ✓ (включая фикс pow-грабли lit-шейдера: степень
из TS в GLSL-шаблоне печаталась «2» (int) — pow не компилировался,
lit-слои молча не рисовали; в шаблоны числа — через `.toFixed(1)`).
diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html
index 31c4b51..5c3b281 100644
--- a/v2/apps/game/index.html
+++ b/v2/apps/game/index.html
@@ -205,7 +205,7 @@
#dev-weather label { color: #c9c9d2; font: 13px monospace; display: flex; justify-content: space-between; }
#dev-weather input[type="range"] { width: 100%; accent-color: #d99a32; }
#dev-w-row { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
- #dev-w-reset {
+ #dev-w-reset, #dev-w-lightning {
background: none; border: 1px solid #44454e; color: #c9c9d2;
font: 12px monospace; padding: 2px 6px; cursor: pointer;
}
@@ -365,6 +365,7 @@
+
diff --git a/v2/apps/game/src/devview.ts b/v2/apps/game/src/devview.ts
index 7c0354c..f85ae12 100644
--- a/v2/apps/game/src/devview.ts
+++ b/v2/apps/game/src/devview.ts
@@ -47,6 +47,7 @@
private readonly setMinutes: (m: number) => void,
private readonly getWeather: () => WeatherPanelInfo,
private readonly setWeather: (spec: Partial | null) => void,
+ private readonly onLightning: () => void,
) {
document.getElementById('dev-open')!.addEventListener('click', () => this.closeViewer());
const playBtn = document.getElementById('dev-play')!;
@@ -94,6 +95,9 @@
this.setWeather(null);
this.refreshWeather();
});
+ document.getElementById('dev-w-lightning')!.addEventListener('click', () => {
+ this.onLightning();
+ });
}
private playing = true;
diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts
index 218a9c0..478d330 100644
--- a/v2/apps/game/src/main.ts
+++ b/v2/apps/game/src/main.ts
@@ -11,7 +11,7 @@
mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
DialogueRunner, Inventory, CutsceneRunner, validateCutscenes,
resolveTiles, collides, worldTileAtlas, backpackModel, torchModel, flashlightModel,
- WeatherSystem, type WeatherSpec,
+ WeatherSystem, LightningSystem, type WeatherSpec,
} from '@rpg/engine';
import type {
VoxelCloud, DialogueGraph, DialogueView,
@@ -245,6 +245,7 @@
let areaAsh = false; // играет ли пепел в текущей области
/** Погода движка (дождь/снег/ветер) + облачность в рендере. */
const weather = new WeatherSystem(renderer);
+const lightning = new LightningSystem(renderer); // разряд + вспышка с тенями
/** Ручная погода дев-панели: поверх базы области (null — только база). */
let weatherManual: Partial | null = null;
/** Активная погода: база области + ручные правки панели. */
@@ -812,6 +813,7 @@
agent.setWeather(spec);
updateHud();
},
+ () => agent.makeLightning(), // кнопка «Молния» секции погоды
);
/** Тост: всплывашка эффекта, уходит сама (реальное время). */
@@ -1079,6 +1081,7 @@
shimmer?.update(elapsed); // блики на воде прудов
airFx?.update(elapsed); // листья рощи / пыль разъезда
weather.update(elapsed); // дождь/снег поверх амбиента области
+ lightning.update(elapsed); // вспышка молнии (если бьёт)
postFx?.update(lighting?.nightFactor ?? 0, elapsed, ashEnabled); // виньетка/жара
fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер)
if (fade.active) {
@@ -1358,6 +1361,16 @@
fogInfo() {
return renderer.fogInfo();
},
+ /** Снапшот молнии (диагностика гейтов): активность, счётчик, вспышка. */
+ lightningInfo() {
+ return lightning.info();
+ },
+ /** Удар молнии (дев-кнопка/гейт): без аргумента — случайная точка окна
+ * видимости; [x, y, z] — точка удара о землю (отладка/гроза). */
+ makeLightning(at?: [number, number, number]): { strikes: number } {
+ lightning.strike(at);
+ return { strikes: lightning.info().strikes };
+ },
/** Окно видимости частиц (диагностика плотности осадков/пепла): AABB
* следа кадра для слоя [y0,y1] + параметры камеры. */
windowInfo(y0 = 0, y1 = 40) {
diff --git a/v2/apps/game/tools/checks/weather.mjs b/v2/apps/game/tools/checks/weather.mjs
index 5976890..08d30c7 100644
--- a/v2/apps/game/tools/checks/weather.mjs
+++ b/v2/apps/game/tools/checks/weather.mjs
@@ -146,6 +146,32 @@
check('ночь: туман тёмный (env-свет), кадр не выбелен',
nightFog < 90 && nightFog < brightness(foggy), { nightFog });
await client.call('setClock', 720);
+ await client.call('setWeather', null); // снять ручную правку перед молнией
+
+ // ---- Молния: разряд + вспышка с тенями, гаснет ----
+ await client.call('setWeather', { rain: 0.6, cloud: 0.6, fog: 0, windStrength: 0.6 });
+ await client.call('stepTicks', 2);
+ const lBefore = brightness(await shot(client, '/tmp/v2_lightning_before.png'));
+ await client.call('makeLightning');
+ await client.call('stepTicks', 2); // внутри окна вспышки (0.55 с)
+ let li = await client.call('lightningInfo');
+ check('молния: вспышка активна (свет + разряд)', li.active === true && li.flash > 0.3, li);
+ const lFlash = brightness(await shot(client, '/tmp/v2_lightning_flash.png'));
+ check('молния: кадр светлее во вспышку', lFlash > lBefore * 1.08, { lBefore, lFlash });
+ await client.call('stepTicks', 60); // вспышка 0.55 с — погасла
+ li = await client.call('lightningInfo');
+ const lAfter = brightness(await shot(client, '/tmp/v2_lightning_after.png'));
+ check('молния: погасла, кадр вернулся', !li.active && li.flash === 0 && lAfter < lFlash * 0.95,
+ { lAfter, lFlash });
+ // ночь: вспышка — главный свет кадра, читается драматичнее
+ await client.call('setClock', 0);
+ await client.call('stepTicks', 5);
+ const nBefore = brightness(await shot(client, '/tmp/v2_lightning_night0.png'));
+ await client.call('makeLightning');
+ await client.call('stepTicks', 2);
+ const nFlash = brightness(await shot(client, '/tmp/v2_lightning_night1.png'));
+ check('молния ночью: вспышка освещает кадр', nFlash > nBefore * 1.4, { nBefore, nFlash });
+ await client.call('setClock', 720);
await client.call('setWeather', null); // снять ручную правку перед рощей
// ---- setAsh(false): гейт-режим глушит и осадки с облачностью ----
@@ -194,6 +220,13 @@
await client.call('devSetWeather', null); // сброс + синхронизация слайдеров
wi = await client.call('weatherInfo');
check('дев-панель: сброс вернул базу области', wi.manual === null && wi.windStrength === 0.2, wi);
+ // кнопка молнии: клик → счётчик ударов растёт
+ const hasBolt = await client.evaluate(() => document.getElementById('dev-w-lightning') !== null);
+ check('дев-панель: кнопка молнии', hasBolt === true);
+ const s0 = (await client.call('lightningInfo')).strikes;
+ await client.evaluate(() => document.getElementById('dev-w-lightning').click());
+ const s1 = (await client.call('lightningInfo')).strikes;
+ check('дев-панель: кнопка молнии бьёт (счётчик +1)', s1 === s0 + 1, { s0, s1 });
await client.call('devClose');
} finally {
await browser.close();
diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts
index 4d0e8bd..705bd42 100644
--- a/v2/packages/engine/src/index.ts
+++ b/v2/packages/engine/src/index.ts
@@ -109,5 +109,7 @@
export type { DayNightSpec } from './render/daynight';
export { DEFAULT_WEATHER, WeatherSystem, clampWeather, snowPoints, windVector, rainPoints } from './render/weather';
export type { WeatherSpec } from './render/weather';
+export { LightningSystem, lightningEnvelope, boltPoints, LIGHTNING_TOP, LIGHTNING_DURATION, LIGHTNING_FLASH } from './render/lightning';
+export type { LightningInfo, BoltPoint } from './render/lightning';
export type { PointLightOptions, PointLightHandle, SpotLightOptions, SpotLightHandle } from './render/voxelRenderer';
// setDayAmbient/setNight/addPointLight/addSpotLight — методы класса VoxelRenderer
diff --git a/v2/packages/engine/src/render/__tests__/lightning.test.ts b/v2/packages/engine/src/render/__tests__/lightning.test.ts
new file mode 100644
index 0000000..2ca0408
--- /dev/null
+++ b/v2/packages/engine/src/render/__tests__/lightning.test.ts
@@ -0,0 +1,101 @@
+/** Тесты чистой части молнии: огибающая мерцания, геометрия разряда,
+ * жизненный цикл вспышки системы (без WebGL — стаб рендера). */
+import { describe, expect, it } from 'vitest';
+import {
+ LIGHTNING_DURATION, LIGHTNING_TOP, LightningSystem, boltPoints, lightningEnvelope,
+} from '../lightning';
+
+describe('lightningEnvelope', () => {
+ it('пик в момент удара, всё в 0..1', () => {
+ expect(lightningEnvelope(0)).toBeCloseTo(1);
+ for (let dt = 0; dt < LIGHTNING_DURATION; dt += 0.01) {
+ const e = lightningEnvelope(dt);
+ expect(e).toBeGreaterThan(0);
+ expect(e).toBeLessThanOrEqual(1);
+ }
+ });
+ it('мерцает (рестрайки): не монотонна', () => {
+ // спад главного разряда, затем рестрайк поднимает огибающую снова
+ expect(lightningEnvelope(0.18)).toBeGreaterThan(lightningEnvelope(0.12));
+ });
+ it('после длительности — ноль', () => {
+ expect(lightningEnvelope(LIGHTNING_DURATION)).toBe(0);
+ expect(lightningEnvelope(LIGHTNING_DURATION + 5)).toBe(0);
+ expect(lightningEnvelope(-1)).toBe(0);
+ });
+});
+
+describe('boltPoints (геометрия разряда)', () => {
+ const at: [number, number, number] = [10, 2, -5];
+ it('детерминизм: тот же seed — та же геометрия; seed меняет форму', () => {
+ expect(boltPoints(7, at)).toEqual(boltPoints(7, at));
+ expect(boltPoints(7, at)).not.toEqual(boltPoints(8, at));
+ });
+ it('канал от земли вверх до LIGHTNING_TOP, снос ограничен', () => {
+ const pts = boltPoints(42, at);
+ expect(pts.length).toBeGreaterThanOrEqual(16);
+ for (const p of pts) {
+ expect(p.pos[1]).toBeGreaterThanOrEqual(at[1]);
+ expect(p.pos[1]).toBeLessThanOrEqual(at[1] + LIGHTNING_TOP);
+ // зигзаг — сумма синусов (ограничен) плюс ветвь — узкий коридор
+ // вокруг точки удара
+ expect(Math.abs(p.pos[0] - at[0])).toBeLessThan(LIGHTNING_TOP * 0.12);
+ expect(Math.abs(p.pos[2] - at[2])).toBeLessThan(LIGHTNING_TOP * 0.12);
+ }
+ });
+ it('есть ветви (более бледные и тонкие) и узлы полной яркости', () => {
+ const pts = boltPoints(42, at);
+ expect(pts.some((p) => p.alpha < 1)).toBe(true); // ветви
+ expect(pts.some((p) => p.alpha === 1)).toBe(true); // канал
+ });
+});
+
+describe('LightningSystem (без WebGL — стаб рендера)', () => {
+ /** Стаб рендера: свет и окно записываются, точки слоя копятся. */
+ function stub() {
+ const flashes: Array<{ at: readonly [number, number, number] | null; k: number }> = [];
+ return {
+ flashes,
+ renderer: {
+ addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
+ setLightning: (at: readonly [number, number, number] | null, k: number) => flashes.push({ at, k }),
+ visibleWindow: () => ({ minX: 0, maxX: 48, minZ: 0, maxZ: 32 }),
+ focusPoint: () => [20, 1, 14] as [number, number, number],
+ } as never,
+ };
+ }
+ it('strike без точки — случайная точка окна; вспышка стартует сразу', () => {
+ const s = stub();
+ const sys = new LightningSystem(s.renderer);
+ sys.strike(); // первый кадр вспышки анимируется сразу
+ const info = sys.info();
+ expect(info.active).toBe(true);
+ expect(info.strikes).toBe(1);
+ expect(info.flash).toBeGreaterThan(0);
+ expect(info.at![0]).toBeGreaterThanOrEqual(0);
+ expect(info.at![0]).toBeLessThanOrEqual(48);
+ expect(s.flashes.at(-1)!.k).toBeGreaterThan(0);
+ });
+ it('по истечении длительности вспышка гаснет (свет выключен, слой пуст)', () => {
+ const s = stub();
+ const sys = new LightningSystem(s.renderer);
+ sys.strike([5, 0, 5]);
+ sys.update(LIGHTNING_DURATION + 0.1);
+ const info = sys.info();
+ expect(info.active).toBe(false);
+ expect(info.flash).toBe(0);
+ expect(s.flashes.at(-1)).toEqual({ at: null, k: 0 });
+ });
+ it('детерминизм удара: тот же номер — та же точка; новый удар — другая', () => {
+ const a = stub();
+ const b = stub();
+ const sysA = new LightningSystem(a.renderer);
+ const sysB = new LightningSystem(b.renderer);
+ sysA.strike();
+ sysB.strike();
+ expect(sysA.info().at).toEqual(sysB.info().at); // номер удара 1 у обоих
+ const first = sysA.info().at;
+ sysA.strike(); // удар 2 — реролл точки от номера удара
+ expect(sysA.info().at).not.toEqual(first);
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/render/lightning.ts b/v2/packages/engine/src/render/lightning.ts
new file mode 100644
index 0000000..3d4fb22
--- /dev/null
+++ b/v2/packages/engine/src/render/lightning.ts
@@ -0,0 +1,172 @@
+/**
+ * Молния: разряд + вспышка с тенями. Разряд — излучающий слой частиц
+ * (lit: false): зигзаг-канал от земли вверх с парой ветвей; форма
+ * детерминирована номером удара (кадр-равенства гейтов). Вспышка —
+ * отдельный направленный свет рендера (VoxelRenderer.setLightning): свет
+ * бьёт из разряда вниз с лёгким наклоном — тени от кастеров качаются
+ * (та же shadow map-механика, что у солнца); ночью вспышка читается ярче —
+ * днём она тонет в солнечном свете. Огибающая мерцания и геометрия разряда
+ * — чистые функции (тесты без 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';
+
+/** Псевдослучай 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); // 58 узлов канала
+ 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,
+ };
+ }
+
+ dispose(): void {
+ this.layer.dispose();
+ this.strikeT = -1;
+ }
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts
index d4a1aad..29fa679 100644
--- a/v2/packages/engine/src/render/voxelRenderer.ts
+++ b/v2/packages/engine/src/render/voxelRenderer.ts
@@ -15,6 +15,7 @@
import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv } from './particles';
import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles';
import { createFogComposite, makeFogUniforms, fogColor } from './fog';
+import { LIGHTNING_FLASH, LIGHTNING_TOP } from './lightning';
import { particleWindow } from './polyWindow';
/** Облако деформированных вокселей (позы рига — не выровнены по сетке). */
@@ -162,6 +163,10 @@
/** Зенитный свет пасмурности (вниз, с тенью): минимальная контактная
* тень прямо под объектами — рассеяный свет переотражается сверху. */
private readonly zenith: THREE.DirectionalLight;
+ /** Вспышка молнии (свет из точки разряда, с тенью) — см. setLightning. */
+ private readonly flash: THREE.DirectionalLight;
+ private flashK = 0; // текущая огибающая вспышки (0 — погашена)
+ private flashAt: [number, number, number] | null = null;
private readonly ambient: THREE.HemisphereLight;
private readonly target: THREE.Vector3;
private readonly viewSize: number;
@@ -239,6 +244,15 @@
this.zenith.shadow.bias = -0.0005;
this.scene.add(this.zenith);
this.scene.add(this.zenith.target);
+ // вспышка молнии: направленный свет с тенями из точки разряда,
+ // по умолчанию погашен (visible false — shadow map не рендерится)
+ this.flash = new THREE.DirectionalLight(0xdfe8ff, 0);
+ this.flash.castShadow = true;
+ this.flash.visible = false;
+ this.flash.shadow.mapSize.set(1024, 1024);
+ this.flash.shadow.bias = -0.0005;
+ this.scene.add(this.flash);
+ this.scene.add(this.flash.target);
this.setSun(0.5);
this.repositionCamera();
}
@@ -588,6 +602,58 @@
}
/**
+ * Вспышка молнии: направленный свет из точки разряда (at — точка удара
+ * о землю, рендер/lightning.ts), k — огибающая мерцания 0..1. Свет бьёт
+ * из разряда вниз с лёгким наклоном — строго вертикальный свет прячет
+ * тени под самим объектом. Фрустум — как у зенитного света (вся видимая
+ * область). null или k ≤ 0 — погасить (visible false — shadow map не
+ * рендерится). Ночь усиливает вспышку: днём она тонет в солнечном свете.
+ */
+ setLightning(at: readonly [number, number, number] | null, k: number): void {
+ this.flashK = k;
+ this.flashAt = at ? [at[0], at[1], at[2]] : null;
+ if (!at || k <= 0) {
+ this.flash.visible = false;
+ this.flash.intensity = 0;
+ return;
+ }
+ this.flash.position.set(
+ at[0] + LIGHTNING_TOP * 0.25,
+ at[1] + LIGHTNING_TOP,
+ at[2] - LIGHTNING_TOP * 0.18,
+ );
+ this.flash.target.position.set(at[0], at[1], at[2]);
+ this.flash.intensity = k * LIGHTNING_FLASH * (1 + this.nightK);
+ const visible = this.viewSize * Math.max(this.viewAspect, 1);
+ const r = Math.ceil(visible) + 2;
+ const c = this.flash.shadow.camera;
+ if (c.right !== r) {
+ c.left = -r;
+ c.right = r;
+ c.top = r;
+ c.bottom = -r;
+ c.near = 0.5;
+ // от света (высота LIGHTNING_TOP над землёй) до земли и дальше —
+ // наклон луча и самые высокие кастеры с запасом
+ c.far = LIGHTNING_TOP * 2 + SHADOW_CASTER_H + this.viewSize;
+ c.updateProjectionMatrix();
+ }
+ this.flash.shadow.radius = shadowBlurTexels(this.shadowSoftK, r, this.flash.shadow.mapSize.x);
+ this.flash.visible = true;
+ }
+
+ /** Снапшот вспышки молнии (диагностика гейтов). */
+ lightningInfo(): { flash: number; at: [number, number, number] | null } {
+ return { flash: this.flashK, at: this.flashAt ? [...this.flashAt] : null };
+ }
+
+ /** Точка фокуса камеры (цель) — опорная точка мира для систем погоды. */
+ focusPoint(): [number, number, number] {
+ const t = this.target;
+ return [t.x, t.y, t.z];
+ }
+
+ /**
* Пост-обработка: сцена рендерится в render target, затем на экран
* полноэкранным квадом с шейдером игры (виньетка, дисторсия...).
* null — вернуться к прямому рендеру. Юниформы прохода игрок меняет