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 — вернуться к прямому рендеру. Юниформы прохода игрок меняет