/**
* Игровая обвязка освещения: статика из LightingDef области + лампа героя,
* проекция в экранные px каждый тик (camera.toScreen), условные источники
* по флагам. Runtime API (setAmbient/addLight) — база day/night и эффектов.
*/
import { Camera, Lighting, tileToWorld, unitsToPx, type PulseSpec, 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;
/** Флаг вечера: включает ночную цель ambient и лампу на светлой улице. */
nightFlag?: string;
lamp?: HeroLampDef | null;
}
export interface LightingSnapshot {
ambient: number;
/** Целевая интенсивность виньетки 0..1 (текущая — пост-лерп движка). */
vignette: number;
sources: { id: string; x: number; y: number; color: number; intensity: number }[];
}
/** Фейд ночного ambient, сек (медленный закат). */
const NIGHT_FADE_SEC = 6;
/** Тёмная аура зоны наката: радиус в юнитах и сила затемнения. */
const HAZARD_SPOT_RADIUS = 1.6;
const HAZARD_SPOT_ALPHA = 0.22;
/** Кап тёмных пятен — под пул движка (maxDarkSpots, по умолчанию 16). */
const MAX_DARK_SPOTS = 16;
/** Считается ли область тёмной для лампы (тот же порог, что darkAreaLamp сцены). */
function isDarkAmbient(ambient: number | undefined): boolean {
if (ambient === undefined) return false;
const r = (ambient >> 16) & 0xff;
const g = (ambient >> 8) & 0xff;
const b = ambient & 0xff;
return (r * 0.3 + g * 0.6 + b * 0.1) / 255 < 0.75;
}
/** Детерминированная фаза мерцания по 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>();
/** Активные id тёмных пятен на прошлом тике (ауры хазардов). */
private spotIds = new Set<string>();
/** Последняя заданная цель ambient — guard от повторного setAmbient. */
private lastAmbientTarget: number | null = null;
private destroyed = false;
constructor(deps: GameLightingDeps) {
this.deps = deps;
if (deps.area.lighting?.ambient !== undefined) {
deps.lighting.setAmbient(deps.area.lighting.ambient);
this.lastAmbientTarget = deps.area.lighting.ambient;
}
}
// --- runtime поверх статики области (фундамент day/night и эффектов) ---
setAmbient(color: number, fadeSec = 0): void {
this.deps.lighting.setAmbient(color, fadeSec);
this.lastAmbientTarget = color;
}
/** Виньетка 0..1 (низкий hp, опасная зона); guard по изменению — у сцены. */
setVignette(intensity: number, fadeSec = 0): void {
this.deps.lighting.setVignette(intensity, fadeSec);
}
/** Текущая целевая виньетка. */
get vignetteLevel(): number {
return this.deps.lighting.vignetteLevel;
}
/** Импульс света в точке (позиция уже в экранных px, радиус — в px). */
pulseLight(args: { x: number; y: number; color: number; intensity?: number; radius?: number; spec: PulseSpec }): void {
this.deps.lighting.pulseLight(args);
}
/** Аддитивная вспышка на весь экран (color, peak 0..1, огибающая). */
pulseAmbient(args: { color: number; peak: number; spec: PulseSpec }): void {
this.deps.lighting.pulseAmbient(args);
}
/** Динамически добавить источник (позиция обязательна — в тайлах). */
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);
}
/** Тик: ночная цель ambient, источники, тёмные пятна хазардов. */
update(): void {
if (this.destroyed) return;
this.updateNightAmbient();
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;
this.updateHazardSpots();
}
/** Ночная цель ambient: evening + nightAmbient в данных → плавный закат. */
private updateNightAmbient(): void {
const light = this.deps.area.lighting;
const night = light?.nightAmbient;
const base = light?.ambient;
if (night === undefined || base === undefined) return;
const target = this.deps.nightFlag && this.deps.hasFlag(this.deps.nightFlag) ? night : base;
if (target !== this.lastAmbientTarget) this.setAmbient(target, NIGHT_FADE_SEC);
}
/** Тёмные ауры зон наката: по тайлу HazardDef, снятие погасших через diff. */
private updateHazardSpots(): void {
const next = new Set<string>();
for (const hazard of this.deps.area.hazards ?? []) {
for (const t of hazard.tiles) {
if (next.size >= MAX_DARK_SPOTS) break;
const id = `hazard@${t.x},${t.y}`;
next.add(id);
const world = tileToWorld(t.x + 0.5, t.y + 0.5);
const s = this.deps.camera.toScreen(world.x, world.y);
this.deps.lighting.upsertDarkSpot({
id,
x: s.x,
y: s.y,
radius: unitsToPx(HAZARD_SPOT_RADIUS),
alpha: HAZARD_SPOT_ALPHA
});
}
}
for (const id of this.spotIds) {
if (!next.has(id)) this.deps.lighting.removeDarkSpot(id);
}
this.spotIds = next;
}
/** Состояние для агентного снапшота (интенсивность — пост-мерцание). */
snapshot(): LightingSnapshot {
const frames = this.deps.lighting.frames();
return {
ambient: this.deps.lighting.ambientColor,
vignette: Math.round(this.deps.lighting.vignetteLevel * 1000) / 1000,
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();
for (const id of this.spotIds) this.deps.lighting.removeDarkSpot(id);
this.spotIds.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;
// Лампа в тёмной области или вечером (ночь на светлой улице тоже темна).
const evening = this.deps.nightFlag !== undefined && this.deps.hasFlag(this.deps.nightFlag);
if (lamp && (isDarkAmbient(this.deps.area.lighting?.ambient) || evening)) {
// Позиция героя уже в мировых юнитах: центры тайлов = 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;
}
}