Newer
Older
rpg / apps / game / src / systems / Lighting.ts
/**
 * Игровая обвязка освещения: статика из LightingDef области + лампа героя,
 * проекция в экранные px каждый тик (camera.toScreen), условные источники
 * по флагам и времени суток. Runtime API (setAmbient) — база day/night.
 */

import {
    Camera,
    Lighting,
    dayNightFactor,
    lerpAmbient,
    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;
    /** Текущее время суток в часах (GameClock.hours) — вход кривой дня. */
    timeHours: () => number;
    lamp?: HeroLampDef | null;
}

export interface LightingSnapshot {
    ambient: number;
    /** Целевая интенсивность виньетки 0..1 (текущая — пост-лерп движка). */
    vignette: number;
    sources: { id: string; x: number; y: number; color: number; intensity: number }[];
}

/** Порог ночи для ночных источников и лампы на светлой улице (≈ 19:00–07:00). */
const NIGHT_SOURCE_FACTOR = 0.5;

/** Тёмная аура зоны наката: радиус в юнитах и сила затемнения. */
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;
    /** Активные 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;
            this.updateNightAmbient(); // ночной вход в область — без мелькания дня
        }
    }

    // --- 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);
    }

    /** Тик: ночная цель 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();
    }

    /** Текущий фактор ночи 0..1 по часам (округлён — для снапшота и фильтров). */
    get nightFactor(): number {
        return Math.round(dayNightFactor(this.deps.timeHours()) * 1000) / 1000;
    }

    /** Цель ambient: микс дневного и ночного по кривой суток (плавный закат — сама кривая). */
    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 = lerpAmbient(base, night, this.nightFactor);
        if (target !== this.lastAmbientTarget) this.setAmbient(target, 0);
    }

    /** Тёмные ауры зон наката: по тайлу 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 ?? [];
        const night = this.nightFactor;
        for (const def of all) {
            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.area.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR)) {
            // Позиция героя уже в мировых юнитах: центры тайлов = 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;
    }
}