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.
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 <colour|brightness|radius|shadows|power> <value> [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 <event|volume|distance|repeat> <value> [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 <colour|density|feather|blend> <value> [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)" : "" ) );
}
}
}