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; time?: number }
) {
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 clock = { hours: opts?.time ?? 12 };
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),
timeHours: () => clock.hours,
lamp: opts?.lamp === undefined ? null : opts.lamp
});
return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p), setTime: (h: number) => (clock.hours = h) };
}
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 }), {
time: 21,
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 { gl, engineLighting, setTime } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), {
time: 12,
lamp: LAMP
});
gl.update();
expect(engineLighting.hasLight('lamp')).toBe(false);
setTime(21);
gl.update();
expect(engineLighting.hasLight('lamp')).toBe(true);
gl.destroy();
});
});
describe('GameLighting: день/ночь по времени', () => {
it('цель ambient — микс день/ночь по кривой суток', () => {
const { gl, engineLighting, setTime } = makeGameLighting(
fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }),
{ time: 12 }
);
gl.update();
expect(engineLighting.ambientColor).toBe(0xd8dade); // день
setTime(19); // k = 0.5 — середина заката
gl.update();
expect(engineLighting.ambientColor).not.toBe(0xd8dade); // микс, не день
expect(engineLighting.ambientColor).not.toBe(0x6a7288); // и не ночь
setTime(21);
gl.update();
expect(engineLighting.ambientColor).toBe(0x6a7288); // ночь
setTime(10);
gl.update();
expect(engineLighting.ambientColor).toBe(0xd8dade); // и обратно к дню
gl.destroy();
});
it('без nightAmbient в данных время не трогает ambient', () => {
const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { time: 21 });
gl.update();
expect(engineLighting.ambientColor).toBe(0xd8dade);
gl.destroy();
});
it('конструктор сразу применяет ночной микс (без мелькания дня)', () => {
const { gl, engineLighting } = makeGameLighting(
fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }),
{ time: 23 }
);
expect(engineLighting.ambientColor).toBe(0x6a7288);
gl.destroy();
});
it('whenNight: ночные источники горят ночью, гаснут днём', () => {
const { gl, engineLighting, setTime } = makeGameLighting(
fakeArea({ sources: [{ id: 'win', at: { x: 1, y: 1 }, flicker: 0, whenNight: true }] }),
{ time: 21 }
);
gl.update();
expect(engineLighting.hasLight('win')).toBe(true);
setTime(12);
gl.update();
expect(engineLighting.hasLight('win')).toBe(false);
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('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);
});
});