Effects/MapLightCommands.cs

Console command helpers for map authoring, exposing placement, listing, editing and clearing of lights, sounds and fog areas and rebake/rebuild operations. It reads/modifies ActiveConfig lists, updates MapEditor default stamp, and calls various managers to rebuild scene data.

File AccessExternal Download
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console access to the placed lights and sounds.
///
/// ⚠️ Every setting in both tool panels has an equivalent here, for the reason the rest of this
/// project gives: a thing that can only be placed by aiming and only checked by walking up to it is
/// a thing that goes untested, and these make both drivable remotely.
/// </summary>
public static class MapLightCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	// ── lights ───────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_light_place [x] [y] [z]` — put one where you are looking, or at explicit coordinates.
	///
	/// ⚠️ THE COORDINATES ARE NOT A CONVENIENCE, THEY ARE THE TESTABILITY. Aim-only placement
	/// cannot be driven from a console at all: on a map with no player spawns the player is falling
	/// through the void and every trace misses, which is precisely where this was first run. A
	/// command that can only work when somebody is already standing in the right place is not a
	/// command, and this project's whole reason for having them is that a thing which can only be
	/// placed by aiming goes untested.
	/// </summary>
	[ConCmd( "nz_light_place" )]
	public static void PlaceLight( float x = float.NaN, float y = 0f, float z = 0f )
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz-light] no MapEditor" ); return; }

		if ( float.IsNaN( x ) ) ed.AddMapLight();
		else ed.AddMapLightAt( new Vector3( x, y, z ) );
	}

	/// <summary>`nz_light_list` — what is placed, and what it costs.</summary>
	[ConCmd( "nz_light_list" )]
	public static void ListLights()
	{
		var list = ActiveConfig.Current.Lights;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-light] none placed - Q > Placeables > Light" );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var l = list[i];
			Log.Info( $"[nz-light] [{i}] {l.Color} x{l.Brightness:0.##}  radius {l.Radius:0}"
				+ ( l.Shadows ? "  SHADOWS" : "" )
				+ ( l.RequiresPower ? "  needs power" : "" )
				+ $"  at {l.Position}" );
		}

		// THE SHADOW COUNT IS THE ACTIONABLE NUMBER. Twenty shadowless lights are cheap and
		// twenty shadowed ones are the bill - the total alone does not distinguish them.
		var shadowed = list.Count( l => l.Shadows );

		Log.Info( $"[nz-light] {list.Count} placed, {shadowed} casting shadows"
			+ ( shadowed > 0 ? "  - shadows are the expensive kind, see nz_light_report" : "" ) );
	}

	/// <summary>
	/// `nz_light_set &lt;colour|brightness|radius|shadows|power&gt; &lt;value&gt; [index]` - retune.
	///
	/// INDEX IS OPTIONAL AND MEANS ALL, the same as every other placeable command here.
	/// </summary>
	[ConCmd( "nz_light_set" )]
	public static void SetLight( string part = "", string value = "", int index = -1 )
	{
		var list = ActiveConfig.Current.Lights;

		if ( string.IsNullOrWhiteSpace( part ) || string.IsNullOrWhiteSpace( value ) )
		{
			Log.Info( "[nz-light] nz_light_set <colour|brightness|radius|shadows|power> <value> [index]" );
			ListLights();
			return;
		}

		if ( index >= 0 && index >= list.Count )
		{
			Log.Warning( $"[nz-light] no light #{index} - there are {list.Count}" );
			return;
		}

		float.TryParse( value, out var f );
		var on = value != "0" && !string.Equals( value, "false", System.StringComparison.OrdinalIgnoreCase );

		for ( int i = 0; i < list.Count; i++ )
		{
			if ( index >= 0 && i != index ) continue;

			switch ( part.ToLowerInvariant() )
			{
				case "colour": case "color": list[i].Color = value; break;
				case "brightness": list[i].Brightness = f; break;
				case "radius": list[i].Radius = f; break;
				case "shadows": list[i].Shadows = on; break;
				case "power": list[i].RequiresPower = on; break;
				default:
					Log.Warning( $"[nz-light] no such part '{part}' - "
						+ "colour, brightness, radius, shadows, power" );
					return;
			}
		}

		// ⛔ THE TOOL'S STAMP TOO, WHEN THIS MEANS "ALL". `nz_dmgwall_set` records why: the config
		// is what the things already placed are, the MapEditor fields are what the NEXT one is
		// stamped with, and setting one alone means the value changes now and silently reverts on
		// the next placement.
		var ed = Editor;

		if ( ed.IsValid() && index < 0 )
		{
			switch ( part.ToLowerInvariant() )
			{
				case "colour": case "color": ed.LightColor = value; break;
				case "brightness": ed.LightBrightness = f; break;
				case "radius": ed.LightRadius = f; break;
				case "shadows": ed.LightShadows = on; break;
				case "power": ed.LightRequiresPower = on; break;
			}
		}

		MapLightManager.Ensure( Game.ActiveScene )?.Rebuild();
		ListLights();

		// THE BOUNCE IS STALE NOW. Probes store the map as it was at bake time, so a light that
		// changed after the bake is still contributing its OLD colour to every surface near it.
		Log.Info( "[nz-light] `nz_atmos_rebake` to fold this into the baked bounce, `nz_save` to keep it" );
	}

	/// <summary>`nz_light_clear` — remove them all.</summary>
	[ConCmd( "nz_light_clear" )]
	public static void ClearLights()
	{
		var n = ActiveConfig.Current.Lights.Count;
		ActiveConfig.Current.Lights.Clear();
		MapLightManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-light] removed {n}" );
	}

	// ── sounds ───────────────────────────────────────────────────────────────

	/// <summary>`nz_sound_place [x] [y] [z]` — aim, or explicit coordinates. See nz_light_place
	/// for why the coordinates matter.</summary>
	[ConCmd( "nz_sound_place" )]
	public static void PlaceSound( float x = float.NaN, float y = 0f, float z = 0f )
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz-sound] no MapEditor" ); return; }

		if ( float.IsNaN( x ) ) ed.AddSoundSpot();
		else ed.AddSoundSpotAt( new Vector3( x, y, z ) );
	}

	/// <summary>`nz_sound_list` — what is placed.</summary>
	[ConCmd( "nz_sound_list" )]
	public static void ListSounds()
	{
		var list = ActiveConfig.Current.Sounds;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-sound] none placed - Q > Placeables > Sound" );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var sp = list[i];
			Log.Info( $"[nz-sound] [{i}] '{sp.Sound}'  vol {sp.Volume:0.##}  dist {sp.Distance:0}"
				// ⛔ THIS LABEL WAS BACKWARDS AND IT HID THE BUG. It printed "looping" for
				// `Repeat == false`, which is PLAY ONCE AND STOP - so a spot that had already
				// finished reported itself as a healthy loop, and the list was the thing being
				// trusted while chasing "i do not hear it".
				+ ( sp.Repeat
					? $"  repeats every {sp.RepeatMin:0.#}s"
					: "  ⚠ PLAYS ONCE (Repeat off)" )
				+ $"  at {sp.Position}"
				// SAYS WHEN ONE IS SILENT. A spot placed before a sound was chosen is a normal
				// intermediate state and looks identical to one whose event failed to load.
				+ ( string.IsNullOrWhiteSpace( sp.Sound ) ? "   <- NO SOUND SET" : "" ) );
		}

		Log.Info( $"[nz-sound] {list.Count} placed" );
	}

	/// <summary>
	/// `nz_sound_set &lt;event|volume|distance|repeat&gt; &lt;value&gt; [index]` - retune.
	/// </summary>
	[ConCmd( "nz_sound_set" )]
	public static void SetSound( string part = "", string value = "", int index = -1 )
	{
		var list = ActiveConfig.Current.Sounds;

		if ( string.IsNullOrWhiteSpace( part ) || string.IsNullOrWhiteSpace( value ) )
		{
			Log.Info( "[nz-sound] nz_sound_set <event|volume|distance|repeat> <value> [index]" );
			ListSounds();
			return;
		}

		if ( index >= 0 && index >= list.Count )
		{
			Log.Warning( $"[nz-sound] no sound #{index} - there are {list.Count}" );
			return;
		}

		float.TryParse( value, out var f );
		var on = value != "0" && !string.Equals( value, "false", System.StringComparison.OrdinalIgnoreCase );

		// WARNS RATHER THAN REFUSES, like the material and damage-wall sound commands: a path
		// that is merely still compiling is worse to reject than a wrong one is to accept.
		if ( string.Equals( part, "event", System.StringComparison.OrdinalIgnoreCase )
			&& ResourceLibrary.Get<SoundEvent>( value ) is null )
			Log.Warning( $"[nz-sound] '{value}' did not load - that spot will be silent" );

		for ( int i = 0; i < list.Count; i++ )
		{
			if ( index >= 0 && i != index ) continue;

			switch ( part.ToLowerInvariant() )
			{
				case "event": case "sound": list[i].Sound = value; break;
				case "volume": list[i].Volume = f; break;
				case "distance": list[i].Distance = f; break;
				case "repeat": list[i].Repeat = on; break;
				default:
					Log.Warning( $"[nz-sound] no such part '{part}' - event, volume, distance, repeat" );
					return;
			}
		}

		// ⛔ AND THE TOOL'S STAMP, same as the light above. Without this, `nz_sound_set event ...`
		// on an empty map does nothing at all and the next placement is still silent — which is
		// exactly how this was first run.
		var ed = Editor;

		if ( ed.IsValid() && index < 0 )
		{
			switch ( part.ToLowerInvariant() )
			{
				case "event": case "sound": ed.SoundEventPath = value; break;
				case "volume": ed.SoundSpotVolume = f; break;
				case "distance": ed.SoundSpotDistance = f; break;
			}
		}

		SoundSpotManager.Ensure( Game.ActiveScene )?.Rebuild();
		ListSounds();

		Log.Info( "[nz-sound] `nz_save` to keep it" );
	}

	/// <summary>`nz_sound_clear` — remove them all.</summary>
	[ConCmd( "nz_sound_clear" )]
	public static void ClearSounds()
	{
		var n = ActiveConfig.Current.Sounds.Count;
		ActiveConfig.Current.Sounds.Clear();
		SoundSpotManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-sound] removed {n}" );
	}

	// ── fog ──────────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_fog_place` — says how to draw one, because a fog area is no longer a point.
	///
	/// ⛔ KEPT AS A SIGNPOST RATHER THAN DELETED. It was the documented way to place fog for as long
	/// as fog was a sphere, and a command that has simply vanished looks like a broken build. A
	/// drawn area needs corners, which is `nz_corner_at` and `nz_build` — the same pair the wall and
	/// debris tools use.
	/// </summary>
	[ConCmd( "nz_fog_place" )]
	public static void PlaceFog()
	{
		Log.Info( "[nz-fog] a fog area is DRAWN now, not placed at a point." );
		Log.Info( "[nz-fog]   in game:  Q > Placeables > Map objects > Fog,"
			+ " click the corners on the floor, then click the height" );
		Log.Info( "[nz-fog]   console:  nz_tool fog_area,"
			+ " then nz_corner_at <x y z> per corner, then nz_build <height>" );
		Log.Info( "[nz-fog]   R (or nz_corner_reset) starts the footprint over" );
	}

	/// <summary>`nz_fog_list` — what is placed.</summary>
	[ConCmd( "nz_fog_list" )]
	public static void ListFog()
	{
		var list = ActiveConfig.Current.Fog;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-fog] none placed - Q > Placeables > Fog" );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var f = list[i];
			Log.Info( $"[nz-fog] [{i}] {f.Color}  density {f.Density:0.##}"
				+ $"  {( f.HasFootprint ? $"{f.Footprint.Count}-sided" : "box" )}"
				+ $"  {f.Size.x:0}x{f.Size.y:0}x{f.Size.z:0}"
				+ $"  feather {f.Feather:0}u  blend {f.Blend:0.##}s"
				+ $"  at {f.Position}"
				// ⚠️ SAYS WHEN ONE IS EFFECTIVELY OFF. A density of 0 is a placed area that does
				// nothing, and it looks identical in a list to one that is merely subtle.
				+ ( f.Density <= 0.001f ? "   <- DENSITY 0, does nothing" : "" ) );
		}

		Log.Info( $"[nz-fog] {list.Count} drawn — they blend ONE scene fog, so they cost the same"
			+ " whether there are two or twenty" );
	}

	/// <summary>
	/// `nz_fog_set &lt;colour|density|feather|blend&gt; &lt;value&gt; [index]`.
	/// </summary>
	[ConCmd( "nz_fog_set" )]
	public static void SetFog( string part = "", string value = "", int index = -1 )
	{
		var list = ActiveConfig.Current.Fog;

		if ( string.IsNullOrWhiteSpace( part ) || string.IsNullOrWhiteSpace( value ) )
		{
			Log.Info( "[nz-fog] nz_fog_set <colour|density|feather|blend> <value> [index]" );
			ListFog();
			return;
		}

		if ( index >= 0 && index >= list.Count )
		{
			Log.Warning( $"[nz-fog] no fog area #{index} - there are {list.Count}" );
			return;
		}

		float.TryParse( value, out var f );

		for ( int i = 0; i < list.Count; i++ )
		{
			if ( index >= 0 && i != index ) continue;

			switch ( part.ToLowerInvariant() )
			{
				case "colour": case "color": list[i].Color = value; break;
				case "density": list[i].Density = f; break;
				case "feather": list[i].Feather = f; break;
				case "blend": list[i].Blend = f; break;

				// ⚠️ NAMED SO THE OLD WORDS GET AN ANSWER. `radius` and `height` were real settings
				// until the areas became drawn volumes; silently rejecting them as "no such part"
				// would read as the command being broken rather than the shape having changed.
				case "radius": case "height":
					Log.Warning( $"[nz-fog] '{part}' is gone — an area is DRAWN now."
						+ " Its size comes from the footprint and the height click."
						+ " Remove it (RMB with the Fog tool) and draw it again." );
					return;

				default:
					Log.Warning( $"[nz-fog] no such part '{part}' - "
						+ "colour, density, feather, blend" );
					return;
			}
		}

		// ⛔ THE TOOL'S STAMP TOO, WHEN THIS MEANS "ALL" — same as the light and sound commands.
		// The config is what is already placed; the MapEditor fields are what the NEXT one gets.
		var ed = Editor;

		if ( ed.IsValid() && index < 0 )
		{
			switch ( part.ToLowerInvariant() )
			{
				case "colour": case "color": ed.FogColor = value; break;
				case "density": ed.FogDensity = f; break;
				case "feather": ed.FogFeather = f; break;
			}
		}

		ListFog();
		Log.Info( "[nz-fog] `nz_save` to keep it" );
	}

	/// <summary>
	/// `nz_fog_rebuild` — rebuild the zone geometry after changing a setting.
	///
	/// ⚠️ THE SHEETS ARE BUILT ONCE, NOT PER FRAME, so `nz_fog_layers`, `nz_fog_sheet` and any edit
	/// to a zone's colour or density need this before they show. Without it the only way to see a
	/// change was to reload the map, which also reloads the config you were editing.
	/// </summary>
	[ConCmd( "nz_fog_rebuild" )]
	public static void RebuildFog() => FogAreaManager.Ensure( Game.ActiveScene )?.Rebuild();

	/// <summary>`nz_fog_clear` — remove them all.</summary>
	[ConCmd( "nz_fog_clear" )]
	public static void ClearFog()
	{
		var n = ActiveConfig.Current.Fog.Count;
		ActiveConfig.Current.Fog.Clear();
		FogAreaManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-fog] removed {n}" );
	}

	/// <summary>
	/// `nz_fog_report` — what the blend is doing RIGHT NOW.
	///
	/// ⛔ THE ONLY WAY TO TELL "SUBTLE" FROM "BROKEN". The whole feature was asked for light, so the
	/// correct result and a completely dead system look very similar through a window. This prints
	/// the weight actually applied and which area won it.
	/// </summary>
	[ConCmd( "nz_fog_report" )]
	public static void ReportFog()
	{
		var m = FogAreaManager.Ensure( Game.ActiveScene );
		if ( !m.IsValid() ) { Log.Warning( "[nz-fog] no manager" ); return; }

		var list = ActiveConfig.Current.Fog;
		var p = NZPlayer.Local;

		Log.Info( $"[nz-fog] {( FogAreaManager.Enabled ? "on" : "OFF (nz_fogzone 1)" )}"
			+ $"   weight {m.Weight:0.###}   end {m.EndDistance:0}u   tint {m.Tint.Hex}"
			+ $"   hold {FogAreaManager.Hold:0.#}s{( m.Holding ? "  (HOLDING)" : "" )}"
			+ ( FogAreaManager.KeepWhileInside ? "" : "   keep-inside OFF" )
			+ $"   inside: {m.Inside}" );

		if ( !p.IsValid() ) { Log.Warning( "[nz-fog] no local player — nothing to measure from" ); return; }

		var ear = p.WorldPosition;

		for ( int i = 0; i < list.Count; i++ )
		{
			var w = FogAreaManager.WeightAt( list[i], ear );

			Log.Info( $"[nz-fog]   [{i}] weight {w:0.###}"
				+ $"   centre {ear.Distance( list[i].Position ):0}u away"
				+ ( w <= 0f ? "   (outside)" : "" ) );
		}
	}
}