diff --git a/docs/engine/recipes.md b/docs/engine/recipes.md index 9f47579..3f1ba08 100644 --- a/docs/engine/recipes.md +++ b/docs/engine/recipes.md @@ -143,6 +143,24 @@ Мотыльки — тёплые цвета (`colors: [0xf0d878, 0xd8b050]`), `blend: 'add'`, `wobble: 8` (дрейф по синусу), `fadeIn: 0.5`, `acceleration: { y: 1.5 }`. +## Источник света (очаг, окно, лампа) + +```ts +const lighting = new Lighting({ width: 480, height: 270, renderer: engine.renderer }); +engine.renderer.lightRoot.addChild(lighting); +lighting.setAmbient(0x54586a); // тёмный интерьер (multiply-цвет) + +// каждый тик сцены: позиция в экранных px — через camera.toScreen, без лага +const s = camera.toScreen(wx, wy); +lighting.upsertLight({ id: 'hearth', x: s.x, y: s.y, color: 0xf2b45a, + radius: 64, flicker: 0.3, seed: 0.2 }); +lighting.update(dt); +``` + +Тёплый цвет (B*/F* палитры) — только у «жизни»: огонь, окна жилых домов, лампа. +Радиусы держите ≤ 5 юнитов и интенсивность ≤ ~1.1 — золото должно быть событием. +Чистая математика мерцания — `lightSim` (тестируется в node). + ## Оживить статику (без кадров) Колышущиеся цветы, парящие сгустки, пульсирующие искры — процедурные diff --git a/docs/engine/render.md b/docs/engine/render.md index c923e5e..e71c6a2 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -36,6 +36,7 @@ ```ts engine.renderer.worldRoot // мир — сюда применяется камера +engine.renderer.lightRoot // освещение — экранное пространство (см. «Освещение») engine.renderer.uiRoot // UI поверх мира, камерой не двигается ``` @@ -78,6 +79,45 @@ Смещение детерминировано (внутри `Shake` — движковый `Rng`) и округляется до целых пикселей, так что пиксель-арт не размывается. +Для слоёв **вне** `worldRoot` (свет, экранные эффекты) есть `camera.toScreen(wx, wy)` — +экранная позиция мировой точки с учётом камеры и тряски, та же математика, что у +`apply`. Не вычисляйте проекцию через `worldRoot.position` — он обновляется при +рендере и отстаёт на кадр; `toScreen` даёт позицию без лага. + +## Освещение + +Два компонента, оба в `renderer.lightRoot` — экранное пространство между миром и UI: + +1. **Ambient** — fullscreen-спрайт с блендом `multiply`. Цвет и есть яркость: + `0xffffff` — свет не трогает сцену, тёмный — затемнение, цветной — тон. + Фундамент смены времени дня: день = `0xffffff`, закат = тёплый, ночь = тёмно-синий. +2. **Источники** — аддитивные спрайты со ступенчатой пиксельной текстурой + свечения (генерируется один раз `makeGlowTexture`; радиус нормируется на + `GLOW_BASE_PX`). Тонкий Pixi-адаптер — `Lighting`, чистая математика — + `lightSim` (`lightFrame`, `flickerFactor`, `lerpAmbient`, `dimColor`). + +```ts +import { Lighting } from '@rpg/engine'; + +const lighting = new Lighting({ width: 480, height: 270, renderer: engine.renderer }); +engine.renderer.lightRoot.addChild(lighting); + +lighting.setAmbient(0x54586a, 1.5); // тёмный интерьер с плавным переходом 1.5 c +lighting.upsertLight({ id: 'hearth', x: 100, y: 40, color: 0xf2b45a, + intensity: 1, radius: 64, flicker: 0.3, seed: 0.2 }); +lighting.setLightPos('hearth', 110, 45); // runtime: move/color/enable/remove +lighting.removeLight('hearth'); + +lighting.update(dt); // каждый тик сцены: время, лерп ambient, мерцание +lighting.frames(); // видимые кадры источников (для снапшота агента) +``` + +Позиции источников — экранные px: сцена пересчитывает их каждый тик через +`camera.toScreen(worldToScreen-координаты точки)` (движок не знает об изометрии). +Пул спрайтов фиксирован (`maxLights`, по умолчанию 16); лишние `upsertLight` +игнорируются. Плавный лерп ambient (`setAmbient(color, fadeSec)`) — база для +day/night: день/ночь — кейфреймы поверх этого API. + ## IsoDepthLayer Сортировка глубины для сущностей на изометрической карте: глубина = `tx + ty` @@ -189,5 +229,6 @@ ## Порядок отрисовки Стек сцен рисуется снизу вверх; внутри сцены порядок задают дочерние контейнеры -(`worldRoot`: карта → сущности → эффекты; `uiRoot`: HUD → диалоги → fade-оверлей). +(`worldRoot`: карта → сущности → эффекты; `lightRoot`: ambient → источники света; +`uiRoot`: дымка/HUD → диалоги → fade-оверлей — всё это поверх освещения). `SceneManager.render()` вызывает `render()` всех сцен в стеке (не только верхней). \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7fb5b21..14f54c7 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -132,9 +132,19 @@ 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 { Shake } from './render/shake'; export { SpriteFlash } from './render/spriteFx'; export { + GLOW_BASE_PX, + flickerFactor, + lightFrame, + lerpAmbient, + dimColor, + type LightDef, + type LightFrame +} from './render/lightSim'; +export { stepParticle, sampleSpawn, sampleBurst, diff --git a/packages/engine/src/render/Camera.ts b/packages/engine/src/render/Camera.ts index 30ad7a1..832ec09 100644 --- a/packages/engine/src/render/Camera.ts +++ b/packages/engine/src/render/Camera.ts @@ -1,6 +1,7 @@ import { Container } from 'pixi.js'; import { Shake } from './shake'; import { DEFAULT_ISO, type IsoLayout, screenToWorld, worldRectToScreen, worldToScreen } from '../math/iso'; +import type { Vec2 } from '../math/Vec2'; /** * Камера виртуального разрешения: позиция — центр взгляда в мировых юнитах @@ -91,12 +92,27 @@ apply(container: Container): void { const o = this.shake.offset; const c = worldToScreen(this.x, this.y, this.iso); - const halfW = Math.floor(this.viewWidth / 2); - const halfH = Math.floor(this.viewHeight / 2); - container.position.set( - halfW - Math.round(c.x) + o.x, - halfH - Math.round(c.y) + o.y - ); + container.position.set(this.originX(c.x) + o.x, this.originY(c.y) + o.y); + } + + /** + * Экранная позиция мировой точки с учётом камеры и тряски — то, куда точка + * реально спроецируется следующим кадром (без округления). Для слоёв вне + * worldRoot (свет, эффекты), которым нужна проекция без лага на кадр. + */ + toScreen(wx: number, wy: number): Vec2 { + const o = this.shake.offset; + const c = worldToScreen(this.x, this.y, this.iso); + const p = worldToScreen(wx, wy, this.iso); + return { x: this.originX(c.x) + o.x + p.x, y: this.originY(c.y) + o.y + p.y }; + } + + private originX(cProjX: number): number { + return Math.floor(this.viewWidth / 2) - Math.round(cProjX); + } + + private originY(cProjY: number): number { + return Math.floor(this.viewHeight / 2) - Math.round(cProjY); } private clamp(): void { diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts new file mode 100644 index 0000000..d609254 --- /dev/null +++ b/packages/engine/src/render/Lighting.ts @@ -0,0 +1,189 @@ +/** + * Слой освещения: fullscreen ambient (multiply-цвет) + аддитивные источники. + * Тонкий Pixi-адаптер над lightSim (чистая математика — там). + * + * Живёт в renderer.lightRoot — экранное пространство между миром и UI: + * 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'; + +export interface LightingOptions { + /** Виртуальные размеры сцены (480×270). */ + width: number; + height: number; + /** Живой рендерер — для генерации текстуры свечения; без него Texture.WHITE. */ + renderer?: Renderer; + /** Размер пула спрайтов источников (по умолчанию 16). */ + maxLights?: number; +} + +/** Кэш текстур свечения по числу ступеней — генерируется один раз на рендерер. */ +const glowCache = new Map(); + +/** + * Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной + * альфой (никаких гладких градиентов). resolution 1 + nearest — пиксельные ступени. + */ +export function makeGlowTexture(renderer: Renderer, steps = 3): Texture { + const cached = glowCache.get(steps); + if (cached) return cached; + const g = new Graphics(); + for (let i = steps; i >= 1; i--) { + const r = (GLOW_BASE_PX * i) / steps; + g.circle(0, 0, r).fill({ color: 0xffffff, alpha: 1 / (i + 1) }); + } + const tex = renderer.app.renderer.generateTexture({ + target: g, + resolution: 1, + antialias: false, + textureSourceOptions: { scaleMode: 'nearest' } + }); + g.destroy(); + glowCache.set(steps, tex); + return tex; +} + +interface LightEntry { + def: LightDef; + sprite: Sprite; +} + +export class Lighting extends Container { + private readonly ambient: Sprite; + private readonly free: Sprite[] = []; + private readonly lights = new Map(); + private readonly glow: Texture; + private readonly maxLights: number; + + private time = 0; + private ambientFrom = 0xffffff; + private ambientTo = 0xffffff; + private fadeLeft = 0; + private fadeTotal = 0; + + constructor(options: LightingOptions) { + super(); + this.maxLights = options.maxLights ?? 16; + // Ambient: Texture.WHITE, растянутый на сцену; цвет — яркость (0xffffff = не трогает). + this.ambient = new Sprite(Texture.WHITE); + this.ambient.width = options.width; + this.ambient.height = options.height; + this.ambient.blendMode = 'multiply'; + this.ambient.tint = 0xffffff; + this.addChild(this.ambient); + + this.glow = options.renderer ? makeGlowTexture(options.renderer) : Texture.WHITE; + for (let i = 0; i < this.maxLights; i++) this.free.push(this.makeLightSprite()); + } + + private makeLightSprite(): Sprite { + const s = new Sprite(this.glow); + s.anchor.set(0.5); + s.blendMode = 'add'; + s.visible = false; + this.addChild(s); + return s; + } + + // --- ambient (фундамент смены времени дня) --- + + /** Задать ambient-цвет; fadeSec > 0 — плавный лерп (день/ночь, закат). */ + setAmbient(color: number, fadeSec = 0): void { + this.ambientFrom = this.currentAmbient; + this.ambientTo = color; + this.fadeTotal = Math.max(0, fadeSec); + this.fadeLeft = this.fadeTotal; + if (this.fadeTotal === 0) this.ambient.tint = color; + } + + /** Целевой (заданный) ambient-цвет. */ + get ambientColor(): number { + return this.ambientTo; + } + + private get currentAmbient(): number { + return this.fadeLeft > 0 ? this.ambientFrom : this.ambient.tint; + } + + // --- источники: runtime add/remove/move/color/enable --- + + /** Добавить источник или обновить его параметры целиком. */ + upsertLight(def: LightDef): void { + let entry = this.lights.get(def.id); + if (!entry) { + const sprite = this.free.pop(); + if (!sprite) return; // пул исчерпан — лишние источники игнорируются + entry = { def: { ...def }, sprite }; + this.lights.set(def.id, entry); + sprite.visible = true; + } + entry.def = { ...def }; + } + + removeLight(id: string): void { + const entry = this.lights.get(id); + if (!entry) return; + entry.sprite.visible = false; + entry.sprite.alpha = 0; + this.free.push(entry.sprite); + this.lights.delete(id); + } + + hasLight(id: string): boolean { + return this.lights.has(id); + } + + setLightPos(id: string, x: number, y: number): void { + const entry = this.lights.get(id); + if (entry) { + entry.def.x = x; + entry.def.y = y; + } + } + + setLightColor(id: string, color: number): void { + const entry = this.lights.get(id); + if (entry) entry.def.color = color; + } + + setLightEnabled(id: string, enabled: boolean): void { + const entry = this.lights.get(id); + if (entry) entry.def.enabled = enabled; + } + + /** Тик: время, лерп ambient, пересчёт кадров источников. */ + update(dt: number): void { + this.time += dt; + if (this.fadeLeft > 0) { + this.fadeLeft = Math.max(0, this.fadeLeft - dt); + const k = this.fadeTotal > 0 ? 1 - this.fadeLeft / this.fadeTotal : 1; + 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); + } + } + + /** Видимые кадры источников (пост-мерцание) — для снапшота агента. */ + frames(): readonly LightFrame[] { + const out: LightFrame[] = []; + for (const { def } of this.lights.values()) out.push(lightFrame(def, this.time)); + return out; + } + + override destroy(): void { + for (const id of [...this.lights.keys()]) this.removeLight(id); + for (const s of this.free) s.destroy(); + this.free.length = 0; + this.ambient.destroy(); + super.destroy(); + } +} \ No newline at end of file diff --git a/packages/engine/src/render/Renderer.ts b/packages/engine/src/render/Renderer.ts index aede832..54cb291 100644 --- a/packages/engine/src/render/Renderer.ts +++ b/packages/engine/src/render/Renderer.ts @@ -24,6 +24,11 @@ readonly app: Application; /** Корневой контейнер мира (к нему применяется камера). */ readonly worldRoot: Container; + /** + * Освещение — экранное пространство: fullscreen ambient + аддитивные источники. + * Камерой не двигается; ниже uiRoot (HUD/fog/fade остаются читаемыми поверх). + */ + readonly lightRoot: Container; /** Контейнер UI поверх мира (камерой не двигается). */ readonly uiRoot: Container; @@ -57,13 +62,14 @@ }); this.worldRoot = new Container(); + this.lightRoot = new Container(); this.uiRoot = new Container(); } /** Дождаться инициализации WebGL и добавить канвас на страницу. */ async setup(): Promise { await this.initPromise; - this.app.stage.addChild(this.worldRoot, this.uiRoot); + this.app.stage.addChild(this.worldRoot, this.lightRoot, this.uiRoot); const canvas = this.app.canvas; canvas.style.imageRendering = 'pixelated'; diff --git a/packages/engine/src/render/__tests__/Lighting.test.ts b/packages/engine/src/render/__tests__/Lighting.test.ts new file mode 100644 index 0000000..a6784e5 --- /dev/null +++ b/packages/engine/src/render/__tests__/Lighting.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { Lighting } from '../Lighting'; +import { GLOW_BASE_PX, type LightFrame } from '../lightSim'; + +function makeLighting(maxLights = 16): Lighting { + // Без renderer: glow-текстура = Texture.WHITE, пул и логика тестируются в node. + return new Lighting({ width: 480, height: 270, maxLights }); +} + +describe('Lighting: ambient', () => { + it('ambient-спрайт: multiply, белый, на всю сцену', () => { + const l = makeLighting(); + const ambient = l.children[0]; + expect(ambient.blendMode).toBe('multiply'); + expect(ambient.tint).toBe(0xffffff); + expect(ambient.width).toBe(480); + expect(ambient.height).toBe(270); + l.destroy(); + }); + + it('setAmbient без fade применяется мгновенно', () => { + const l = makeLighting(); + l.setAmbient(0x54586a); + expect(l.ambientColor).toBe(0x54586a); + expect(l.children[0].tint).toBe(0x54586a); + l.destroy(); + }); + + it('setAmbient с fade — лерп к целевому цвету', () => { + const l = makeLighting(); + l.setAmbient(0x000000); + l.setAmbient(0xffffff, 1); + l.update(0.5); + const mid = l.children[0].tint; + expect(mid).not.toBe(0x000000); + expect(mid).not.toBe(0xffffff); + l.update(0.5); + expect(l.children[0].tint).toBe(0xffffff); + expect(l.ambientColor).toBe(0xffffff); + l.destroy(); + }); +}); + +describe('Lighting: источники', () => { + it('upsertLight добавляет источник, кадр читается после update', () => { + const l = makeLighting(); + l.upsertLight({ id: 'hearth', x: 100, y: 50, color: 0xf2b45a }); + l.update(1 / 60); + const frames = l.frames(); + expect(frames.length).toBe(1); + expect(frames[0].tint).toBe(0xf2b45a); + expect(frames[0].alpha).toBeGreaterThan(0); + l.destroy(); + }); + + it('лишние источники сверх пула игнорируются', () => { + const l = makeLighting(2); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.upsertLight({ id: 'b', x: 0, y: 0, color: 0xffffff }); + l.upsertLight({ id: 'c', x: 0, y: 0, color: 0xffffff }); + expect(l.frames().length).toBe(2); + expect(l.hasLight('c')).toBe(false); + l.destroy(); + }); + + it('removeLight возвращает спрайт в пул: число детей стабильно', () => { + const l = makeLighting(); + const before = l.children.length; + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.upsertLight({ id: 'b', x: 0, y: 0, color: 0xffffff }); + l.removeLight('a'); + l.upsertLight({ id: 'c', x: 0, y: 0, color: 0xffffff }); + expect(l.children.length).toBe(before); + expect(l.hasLight('a')).toBe(false); + expect(l.hasLight('c')).toBe(true); + l.destroy(); + }); + + it('setLightPos/setLightEnabled отражаются в кадрах', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, radius: 2 * GLOW_BASE_PX }); + l.setLightPos('a', 123, 45); + l.setLightEnabled('a', false); + l.update(1 / 60); + const f: LightFrame = l.frames()[0]; + expect(f.x).toBe(123); + expect(f.y).toBe(45); + expect(f.alpha).toBe(0); + l.destroy(); + }); + + it('upsertLight обновляет параметры существующего', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, intensity: 0.5 }); + l.upsertLight({ id: 'a', x: 7, y: 8, color: 0x112233, intensity: 1, flicker: 0 }); + l.update(1 / 60); + const f = l.frames()[0]; + expect(f.x).toBe(7); + expect(f.tint).toBe(0x112233); + expect(l.children.length).toBe(l.children.length); // пул не рос + l.destroy(); + }); + + it('мерцание меняет alpha между апдейтами', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, flicker: 0.5 }); + l.update(0); + const a1 = l.frames()[0].alpha; + l.update(0.37); + const a2 = l.frames()[0].alpha; + expect(a1).not.toBe(a2); + l.destroy(); + }); +}); + +describe('Lighting: destroy', () => { + it('не бросает и повторный вызов безопасен', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.setAmbient(0x334455, 0.5); + l.destroy(); + expect(() => l.destroy()).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/camera.test.ts b/packages/engine/src/render/__tests__/camera.test.ts index c18d212..0160672 100644 --- a/packages/engine/src/render/__tests__/camera.test.ts +++ b/packages/engine/src/render/__tests__/camera.test.ts @@ -75,4 +75,27 @@ expect(view.x).toBe(240 - Math.round(applied.x)); expect(view.y).toBe(135 - Math.round(applied.y)); }); + + it('toScreen согласована с apply: точка мира проецируется в то же место', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + const view = new Container(); + cam.apply(view); + // Точка мира в worldRoot: worldToScreen(p) + позиция worldRoot. + const wx = 3.5, wy = 5.5; + const p = worldToScreen(wx, wy); + expect(view.x + p.x).toBeCloseTo(cam.toScreen(wx, wy).x, 6); + expect(view.y + p.y).toBeCloseTo(cam.toScreen(wx, wy).y, 6); + }); + + it('toScreen учитывает тряску так же, как apply', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.addShake(8, 1); // тряска активна, offset фиксирован до update + const view = new Container(); + cam.apply(view); + const p = worldToScreen(3.5, 5.5); + expect(view.x + p.x).toBeCloseTo(cam.toScreen(3.5, 5.5).x, 6); + expect(view.y + p.y).toBeCloseTo(cam.toScreen(3.5, 5.5).y, 6); + }); }); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/lightSim.test.ts b/packages/engine/src/render/__tests__/lightSim.test.ts new file mode 100644 index 0000000..8d26b88 --- /dev/null +++ b/packages/engine/src/render/__tests__/lightSim.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { + GLOW_BASE_PX, + dimColor, + flickerFactor, + lightFrame, + lerpAmbient +} from '../lightSim'; + +describe('flickerFactor', () => { + it('детерминирован: одинаковые (time, seed) — одинаковый результат', () => { + const a = flickerFactor(1.234, 0.37, 0.3, 0.9); + const b = flickerFactor(1.234, 0.37, 0.3, 0.9); + expect(a).toBe(b); + }); + + it('разные seed дают разные значения', () => { + const a = flickerFactor(0.5, 0.1, 0.3, 0.9); + const b = flickerFactor(0.5, 0.9, 0.3, 0.9); + expect(a).not.toBe(b); + }); + + it('в диапазоне [1-amount, 1] на сетке времени', () => { + for (let i = 0; i < 180; i++) { + const f = flickerFactor(i / 60, 0.25, 0.35, 0.9); + expect(f).toBeGreaterThanOrEqual(1 - 0.35 - 1e-9); + expect(f).toBeLessThanOrEqual(1 + 1e-9); + } + }); + + it('amount = 0 — ровный свет', () => { + for (let i = 0; i < 60; i++) { + expect(flickerFactor(i / 60, 0.7, 0, 0.9)).toBe(1); + } + }); +}); + +describe('lightFrame', () => { + it('дефолты: intensity 1, radius 2*GLOW_BASE_PX', () => { + const f = lightFrame({ id: 'l', x: 10, y: 20, color: 0xf2b45a }, 0); + expect(f.tint).toBe(0xf2b45a); + expect(f.scale).toBe(2); + expect(f.alpha).toBeGreaterThan(0); + expect(f.alpha).toBeLessThanOrEqual(1); + expect(f.x).toBe(10); + expect(f.y).toBe(20); + }); + + it('alpha = intensity * flickerFactor, scale = radius / GLOW_BASE_PX', () => { + const def = { id: 'l', x: 0, y: 0, color: 0xffaa00, intensity: 1.5, radius: 64, flicker: 0, seed: 0 }; + const f = lightFrame(def, 1); + expect(f.alpha).toBeCloseTo(1.5, 10); + expect(f.scale).toBe(64 / GLOW_BASE_PX); + // alpha клампится в адаптере, но чистый кадр может превышать 1 + expect(f.alpha).toBe(1.5); + }); + + it('выключенный источник — alpha 0', () => { + const f = lightFrame({ id: 'l', x: 0, y: 0, color: 0xffffff, enabled: false }, 0); + expect(f.alpha).toBe(0); + }); + + it('мерцание меняет alpha между шагами', () => { + const def = { id: 'l', x: 0, y: 0, color: 0xffffff, intensity: 1, flicker: 0.5 }; + const a1 = lightFrame(def, 0).alpha; + const a2 = lightFrame(def, 0.37).alpha; + expect(a1).not.toBe(a2); + }); +}); + +describe('lerpAmbient', () => { + it('краи и середина по каналам', () => { + expect(lerpAmbient(0x000000, 0xffffff, 0)).toBe(0x000000); + expect(lerpAmbient(0x000000, 0xffffff, 1)).toBe(0xffffff); + expect(lerpAmbient(0xff0000, 0x00ff00, 0.5)).toBe(0x808000); // round(127.5) = 128 + }); +}); + +describe('dimColor', () => { + it('краи: полная яркость и ноль', () => { + expect(dimColor(0x54586a, 1)).toBe(0x54586a); + expect(dimColor(0x54586a, 0)).toBe(0x000000); + }); + + it('монотонность по яркости', () => { + let prev = 0; + for (let k = 0; k <= 1.001; k += 0.1) { + const c = dimColor(0x808080, k); + expect(c).toBeGreaterThanOrEqual(prev); + prev = c; + } + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/lightSim.ts b/packages/engine/src/render/lightSim.ts new file mode 100644 index 0000000..9ad1550 --- /dev/null +++ b/packages/engine/src/render/lightSim.ts @@ -0,0 +1,79 @@ +/** + * Чистая математика освещения — без Pixi, тестируется в Vitest. + * Lighting использует эти функции для кадров источников и лерпа ambient. + */ + +import { lerpColor } from '../anim/flash'; + +/** Базовый радиус текстуры свечения в px — на него нормируется scale спрайта. */ +export const GLOW_BASE_PX = 32; + +/** + * Источник света в экранных пикселях (позицию считает сцена через Camera.toScreen — + * движок ничего не знает об изометрии). + */ +export interface LightDef { + id: string; + x: number; + y: number; + /** Цвет свечения 0xRRGGBB. */ + color: number; + /** Яркость: 0..~1.5 (по умолчанию 1). */ + intensity?: number; + /** Радиус гало в px (по умолчанию 2 * GLOW_BASE_PX). */ + radius?: number; + /** Амплитуда мерцания 0..1 (0 — ровный свет). */ + flicker?: number; + /** Период мерцания, сек (по умолчанию 0.9). */ + flickerPeriod?: number; + /** Фаза мерцания — детерминизм между запусками. */ + seed?: number; + /** Выключенный источник не рисуется (кадр с alpha 0). */ + enabled?: boolean; +} + +/** Видимое состояние источника на шаге. */ +export interface LightFrame { + x: number; + y: number; + tint: number; + alpha: number; + scale: number; +} + +/** + * Детерминированное мерцание: сумма двух синусов с фазой от seed. + * Одинаковые (time, seed) — одинаковый результат; диапазон [1-amount, 1]. + */ +export function flickerFactor(time: number, seed: number, amount: number, period: number): number { + if (amount <= 0) return 1; + const p = period > 0 ? period : 0.9; + const f = time / p + seed * Math.PI * 2; + const wave = (Math.sin(f) + Math.sin(f * 2.37 + seed * 5)) * 0.5; // -1..1 + return 1 - amount * (wave * 0.5 + 0.5); +} + +/** Кадр источника: чистая функция, без Pixi и без мутаций. */ +export function lightFrame(def: LightDef, time: number): LightFrame { + const intensity = def.intensity ?? 1; + const radius = def.radius ?? 2 * GLOW_BASE_PX; + const enabled = def.enabled ?? true; + const alpha = enabled + ? intensity * flickerFactor(time, def.seed ?? 0, def.flicker ?? 0, def.flickerPeriod ?? 0.9) + : 0; + return { x: def.x, y: def.y, tint: def.color, alpha, scale: radius / GLOW_BASE_PX }; +} + +/** Лерп ambient-цвета по каналам: k=0 → from, k=1 → to. */ +export function lerpAmbient(from: number, to: number, k: number): number { + return lerpColor(from, to, k); +} + +/** Яркость ambient-цвета: dimColor(c, 1) === c, dimColor(c, 0) === 0x000000. */ +export function dimColor(color: number, brightness: number): number { + const k = Math.max(0, Math.min(1, brightness)); + const r = Math.round(((color >> 16) & 0xff) * k); + const g = Math.round(((color >> 8) & 0xff) * k); + const b = Math.round((color & 0xff) * k); + return (r << 16) | (g << 8) | b; +} \ No newline at end of file