/**
* Игровая обвязка освещения: статика из LightingDef области + лампа героя,
* проекция в экранные px каждый тик (camera.toScreen), условные источники
* по флагам. Runtime API (setAmbient/addLight) — база day/night и эффектов.
*/
import { Camera, Lighting, tileToWorld, unitsToPx, type Vec2 } from '@rpg/engine';
import type { AreaDef, LightSourceDef } from '../data/locations';
/** Параметры лампы героя (следует за героем в тёмных областях). */
export interface HeroLampDef {
color: number;
radius: number; // юниты
intensity: number;
flicker: number;
}
/** Кап активных источников — защита от опечатки в данных (tiles одного id). */
export const MAX_LIGHTS = 24;
export interface GameLightingDeps {
area: AreaDef;
/** Движковый адаптер (уже добавлен в renderer.lightRoot). */
lighting: Lighting;
/** Позиции тайлов по id (один проход по карте, см. LocationScene). */
layerTilePos: Map<number, Vec2[]>;
camera: Camera;
/** Позиция героя в мировых юнитах. */
heroPos: () => Vec2;
hasFlag: (flag: string) => boolean;
lamp?: HeroLampDef | null;
}
export interface LightingSnapshot {
ambient: number;
sources: { id: string; x: number; y: number; color: number; intensity: number }[];
}
/** Детерминированная фаза мерцания по id: снапшот воспроизводим между запусками. */
function seedOf(id: string): number {
let h = 2166136261;
for (let i = 0; i < id.length; i++) {
h ^= id.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 1000) / 1000;
}
export class GameLighting {
private readonly deps: GameLightingDeps;
/** Runtime-источники поверх статики области (эффекты, скрипты). */
private readonly extra: LightSourceDef[] = [];
/** Активные id на прошлом тике — чтобы снимать погасшие (условия по флагам). */
private activeIds = new Set<string>();
private destroyed = false;
constructor(deps: GameLightingDeps) {
this.deps = deps;
if (deps.area.lighting?.ambient !== undefined) {
deps.lighting.setAmbient(deps.area.lighting.ambient);
}
}
// --- runtime поверх статики области (фундамент day/night и эффектов) ---
setAmbient(color: number, fadeSec = 0): void {
this.deps.lighting.setAmbient(color, fadeSec);
}
/** Динамически добавить источник (позиция обязательна — в тайлах). */
addLight(def: LightSourceDef & { at: { x: number; y: number } }): void {
this.extra.push(def);
}
removeLight(id: string): void {
const i = this.extra.findIndex((d) => d.id === id);
if (i >= 0) this.extra.splice(i, 1);
}
/** Тик: пересобрать активные источники и протолкнуть в движковый слой. */
update(): void {
if (this.destroyed) return;
const next = new Set<string>();
for (const def of this.activeDefs()) {
for (const pos of this.sourcePositions(def)) {
if (next.size >= MAX_LIGHTS) break;
const id = def.tiles ? `${def.id}@${pos.x},${pos.y}` : def.id;
next.add(id);
const world = tileToWorld(pos.x + 0.5, pos.y + 0.5);
const s = this.deps.camera.toScreen(world.x, world.y);
this.deps.lighting.upsertLight({
id,
x: s.x,
y: s.y,
color: def.color ?? 0xf2b45a,
intensity: def.intensity ?? 0.9,
radius: unitsToPx(def.radius ?? 3),
flicker: def.flicker ?? 0,
seed: seedOf(id)
});
}
}
for (const id of this.activeIds) {
if (!next.has(id)) this.deps.lighting.removeLight(id);
}
this.activeIds = next;
}
/** Состояние для агентного снапшота (интенсивность — пост-мерцание). */
snapshot(): LightingSnapshot {
const frames = this.deps.lighting.frames();
return {
ambient: this.deps.lighting.ambientColor,
sources: frames.map((f) => ({
id: f.id,
x: Math.round(f.x * 10) / 10,
y: Math.round(f.y * 10) / 10,
color: f.tint,
intensity: Math.round(f.alpha * 1000) / 1000
}))
};
}
destroy(): void {
for (const id of this.activeIds) this.deps.lighting.removeLight(id);
this.activeIds.clear();
this.destroyed = true;
}
// --- внутреннее ---
private activeDefs(): LightSourceDef[] {
const out: LightSourceDef[] = [];
const all = [...(this.deps.area.lighting?.sources ?? []), ...this.extra];
for (const def of all) {
if (def.whenFlag && !this.deps.hasFlag(def.whenFlag)) continue;
if (def.notFlag && this.deps.hasFlag(def.notFlag)) continue;
out.push(def);
}
const lamp = this.deps.lamp;
if (lamp) {
// Позиция героя уже в мировых юнитах: центры тайлов = tileToWorld(x+0.5),
// поэтому отнимаем 0.5 — источник ляжет ровно на героя.
const hero = this.deps.heroPos();
out.push({
id: 'lamp',
at: { x: hero.x - 0.5, y: hero.y - 0.5 },
radius: lamp.radius,
color: lamp.color,
intensity: lamp.intensity,
flicker: lamp.flicker
});
}
return out;
}
/** Позиции источника в тайлах: точка at или все тайлы заданных id. */
private sourcePositions(def: LightSourceDef): { x: number; y: number }[] {
if (def.at) return [def.at];
const out: { x: number; y: number }[] = [];
for (const id of def.tiles ?? []) out.push(...(this.deps.layerTilePos.get(id) ?? []));
return out;
}
}