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