using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; using UnityEngine; namespace ProjectM.Client { /// /// Client-only DYNAMIC LIGHT layer (Phase 1.5 lighting pass — dark ambient, dynamic-light-first): pooled /// point lights that follow projectile ghosts, a pulsing portal light during the RoomExplore loot window /// (same authority the beacon + HUD prompt use), a soft glow /// on resource nodes, and short impact FLASHES fed by CombatFeedbackSystem's burst funnel via /// . Observe-only in /// (once per frame, no rollback double-fire); never mutates the sim; /// Entity keys are pruned every frame (a pruned ghost = despawn); lights are pooled GameObjects under a /// private root, NEVER scene-saved. Knobs live in (defaults when absent). /// Wall-clock time is fine here (presentation only). URP: point lights, shadows off. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class DynamicLightSystem : SystemBase { struct FlashRequest { public Vector3 Pos; public Color Color; public float Scale; } struct ActiveFlash { public Light Light; public float EndTime; public float BaseIntensity; public float Duration; } // Static request queue: CombatFeedbackSystem (same thread, PresentationSystemGroup) enqueues, we drain. static readonly List Pending = new List(); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void ResetStatics() { Pending.Clear(); } /// Queue a short impact flash. with a==0 means "use the config /// default colour"; scales intensity+range (clamped by the drainer). public static void RequestFlash(Vector3 pos, Color color, float scale) { if (Pending.Count < 64) Pending.Add(new FlashRequest { Pos = pos, Color = color, Scale = scale }); } GameObject _root; readonly Dictionary _projectileLights = new Dictionary(); readonly Dictionary _nodeLights = new Dictionary(); readonly Dictionary _barrelLights = new Dictionary(); readonly HashSet _seen = new HashSet(); readonly List _dead = new List(); readonly Stack _pool = new Stack(); readonly List _flashes = new List(); Light _portalLight; protected override void OnDestroy() { if (_root != null) Object.Destroy(_root); } protected override void OnUpdate() { if (!ScenePolicy.IsGameplayScene()) return; var cfg = DynamicLightConfig.Instance; bool enabled = cfg == null || cfg.Enabled; if (!enabled) { Pending.Clear(); if (_root != null && _root.activeSelf) _root.SetActive(false); return; } if (_root == null) { _root = new GameObject("~DynamicLights"); Object.DontDestroyOnLoad(_root); } if (!_root.activeSelf) _root.SetActive(true); // ---- projectile lights (pooled follow-lights) ---- int projCap = cfg != null ? cfg.MaxProjectileLights : 24; Color projColor = cfg != null ? cfg.ProjectileColor : new Color(0.45f, 0.85f, 1f, 1f); float projIntensity = cfg != null ? cfg.ProjectileIntensity : 2.6f; float projRange = cfg != null ? cfg.ProjectileRange : 7f; Color enemyProjColor = cfg != null ? cfg.EnemyProjectileColor : new Color(0.55f, 1f, 0.35f, 1f); _seen.Clear(); foreach (var (lt, entity) in SystemAPI.Query>().WithAll().WithEntityAccess()) { _seen.Add(entity); if (!_projectileLights.TryGetValue(entity, out var light)) { if (_projectileLights.Count >= projCap) continue; light = Rent(); _projectileLights[entity] = light; } // Attack-distinctness: owned shots glow the player cyan; un-owned (enemy spit) glows toxic green. light.color = EntityManager.HasComponent(entity) ? projColor : enemyProjColor; light.intensity = projIntensity; light.range = projRange; var p = lt.ValueRO.Position; light.transform.position = new Vector3(p.x, p.y + 0.4f, p.z); } PruneUnseen(_projectileLights); // ---- resource-node glow ---- bool nodeGlow = cfg == null || cfg.NodeGlow; int nodeCap = cfg != null ? cfg.MaxNodeLights : 12; Color nodeColor = cfg != null ? cfg.NodeColor : new Color(0.40f, 1f, 0.75f, 1f); float nodeIntensity = cfg != null ? cfg.NodeIntensity : 1.3f; float nodeRange = cfg != null ? cfg.NodeRange : 4.5f; _seen.Clear(); if (nodeGlow) { foreach (var (lt, entity) in SystemAPI.Query>().WithAll().WithEntityAccess()) { _seen.Add(entity); if (!_nodeLights.TryGetValue(entity, out var light)) { if (_nodeLights.Count >= nodeCap) continue; light = Rent(); _nodeLights[entity] = light; } light.color = nodeColor; // scale the glow with the node's harvest-shrink so it fades as the node depletes light.intensity = nodeIntensity * Mathf.Clamp01(lt.ValueRO.Scale); light.range = nodeRange; var p = lt.ValueRO.Position; light.transform.position = new Vector3(p.x, p.y + 1.1f, p.z); } } PruneUnseen(_nodeLights); // ---- explosive-barrel danger glow (BlightClutter.Variant==3): steady red, STROBING once the // fuse is lit (replicated Remaining hits 0 while the barrel still exists) — the flee cue. _seen.Clear(); foreach (var (clutter, lt, entity) in SystemAPI.Query, RefRO>().WithEntityAccess()) { if (clutter.ValueRO.Variant != 3) continue; _seen.Add(entity); if (!_barrelLights.TryGetValue(entity, out var blight)) { if (_barrelLights.Count >= 16) continue; blight = Rent(); _barrelLights[entity] = blight; } bool fused = clutter.ValueRO.Remaining <= 0; blight.color = new Color(1f, 0.28f, 0.12f); blight.range = fused ? 6f : 4f; blight.intensity = fused ? 2.6f + 1.8f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 14f)) : 1.1f; var bp = lt.ValueRO.Position; blight.transform.position = new Vector3(bp.x, bp.y + 0.9f, bp.z); } PruneUnseen(_barrelLights); // 2026-08-07 audit purge: the room-exit portal light keyed off RunInfo.Lifecycle == RoomExplore. // Portals went with the run FSM; release any pooled light so nothing leaks. if (_portalLight != null) { Return(_portalLight); _portalLight = null; } // ---- impact flashes ---- float now = UnityEngine.Time.time; int flashCap = cfg != null ? cfg.MaxFlashLights : 8; Color flashDefault = cfg != null ? cfg.FlashDefaultColor : new Color(1f, 0.85f, 0.55f, 1f); float flashIntensity = cfg != null ? cfg.FlashIntensity : 3.2f; float flashRange = cfg != null ? cfg.FlashRange : 6.5f; float flashDur = cfg != null ? cfg.FlashDuration : 0.18f; for (int i = 0; i < Pending.Count; i++) { if (_flashes.Count >= flashCap) break; var req = Pending[i]; float scale = Mathf.Clamp(req.Scale, 0.3f, 1.6f); var light = Rent(); light.color = req.Color.a > 0f ? req.Color : flashDefault; light.intensity = flashIntensity * scale; light.range = flashRange * Mathf.Sqrt(scale); light.transform.position = req.Pos + new Vector3(0f, 0.6f, 0f); _flashes.Add(new ActiveFlash { Light = light, EndTime = now + flashDur, BaseIntensity = light.intensity, Duration = flashDur }); } Pending.Clear(); for (int i = _flashes.Count - 1; i >= 0; i--) { var f = _flashes[i]; float remain = f.EndTime - now; if (remain <= 0f) { Return(f.Light); _flashes.RemoveAt(i); continue; } f.Light.intensity = f.BaseIntensity * (remain / f.Duration); } } void PruneUnseen(Dictionary map) { _dead.Clear(); foreach (var kv in map) if (!_seen.Contains(kv.Key)) _dead.Add(kv.Key); for (int i = 0; i < _dead.Count; i++) { Return(map[_dead[i]]); map.Remove(_dead[i]); } } Light Rent() { while (_pool.Count > 0) { var pooled = _pool.Pop(); if (pooled == null) continue; // scene reload can null pooled objects pooled.gameObject.SetActive(true); return pooled; } var go = new GameObject("PointLight"); go.transform.SetParent(_root.transform, false); var light = go.AddComponent(); light.type = LightType.Point; light.shadows = LightShadows.None; return light; } void Return(Light light) { if (light == null) return; light.gameObject.SetActive(false); _pool.Push(light); } } }