Effects/NZAtmosphere.cs

A GameObjectSystem that scales a scene's AmbientLight and DirectionalLight colours to darken or restore imported maps. It stores original colours per-component, applies configured multipliers (ambient, sky, sun), exposes console commands to set colours, trigger rebakes, report status, reset, and toggle behavior, and ensures values are applied periodically.

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

namespace NZombies;

/// <summary>
/// Scales down whatever ambient and sun a ported map arrived with, so the map goes DARK and its
/// own light fixtures become the light.
///
/// ⛔ A MULTIPLIER, NOT A COLOUR, AND THAT IS THE WHOLE REASON THIS WORKS ON THIRTEEN MAPS. Every
/// imported `light_environment` carries its own authored colour — Basalt's sun is a pale blue-white
/// `0.583 0.767 0.797` over a `0.169 0.176 0.224` sky. Writing an absolute colour here would
/// flatten every map to the same one and throw away the character the original mapper chose.
/// Scaling keeps the hue and takes the level.
///
/// ⚠️ A MAP MAY NAME ITS OWN COLOURS NOW (`LightingSettings.SunColour`, `SkyColour`, `AmbientColour`, 2026-09-29), for a map
/// imported in grey that no level of grey makes look like daylight (City Uprising). Blank keeps the imported colour, so the rule
/// above still holds for every map that names none.
///
/// ⛔ AND IT SCALES FROM THE ORIGINAL EVERY TIME, NEVER FROM THE CURRENT VALUE. Reading the live
/// colour and multiplying it would compound: at 0.2 the map is a fifth as bright on the first
/// frame, a twenty-fifth on the second, black by the third — and the cause is invisible, because
/// each individual frame did exactly what it was told. This project has already paid for that once
/// in `ModelTuner`, where a bone read in one space and written in another doubled its offset every
/// frame until the position reached 116,818,000.
///
/// ⚠️ RUNTIME, NOT A SCENE EDIT, FOR TWO REASONS. The map's lights live inside the `MapInstance`'s
/// loaded copy, and INSTRUCTIONS has a whole section on what happens when tool passes and the
/// editor fight over `scenes/maps/*.scene` — *"81 objects became 111 again, the AmbientLight
/// vanished"*. Verified: driving the values through the MapInstance leaves the imported file's
/// mtime untouched. The second reason is that lighting step 5 needs the ambient to be runtime-
/// driven anyway, so zones can retint it as the player crosses between them.
///
///     # MAPPORT: lighting step 3
/// </summary>
public sealed class NZAtmosphere : GameObjectSystem<NZAtmosphere>
{
	public NZAtmosphere( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, 0, Tick, "nz.atmos" );
	}

	/// <summary>`nz_atmos 0` — leave every map exactly as it was imported.</summary>
	[ConVar( "nz_atmos" )] public static bool Enabled { get; set; } = true;

	/// <summary>
	/// The numbers themselves live in the MAP'S CONFIG, not here.
	///
	/// ⛔ ONE SOURCE OF TRUTH, AND IT IS THE ONE THAT SAVES. Statics would mean the darkness is
	/// whatever the last console command left behind — right until a map load, at which point it
	/// silently persists onto a map that wanted its sun. Per-map is also the only correct answer:
	/// `gm_island_d` has no emissive fixtures to see by, so a global "dark" would make it
	/// unplayable rather than moody.
	///
	/// ⚠️ NEVER NULL. `ActiveConfig.Current` is a fresh MapConfig before anything is loaded, whose
	/// Lighting is all 1.0 — as imported, which is exactly the right behaviour for a map with no
	/// config yet.
	/// </summary>
	static LightingSettings Cfg => ActiveConfig.Current?.Lighting ?? new LightingSettings();

	/// <summary>
	/// How much of the map's flat fill to keep. 1 is as imported.
	///
	/// ⚠️ THIS IS THE ONE THAT DECIDES WHETHER A MAP IS DARK. Measured on Basalt: with the sun and
	/// sky zeroed the interior barely changed, and with the ambient zeroed as well it went COMPLETELY
	/// black but for two white marks. Practically all of that map's light is this number.
	/// </summary>
	public static float Ambient => BakeOverride ?? Cfg.Ambient;

	/// <summary>
	/// While a bake runs, the ambient the probes should see — see `LightingSettings.BakeAmbient`.
	///
	/// ⛔ IT HAS TO OVERRIDE RATHER THAN BE WRITTEN ONCE, because this class re-applies the scaled
	/// colours every 0.25s from the config. Setting the lights directly before a bake would simply
	/// be undone by the next tick, mid-bake, and the probes would capture whatever the poll had just
	/// restored — a race that would look like an intermittently dim bake.
	///
	/// ⚠️ AND IT COVERS SKY AS WELL AS AMBIENT, because on these maps the sky term is half the
	/// fill and raising only one leaves the bake short.
	/// </summary>
	public static float? BakeOverride { get; set; }

	/// <summary>
	/// The second ambient term, the one that lives on the DirectionalLight.
	///
	/// ⛔ SEPARATE FROM `Ambient`, AND FORGETTING IT IS WHY "THE AMBIENT IS OFF" CAN STILL
	/// LOOK LIT. `DirectionalLight.SkyColor` is a sky-tinted fill applied independently of the
	/// `AmbientLight` component; zeroing one alone left Basalt's interior almost unchanged. Both
	/// have to come down together or the map does not get dark.
	/// </summary>
	public static float Sky => BakeOverride ?? Cfg.Sky;

	/// <summary>The directional's own light. Outdoors this is the sun; indoors on a map like Basalt
	/// it contributes almost nothing.</summary>
	public static float Sun => Cfg.Sun;

	/// <summary>
	/// Whether the directional still casts shadows. `nz_atmos_set sunshadows 0`.
	///
	/// ⚠️ THE ONLY SHADOW-CASTER MOST OF THESE MAPS HAVE, and on Basalt it is a 4-cascade one doing
	/// almost nothing indoors. Worth being able to switch off and measure, given shadows are the
	/// single biggest cost in this engine — see `LightBake`.
	/// </summary>
	public static bool SunShadows => Cfg.SunShadows;

	/// <summary>
	/// What each light looked like before we touched it. See the compounding note in the class doc.
	///
	/// ⚠️ KEYED BY COMPONENT, so a map load that brings new lights captures them fresh rather than
	/// scaling a new map's lights by an old map's baseline.
	/// </summary>
	readonly Dictionary<AmbientLight, Color> _ambientRest = new();
	readonly Dictionary<DirectionalLight, (Color Light, Color Sky, bool Shadows)> _sunRest = new();

	TimeSince _sinceScan;


	void Tick()
	{
		// ⚠️ POLLED, NOT EVERY FRAME. `GetAllComponents` walks the scene, and a map's lights change
		// only when a map loads. A quarter second is imperceptible to a mapper and costs nothing.
		if ( _sinceScan < 0.25f ) return;
		_sinceScan = 0f;

		Apply( Scene );
	}

	// ⛔ THE AUTOMATIC BAKE USED TO LIVE HERE AND IT WAS ACTIVELY HARMFUL. It fired the moment the
	// map's AmbientLight appeared, which is map-LOAD time — before `NZGame.ShowConfig` has built a
	// single placeable. On Basalt the map's only real light source is a LAVA damage wall built by
	// `DamageWallManager.Rebuild()`, so this reliably baked a room with no lava in it.
	//
	// ⚠️ AND IT MASQUERADED AS DECAY. A hand-run `nz_light_bake` lit the map perfectly, then it
	// went dark again "on its own" — reported twice as the bake being unreliable. It was this,
	// overwriting a good bake with a pre-config snapshot.
	//
	// ⚠️ WHAT THE PROBES CAPTURE IS WHATEVER EXISTS WHEN THEY RENDER, so the trigger belongs with
	// whatever finishes building the world, not with a light appearing. It is now the last line of
	// `NZGame.ShowConfig`, via `LightBake.BakeIfConfigured`.

	/// <summary>Capture-then-scale every light in the scene. Idempotent by construction.</summary>
	public static void Apply( Scene scene )
	{
		var self = Current;
		if ( self is null || !scene.IsValid() ) return;

		// ⛔ THE EDITOR'S OWN SCENE KEEPS ITS LIGHTS AS IMPORTED (2026-09-28). This system ticks there as well, and
		// `ActiveConfig.Current` outlives a game: after playing basalt, the editor's own map sat at basalt's darkness — countdown's
		// light_environment measured at 0.001 of its ambient, 0.2 of its sky and 0.1 of its sun (`NZPostProcess` had the same leak).
		var on = Enabled && !scene.IsEditor;

		// ⛔ A BAKE SET UP IN THE EDITOR'S SCENE HAS ITS OWN LIGHTS (`MapBake`, 2026-09-29): putting them back to imported here, four
		// times a second, would bake the map under other lights than the game's
		if ( scene.IsEditor && MapBake.Prepared( scene ) ) return;

		// ⚠️ THE CONFIG'S COLOUR, WHERE IT NAMES ONE, IN PLACE OF THE IMPORTED ONE (`LightingSettings.SunColour`…), then the multiplier
		var cfg = Cfg;

		foreach ( var a in scene.GetAllComponents<AmbientLight>() )
		{
			if ( !a.IsValid() ) continue;

			if ( !self._ambientRest.TryGetValue( a, out var rest ) )
				self._ambientRest[a] = rest = a.Color;

			a.Color = on ? Scale( LightingSettings.ColourOr( cfg.AmbientColour, rest ), Ambient ) : rest;
		}

		foreach ( var d in scene.GetAllComponents<DirectionalLight>() )
		{
			if ( !d.IsValid() ) continue;

			if ( !self._sunRest.TryGetValue( d, out var rest ) )
				self._sunRest[d] = rest = (d.LightColor, d.SkyColor, d.Shadows);

			d.LightColor = on ? Scale( LightingSettings.ColourOr( cfg.SunColour, rest.Light ), Sun ) : rest.Light;
			d.SkyColor = on ? Scale( LightingSettings.ColourOr( cfg.SkyColour, rest.Sky ), Sky ) : rest.Sky;
			d.Shadows = on ? SunShadows : rest.Shadows;
		}

		// ⚠️ DROP DEAD ENTRIES, or a map that has been loaded and unloaded a dozen times leaves a
		// dozen dead lights in here holding references the scene has finished with.
		self._ambientRest.Keys.Where( k => !k.IsValid() ).ToList()
			.ForEach( k => self._ambientRest.Remove( k ) );
		self._sunRest.Keys.Where( k => !k.IsValid() ).ToList()
			.ForEach( k => self._sunRest.Remove( k ) );
	}

	/// <summary>⚠️ ALPHA IS LEFT ALONE. It is not a brightness on any of these and scaling it would
	/// quietly change what "fully opaque" means.</summary>
	static Color Scale( Color c, float f )
		=> new( c.r * f, c.g * f, c.b * f, c.a );

	/// <summary>
	/// `nz_dark [amount]` — the whole step-3 look in one command. 0.15 by default.
	///
	/// ⚠️ ONE COMMAND BECAUSE IT IS THREE NUMBERS AND TWO OF THEM ARE NOT OBVIOUS. Turning down
	/// only `nz_atmos_ambient` leaves the sky term lighting the map and looks like the command did
	/// nothing — which is exactly the trap the `nz_atmos_sky` note describes.
	/// </summary>
	[ConCmd( "nz_dark" )]
	public static void Dark( float amount = 0.15f )
	{
		Enabled = true;

		var c = ActiveConfig.Current?.Lighting;
		if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }

		c.Ambient = c.Sky = c.Sun = amount;

		// ⛔ THE BAKE COMES WITH IT, BECAUSE A DARK MAP WITHOUT ONE IS BLACK. Measured: at ambient
		// 0 the only things visible in Basalt were the two light strips. The probes are what carry
		// light off those fixtures onto the concrete, so darkening without baking is not a dimmer
		// version of the look, it is a different and unusable one.
		if ( amount < 0.9f ) c.Bake = true;

		ActiveConfig.NotifyChanged();
		Apply( Game.ActiveScene );
		Rebake();
		Report();

		Log.Info( "[nz-atmos] `nz_save` to keep this with the map's config" );
	}

	/// <summary>
	/// `nz_atmos_set &lt;ambient|sky|sun|sunshadows|bake|density&gt; &lt;value&gt;` — one knob at a time.
	///
	/// ⚠️ NAMED PARTS RATHER THAN A CONVAR EACH. These live in the map's config now, so a ConVar
	/// would be a second copy that disagrees with the saved one the moment either is touched.
	/// </summary>
	[ConCmd( "nz_atmos_set" )]
	public static void Set( string part = "", float value = float.NaN )
	{
		var c = ActiveConfig.Current?.Lighting;
		if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }

		if ( string.IsNullOrWhiteSpace( part ) || float.IsNaN( value ) )
		{
			Log.Info( "[nz-atmos] nz_atmos_set <ambient|bakeambient|sky|sun|sunshadows|bake|density> <value>" );
			Report();
			return;
		}

		switch ( part.ToLowerInvariant() )
		{
			case "ambient": c.Ambient = value; break;
			case "bakeambient": c.BakeAmbient = value; break;
			case "sky": c.Sky = value; break;
			case "sun": c.Sun = value; break;
			case "sunshadows": c.SunShadows = value != 0f; break;
			case "bake": c.Bake = value != 0f; break;
			case "density": c.BakeDensity = (int)MathF.Max( 1f, value ); break;
			default:
				Log.Warning( $"[nz-atmos] no such part '{part}' — "
					+ "ambient, bakeambient, sky, sun, sunshadows, bake, density" );
				return;
		}

		ActiveConfig.NotifyChanged();
		Apply( Game.ActiveScene );
		Report();
	}

	/// <summary>`nz_atmos_rebake` — bake the indirect volume again, at the config's density.
	///
	/// ⚠️ NEEDED AFTER ANY LIGHTING CHANGE. The probes store what the map looked like AT BAKE
	/// TIME, so brightening a panel or dropping the ambient leaves the bounce describing the old
	/// map until this is run.</summary>
	/// <summary>
	/// `nz_atmos_colour &lt;sun|sky|ambient&gt; &lt;#rrggbb | off&gt;` — a light's colour in place of the imported one, `off` for the
	/// imported one again (`LightingSettings.SunColour`…). Bare, it says what is set. `nz_save` keeps it; a baked map needs baking
	/// again for its bounce light to follow (`nz_map_bake`).
	/// </summary>
	[ConCmd( "nz_atmos_colour" )]
	public static void Colour( string part = "", string value = "" )
	{
		var c = ActiveConfig.Current?.Lighting;
		if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }

		var v = value.Trim();
		if ( v.Equals( "off", StringComparison.OrdinalIgnoreCase ) || v == "-" ) v = "";
		else if ( v.Length > 0 && Color.Parse( v ) is null ) { Log.Warning( $"[nz-atmos] '{value}' is not a colour — #RRGGBB, or off" ); return; }

		switch ( part.Trim().ToLowerInvariant() )
		{
			case "sun": c.SunColour = v; break;
			case "sky": c.SkyColour = v; break;
			case "ambient": c.AmbientColour = v; break;
			default:
				Log.Info( "[nz-atmos] nz_atmos_colour <sun|sky|ambient> <#rrggbb|off> — now: sun "
					+ $"'{c.SunColour}', sky '{c.SkyColour}', ambient '{c.AmbientColour}' (blank: as imported)" );
				return;
		}

		ActiveConfig.NotifyChanged();
		Apply( Game.ActiveScene );
		Report();
		Log.Info( "[nz-atmos] `nz_save` to keep it with the map's config; a baked map needs baking again to match (nz_map_bake)" );
	}

	[ConCmd( "nz_atmos_rebake" )]
	public static void Rebake()
	{
		var c = ActiveConfig.Current?.Lighting;
		if ( c is null || !c.Bake ) { Log.Info( "[nz-atmos] baking is off (nz_atmos_set bake 1)" ); return; }

		LightBake.Bake( c.BakeDensity );
	}

	/// <summary>`nz_atmos_reset` — put every map back exactly as it was imported.</summary>
	[ConCmd( "nz_atmos_reset" )]
	public static void Reset()
	{
		var c = ActiveConfig.Current?.Lighting;
		if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }

		c.Ambient = c.Sky = c.Sun = 1f;
		c.SunShadows = true;
		c.Bake = false;
		Enabled = true;

		ActiveConfig.NotifyChanged();
		Apply( Game.ActiveScene );

		// ⚠️ THE VOLUME HAS TO GO TOO. Leaving it behind means the ambient is restored and still
		// overridden by stale probes — "reset" that does not look reset.
		LightBake.Volume( 0 );

		Log.Info( "[nz-atmos] back to as-imported (indirect volume removed too)" );
	}

	/// <summary>`nz_atmos_report` — what the map's lights are, and what they started as.</summary>
	[ConCmd( "nz_atmos_report" )]
	public static void Report()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-atmos] no scene" ); return; }

		Apply( scene );

		var self = Current;
		var amb = scene.GetAllComponents<AmbientLight>().Where( x => x.IsValid() ).ToList();
		var sun = scene.GetAllComponents<DirectionalLight>().Where( x => x.IsValid() ).ToList();

		Log.Info( $"[nz-atmos] {( scene.IsEditor ? "the editor's own scene - as imported" : Enabled ? "on" : "OFF - as imported" )}"
			+ $"   ambient x{Ambient:0.###}   sky x{Sky:0.###}   sun x{Sun:0.###}"
			+ $"   sun shadows {( SunShadows ? "on" : "off" )}" );

		Log.Info( $"[nz-atmos] {amb.Count} ambient light(s), {sun.Count} directional(s)" );

		foreach ( var a in amb )
		{
			var rest = self is not null && self._ambientRest.TryGetValue( a, out var r ) ? r : a.Color;
			Log.Info( $"[nz-atmos]   ambient now {Fmt( a.Color )}   was {Fmt( rest )}" );
		}

		foreach ( var d in sun )
		{
			// ⚠️ THE FALLBACK TUPLE IS NAMED EXPLICITLY. Written as `(d.LightColor, d.SkyColor,
			// d.Shadows)` the compiler infers the names from the members, giving a tuple whose
			// fields are `LightColor`/`SkyColor` — which does not match the dictionary's
			// `Light`/`Sky` and fails to compile on the ternary rather than at the tuple.
			var rest = self is not null && self._sunRest.TryGetValue( d, out var r )
				? r
				: (Light: d.LightColor, Sky: d.SkyColor, Shadows: d.Shadows);

			Log.Info( $"[nz-atmos]   sun now {Fmt( d.LightColor )} sky {Fmt( d.SkyColor )}"
				+ $"   was {Fmt( rest.Light )} / {Fmt( rest.Sky )}" );
		}

		// ⚠️ NAMES THE TRAP. "I turned the ambient down and nothing happened" is the sky term, every
		// time, and it is invisible from the values alone unless you know to look at both.
		if ( Enabled && Ambient < 0.5f && Sky >= 0.9f )
			Log.Info( "[nz-atmos]   note: ambient is down but SKY is still full — on these maps the"
				+ " sky term alone keeps interiors lit. `nz_dark` sets all three." );
	}

	static string Fmt( Color c ) => $"({c.r:0.##},{c.g:0.##},{c.b:0.##})";
}