/**
* Слой освещения «тьма с прорезями»: карта силы тьмы в RenderTexture,
* источники прорезают её erase-спрайтами, тон (мягкая аддитивная подсветка
* вокруг прорези) рисуется поверх. Плюс импульсы (pulseLight/pulseAmbient),
* виньетка и тёмные пятна. Тонкий Pixi-адаптер над lightSim (чистая математика — там).
*
* Механика: RT хранит не цвет, а силу тьмы (белый fill → erase-спрайты);
* display-спрайт RT рисуется с blendMode 'multiply' и tint = ambient —
* смена ambient-цвета не требует перерисовки RT. Bake (отрисовка в RT)
* делается только при изменении квантованных подписей источников —
* мерцание живёт в квантовании альфы и тоне, обновляемом каждый тик.
*
* Живёт в renderer.lightRoot — экранное пространство между миром и UI.
* Порядок детей: карта тьмы → виньетка → тёмные пятна → тон → ambient-вспышка.
* Важно: bake-контейнер НЕ в дереве сцены — erase-спрайты вне RT стёрли бы мир.
*/
import { Container, Graphics, RenderTexture, Sprite, Texture } from 'pixi.js';
import { FadeState } from '../core/fade';
import type { Renderer } from './Renderer';
import {
GLOW_BASE_PX,
HOLE_PROFILE,
TONE_PROFILE,
lightFrame,
lerpAmbient,
pulseEnvelope,
pulseTotal,
quantizeHoleAlpha,
stepAlphas,
type LightDef,
type LightFrame,
type PulseSpec,
type RingStep
} from './lightSim';
export interface LightingOptions {
/** Виртуальные размеры сцены (480×270). */
width: number;
height: number;
/** Живой рендерер — для генерации текстур и bake; без него Texture.WHITE. */
renderer?: Renderer;
/** Размер пула спрайтов источников (по умолчанию 16). */
maxLights?: number;
/** Размер пула спрайтов тёмных пятен (по умолчанию 16). */
maxDarkSpots?: number;
}
/** Сила аддитивного тона поверх прорези: тон намёк, яркость несёт сама прорезь. */
const TONE_GAIN = 0.3;
/** Масштаб тона относительно радиуса прорези: тон уже, чем дыра. */
const TONE_SCALE = 0.8;
/**
* Ступенчатая круговая текстура из колец (в порядке stepAlphas) — общая
* для прорези, тона и свечения. resolution 1 + nearest — пиксельные ступени.
*/
function makeSteppedTexture(renderer: Renderer, rings: RingStep[], dither: boolean): Texture {
const g = new Graphics();
// Дизер-кольцо: редкие точки по внешнему радиусу — смягчают резкую ступень.
if (dither) {
const outer = rings[0].radius;
for (let i = 0; i < 32; i++) {
const a = ((i * 2) / 64) * Math.PI * 2;
g.rect(Math.cos(a) * outer - 0.5, Math.sin(a) * outer - 0.5, 1, 1).fill({
color: 0xffffff,
alpha: rings[0].paintAlpha
});
}
}
for (const ring of rings) {
g.circle(0, 0, ring.radius).fill({ color: 0xffffff, alpha: ring.paintAlpha });
}
const tex = renderer.app.renderer.generateTexture({
target: g,
resolution: 1,
antialias: false,
textureSourceOptions: { scaleMode: 'nearest' }
});
g.destroy();
return tex;
}
/**
* Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной
* альфой (никаких гладких градиентов) + дизер-кольцо. Используется тёмными
* пятнами. Кэшируется вызывающим (Lighting держит её на инстансе — модульный
* кэш переживал destroy рендерера).
*/
export function makeGlowTexture(renderer: Renderer, steps = 4): Texture {
const profile: number[] = [];
for (let i = 1; i <= steps; i++) profile.push(1 - 1 / (i + 1)); // накопление красок 1/(i+1)
return makeSteppedTexture(renderer, stepAlphas(profile), true);
}
/** Текстура прорези (erase в RT): 7 ступеней из HOLE_PROFILE + дизер-кольцо. */
export function makeHoleTexture(renderer: Renderer): Texture {
return makeSteppedTexture(renderer, stepAlphas(HOLE_PROFILE), true);
}
/** Текстура тона (аддитивный свет вокруг прорези): приглушённое ядро. */
export function makeToneTexture(renderer: Renderer): Texture {
return makeSteppedTexture(renderer, stepAlphas(TONE_PROFILE), false);
}
/**
* Ступенчатая виньетка: белый центр → серые кольца к тёмным углам (multiply).
* Интенсивность задаётся alpha спрайта, текстура постоянна. Кэшируется
* вызывающим (см. makeGlowTexture).
*/
export function makeVignetteTexture(renderer: Renderer, width: number, height: number): Texture {
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();
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;
/** Спрайт тона (аддитивный, в toneLayer). */
sprite: Sprite;
/** Erase-спрайт в bake — прорезает карту тьмы (есть только с RT). */
hole: Sprite | null;
}
/** Апсейт def по полям (id — ключ карты, не меняется): без пересоздания на тике. */
function copyLightDef(into: LightDef, from: LightDef): void {
into.x = from.x;
into.y = from.y;
into.color = from.color;
into.intensity = from.intensity;
into.radius = from.radius;
into.flicker = from.flicker;
into.flickerPeriod = from.flickerPeriod;
into.seed = from.seed;
into.enabled = from.enabled;
}
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 {
/** Display-спрайт карты тьмы: multiply, tint = ambient-цвет. */
private readonly darkSprite: Sprite;
private readonly vignette: Sprite;
private readonly darkLayer: Container;
private readonly toneLayer: Container;
private readonly pulseAmb: Sprite;
private readonly toneFree: Sprite[] = [];
private readonly holeFree: 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 renderer: Renderer | null;
/** Карта силы тьмы (resolution 1); null — fallback без RT (Vitest, node). */
private readonly rt: RenderTexture | null;
/** Bake-контейнер: fill + erase-спрайты. НЕ в дереве сцены! */
private readonly bake: Container;
private readonly bakeFill: Sprite;
private readonly glow: Texture;
private readonly hole: Texture;
private readonly tone: Texture;
/** Сгенерированные (не WHITE) текстуры — уничтожаются в destroy(). */
private readonly generated: Texture[] = [];
private readonly maxLights: number;
private readonly maxDarkSpots: number;
private pulseSeq = 0;
/** Подпись набора квантованных прорезей; null — bake ещё не делался. */
private bakeSig: string | null = null;
/** Тьма была активна на прошлом тике (для форс-bake на включении). */
private darkOn = false;
/** Переиспользуемый кадр applyFrame — без аллокации на источник/тик. */
private readonly frame: LightFrame = { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 };
private time = 0;
private ambientFade: FadeState<number>;
private vignetteFade: FadeState<number>;
private ambPulse: AmbientPulse | null = null;
constructor(options: LightingOptions) {
super();
this.maxLights = options.maxLights ?? 16;
this.maxDarkSpots = options.maxDarkSpots ?? 16;
this.renderer = options.renderer ?? null;
// Карта тьмы: Texture.WHITE без рендерера (multiply белого = не трогает).
this.darkSprite = new Sprite(Texture.WHITE);
this.darkSprite.width = options.width;
this.darkSprite.height = options.height;
this.darkSprite.blendMode = 'multiply';
this.darkSprite.tint = 0xffffff;
this.ambientFade = new FadeState(lerpAmbient, 0xffffff);
this.addChild(this.darkSprite);
// Виньетка: Texture.WHITE без рендерера (multiply белого = не трогает).
const vignetteTex = this.renderer
? makeVignetteTexture(this.renderer, options.width, options.height)
: Texture.WHITE;
if (this.renderer) this.generated.push(vignetteTex);
this.vignette = new Sprite(vignetteTex);
this.vignette.width = options.width;
this.vignette.height = options.height;
this.vignette.blendMode = 'multiply';
this.vignette.alpha = 0;
this.vignetteFade = new FadeState((a, b, k) => a + (b - a) * k, 0);
this.addChild(this.vignette);
// Тёмные пятна — под тоном: свет пробивает локальную тень.
this.darkLayer = new Container();
this.addChild(this.darkLayer);
// Тон источников — аддитивный слой над тьмой (не в RT: цвет каждый тик).
this.toneLayer = new Container();
this.addChild(this.toneLayer);
// 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);
// RT карты тьмы + bake-контейнер (вне сцены): fill → erase-прорези.
this.rt = this.renderer
? RenderTexture.create({
width: options.width,
height: options.height,
resolution: 1,
scaleMode: 'nearest',
antialias: false
})
: null;
this.bake = new Container();
this.bakeFill = new Sprite(Texture.WHITE);
this.bakeFill.width = options.width;
this.bakeFill.height = options.height;
this.bake.addChild(this.bakeFill);
this.glow = this.renderer ? makeGlowTexture(this.renderer) : Texture.WHITE;
this.hole = this.renderer ? makeHoleTexture(this.renderer) : Texture.WHITE;
this.tone = this.renderer ? makeToneTexture(this.renderer) : Texture.WHITE;
for (const tex of [this.glow, this.hole, this.tone]) {
if (tex !== Texture.WHITE) this.generated.push(tex);
}
for (let i = 0; i < this.maxLights; i++) this.toneFree.push(this.makeToneSprite());
for (let i = 0; i < this.maxLights; i++) this.holeFree.push(this.makeHoleSprite());
for (let i = 0; i < this.maxDarkSpots; i++) this.darkFree.push(this.makeDarkSprite());
}
private makeToneSprite(): Sprite {
const s = new Sprite(this.tone);
s.anchor.set(0.5);
s.blendMode = 'add';
s.visible = false;
this.toneLayer.addChild(s);
return s;
}
private makeHoleSprite(): Sprite {
const s = new Sprite(this.hole);
s.anchor.set(0.5);
s.blendMode = 'erase';
s.visible = false;
this.bake.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.ambientFade.to(color, Math.max(0, fadeSec));
this.applyAmbient();
}
/** Целевой (заданный) ambient-цвет. */
get ambientColor(): number {
return this.ambientFade.target;
}
/** ambient на display-спрайт карты тьмы; белый — день, слой выключен. */
private applyAmbient(): void {
this.darkSprite.tint = this.ambientFade.value;
this.darkSprite.visible = this.ambientFade.value !== 0xffffff;
}
// --- виньетка (край экрана темнеет: низкий hp, опасная зона) ---
/** Виньетка 0..1 (0 — не трогает); fadeSec > 0 — плавный лерп. */
setVignette(intensity: number, fadeSec = 0): void {
this.vignetteFade.to(Math.max(0, Math.min(1, intensity)), Math.max(0, fadeSec));
this.vignette.alpha = this.vignetteFade.value;
}
/** Целевая интенсивность виньетки. */
get vignetteLevel(): number {
return this.vignetteFade.target;
}
// --- источники: runtime add/remove/move/color/enable ---
/** Добавить источник или обновить его параметры целиком. */
upsertLight(def: LightDef): void {
let entry = this.lights.get(def.id);
if (!entry) {
const sprite = this.toneFree.pop();
if (!sprite) return; // пул исчерпан — лишние источники игнорируются
entry = { def: { ...def }, sprite, hole: this.takeHole() };
this.lights.set(def.id, entry);
sprite.visible = true;
}
copyLightDef(entry.def, def); // апсейт по полям — без пересоздания на тике
}
/** Erase-слот из пула (или null без RT — прорезать нечего). */
private takeHole(): Sprite | null {
if (!this.rt) return null;
const hole = this.holeFree.pop();
if (hole) hole.visible = true;
return hole ?? null;
}
removeLight(id: string): void {
const entry = this.lights.get(id);
if (!entry) return;
entry.sprite.visible = false;
entry.sprite.alpha = 0;
this.toneFree.push(entry.sprite);
if (entry.hole) {
entry.hole.visible = false;
entry.hole.alpha = 0;
this.holeFree.push(entry.hole);
}
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.toneFree.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, hole: this.takeHole() });
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;
this.pulseAmb.visible = true; // оживёт в ближайшем update
}
/** Текущая альфа 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;
}
const d = entry.def;
d.x = def.x;
d.y = def.y;
d.radius = def.radius;
d.alpha = def.alpha;
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;
this.ambientFade.update(dt);
this.applyAmbient();
// Тьма включилась на этом тике — форс-bake, даже если подпись та же.
const on = this.ambientFade.value !== 0xffffff;
if (on && !this.darkOn) this.bakeSig = null;
this.darkOn = on;
this.vignetteFade.update(dt);
this.vignette.alpha = this.vignetteFade.value;
this.vignette.visible = this.vignetteFade.value > 0;
for (const [id, p] of this.pulses) {
p.elapsed += dt;
if (p.elapsed >= pulseTotal(p.spec)) {
this.removeLight(id);
this.pulses.delete(id); // удаление во время итерации Map — безопасно
continue;
}
p.def.intensity = p.base * pulseEnvelope(p.elapsed, p.spec);
}
this.applyLights();
if (this.ambPulse) {
this.ambPulse.elapsed += dt;
if (this.ambPulse.elapsed >= pulseTotal(this.ambPulse.spec)) {
this.ambPulse = null;
this.pulseAmb.alpha = 0;
this.pulseAmb.visible = false;
} else {
const e = pulseEnvelope(this.ambPulse.elapsed, this.ambPulse.spec);
this.pulseAmb.alpha = Math.min(1, this.ambPulse.peak * e);
this.pulseAmb.visible = this.pulseAmb.alpha > 0;
}
}
}
/** Кадры источников: тон каждый тик, прорези — по квантованной подписи. */
private applyLights(): void {
let sig = '';
let idx = 0;
for (const entry of this.lights.values()) {
const f = lightFrame(entry.def, this.time, this.frame);
// Тон: полный precision, обновляется каждый тик (живость мерцания).
const s = entry.sprite;
s.position.set(f.x, f.y);
s.tint = f.tint;
s.alpha = Math.min(1, f.alpha) * TONE_GAIN;
s.scale.set(f.scale * TONE_SCALE);
s.visible = s.alpha > 0.01;
// Прорезь: квантованные значения — сигнатура = содержимое RT.
if (entry.hole) {
const qx = Math.round(f.x);
const qy = Math.round(f.y);
const qs = Math.round(f.scale * 8) / 8;
const qa = quantizeHoleAlpha(f.alpha);
entry.hole.position.set(qx, qy);
entry.hole.scale.set(qs);
entry.hole.alpha = qa;
entry.hole.visible = qa > 0;
sig += `${idx}:${qx},${qy},${qs},${qa};`;
}
idx++;
}
if (sig !== this.bakeSig) {
this.bakeSig = sig;
this.bakeNow();
}
}
/** Перерисовать карту тьмы в RT; день (белый ambient) — не нужно. */
private bakeNow(): void {
if (!this.rt || !this.renderer || !this.darkSprite.visible) return;
this.renderer.app.renderer.render({ container: this.bake, target: this.rt, clear: true });
}
/** Видимые кадры источников (пост-мерцание, включая импульсы) — для снапшота. */
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 id of [...this.darks.keys()]) this.removeDarkSpot(id);
for (const s of this.toneFree) s.destroy();
this.toneFree.length = 0;
for (const s of this.holeFree) s.destroy();
this.holeFree.length = 0;
this.bakeFill.destroy();
this.bake.destroy({ children: false });
this.rt?.destroy(true);
this.darks.clear();
// Тёмные спрайты — дети darkLayer: destroy явно и один раз, слой без children.
for (const s of this.darkFree) s.destroy();
this.darkFree.length = 0;
this.darkLayer.destroy({ children: false });
this.darkSprite.destroy();
this.vignette.destroy();
this.pulseAmb.destroy();
for (const tex of this.generated) {
if (tex !== Texture.WHITE) tex.destroy(true); // стаб-рендерер отдаёт WHITE
}
this.generated.length = 0;
super.destroy(options);
}
}