diff --git a/docs/engine/recipes.md b/docs/engine/recipes.md index 3f1ba08..e673f43 100644 --- a/docs/engine/recipes.md +++ b/docs/engine/recipes.md @@ -161,6 +161,42 @@ Радиусы держите ≤ 5 юнитов и интенсивность ≤ ~1.1 — золото должно быть событием. Чистая математика мерцания — `lightSim` (тестируется в node). +## Вспышка в точке / на весь экран (импульсы) + +Событие боя или квеста — короткая вспышка, не постоянный источник: + +```ts +// Удар по врагу — тёплая вспышка в точке (spec — огибающая «рост→плато→спад») +lighting.pulseLight({ x: s.x, y: s.y, color: 0xf2b45a, intensity: 0.5, radius: 48, + spec: { attack: 0.02, decay: 0.2 } }); + +// Урон герою — красная вспышка на весь экран (аддитивная) +lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, + spec: { attack: 0.02, decay: 0.35 } }); +``` + +Импульс занимает спрайт общего пула источников и снимается сам по концу +огибающей — `removeLight` не нужен. Позиция — экранные px через +`camera.toScreen`, как у постоянных источников. + +## Виньетка (низкий hp, опасная зона) + +```ts +// каждый тик: цель = max(условия), переход — плавный +const target = lowHp ? 0.3 : inHazard && !masked ? 0.35 : 0; +if (target !== lighting.vignetteLevel) lighting.setVignette(target, 0.5); +``` + +## Тёмная аура зоны (локальная тень) + +```ts +// поставить под источники (свет пробивает тень); id стабильный — upsert +lighting.upsertDarkSpot({ id: `hazard@${tx},${ty}`, x: s.x, y: s.y, + radius: 51, alpha: 0.22 }); +// зона кончилась — снять +lighting.removeDarkSpot(`hazard@${tx},${ty}`); +``` + ## Оживить статику (без кадров) Колышущиеся цветы, парящие сгустки, пульсирующие искры — процедурные diff --git a/docs/engine/render.md b/docs/engine/render.md index e71c6a2..bd3990d 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -118,6 +118,36 @@ игнорируются. Плавный лерп ambient (`setAmbient(color, fadeSec)`) — база для day/night: день/ночь — кейфреймы поверх этого API. +### Импульсы, виньетка, тёмные пятна + +Поверх базового API у `Lighting` есть три короткоживущих/локальных эффекта — +все на тех же ступенчатых текстурах, без шейдеров: + +- **`pulseLight({ x, y, color, intensity, radius, spec })`** — вспышка в точке: + занимает спрайт общего пула источников, интенсивность модулируется огибающей + `PulseSpec { attack, hold?, decay }` (чистая математика — `pulseEnvelope` + в `lightSim`), по концу огибающей источник снимается сам. В кадрах `frames()` + импульсы видны с id `pulse#N`. +- **`pulseAmbient({ color, peak, spec })`** — вспышка на весь экран + (аддитивный fullscreen-спрайт, alpha = `peak × огибающая`); один слот — + новая вспышка побеждает старую. +- **`setVignette(intensity 0..1, fadeSec)`** — затемнение краёв экрана + (multiply-текстура `makeVignetteTexture`, ступенчатые кольца от белого + центра): низкий hp, опасная зона. Лерп по образцу ambient. +- **`upsertDarkSpot({ id, x, y, radius, alpha })` / `removeDarkSpot`** — + локальная тень (multiply-спрайт с той же glow-текстурой, tint тёмный): + ауры опасных зон. Отдельный пул `maxDarkSpots` (по умолчанию 16); пятна + рисуются **под** источниками — свет пробивает локальную тень. + +```ts +lighting.pulseLight({ x: 120, y: 60, color: 0xf2b45a, intensity: 0.5, + spec: { attack: 0.02, decay: 0.2 } }); // удар +lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, + spec: { attack: 0.02, decay: 0.35 } }); // урон герою +lighting.setVignette(0.35, 0.5); // край экрана темнеет за полсекунды +lighting.upsertDarkSpot({ id: 'hazard@3,4', x: 200, y: 100, radius: 51, alpha: 0.22 }); +``` + ## IsoDepthLayer Сортировка глубины для сущностей на изометрической карте: глубина = `tx + ty` diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 14f54c7..5f810dc 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -132,7 +132,13 @@ export { IsoDepthLayer } from './render/IsoDepthLayer'; export { computeScale } from './render/scale'; export { ParticleEmitter, type EmitterOptions } from './render/Particles'; -export { Lighting, makeGlowTexture, type LightingOptions } from './render/Lighting'; +export { + Lighting, + makeGlowTexture, + makeVignetteTexture, + type LightingOptions, + type DarkSpotDef +} from './render/Lighting'; export { Shake } from './render/shake'; export { SpriteFlash } from './render/spriteFx'; export { @@ -141,8 +147,11 @@ lightFrame, lerpAmbient, dimColor, + pulseEnvelope, + pulseTotal, type LightDef, - type LightFrame + type LightFrame, + type PulseSpec } from './render/lightSim'; export { stepParticle, diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts index 3d545c5..2351dde 100644 --- a/packages/engine/src/render/Lighting.ts +++ b/packages/engine/src/render/Lighting.ts @@ -1,23 +1,36 @@ /** - * Слой освещения: fullscreen ambient (multiply-цвет) + аддитивные источники. + * Слой освещения: fullscreen ambient (multiply-цвет) + аддитивные источники, + * импульсы (pulseLight/pulseAmbient), виньетка и тёмные пятна. * Тонкий Pixi-адаптер над lightSim (чистая математика — там). * * Живёт в renderer.lightRoot — экранное пространство между миром и UI: * ambient затемняет сцену целиком, источники рисуются поверх затемнения. + * Порядок детей: ambient → виньетка → тёмные пятна → источники → ambient-вспышка. */ import { Container, Graphics, Sprite, Texture } from 'pixi.js'; import type { Renderer } from './Renderer'; -import { GLOW_BASE_PX, lightFrame, lerpAmbient, type LightDef, type LightFrame } from './lightSim'; +import { + GLOW_BASE_PX, + lightFrame, + lerpAmbient, + pulseEnvelope, + pulseTotal, + type LightDef, + type LightFrame, + type PulseSpec +} from './lightSim'; export interface LightingOptions { /** Виртуальные размеры сцены (480×270). */ width: number; height: number; - /** Живой рендерер — для генерации текстуры свечения; без него Texture.WHITE. */ + /** Живой рендерер — для генерации текстур; без него Texture.WHITE. */ renderer?: Renderer; /** Размер пула спрайтов источников (по умолчанию 16). */ maxLights?: number; + /** Размер пула спрайтов тёмных пятен (по умолчанию 16). */ + maxDarkSpots?: number; } /** Кэш текстур свечения по числу ступеней — генерируется один раз на рендерер. */ @@ -25,9 +38,10 @@ /** * Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной - * альфой (никаких гладких градиентов). resolution 1 + nearest — пиксельные ступени. + * альфой (никаких гладких градиентов) + дизер-кольцо по внешнему радиусу. + * resolution 1 + nearest — пиксельные ступени. */ -export function makeGlowTexture(renderer: Renderer, steps = 3): Texture { +export function makeGlowTexture(renderer: Renderer, steps = 4): Texture { const cached = glowCache.get(steps); if (cached) return cached; const g = new Graphics(); @@ -35,6 +49,14 @@ const r = (GLOW_BASE_PX * i) / steps; g.circle(0, 0, r).fill({ color: 0xffffff, alpha: 1 / (i + 1) }); } + // Дизер-кольцо: редкие точки по внешнему радиусу — смягчают резкую ступень. + for (let i = 0; i < 32; i++) { + const a = ((i * 2) / 64) * Math.PI * 2; + g.rect(Math.cos(a) * GLOW_BASE_PX - 0.5, Math.sin(a) * GLOW_BASE_PX - 0.5, 1, 1).fill({ + color: 0xffffff, + alpha: 1 / (steps + 1) + }); + } const tex = renderer.app.renderer.generateTexture({ target: g, resolution: 1, @@ -46,27 +68,103 @@ return tex; } +/** Кэш текстур виньетки по размеру сцены. */ +const vignetteCache = new Map(); + +/** + * Ступенчатая виньетка: белый центр → серые кольца к тёмным углам (multiply). + * Интенсивность задаётся alpha спрайта, текстура постоянна. + */ +export function makeVignetteTexture(renderer: Renderer, width: number, height: number): Texture { + const key = `${width}x${height}`; + const cached = vignetteCache.get(key); + if (cached) return cached; + const g = new Graphics(); + const r = Math.hypot(width, height) / 2; // дистанция центра до угла + g.rect(0, 0, width, height).fill({ color: 0x6a6a74 }); + g.circle(width / 2, height / 2, r * 0.72).fill({ color: 0xa8a8b0 }); + g.circle(width / 2, height / 2, r * 0.42).fill({ color: 0xffffff }); + const tex = renderer.app.renderer.generateTexture({ + target: g, + resolution: 1, + antialias: false, + textureSourceOptions: { scaleMode: 'nearest' } + }); + g.destroy(); + vignetteCache.set(key, tex); + return tex; +} + +/** Тёмное пятно: локальная аура опасности/тени (multiply). */ +export interface DarkSpotDef { + id: string; + /** Позиция центра в экранных px. */ + x: number; + y: number; + /** Радиус в px. */ + radius: number; + /** Сила затемнения 0..1. */ + alpha: number; +} + +/** Цвет тени тёмного пятна (умножается на сцену). */ +const DARK_SPOT_TINT = 0x3e3e48; + interface LightEntry { def: LightDef; sprite: Sprite; } +interface PulseEntry { + def: LightDef; + /** Базовая интенсивность до огибающей. */ + base: number; + spec: PulseSpec; + elapsed: number; +} + +interface AmbientPulse { + color: number; + peak: number; + spec: PulseSpec; + elapsed: number; +} + +interface DarkEntry { + def: DarkSpotDef; + sprite: Sprite; +} + export class Lighting extends Container { private readonly ambient: Sprite; + private readonly vignette: Sprite; + private readonly darkLayer: Container; + private readonly pulseAmb: Sprite; private readonly free: Sprite[] = []; + private readonly darkFree: Sprite[] = []; private readonly lights = new Map(); + private readonly darks = new Map(); + private readonly pulses = new Map(); private readonly glow: Texture; private readonly maxLights: number; + private readonly maxDarkSpots: number; + private pulseSeq = 0; private time = 0; private ambientFrom = 0xffffff; private ambientTo = 0xffffff; private fadeLeft = 0; private fadeTotal = 0; + private vignetteFrom = 0; + private vignetteTo = 0; + private vignetteLeft = 0; + private vignetteTotal = 0; + private ambPulse: AmbientPulse | null = null; constructor(options: LightingOptions) { super(); this.maxLights = options.maxLights ?? 16; + this.maxDarkSpots = options.maxDarkSpots ?? 16; // Ambient: Texture.WHITE, растянутый на сцену; цвет — яркость (0xffffff = не трогает). this.ambient = new Sprite(Texture.WHITE); this.ambient.width = options.width; @@ -75,8 +173,31 @@ this.ambient.tint = 0xffffff; this.addChild(this.ambient); + // Виньетка: Texture.WHITE без рендерера (multiply белого = не трогает). + this.vignette = new Sprite( + options.renderer ? makeVignetteTexture(options.renderer, options.width, options.height) : Texture.WHITE + ); + this.vignette.width = options.width; + this.vignette.height = options.height; + this.vignette.blendMode = 'multiply'; + this.vignette.alpha = 0; + this.addChild(this.vignette); + + // Тёмные пятна — под источниками: свет пробивает локальную тень. + this.darkLayer = new Container(); + this.addChild(this.darkLayer); + this.glow = options.renderer ? makeGlowTexture(options.renderer) : Texture.WHITE; for (let i = 0; i < this.maxLights; i++) this.free.push(this.makeLightSprite()); + for (let i = 0; i < this.maxDarkSpots; i++) this.darkFree.push(this.makeDarkSprite()); + + // Ambient-вспышка: Texture.WHITE, аддитивная, alpha по огибающей. + this.pulseAmb = new Sprite(Texture.WHITE); + this.pulseAmb.width = options.width; + this.pulseAmb.height = options.height; + this.pulseAmb.blendMode = 'add'; + this.pulseAmb.alpha = 0; + this.addChild(this.pulseAmb); } private makeLightSprite(): Sprite { @@ -88,6 +209,16 @@ return s; } + private makeDarkSprite(): Sprite { + const s = new Sprite(this.glow); + s.anchor.set(0.5); + s.blendMode = 'multiply'; + s.tint = DARK_SPOT_TINT; + s.visible = false; + this.darkLayer.addChild(s); + return s; + } + // --- ambient (фундамент смены времени дня) --- /** Задать ambient-цвет; fadeSec > 0 — плавный лерп (день/ночь, закат). */ @@ -108,6 +239,26 @@ return this.fadeLeft > 0 ? this.ambientFrom : this.ambient.tint; } + // --- виньетка (край экрана темнеет: низкий hp, опасная зона) --- + + /** Виньетка 0..1 (0 — не трогает); fadeSec > 0 — плавный лерп. */ + setVignette(intensity: number, fadeSec = 0): void { + this.vignetteFrom = this.currentVignette; + this.vignetteTo = Math.max(0, Math.min(1, intensity)); + this.vignetteTotal = Math.max(0, fadeSec); + this.vignetteLeft = this.vignetteTotal; + if (this.vignetteTotal === 0) this.vignette.alpha = this.vignetteTo; + } + + /** Целевая интенсивность виньетки. */ + get vignetteLevel(): number { + return this.vignetteTo; + } + + private get currentVignette(): number { + return this.vignetteLeft > 0 ? this.vignetteFrom : this.vignette.alpha; + } + // --- источники: runtime add/remove/move/color/enable --- /** Добавить источник или обновить его параметры целиком. */ @@ -154,7 +305,87 @@ if (entry) entry.def.enabled = enabled; } - /** Тик: время, лерп ambient, пересчёт кадров источников. */ + // --- импульсы: короткие вспышки в пуле источников и на весь экран --- + + /** + * Импульс света в точке: занимает спрайт общего пула, снимается по концу + * огибающей. Возвращает id (или пустую строку, если пул исчерпан). + */ + pulseLight(args: { + x: number; + y: number; + color: number; + intensity?: number; + radius?: number; + spec: PulseSpec; + }): string { + const id = `pulse#${this.pulseSeq++}`; + const sprite = this.free.pop(); + if (!sprite) return ''; + const base = args.intensity ?? 1; + const def: LightDef = { + id, + x: args.x, + y: args.y, + color: args.color, + intensity: base * pulseEnvelope(0, args.spec), + radius: args.radius, + flicker: 0, + seed: 0 + }; + this.lights.set(id, { def, sprite }); + sprite.visible = true; + this.pulses.set(id, { def, base, spec: args.spec, elapsed: 0 }); + return id; + } + + /** Вспышка на весь экран (аддитивная); один слот — новая побеждает старую. */ + pulseAmbient(args: { color: number; peak: number; spec: PulseSpec }): void { + this.pulseAmb.tint = args.color; + this.ambPulse = { ...args, elapsed: 0 }; + this.pulseAmb.alpha = 0; + } + + /** Текущая альфа ambient-вспышки (0..1) — для тестов и снапшота. */ + get pulseAmbientAlpha(): number { + return this.pulseAmb.alpha; + } + + // --- тёмные пятна: локальные ауры опасности (multiply) --- + + /** Добавить тёмное пятно или обновить его параметры. */ + upsertDarkSpot(def: DarkSpotDef): void { + let entry = this.darks.get(def.id); + if (!entry) { + const sprite = this.darkFree.pop(); + if (!sprite) return; // пул исчерпан — лишние пятна игнорируются + entry = { def: { ...def }, sprite }; + this.darks.set(def.id, entry); + sprite.visible = true; + } + entry.def = { ...def }; + const s = entry.sprite; + s.position.set(def.x, def.y); + s.alpha = Math.max(0, Math.min(1, def.alpha)); + s.scale.set(def.radius / GLOW_BASE_PX); + } + + removeDarkSpot(id: string): void { + const entry = this.darks.get(id); + if (!entry) return; + entry.sprite.visible = false; + entry.sprite.alpha = 0; + this.darkFree.push(entry.sprite); + this.darks.delete(id); + } + + hasDarkSpot(id: string): boolean { + return this.darks.has(id); + } + + // --- тик --- + + /** Тик: время, лерп ambient/виньетки, огибающие импульсов, кадры источников. */ update(dt: number): void { this.time += dt; if (this.fadeLeft > 0) { @@ -163,16 +394,43 @@ this.ambient.tint = lerpAmbient(this.ambientFrom, this.ambientTo, k); if (this.fadeLeft === 0) this.ambient.tint = this.ambientTo; } - for (const { def, sprite } of this.lights.values()) { - const f = lightFrame(def, this.time); - sprite.position.set(f.x, f.y); - sprite.tint = f.tint; - sprite.alpha = Math.min(1, f.alpha); - sprite.scale.set(f.scale); + if (this.vignetteLeft > 0) { + this.vignetteLeft = Math.max(0, this.vignetteLeft - dt); + const k = this.vignetteTotal > 0 ? 1 - this.vignetteLeft / this.vignetteTotal : 1; + this.vignette.alpha = this.vignetteFrom + (this.vignetteTo - this.vignetteFrom) * k; + if (this.vignetteLeft === 0) this.vignette.alpha = this.vignetteTo; + } + for (const [id, p] of [...this.pulses]) { + p.elapsed += dt; + if (p.elapsed >= pulseTotal(p.spec)) { + this.removeLight(id); + this.pulses.delete(id); + continue; + } + p.def.intensity = p.base * pulseEnvelope(p.elapsed, p.spec); + } + for (const { def, sprite } of this.lights.values()) this.applyFrame(def, sprite); + if (this.ambPulse) { + this.ambPulse.elapsed += dt; + if (this.ambPulse.elapsed >= pulseTotal(this.ambPulse.spec)) { + this.ambPulse = null; + this.pulseAmb.alpha = 0; + } else { + const e = pulseEnvelope(this.ambPulse.elapsed, this.ambPulse.spec); + this.pulseAmb.alpha = Math.min(1, this.ambPulse.peak * e); + } } } - /** Видимые кадры источников (пост-мерцание) — для снапшота агента. */ + private applyFrame(def: LightDef, sprite: Sprite): void { + const f = lightFrame(def, this.time); + sprite.position.set(f.x, f.y); + sprite.tint = f.tint; + sprite.alpha = Math.min(1, f.alpha); + sprite.scale.set(f.scale); + } + + /** Видимые кадры источников (пост-мерцание, включая импульсы) — для снапшота. */ frames(): readonly LightFrame[] { const out: LightFrame[] = []; for (const { def } of this.lights.values()) out.push(lightFrame(def, this.time)); @@ -180,10 +438,16 @@ } override destroy(options?: Parameters[0]): void { + this.pulses.clear(); for (const id of [...this.lights.keys()]) this.removeLight(id); for (const s of this.free) s.destroy(); this.free.length = 0; + this.darks.clear(); + this.darkFree.length = 0; + this.darkLayer.destroy({ children: true }); this.ambient.destroy(); + this.vignette.destroy(); + this.pulseAmb.destroy(); super.destroy(options); } } \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/Lighting.test.ts b/packages/engine/src/render/__tests__/Lighting.test.ts index a6784e5..5679a35 100644 --- a/packages/engine/src/render/__tests__/Lighting.test.ts +++ b/packages/engine/src/render/__tests__/Lighting.test.ts @@ -113,6 +113,110 @@ }); }); +describe('Lighting: импульсы', () => { + it('pulseLight появляется в кадрах, модулируется огибающей и снимается', () => { + const l = makeLighting(2); // дети: ambient + виньетка + darkLayer + pulseAmb + 2 спрайта + const id = l.pulseLight({ x: 10, y: 20, color: 0xf2b45a, intensity: 0.5, spec: { attack: 0.05, decay: 0.1 } }); + expect(id).toMatch(/^pulse#/); + l.update(0.01); + let f = l.frames().find((fr) => fr.id === id); + expect(f).toBeTruthy(); + expect(f!.tint).toBe(0xf2b45a); + expect(f!.alpha).toBeCloseTo(0.5 * 0.2, 5); // elapsed 0.01 из attack 0.05 + l.update(0.04); // elapsed 0.05 — пик огибающей + f = l.frames().find((fr) => fr.id === id); + expect(f!.alpha).toBeCloseTo(0.5, 5); + l.update(0.1); // elapsed 0.15 = total — снят + expect(l.frames().length).toBe(0); + expect(l.hasLight(id)).toBe(false); + expect(l.children.length).toBe(6); // ambient+виньетка+darkLayer+pulseAmb+2 спрайта + l.destroy(); + }); + + it('pulseLight при исчерпании пула не рисуется (пустой id)', () => { + const l = makeLighting(1); + l.upsertLight({ id: 'hearth', x: 0, y: 0, color: 0xffffff }); + const id = l.pulseLight({ x: 0, y: 0, color: 0xffffff, spec: { attack: 0.05, decay: 0.1 } }); + expect(id).toBe(''); + expect(l.hasLight('pulse#1')).toBe(false); + expect(l.frames().length).toBe(1); + l.destroy(); + }); + + it('pulseAmbient: alpha по огибающей, новая вспышка побеждает', () => { + const l = makeLighting(); + l.pulseAmbient({ color: 0xd99a32, peak: 0.2, spec: { attack: 0.1, decay: 0.1 } }); + l.update(0.05); + expect(l.pulseAmbientAlpha).toBeCloseTo(0.1, 5); // 0.2 * 0.5 + l.pulseAmbient({ color: 0xb0453f, peak: 0.3, spec: { attack: 0, decay: 0.2 } }); + l.update(0.1); + expect(l.pulseAmbientAlpha).toBeCloseTo(0.15, 5); // 0.3 * 0.5 + l.update(0.2); // конец второй вспышки + expect(l.pulseAmbientAlpha).toBe(0); + l.destroy(); + }); +}); + +describe('Lighting: виньетка', () => { + it('setVignette мгновенно; значение клампится в 0..1', () => { + const l = makeLighting(); + l.setVignette(1.5); + expect(l.vignetteLevel).toBe(1); + expect(l.children[1].alpha).toBe(1); // спрайт виньетки — второй ребёнок + l.setVignette(-0.5); + expect(l.vignetteLevel).toBe(0); + expect(l.children[1].alpha).toBe(0); + l.destroy(); + }); + + it('setVignette с fade — лерп к целевой интенсивности', () => { + const l = makeLighting(); + l.setVignette(1); + l.setVignette(0, 1); + expect(l.vignetteLevel).toBe(0); + l.update(0.5); + const mid = l.children[1].alpha; + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(1); + l.update(0.5); + expect(l.children[1].alpha).toBe(0); + l.destroy(); + }); +}); + +describe('Lighting: тёмные пятна', () => { + it('upsert/remove/has; спрайт multiply с нужным масштабом и альфой', () => { + const l = makeLighting(2); + l.upsertDarkSpot({ id: 'hazard@3,4', x: 10, y: 10, radius: 51, alpha: 0.22 }); + expect(l.hasDarkSpot('hazard@3,4')).toBe(true); + const layer = l.children[2]; // darkLayer — третий ребёнок + expect(layer.children.filter((c) => c.visible).length).toBe(1); + const spot = layer.children.find((c) => c.visible)!; + expect(spot.blendMode).toBe('multiply'); + expect(spot.alpha).toBeCloseTo(0.22, 5); + expect(spot.scale.x).toBeCloseTo(51 / GLOW_BASE_PX, 5); + l.upsertDarkSpot({ id: 'hazard@3,4', x: 12, y: 14, radius: 51, alpha: 0.3 }); + expect(layer.children.filter((c) => c.visible).length).toBe(1); // обновление без нового спрайта + expect(spot.x).toBe(12); + expect(spot.y).toBe(14); + l.removeDarkSpot('hazard@3,4'); + expect(l.hasDarkSpot('hazard@3,4')).toBe(false); + expect(l.children.length).toBe(6); // слои на месте, спрайт в пуле + l.destroy(); + }); + + it('пятна сверх пула игнорируются', () => { + const l = new Lighting({ width: 480, height: 270, maxDarkSpots: 2 }); + l.upsertDarkSpot({ id: 'a', x: 0, y: 0, radius: 32, alpha: 0.2 }); + l.upsertDarkSpot({ id: 'b', x: 0, y: 0, radius: 32, alpha: 0.2 }); + l.upsertDarkSpot({ id: 'c', x: 0, y: 0, radius: 32, alpha: 0.2 }); + expect(l.hasDarkSpot('a')).toBe(true); + expect(l.hasDarkSpot('b')).toBe(true); + expect(l.hasDarkSpot('c')).toBe(false); + l.destroy(); + }); +}); + describe('Lighting: destroy', () => { it('не бросает и повторный вызов безопасен', () => { const l = makeLighting(); diff --git a/packages/engine/src/render/__tests__/lightSim.test.ts b/packages/engine/src/render/__tests__/lightSim.test.ts index 8d26b88..da21807 100644 --- a/packages/engine/src/render/__tests__/lightSim.test.ts +++ b/packages/engine/src/render/__tests__/lightSim.test.ts @@ -4,7 +4,9 @@ dimColor, flickerFactor, lightFrame, - lerpAmbient + lerpAmbient, + pulseEnvelope, + pulseTotal } from '../lightSim'; describe('flickerFactor', () => { @@ -68,6 +70,40 @@ }); }); +describe('pulseEnvelope / pulseTotal', () => { + it('трапеция: рост, плато, спад, нули за пределами', () => { + const spec = { attack: 0.1, hold: 0.2, decay: 0.3 }; + expect(pulseEnvelope(0, spec)).toBe(0); + expect(pulseEnvelope(0.05, spec)).toBeCloseTo(0.5, 10); + expect(pulseEnvelope(0.15, spec)).toBe(1); + expect(pulseEnvelope(0.25, spec)).toBe(1); + expect(pulseEnvelope(0.45, spec)).toBeCloseTo(0.5, 10); + expect(pulseEnvelope(0.7, spec)).toBe(0); + expect(pulseTotal(spec)).toBeCloseTo(0.6, 10); + }); + + it('без hold — пик сразу после attack', () => { + const spec = { attack: 0.1, decay: 0.2 }; + expect(pulseEnvelope(0.1, spec)).toBe(1); + expect(pulseEnvelope(0.2, spec)).toBeCloseTo(0.5, 10); + expect(pulseTotal(spec)).toBeCloseTo(0.3, 10); + }); + + it('attack = 0 — мгновенный пик', () => { + const spec = { attack: 0, decay: 0.2 }; + expect(pulseEnvelope(0, spec)).toBe(0); + expect(pulseEnvelope(0.1, spec)).toBeCloseTo(0.5, 10); + expect(pulseEnvelope(0.2, spec)).toBe(0); + expect(pulseTotal(spec)).toBeCloseTo(0.2, 10); + }); + + it('отрицательное время и значения вне диапазона — 0', () => { + const spec = { attack: 0.1, decay: 0.2 }; + expect(pulseEnvelope(-0.5, spec)).toBe(0); + expect(pulseEnvelope(10, spec)).toBe(0); + }); +}); + describe('lerpAmbient', () => { it('краи и середина по каналам', () => { expect(lerpAmbient(0x000000, 0xffffff, 0)).toBe(0x000000); diff --git a/packages/engine/src/render/lightSim.ts b/packages/engine/src/render/lightSim.ts index 186aeb6..abf96c0 100644 --- a/packages/engine/src/render/lightSim.ts +++ b/packages/engine/src/render/lightSim.ts @@ -65,6 +65,35 @@ return { id: def.id, x: def.x, y: def.y, tint: def.color, alpha, scale: radius / GLOW_BASE_PX }; } +/** + * Огибающая импульса (вспышки): трапеция «рост → плато → спад». + * attack/decay — сек; hold — плато на пике (по умолчанию 0). + */ +export interface PulseSpec { + /** Рост 0→1, сек (0 — мгновенный пик). */ + attack: number; + /** Плато на пике, сек (по умолчанию 0). */ + hold?: number; + /** Спад 1→0, сек. */ + decay: number; +} + +/** Полная длительность импульса, сек. */ +export function pulseTotal(spec: PulseSpec): number { + return Math.max(0, spec.attack) + Math.max(0, spec.hold ?? 0) + Math.max(0, spec.decay); +} + +/** Огибающая импульса в момент t от старта: 0..1, вне [0, total] — 0. */ +export function pulseEnvelope(t: number, spec: PulseSpec): number { + const attack = Math.max(0, spec.attack); + const hold = Math.max(0, spec.hold ?? 0); + const decay = Math.max(0, spec.decay); + if (t <= 0 || t >= attack + hold + decay) return 0; + if (t < attack) return t / attack; + if (t < attack + hold) return 1; + return 1 - (t - attack - hold) / decay; +} + /** Лерп ambient-цвета по каналам: k=0 → from, k=1 → to. */ export function lerpAmbient(from: number, to: number, k: number): number { return lerpColor(from, to, k);