/**
* Игровая обвязка освещения (перенос v1 Lighting.ts на v2-рендер): ночная
* кривая ambient (setNight), точечные источники области по флагам и времени
* суток (three.js PointLight через renderer.addPointLight) и лампа героя
* в тёмных областях/ночью. Источники — diff по id каждый тик; мерцание —
* детерминированный flickerFactor от времени цикла.
*/
import { dayNightAmbient, dayNightFactor, flickerFactor, lerpColor } from '@rpg/engine';
import type { ParticleLight, ParticleSpot, PointLightHandle, SpotLightHandle, 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;
}
/** Фонарик героя: конус света по направлению взгляда (addSpotLight). */
export interface FlashlightDef {
color: number;
intensity: number;
/** Дальность луча в вокселях. */
distance: number;
/** Полный угол конуса (рад). */
angle: number;
/** Мягкость края конуса 0..1. */
penumbra: number;
}
/** Кап активных источников — защита от опечатки в данных (tiles одного id). */
export const MAX_LIGHTS = 24;
/** Порог ночи для ночных источников и лампы (≈ 19:00–07:00). */
const NIGHT_SOURCE_FACTOR = 0.5;
/**
* Тинт частиц ночью (0xRRGGBB-множитель): слои частиц движка не освещаются
* — без тинта пепел/листья/дымка светятся в полную яркость и ночью («вне
* системы освещения», фидбек). Днём — белый (без множителя), к ночи —
* холодная синева в четверть яркости, как у сцены.
*/
const PARTICLE_NIGHT = 0x40465c;
/** Считается ли область тёмной для лампы (та же эвристика, что 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;
/** Фонарик героя (луч по направлению взгляда); null — нет. */
flashlight?: FlashlightDef | null;
/** Куда смотрит герой (yaw, рад; 0 — +Z) — направление луча. */
heroFacing: () => number;
/** Тинт частиц слоям (ночь гасит пепел/листья/дымку/блики вместе со сценой). */
particleTint?: (color: number) => void;
/** Свет частицам: снапшот источников + фонарик (подсветка в шейдере
* частиц — пепел у очага/колокольчика светится, луч фонарика ловится). */
particleLights?: (points: readonly ParticleLight[], spot: ParticleSpot | null) => void;
}
/** Снапшот для моста: фактор ночи, путь солнца + активные источники. */
export interface LightingSnapshot {
nightFactor: number;
/** Путь солнца по дню 0..1 (тени следуют за временем суток). */
sunK: number;
/** Тинт частиц этого тика (день — белый 0xffffff, к ночи темнеет). */
particleTint: number;
/** Источников отдано слоям частиц (подсветка в шейдере частиц). */
particleLights: number;
sources: { id: string; x: number; y: number; intensity: number }[];
/** Фонарик: горит ли и с какой яркостью (пост-мерцание). */
flashlight: { on: boolean; intensity: number };
}
export class GameLighting {
private readonly deps: GameLightingDeps;
/** Активные источники прошлого тика (diff: снять погасшие по флагам);
* light — снапшот для частиц (цвет/радиус/позиция + текущая яркость). */
private readonly handles = new Map<string, { handle: PointLightHandle; intensity: number; light: ParticleLight }>();
/** Прожектор фонарика (создаётся лениво, гаснет — dispose). */
private spot: SpotLightHandle | null = null;
/** Интенсивность луча прошлого тика (снапшот — пост-мерцание). */
private spotIntensity = 0;
/** Снапшот луча для частиц (null — погашен). */
private spotLight: ParticleSpot | null = null;
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;
}
/** Путь солнца по дню 0..1: 6:00 → 0, 12:00 → 0.5, 18:00 → 1. */
private sunK(): number {
return Math.min(1, Math.max(0, (this.deps.timeHours() - 6) / 12));
}
/** Тик: ночная цель 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 pTint = lerpColor(0xffffff, PARTICLE_NIGHT, k);
this.deps.particleTint?.(pTint);
// Солнце едет по часам (6:00 восход → 12:00 зенит → 18:00 закат):
// тени меняют направление и длину в течение дня (setSun сам гасит
// свет ночью — до и после окна клампимся в [0..1]).
this.deps.renderer.setSun(this.sunK());
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;
existing.light.intensity = intensity;
existing.light.pos = [c.x, GROUND_Y + 2, c.z];
} else {
const pos: [number, number, number] = [c.x, GROUND_Y + 2, c.z];
const light: ParticleLight = {
pos,
color: def.color ?? 0xf2b45a,
intensity,
radius: (def.radius ?? 3) * UNIT,
};
this.handles.set(key, {
handle: this.deps.renderer.addPointLight({
pos, color: light.color,
intensity, radius: light.radius,
}),
intensity,
light,
});
}
}
}
for (const [key, entry] of this.handles) {
if (!next.has(key)) {
entry.handle.dispose();
this.handles.delete(key);
}
}
this.updateFlashlight(k);
// частицы подсвечиваются источниками как мир (шейдер частиц движка)
this.deps.particleLights?.(
[...this.handles.values()].map((e) => e.light),
this.spotLight,
);
}
/**
* Фонарик: конус по направлению взгляда героя (как лампа — в тёмных
* областях и ночью). Луч — лениво созданный SpotLight: каждый тик
* позиция/цель/яркость (мягкое мерцание — живость), погас — dispose.
*/
private updateFlashlight(night: number): void {
const fl = this.deps.flashlight;
const on = fl !== undefined && fl !== null
&& (isDarkAmbient(this.deps.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR);
if (!fl || !on) {
if (this.spot) {
this.spot.dispose();
this.spot = null;
}
this.spotIntensity = 0;
this.spotLight = null;
return;
}
const hero = this.deps.heroPos();
const fx = Math.sin(this.deps.heroFacing()), fz = Math.cos(this.deps.heroFacing());
const origin: [number, number, number] = [hero.x + fx * 3, GROUND_Y + 5, hero.z + fz * 3];
const hit: [number, number, number] = [hero.x + fx * fl.distance, GROUND_Y, hero.z + fz * fl.distance];
const flicker = flickerFactor(this.deps.timeSeconds(), seedOf('flashlight'), 0.06, 0.9);
this.spotIntensity = fl.intensity * flicker;
// конус для частиц: тот же луч (направление нормирует движок)
this.spotLight = {
pos: origin,
dir: [hit[0] - origin[0], hit[1] - origin[1], hit[2] - origin[2]],
color: fl.color, intensity: this.spotIntensity,
distance: fl.distance * 1.2, angle: fl.angle, penumbra: fl.penumbra,
};
if (this.spot) {
this.spot.setPos(origin);
this.spot.setTarget(hit);
this.spot.setIntensity(this.spotIntensity);
} else {
this.spot = this.deps.renderer.addSpotLight({
pos: origin, color: fl.color, intensity: this.spotIntensity,
distance: fl.distance * 1.2, angle: fl.angle, penumbra: fl.penumbra,
});
this.spot.setTarget(hit);
}
}
/** Состояние для агентного снапшота (интенсивность — пост-мерцание). */
snapshot(): LightingSnapshot {
return {
nightFactor: this.nightFactor,
sunK: Math.round(this.sunK() * 1000) / 1000,
particleTint: lerpColor(0xffffff, PARTICLE_NIGHT, this.nightFactor),
particleLights: this.handles.size,
flashlight: { on: this.spot !== null, intensity: Math.round(this.spotIntensity * 1000) / 1000 },
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();
this.spot?.dispose();
this.spot = null;
}
// --- внутреннее ---
/** Активные 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;
}
}