Newer
Older
rpg / packages / engine / src / render / __tests__ / Lighting.test.ts
import { describe, expect, it } from 'vitest';
import { Container, Texture } from 'pixi.js';
import { Lighting } from '../Lighting';
import { GLOW_BASE_PX, HOLE_PROFILE, quantizeHoleAlpha, stepAlphas, type LightFrame } from '../lightSim';
import type { Renderer } from '../Renderer';

function makeLighting(maxLights = 16): Lighting {
    // Без renderer: RT не создаётся, glow/hole/tone = Texture.WHITE, bake no-op.
    return new Lighting({ width: 480, height: 270, maxLights });
}

/**
 * Стаб-рендерер: generateTexture отдаёт WHITE (destroy защищён guard'ом),
 * render() записывает вызовы bake — dirty-логика тестируется без GPU.
 */
function makeFakeRenderer() {
    const bakes: Array<{ container: Container; clear: boolean }> = [];
    const renderer = {
        app: {
            renderer: {
                generateTexture: () => Texture.WHITE,
                render: (opts: { container: Container; clear: boolean }) => {
                    bakes.push(opts);
                }
            }
        }
    } as unknown as Renderer;
    return { renderer, bakes };
}

describe('Lighting: ambient', () => {
    it('карта тьмы: multiply, белый (день), на всю сцену', () => {
        const l = makeLighting();
        const dark = l.children[0];
        expect(dark.blendMode).toBe('multiply');
        expect(dark.tint).toBe(0xffffff);
        expect(dark.width).toBe(480);
        expect(dark.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 toneLayer = l.children[3];
        const before = toneLayer.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(toneLayer.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);
        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: импульсы', () => {
    it('pulseLight появляется в кадрах, модулируется огибающей и снимается', () => {
        const l = makeLighting(2);
        const id = l.pulseLight({ x: 10, y: 20, color: 0xf2b45a, intensity: 0.5, spec: { attack: 0.05, decay: 0.1 } });
        expect(id).toMatch(/^pulse#/);
        l.update(0.01);
        let f = l.frames().find((fr) => fr.id === id);
        expect(f).toBeTruthy();
        expect(f!.tint).toBe(0xf2b45a);
        expect(f!.alpha).toBeCloseTo(0.5 * 0.2, 5); // elapsed 0.01 из attack 0.05
        l.update(0.04); // elapsed 0.05 — пик огибающей
        f = l.frames().find((fr) => fr.id === id);
        expect(f!.alpha).toBeCloseTo(0.5, 5);
        l.update(0.1); // elapsed 0.15 = total — снят
        expect(l.frames().length).toBe(0);
        expect(l.hasLight(id)).toBe(false);
        expect(l.children.length).toBe(5); // карта тьмы+виньетка+пятна+тон+вспышка
        l.destroy();
    });

    it('pulseLight при исчерпании пула не рисуется (пустой id)', () => {
        const l = makeLighting(1);
        l.upsertLight({ id: 'hearth', x: 0, y: 0, color: 0xffffff });
        const id = l.pulseLight({ x: 0, y: 0, color: 0xffffff, spec: { attack: 0.05, decay: 0.1 } });
        expect(id).toBe('');
        expect(l.hasLight('pulse#1')).toBe(false);
        expect(l.frames().length).toBe(1);
        l.destroy();
    });

    it('pulseAmbient: alpha по огибающей, новая вспышка побеждает', () => {
        const l = makeLighting();
        l.pulseAmbient({ color: 0xd99a32, peak: 0.2, spec: { attack: 0.1, decay: 0.1 } });
        l.update(0.05);
        expect(l.pulseAmbientAlpha).toBeCloseTo(0.1, 5); // 0.2 * 0.5
        l.pulseAmbient({ color: 0xb0453f, peak: 0.3, spec: { attack: 0, decay: 0.2 } });
        l.update(0.1);
        expect(l.pulseAmbientAlpha).toBeCloseTo(0.15, 5); // 0.3 * 0.5
        l.update(0.2); // конец второй вспышки
        expect(l.pulseAmbientAlpha).toBe(0);
        l.destroy();
    });
});

describe('Lighting: виньетка', () => {
    it('setVignette мгновенно; значение клампится в 0..1', () => {
        const l = makeLighting();
        l.setVignette(1.5);
        expect(l.vignetteLevel).toBe(1);
        expect(l.children[1].alpha).toBe(1); // спрайт виньетки — второй ребёнок
        l.setVignette(-0.5);
        expect(l.vignetteLevel).toBe(0);
        expect(l.children[1].alpha).toBe(0);
        l.destroy();
    });

    it('setVignette с fade — лерп к целевой интенсивности', () => {
        const l = makeLighting();
        l.setVignette(1);
        l.setVignette(0, 1);
        expect(l.vignetteLevel).toBe(0);
        l.update(0.5);
        const mid = l.children[1].alpha;
        expect(mid).toBeGreaterThan(0);
        expect(mid).toBeLessThan(1);
        l.update(0.5);
        expect(l.children[1].alpha).toBe(0);
        l.destroy();
    });
});

describe('Lighting: тёмные пятна', () => {
    it('upsert/remove/has; спрайт multiply с нужным масштабом и альфой', () => {
        const l = makeLighting(2);
        l.upsertDarkSpot({ id: 'hazard@3,4', x: 10, y: 10, radius: 51, alpha: 0.22 });
        expect(l.hasDarkSpot('hazard@3,4')).toBe(true);
        const layer = l.children[2]; // darkLayer — третий ребёнок
        expect(layer.children.filter((c) => c.visible).length).toBe(1);
        const spot = layer.children.find((c) => c.visible)!;
        expect(spot.blendMode).toBe('multiply');
        expect(spot.alpha).toBeCloseTo(0.22, 5);
        expect(spot.scale.x).toBeCloseTo(51 / GLOW_BASE_PX, 5);
        l.upsertDarkSpot({ id: 'hazard@3,4', x: 12, y: 14, radius: 51, alpha: 0.3 });
        expect(layer.children.filter((c) => c.visible).length).toBe(1); // обновление без нового спрайта
        expect(spot.x).toBe(12);
        expect(spot.y).toBe(14);
        l.removeDarkSpot('hazard@3,4');
        expect(l.hasDarkSpot('hazard@3,4')).toBe(false);
        expect(l.children.length).toBe(5); // слои на месте, спрайт в пуле
        l.destroy();
    });

    it('пятна сверх пула игнорируются', () => {
        const l = new Lighting({ width: 480, height: 270, maxDarkSpots: 2 });
        l.upsertDarkSpot({ id: 'a', x: 0, y: 0, radius: 32, alpha: 0.2 });
        l.upsertDarkSpot({ id: 'b', x: 0, y: 0, radius: 32, alpha: 0.2 });
        l.upsertDarkSpot({ id: 'c', x: 0, y: 0, radius: 32, alpha: 0.2 });
        expect(l.hasDarkSpot('a')).toBe(true);
        expect(l.hasDarkSpot('b')).toBe(true);
        expect(l.hasDarkSpot('c')).toBe(false);
        l.destroy();
    });
});

describe('Lighting: bake карты тьмы (fake-renderer)', () => {
    it('bake-контейнер не в дереве сцены; erase-спрайты вне Lighting', () => {
        const { renderer } = makeFakeRenderer();
        const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 });
        l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff });
        l.setAmbient(0x3e4658);
        l.update(1 / 60);
        // дети: карта тьмы, виньетка, пятна, тон, вспышка — bake среди них нет
        expect(l.children.length).toBe(5);
        for (const child of l.children) {
            expect(child.blendMode).not.toBe('erase');
        }
        l.destroy();
    });

    it('первый update с тьмой — один bake; без изменений — без повторных', () => {
        const { renderer, bakes } = makeFakeRenderer();
        const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 });
        l.setAmbient(0x3e4658);
        l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff });
        l.update(1 / 60);
        expect(bakes.length).toBe(1);
        l.update(1 / 60);
        l.update(1 / 60);
        expect(bakes.length).toBe(1); // подпись не изменилась — bake пропущен
        l.destroy();
    });

    it('квантованные значения в прорези: движение на суб-пиксель — без bake', () => {
        const { renderer, bakes } = makeFakeRenderer();
        const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 });
        l.setAmbient(0x3e4658);
        l.upsertLight({ id: 'a', x: 40.7, y: 30.2, color: 0xffffff, flicker: 0 });
        l.update(1 / 60);
        const first = bakes.length;
        l.setLightPos('a', 41.3, 30.4); // округление то же (41, 30)
        l.update(1 / 60);
        expect(bakes.length).toBe(first); // округление погасило изменение
        l.setLightPos('a', 44, 30);
        l.update(1 / 60);
        expect(bakes.length).toBe(first + 1); // сдвиг на целый px — bake
        l.destroy();
    });

    it('день (белый ambient) — fast-path: bake не вызывается', () => {
        const { renderer, bakes } = makeFakeRenderer();
        const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 });
        l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff });
        l.update(1 / 60);
        expect(bakes.length).toBe(0); // день — карта тьмы выключена
        l.setAmbient(0x3e4658);
        l.update(1 / 60);
        expect(bakes.length).toBeGreaterThan(0); // тьма включилась — bake
        l.setAmbient(0xffffff);
        l.update(1 / 60);
        const n = bakes.length;
        l.upsertLight({ id: 'b', x: 10, y: 10, color: 0xffffff });
        l.update(1 / 60);
        expect(bakes.length).toBe(n); // снова день — bake не нужен
        l.destroy();
    });

    it('тьма включилась после дня — форс-bake даже при той же подписи', () => {
        const { renderer, bakes } = makeFakeRenderer();
        const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 });
        l.setAmbient(0x3e4658);
        l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 });
        l.update(1 / 60);
        expect(bakes.length).toBe(1);
        l.setAmbient(0xffffff);
        l.update(1 / 60); // день — без bake
        l.setAmbient(0x3e4658);
        l.update(1 / 60); // та же подпись, но тьма включилась заново
        expect(bakes.length).toBe(2);
        l.destroy();
    });

    it('removeLight меняет подпись — перерисовка', () => {
        const { renderer, bakes } = makeFakeRenderer();
        const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 });
        l.setAmbient(0x3e4658);
        l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 });
        l.update(1 / 60);
        const first = bakes.length;
        l.removeLight('a');
        l.update(1 / 60);
        expect(bakes.length).toBe(first + 1);
        l.destroy();
    });

    it('без RT (fallback) update и destroy не бросают', () => {
        const l = makeLighting();
        l.setAmbient(0x3e4658);
        l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff });
        expect(() => l.update(1 / 60)).not.toThrow();
        expect(l.frames().length).toBe(1);
        l.destroy();
        expect(() => l.destroy()).not.toThrow();
    });
});

describe('Lighting: профиль прорези (ступени)', () => {
    it('paint-краски воспроизводят накопленный профиль при рисовании снаружи внутрь', () => {
        const rings = stepAlphas(HOLE_PROFILE);
        expect(rings.length).toBe(HOLE_PROFILE.length);
        expect(rings[0].radius).toBe(GLOW_BASE_PX); // внешнее кольцо = полный радиус
        // Симуляция source-over: красим снаружи внутрь, накопление к центру.
        // rings[i] — кольцо k=n-i, накопленный профиль — HOLE_PROFILE[n-1-i].
        let acc = 0;
        for (let i = 0; i < rings.length; i++) {
            acc = 1 - (1 - rings[i].paintAlpha) * (1 - acc);
            expect(acc).toBeCloseTo(HOLE_PROFILE[rings.length - 1 - i], 5);
        }
        // Центр прорези: тьма снята полностью.
        expect(rings[rings.length - 1].paintAlpha).toBe(1);
    });

    it('quantizeHoleAlpha — сетка 1/8 с клампом', () => {
        expect(quantizeHoleAlpha(0)).toBe(0);
        expect(quantizeHoleAlpha(1)).toBe(1);
        expect(quantizeHoleAlpha(0.1)).toBe(0.125);
        expect(quantizeHoleAlpha(0.13)).toBe(0.125);
        expect(quantizeHoleAlpha(2)).toBe(1);
        expect(quantizeHoleAlpha(-1)).toBe(0);
        expect(quantizeHoleAlpha(0.0625)).toBe(0.125); // половина шага — вверх
        expect(quantizeHoleAlpha(0.06)).toBe(0);
    });
});

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();
    });

    it('с fake-renderer (RT + bake) тоже безопасен', () => {
        const { renderer } = makeFakeRenderer();
        const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 });
        l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff });
        l.setAmbient(0x3e4658);
        l.update(1 / 60);
        expect(() => l.destroy()).not.toThrow();
    });
});