Files
Project-M/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs
T
kronic cd607ae156 Hazard: exploding barrels — fused explosive clutter, friendly-fire bait
Phase 1.5 environmental hazard v1 (design-review wf_2cb10454-fdf: 13
findings confirmed + folded; Build Spec in the vault). ~25% of room
clutter seeds as EXPLOSIVE (Variant 3 on the existing replicated byte
- zero new GhostFields/prefabs/RPCs).

The FUSE is the review's fold - one mechanism closes both HIGHs:
- Pop sites (projectile sweep + isServer-gated melee harvest) do NOT
  destroy an explosive: they zero the replicated Remaining + add a
  server-only BarrelFuse (~36 ticks). The client sees Remaining=0 on a
  still-alive barrel across snapshots -> unambiguous fuse cue, and
  booms at despawn ONLY when cached Remaining<=0 - a teardown despawn
  carries Remaining>0, so portal-advance teardowns can never fire
  false booms (HIGH #1).
- HazardExplosionSystem (server, plain group, presence-gated on
  BarrelFuse, never lifecycle-gated) detonates at ExplodeTick: radius
  damage to LIVING enemies AND players (boss-slam player filter
  verbatim; friendly fire = the bait mechanic), SourceTick stamped at
  detonation = the authoring moment (HIGH #2: the authored-tick
  contract dash i-frames negate against), SourceNetworkId=-1 (the
  environment convention - a player id would consume Charger
  whiff-punish windows). Fuse rides the RoomTag'd barrel -> teardown
  cleans lit barrels free.
- Lit barrels are unhittable at both snapshot sites (no double-pop).
- Client: WorldFeedback caches Variant -> boom-vs-puff split (big
  burst + light flash + boom SFX vs the old puff); DynamicLightSystem
  gives Variant-3 barrels a red danger glow that STROBES once fused.

Verified: 470/470 EditMode (4 new: fused pop instead of destroy +
unhittable, both-sides damage w/ -1 source + authored tick, dead-player
+ radius filters, unelapsed-fuse inert); live smoke - seeded 3/8
explosive, real-sweep pop, CLIENT observed the fuse cue on the live
ghost, pinned bait enemy took exactly 26 (30->4), walk-out escape
confirmed; console clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:41:47 -07:00

235 lines
11 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// 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 <see cref="RegionMath.ExpeditionPortalPos"/> authority the beacon + HUD prompt use), a soft glow
/// on resource nodes, and short impact FLASHES fed by CombatFeedbackSystem's burst funnel via
/// <see cref="RequestFlash"/>. Observe-only <see cref="SystemBase"/> in
/// <see cref="PresentationSystemGroup"/> (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 <see cref="DynamicLightConfig"/> (defaults when absent).
/// Wall-clock time is fine here (presentation only). URP: point lights, shadows off.
/// </summary>
[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<FlashRequest> Pending = new List<FlashRequest>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() { Pending.Clear(); }
/// <summary>Queue a short impact flash. <paramref name="color"/> with a==0 means "use the config
/// default colour"; <paramref name="scale"/> scales intensity+range (clamped by the drainer).</summary>
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<Entity, Light> _projectileLights = new Dictionary<Entity, Light>();
readonly Dictionary<Entity, Light> _nodeLights = new Dictionary<Entity, Light>();
readonly Dictionary<Entity, Light> _barrelLights = new Dictionary<Entity, Light>();
readonly HashSet<Entity> _seen = new HashSet<Entity>();
readonly List<Entity> _dead = new List<Entity>();
readonly Stack<Light> _pool = new Stack<Light>();
readonly List<ActiveFlash> _flashes = new List<ActiveFlash>();
Light _portalLight;
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
}
protected override void OnUpdate()
{
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") 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<RefRO<LocalTransform>>().WithAll<Projectile>().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<GhostOwner>(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<RefRO<LocalTransform>>().WithAll<ResourceNode>().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<BlightClutter>, RefRO<LocalTransform>>().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);
// ---- portal light (RoomExplore only) ----
bool portalOn = false;
if (SystemAPI.TryGetSingleton<RunInfo>(out var runInfo)
&& runInfo.Lifecycle == RunLifecycle.RoomExplore
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
{
portalOn = true;
var pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1));
if (_portalLight == null) _portalLight = Rent();
float baseI = cfg != null ? cfg.PortalIntensity : 3.5f;
float amp = cfg != null ? cfg.PortalPulseAmp : 1.2f;
_portalLight.color = cfg != null ? cfg.PortalColor : new Color(0.55f, 0.95f, 1f, 1f);
_portalLight.range = cfg != null ? cfg.PortalRange : 13f;
_portalLight.intensity = baseI + amp * Mathf.Sin(UnityEngine.Time.time * 4f);
_portalLight.transform.position = new Vector3(pos.x, pos.y + 2.2f, pos.z);
}
if (!portalOn && _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<Entity, Light> 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>();
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);
}
}
}