Effects/MapLightManager.cs

Manager component that creates and manages map-placed dynamic PointLights from the active map configuration. It builds light GameObjects, tracks live lights and per-light flicker/brightness state, and updates light enable/state and color each frame based on power waves and tremor-based flicker.

File Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Builds the config's hand-placed lights.
///
/// ⛔ THE ONLY MANAGER HERE THAT COSTS FRAMERATE, so it is the only one that reports its own bill.
/// s&box has no lightmaps: every light is dynamic every frame. Porting `ttt_canyon_labs`'s 71
/// Source lights — free baked data in Source 1 — produced a 3.5x fps swing by view direction. See
/// `LightBake`.
///
/// ⚠️ AND IT MUST REBUILD BEFORE THE INDIRECT BAKE, NOT AFTER. The probes capture whatever exists
/// when they render, so a light created after the bake contributes nothing to the bounce and a
/// light destroyed before it leaves bounce with no source. `NZGame.ShowConfig` builds every manager
/// and bakes on its last line, which is the ordering that makes this work.
///
///     # MAPPORT: light placeable
/// </summary>
public sealed class MapLightManager : Component
{
	public static MapLightManager Instance { get; private set; }

	protected override void OnAwake() => Instance = this;
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

	public static MapLightManager Ensure( Scene scene = null )
	{
		if ( Instance.IsValid() ) return Instance;

		scene ??= Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Map Light Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<MapLightManager>();
	}

	readonly List<GameObject> _built = new();

	/// <summary>How many are standing right now.</summary>
	public int Built => _built.Count( g => g.IsValid() );

	/// <summary>Every live light, so the power gate can reach them without a scene walk.</summary>
	public readonly List<(PointLight Light, MapLight Spot)> Live = new();

	/// <summary>Each light's own colour at full, its flicker, and what it shows now — beside <see cref="Live"/>, index for index.</summary>
	readonly List<Color> _base = new();
	readonly List<TremorFlicker> _flicker = new();
	readonly List<float> _shown = new();

	/// <summary>
	/// A light this wide is the map's fill, not a lamp: it never flickers, or the whole map would. Basalt's one fill reaches 8192.
	/// </summary>
	const float FillRadius = 2000f;

	public void Rebuild()
	{
		foreach ( var g in _built ) g?.Destroy();
		_built.Clear();
		Live.Clear();
		_base.Clear();
		_flicker.Clear();
		_shown.Clear();

		var list = ActiveConfig.Current?.Lights;
		if ( list is null || list.Count == 0 ) return;

		for ( int i = 0; i < list.Count; i++ )
			Build( i, list[i] );

		var shadowed = list.Count( l => l.Shadows );

		// ⚠️ SAYS WHAT IT COSTS, NOT JUST WHAT IT BUILT. A count alone reads as harmless; the
		// shadow count is the number that actually predicts the framerate.
		Log.Info( $"[nz-light] {Built} placed light(s) built"
			+ ( shadowed > 0
				? $"  ⚠ {shadowed} CASTING SHADOWS — the expensive kind"
				: "  none casting shadows" ) );
	}

	void Build( int index, MapLight spot )
	{
		var go = Scene.CreateObject();
		go.Name = $"Map Light {index}";
		go.WorldPosition = spot.Position;
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		var light = go.Components.Create<PointLight>();

		// ⚠️ THE HEX IS PARSED, NOT ASSUMED. A swatch row writes whatever the mapper typed, and a
		// bad string would otherwise become black — a light that silently does nothing.
		var col = Color.Parse( spot.Color ) ?? Color.White;

		light.LightColor = new Color( col.r * spot.Brightness,
			col.g * spot.Brightness, col.b * spot.Brightness, 1f );

		light.Radius = spot.Radius;
		light.Shadows = spot.Shadows;

		_built.Add( go );
		Live.Add( (light, spot) );
		_base.Add( light.LightColor );
		_flicker.Add( new TremorFlicker() );
		_shown.Add( 1f );
	}

	/// <summary>
	/// Every frame, on every machine: the lights that need the power dark until it is on, then lit in its wave from the switch
	/// (`PowerWave`); and while the ground shakes, each flickering on its own (`MapTremor`, `Gameplay.LightFlicker`).
	///
	/// ⛔ THIS IS WHAT MAKES "NEEDS POWER" WORK AT ALL (2026-09-28). The switch was read by nothing — the old `SetPowered` had no
	/// caller — so a light marked to wait for the power was lit from the start on every map.
	/// ⚠️ `Enabled` ON THE COMPONENT, and the colour dimmed, never a rebuild: rebuilding would make a different light than the one
	/// the indirect bake saw.
	/// ⚠️ A DROPOUT DIMS A LAMP, never quite to black, as a real one fades and catches.
	/// ⛔ A PLACED LIGHT STAYS ON WITH THE POWER OFF unless its own "Needs power" is ticked — and on basalt none is (2026-09-28):
	/// *"the lights i placed, keep them on even with the power off, only the world geometry lights turn on with power"*. The world's
	/// lights are the map's strips (`StripLights`, `Gameplay.StripsNeedPower`). 27 of basalt's 36 had the flag, which did nothing
	/// until the gate above began to read it, the same day; it was cleared in the config, not ignored here.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( Live.Count == 0 ) return;

		PowerWave.Sync();
		var dark = PowerWave.Dark;
		var level = MapTremor.Level * (ActiveConfig.Current?.Gameplay?.LightFlicker ?? 0f);

		for ( var i = 0; i < Live.Count && i < _base.Count; i++ )
		{
			var (light, spot) = Live[i];
			if ( !light.IsValid() ) continue;

			var off = spot.RequiresPower && dark;
			if ( light.Enabled == off ) light.Enabled = !off;
			if ( off ) continue;

			var lit = (spot.RequiresPower ? PowerWave.Lit( spot.Position, i * 31 + 7 ) : 1f)
				* (spot.Radius < FillRadius ? _flicker[i].Lit( level ) : 1f);
			var want = lit <= 0f ? 0f : MathF.Max( 0.02f, lit );
			if ( MathF.Abs( want - _shown[i] ) < 0.004f ) continue;

			_shown[i] = want;
			var c = _base[i];
			light.LightColor = new Color( c.r * want, c.g * want, c.b * want, 1f );
		}
	}
}