/**
* Слой освещения: 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<number, Texture>();
/**
* Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной
* альфой (никаких гладких градиентов). 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<string, LightEntry>();
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();
}
}