/**
* Игровая обвязка освещения (перенос v1 Lighting.ts на v2-рендер): ночная
* кривая ambient (setNight), точечные источники области по флагам и времени
* суток (three.js PointLight через renderer.addPointLight) и лампа героя
* в тёмных областях/ночью. Источники — diff по id каждый тик; мерцание —
* детерминированный flickerFactor от времени цикла.
*/
import { dayNightAmbient, dayNightFactor, flickerFactor } from '@rpg/engine';
import type { PointLightHandle, VoxelRenderer } from '@rpg/engine';
import { UNIT, GROUND_Y, tileCenter } from './areas';
import type { LightingDef, LightSourceDef } from './data/lighting';
/** Лампа героя (следует за героем в тёмных областях и ночью). */
export interface HeroLampDef {
color: number;
radius: number; // юниты v1
intensity: number;
flicker: number;
}
/** Кап активных источников — защита от опечатки в данных (tiles одного id). */
export const MAX_LIGHTS = 24;
/** Порог ночи для ночных источников и лампы (≈ 19:00–07:00). */
const NIGHT_SOURCE_FACTOR = 0.5;
/** Считается ли область тёмной для лампы (та же эвристика, что v1). */
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 interface GameLightingDeps {
renderer: VoxelRenderer;
/** Освещение области (нет — нейтральный свет по умолчанию). */
lighting: LightingDef | undefined;
/** Позиции тайлов по id раскладки (источники `tiles`; один проход по карте). */
layerTilePos: Map<number, { x: number; y: number }[]>;
/** Позиция героя в вокселях (для лампы). */
heroPos: () => { x: number; z: number };
hasFlag: (flag: string) => boolean;
/** Время суток в часах (GameClock.hours) — вход кривой дня. */
timeHours: () => number;
/** Время цикла в секундах (тик/60) — детерминизм мерцания в гейтах. */
timeSeconds: () => number;
/** Лампа героя (v1 HERO_LAMP); null — без лампы. */
lamp?: HeroLampDef | null;
}
/** Снапшот для моста: фактор ночи + активные источники (пост-мерцание). */
export interface LightingSnapshot {
nightFactor: number;
sources: { id: string; x: number; y: number; intensity: number }[];
}
export class GameLighting {
private readonly deps: GameLightingDeps;
/** Активные источники прошлого тика (diff: снять погасшие по флагам). */
private readonly handles = new Map<string, { handle: PointLightHandle; intensity: number }>();
constructor(deps: GameLightingDeps) {
this.deps = deps;
this.applyDayAmbient();
this.update(); // вход в область сразу при нужном свете (без мелькания дня)
}
/** Дневной базовый тон области: тёмный интерьер гасит hemisphere и солнце. */
private applyDayAmbient(): void {
const ambient = this.deps.lighting?.ambient;
this.deps.renderer.setDayAmbient(
ambient !== undefined && isDarkAmbient(ambient) ? ambient : 0x94949e,
);
}
/** Текущий фактор ночи 0..1 (округлён — для снапшота). */
get nightFactor(): number {
return Math.round(dayNightFactor(this.deps.timeHours()) * 1000) / 1000;
}
/** Тик: ночная цель ambient + источники (diff по id, мерцание). */
update(): void {
const light = this.deps.lighting;
const k = this.nightFactor;
// цель ambient: микс день → золотой час → ночь (рассвет тем же тёплым)
const tint =
light?.ambient !== undefined && light?.nightAmbient !== undefined
? dayNightAmbient(light.ambient, light.nightAmbient, k, light.duskColor)
: light?.ambient ?? 0x94949e;
this.deps.renderer.setNight(k, tint);
const next = new Set<string>();
for (const def of this.activeDefs(k)) {
for (const pos of this.positions(def)) {
if (next.size >= MAX_LIGHTS) break;
const key = def.tiles ? `${def.id}@${pos.x},${pos.y}` : def.id;
next.add(key);
const c = tileCenter(pos.x, pos.y);
const flicker = def.flicker ?? 0;
const intensity =
(def.intensity ?? 0.9) * flickerFactor(this.deps.timeSeconds(), seedOf(key), flicker, 0.9);
const existing = this.handles.get(key);
if (existing) {
existing.handle.setIntensity(intensity);
existing.handle.setPos([c.x, GROUND_Y + 2, c.z]); // лампа героя едет
existing.intensity = intensity;
} else {
this.handles.set(key, {
handle: this.deps.renderer.addPointLight({
pos: [c.x, GROUND_Y + 2, c.z],
color: def.color ?? 0xf2b45a,
intensity,
radius: (def.radius ?? 3) * UNIT,
}),
intensity,
});
}
}
}
for (const [key, entry] of this.handles) {
if (!next.has(key)) {
entry.handle.dispose();
this.handles.delete(key);
}
}
}
/** Состояние для агентного снапшота (интенсивность — пост-мерцание). */
snapshot(): LightingSnapshot {
return {
nightFactor: this.nightFactor,
sources: [...this.handles.entries()].map(([key, entry]) => {
const [id, at] = key.split('@');
const [tx, ty] = at ? at.split(',').map(Number) : [null, null];
return {
id: id!,
x: tx ?? -1,
y: ty ?? -1,
intensity: Math.round(entry.intensity * 1000) / 1000,
};
}),
};
}
destroy(): void {
for (const h of this.handles.values()) h.handle.dispose();
this.handles.clear();
}
// --- внутреннее ---
/** Активные def области (фильтры флагов/ночи) + лампа героя. */
private activeDefs(night: number): LightSourceDef[] {
const out: LightSourceDef[] = [];
for (const def of this.deps.lighting?.sources ?? []) {
if (def.whenFlag && !this.deps.hasFlag(def.whenFlag)) continue;
if (def.notFlag && this.deps.hasFlag(def.notFlag)) continue;
if (def.whenNight && night < NIGHT_SOURCE_FACTOR) continue;
out.push(def);
}
const lamp = this.deps.lamp;
if (lamp && (isDarkAmbient(this.deps.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR)) {
const hero = this.deps.heroPos();
out.push({
id: 'hero_lamp',
// позиция в тайлах (tileCenter конвертирует обратно): центр героя
at: { x: (hero.x - UNIT / 2) / UNIT, y: (hero.z - UNIT / 2) / UNIT },
radius: lamp.radius,
color: lamp.color,
intensity: lamp.intensity,
flicker: lamp.flicker,
});
}
return out;
}
/** Позиции источника в тайлах: точка at или все тайлы заданных id. */
private positions(def: LightSourceDef): { x: number; y: number }[] {
if (def.at) return [def.at];
const out: { x: number; y: number }[] = [];
for (const id of def.tiles ?? []) {
const positions = this.deps.layerTilePos.get(id);
if (positions) out.push(...positions);
}
return out;
}
}