Newer
Older
rpg / apps / game / src / systems / __tests__ / lighting.test.ts
import { describe, expect, it } from 'vitest';
import { Camera, Lighting, tileToWorld } from '@rpg/engine';
import { GameLighting, type HeroLampDef } from '../Lighting';
import type { AreaDef } from '../../data/locations';

const LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 };

function fakeArea(sources?: AreaDef['lighting'], hazards?: AreaDef['hazards']): AreaDef {
    return {
        id: 'meadows', // AreaId — юнион; значение не важно, свет приходит из fakeArea-аргумента
        name: 'Тест',
        spawn: { x: 1, y: 1 },
        npcs: [],
        enemies: [],
        transitions: [],
        atmosphere: 'none',
        lighting: sources,
        hazards
    };
}

function makeGameLighting(
    area: AreaDef,
    opts?: { flags?: Set<string>; hero?: { x: number; y: number }; lamp?: HeroLampDef | null; nightFlag?: string }
) {
    const engineLighting = new Lighting({ width: 480, height: 270 });
    const camera = new Camera(480, 270);
    camera.snap(10, 10);
    const flags = opts?.flags ?? new Set<string>();
    let hero = opts?.hero ?? { x: 5, y: 5 };
    const gl = new GameLighting({
        area,
        lighting: engineLighting,
        layerTilePos: new Map([[11, [{ x: 2, y: 3 }, { x: 8, y: 3 }]]]), // TILES.HOUSE = 11
        camera,
        heroPos: () => hero,
        hasFlag: (f) => flags.has(f),
        nightFlag: opts?.nightFlag,
        lamp: opts?.lamp === undefined ? null : opts.lamp
    });
    return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p) };
}

describe('GameLighting: статика области', () => {
    it('ambient из данных применяется к движковому слою', () => {
        const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a }));
        expect(engineLighting.ambientColor).toBe(0x54586a);
        gl.destroy();
    });

    it('деф с at — один источник в центре ромба', () => {
        const { gl, engineLighting, camera } = makeGameLighting(
            fakeArea({ sources: [{ id: 'hearth', at: { x: 8, y: 0 }, intensity: 1, flicker: 0 }] })
        );
        gl.update();
        const frames = engineLighting.frames();
        expect(frames.length).toBe(1);
        expect(frames[0].id).toBe('hearth');
        // Позиция — ровно проекция центра ромба тайла (8.5, 0.5)
        const w = tileToWorld(8.5, 0.5);
        const s = camera.toScreen(w.x, w.y);
        expect(frames[0].x).toBeCloseTo(s.x, 3);
        expect(frames[0].y).toBeCloseTo(s.y, 3);
        gl.destroy();
    });

    it('деф с tiles — источник на каждый тайл id', () => {
        const { gl, engineLighting } = makeGameLighting(
            fakeArea({ sources: [{ id: 'window', tiles: [11], intensity: 0.4, flicker: 0 }] })
        );
        gl.update();
        const ids = engineLighting.frames().map((f) => f.id).sort();
        expect(ids).toEqual(['window@2,3', 'window@8,3']);
        gl.destroy();
    });

    it('whenFlag/notFlag управляют активностью', () => {
        const { gl, engineLighting } = makeGameLighting(
            fakeArea({
                sources: [
                    { id: 'off', at: { x: 1, y: 1 }, whenFlag: 'quest_bells_done', flicker: 0 },
                    { id: 'on', at: { x: 1, y: 1 }, notFlag: 'quest_bells_done', flicker: 0 }
                ]
            })
        );
        gl.update();
        expect(engineLighting.frames().map((f) => f.id)).toEqual(['on']);
        gl.destroy();
    });

    it('погасшие источники снимаются с движкового слоя (флаг-условие)', () => {
        const flags = new Set<string>();
        const { gl, engineLighting } = makeGameLighting(
            fakeArea({ sources: [{ id: 'a', at: { x: 1, y: 1 }, flicker: 0, notFlag: 'lit' }] }),
            { flags }
        );
        gl.update();
        expect(engineLighting.hasLight('a')).toBe(true);
        flags.add('lit');
        gl.update();
        expect(engineLighting.hasLight('a')).toBe(false);
        gl.destroy();
    });
});

describe('GameLighting: лампа героя', () => {
    it('следует за героем, id стабилен (вечер на светлой улице)', () => {
        const { gl, engineLighting, setHero } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), {
            flags: new Set(['evening']),
            nightFlag: 'evening',
            lamp: LAMP
        });
        gl.update();
        const first = engineLighting.frames()[0];
        setHero({ x: 7.5, y: 9.5 });
        gl.update();
        const second = engineLighting.frames()[0];
        expect(first.id).toBe('lamp');
        expect(second.id).toBe('lamp');
        expect(second.x).not.toBe(first.x); // лампа переехала вместе с героем
        gl.destroy();
    });

    it('радиус в юнитах переводится в px', () => {
        const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a }), { lamp: LAMP });
        gl.update();
        // unitsToPx(2.5) = 2.5 * 32 = 80; scale = 80 / GLOW_BASE_PX(32) = 2.5
        expect(engineLighting.frames()[0].scale).toBeCloseTo(2.5, 6);
        gl.destroy();
    });

    it('на светлой улице днём лампы нет, вечером появляется', () => {
        const flags = new Set<string>();
        const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), {
            flags,
            nightFlag: 'evening',
            lamp: LAMP
        });
        gl.update();
        expect(engineLighting.hasLight('lamp')).toBe(false);
        flags.add('evening');
        gl.update();
        expect(engineLighting.hasLight('lamp')).toBe(true);
        gl.destroy();
    });
});

describe('GameLighting: день/ночь', () => {
    it('ночная цель ambient по флагу вечера — лерп к nightAmbient', () => {
        const flags = new Set<string>();
        const { gl, engineLighting } = makeGameLighting(
            fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }),
            { flags, nightFlag: 'evening' }
        );
        gl.update();
        expect(engineLighting.ambientColor).toBe(0xd8dade); // день
        flags.add('evening');
        gl.update();
        expect(engineLighting.ambientColor).toBe(0x6a7288); // ночь — цель сменилась
        engineLighting.update(0.1);
        expect(engineLighting.children[0].tint).not.toBe(0xd8dade); // лерп пошёл
        flags.delete('evening');
        gl.update();
        expect(engineLighting.ambientColor).toBe(0xd8dade); // и обратно к дню
        gl.destroy();
    });

    it('без nightAmbient в данных вечер не трогает ambient', () => {
        const flags = new Set<string>(['evening']);
        const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), {
            flags,
            nightFlag: 'evening'
        });
        gl.update();
        expect(engineLighting.ambientColor).toBe(0xd8dade);
        gl.destroy();
    });
});

describe('GameLighting: тёмные ауры хазардов', () => {
    it('пятно на каждый тайл зоны, снятие при исчезновении зоны', () => {
        const hazards = [{ name: 'накат', tiles: [{ x: 3, y: 4 }, { x: 4, y: 4 }], requiresItem: 'cloth' as const }];
        const { gl, engineLighting } = makeGameLighting(fakeArea(undefined, hazards));
        gl.update();
        expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(true);
        expect(engineLighting.hasDarkSpot('hazard@4,4')).toBe(true);
        gl.destroy();
        // Пятна сняты destroy
        expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(false);
    });
});

describe('GameLighting: runtime API и снапшот', () => {
    it('addLight/removeLight поверх статики', () => {
        const { gl, engineLighting } = makeGameLighting(fakeArea());
        gl.addLight({ id: 'fx', at: { x: 3, y: 3 }, flicker: 0 });
        gl.update();
        expect(engineLighting.hasLight('fx')).toBe(true);
        gl.removeLight('fx');
        gl.update();
        expect(engineLighting.hasLight('fx')).toBe(false);
        gl.destroy();
    });

    it('setAmbient пробрасывается в движковый слой с лерпом', () => {
        const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xffffff }));
        gl.setAmbient(0x000000, 1);
        engineLighting.update(0.5);
        expect(engineLighting.ambientColor).toBe(0x000000);
        expect(engineLighting.children[0].tint).not.toBe(0xffffff);
        expect(engineLighting.children[0].tint).not.toBe(0x000000);
        gl.destroy();
    });

    it('snapshot: ambient + виньетка + источники с пост-мерцанием', () => {
        const { gl } = makeGameLighting(
            fakeArea({ ambient: 0x334455, sources: [{ id: 'h', at: { x: 1, y: 1 }, intensity: 0.8, flicker: 0 }] })
        );
        gl.update();
        gl.setVignette(0.35);
        const s = gl.snapshot();
        expect(s.ambient).toBe(0x334455);
        expect(s.vignette).toBe(0.35);
        expect(s.sources.length).toBe(1);
        expect(s.sources[0].id).toBe('h');
        expect(s.sources[0].intensity).toBeCloseTo(0.8, 3);
        expect(Number.isFinite(s.sources[0].x)).toBe(true);
        gl.destroy();
    });

    it('destroy снимает все источники', () => {
        const { gl, engineLighting } = makeGameLighting(fakeArea(), { lamp: LAMP });
        gl.update();
        gl.destroy();
        expect(engineLighting.frames().length).toBe(0);
    });
});