/**
* Слой освещения: 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,
pulseEnvelope,
pulseTotal,
type LightDef,
type LightFrame,
type PulseSpec
} from './lightSim';
export interface LightingOptions {
/** Виртуальные размеры сцены (480×270). */
width: number;
height: number;
/** Живой рендерер — для генерации текстур; без него Texture.WHITE. */
renderer?: Renderer;
/** Размер пула спрайтов источников (по умолчанию 16). */
maxLights?: number;
/** Размер пула спрайтов тёмных пятен (по умолчанию 16). */
maxDarkSpots?: number;
}
/** Кэш текстур свечения по числу ступеней — генерируется один раз на рендерер. */
const glowCache = new Map<number, Texture>();
/**
* Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной
* альфой (никаких гладких градиентов) + дизер-кольцо по внешнему радиусу.
* resolution 1 + nearest — пиксельные ступени.
*/
export function makeGlowTexture(renderer: Renderer, steps = 4): 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) });
}
// Дизер-кольцо: редкие точки по внешнему радиусу — смягчают резкую ступень.
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,
antialias: false,
textureSourceOptions: { scaleMode: 'nearest' }
});
g.destroy();
glowCache.set(steps, tex);
return tex;
}
/** Кэш текстур виньетки по размеру сцены. */
const vignetteCache = new Map<string, Texture>();
/**
* Ступенчатая виньетка: белый центр → серые кольца к тёмным углам (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<string, LightEntry>();
private readonly darks = new Map<string, DarkEntry>();
private readonly pulses = new Map<string, PulseEntry>();
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;
this.ambient.height = options.height;
this.ambient.blendMode = 'multiply';
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 {
const s = new Sprite(this.glow);
s.anchor.set(0.5);
s.blendMode = 'add';
s.visible = false;
this.addChild(s);
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 — плавный лерп (день/ночь, закат). */
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;
}
// --- виньетка (край экрана темнеет: низкий 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 ---
/** Добавить источник или обновить его параметры целиком. */
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;
}
// --- импульсы: короткие вспышки в пуле источников и на весь экран ---
/**
* Импульс света в точке: занимает спрайт общего пула, снимается по концу
* огибающей. Возвращает 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) {
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;
}
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));
return out;
}
override destroy(options?: Parameters<Container['destroy']>[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);
}
}