Newer
Older
rpg / packages / engine / src / render / Lighting.ts
/**
 * Слой освещения «тьма с прорезями»: карта силы тьмы в 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);
    }
}