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']): AreaDef {
return {
id: 'meadows', // AreaId — юнион; значение не важно, свет приходит из fakeArea-аргумента
name: 'Тест',
spawn: { x: 1, y: 1 },
npcs: [],
enemies: [],
transitions: [],
atmosphere: 'none',
lighting: sources
};
}
function makeGameLighting(area: AreaDef, opts?: { flags?: Set<string>; hero?: { x: number; y: number }; lamp?: HeroLampDef | null }) {
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),
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(), { 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(), { 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();
});
});
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();
const s = gl.snapshot();
expect(s.ambient).toBe(0x334455);
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);
});
});