Tools/WallCommands.cs

Console command helpers for the invisible-wall editor tool. Exposes convars to set the next-placed wall properties, list/remove/clear/rebuild walls, toggle visibility and whether a wall blocks zombies, tilt walls, and inspect navmesh exclusion status.

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

namespace NZombies;

/// <summary>
/// WALLS/COMMANDS — the invisible wall tool, without a mouse.
///
/// ⚠️ This tool needs console access more than any other, for a reason specific
/// to it: its output is INVISIBLE by default. "Did that work?" cannot be
/// answered by looking, so `nz_wall_list` is not a convenience here, it is the
/// only way to check a wall exists at all from outside creative.
/// </summary>
public static class WallCommands
{
	static MapEditor Editor
	{
		get
		{
			var p = NZPlayer.Local;
			return p?.Components.GetOrCreate<MapEditor>();
		}
	}

	static InvisibleWallManager Manager => InvisibleWallManager.Ensure( Game.ActiveScene );

	/// <summary>What the tool places next: nz_wall_set [visible] [material].</summary>
	[ConCmd( "nz_wall_set" )]
	public static void Set( bool visible = false, string material = "" )
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor" ); return; }

		ed.WallVisible = visible;
		if ( !string.IsNullOrWhiteSpace( material ) ) ed.WallMaterial = material;

		Log.Info( $"[nz] next invisible wall: {(visible ? "VISIBLE" : "invisible")}"
			+ $", {ed.DebrisCorners} corners then a height click"
			+ (visible ? $", {ed.WallMaterial}" : "") );
	}

	/// <summary>
	/// Show or hide one that already exists: nz_wall_vis &lt;index&gt; [visible].
	///
	/// ⚠️ Edits the CONFIG, so it is a real change that needs saving — not a
	/// view setting. Toggling every wall on to see where they are and then
	/// saving would ship a map full of grey slabs.
	/// </summary>
	[ConCmd( "nz_wall_vis" )]
	public static void SetVisible( int index = 0, bool visible = true )
	{
		var m = Manager;
		if ( m is null ) { Log.Warning( "[nz] no scene" ); return; }

		if ( !m.SetVisible( index, visible ) )
		{
			Log.Warning( $"[nz] no invisible wall #{index}" );
			return;
		}

		Log.Info( $"[nz] wall #{index} -> {(visible ? "visible" : "invisible")}"
			+ "  (unsaved — nz_save to keep it)" );
	}

	/// <summary>
	/// Does the horde stop at this wall too? nz_wall_zombies &lt;index&gt; [on].
	///
	/// ⚠️ OFF IS THE DEFAULT AND USUALLY RIGHT. An invisible wall is normally a map
	/// boundary keeping the PLAYER in; zombies spawn inside it anyway. Turning this on
	/// bakes the wall into the navmesh so the horde routes around it — useful for fencing
	/// off a route, and a real risk if that route was one they depended on.
	///
	/// ⚠️ Edits the CONFIG, like nz_wall_vis. nz_save to keep it.
	/// </summary>
	[ConCmd( "nz_wall_zombies" )]
	public static void SetBlocksZombies( int index = 0, bool on = true )
	{
		var m = Manager;
		if ( m is null ) { Log.Warning( "[nz] no scene" ); return; }

		if ( !m.SetBlocksZombies( index, on ) )
		{
			Log.Warning( $"[nz] no invisible wall #{index}" );
			return;
		}

		Log.Info( $"[nz] wall #{index} -> {(on ? "BLOCKS zombies (baked into the navmesh)" : "player-only (zombies walk through)")}"
			+ "  (unsaved — nz_save to keep it)" );
	}

	/// <summary>Every wall in the config, with what it is and whether it stands.</summary>
	[ConCmd( "nz_wall_list" )]
	public static void List()
	{
		var list = ActiveConfig.Current.InvisibleWalls;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz] no invisible walls placed" );
			return;
		}

		var m = InvisibleWallManager.Instance;

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

			Log.Info( $"[nz] wall #{i}  {(w.Visible ? "VISIBLE  " : "invisible")}"
				+ $"  {Shape( w )}"
				+ (MathF.Abs( w.Pitch ) > 0.5f || MathF.Abs( w.Roll ) > 0.5f
					? $"  tilt {w.Pitch:0.#}°/{w.Roll:0.#}°"
					: "")
				+ $"  at {w.Position}"
				+ (m is null ? "  (no manager)" : m.IsStanding( i ) ? "  standing" : "  NOT STANDING") );
		}

		Log.Info( $"[nz] {list.Count} invisible wall(s) — solid, none block the navmesh" );
	}

	/// <summary>
	/// What a wall is, in the words nz_wall_list uses. Shared with nz_wall_remove and RMB so
	/// all three describe one wall the same way.
	///
	/// ⚠️ THE WIDTH IS THE POINT. A drawn wall used to list as "4-sided, 283 tall" and
	/// nothing else, so a 550x558 block drawn by mistake read exactly like a 68x44 post.
	/// </summary>
	public static string Shape( InvisibleWall w )
		=> w.HasFootprint
			? $"{w.Footprint.Count}-sided, {w.Size.x:0}x{w.Size.y:0} across, {w.Size.z:0} tall"
			: $"{w.Size.x:0}x{w.Size.y:0}x{w.Size.z:0}";

	/// <summary>
	/// Delete one wall by the number nz_wall_list gives it: nz_wall_remove &lt;index&gt;.
	///
	/// ⛔ THE WAY OUT FOR A WALL RMB CANNOT REACH. RMB takes the wall the crosshair is on, or
	/// the one whose centre marker is near it; a wall you are standing inside, or one buried in
	/// the map, gives it neither. Before this the only command that removed a wall was
	/// nz_wall_clear, which removes every one of them.
	///
	/// ⚠️ THE WALLS AFTER IT MOVE DOWN ONE NUMBER. Remove #3 and the old #4 is #3 now, so run
	/// nz_wall_list again before removing a second — the next number off the old list is the
	/// wrong wall.
	///
	/// ⚠️ IN MEMORY ONLY UNTIL `nz_save`, like every other wall command here.
	/// </summary>
	[ConCmd( "nz_wall_remove" )]
	public static void Remove( int index = -1 )
	{
		var list = ActiveConfig.Current.InvisibleWalls;

		if ( index < 0 || index >= list.Count )
		{
			Log.Warning( $"[nz] no wall #{index} — nz_wall_list shows the numbers"
				+ (list.Count > 0 ? $" (0 to {list.Count - 1})" : " (there are none)") );
			return;
		}

		var w = list[index];
		list.RemoveAt( index );

		// Rebuild, not one Destroy — the manager keys its objects by number, and every wall
		// after this one has just changed number. Same reason as nz_wall_clear.
		Manager?.Rebuild();

		Log.Info( $"[nz] wall #{index} removed: {Shape( w )} at {w.Position}"
			+ $"  ({list.Count} left, the ones after it moved down a number)"
			+ "  (unsaved — nz_save to keep it)" );
	}

	/// <summary>
	/// Tilt a wall so it can be a ramp: `nz_wall_tilt &lt;index&gt; &lt;pitch&gt; [roll]`.
	/// </summary>
	///
	/// ⚠️ A FLAT SLAB IS A STEP AND A TILTED ONE IS A ROUTE, which is the whole reason the field
	/// exists — but only up to the map's `Nav.MaxSlope`. Past that the navmesh puts no walkable
	/// polygon on it however solid it is, and the horde treats a perfectly good ramp as a wall.
	/// The command says so rather than letting it be discovered in a play test.
	///
	/// ⚠️ IN MEMORY ONLY UNTIL `nz_save`, like every other wall command here.
	[ConCmd( "nz_wall_tilt" )]
	public static void Tilt( int index, float pitch, float roll = 0f )
	{
		var list = ActiveConfig.Current.InvisibleWalls;

		if ( index < 0 || index >= list.Count )
		{
			Log.Warning( $"[nz] no wall #{index} — nz_wall_list" );
			return;
		}

		var w = list[index];
		w.Pitch = pitch;
		w.Roll = roll;

		InvisibleWallManager.Ensure( Game.ActiveScene )?.Rebuild();

		var max = ActiveConfig.Current?.Nav?.MaxSlope ?? 45f;
		if ( max > 0f && MathF.Abs( pitch ) > max )
			Log.Warning( $"[nz] wall #{index} tilted to {pitch:0.#}° — STEEPER THAN MaxSlope"
				+ $" ({max:0}°), so the navmesh will not make it walkable" );
		else
			Log.Info( $"[nz] wall #{index} tilted to {pitch:0.#}°/{roll:0.#}°"
				+ "  (unsaved — nz_save to keep it)" );
	}

	/// <summary>Delete them all. ⚠️ In-memory only until nz_save.</summary>
	[ConCmd( "nz_wall_clear" )]
	public static void ClearAll()
	{
		var n = ActiveConfig.Current.InvisibleWalls.Count;
		ActiveConfig.Current.InvisibleWalls.Clear();

		// Rebuild, not Clear — the manager owns GameObjects that would otherwise
		// stand in the world with nothing in the config pointing at them, which
		// is a wall nobody can select or remove.
		Manager?.Rebuild();

		Log.Info( $"[nz] {n} invisible wall(s) removed  (unsaved — nz_save to keep it)" );
	}

	/// <summary>
	/// Rebuild them from the config: nz_wall_rebuild.
	///
	/// Also re-applies the navmesh exclusion tag, which is the thing to reach for
	/// if zombies ever start refusing a route near a wall.
	/// </summary>
	[ConCmd( "nz_wall_rebuild" )]
	public static void RebuildAll()
	{
		var m = Manager;
		if ( m is null ) { Log.Warning( "[nz] no scene" ); return; }

		m.Rebuild();
	}

	/// <summary>
	/// Is the navmesh actually ignoring them? nz_wall_nav.
	///
	/// ⛔ WORTH A COMMAND OF ITS OWN because the failure is silent and delayed:
	/// a wall that HAS been baked into the navmesh looks exactly like one that
	/// has not until a zombie declines a route, possibly rounds later.
	/// </summary>
	[ConCmd( "nz_wall_nav" )]
	public static void NavStatus()
	{
		var nav = Game.ActiveScene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz] no navmesh on this scene" ); return; }

		bool excluded = nav.ExcludedBodies.Has( InvisibleWallManager.NavIgnoreTag );

		var walls = ActiveConfig.Current.InvisibleWalls;
		var blocking = walls.Count( w => w.BlocksZombies );

		Log.Info( $"[nz] navmesh excludes '{InvisibleWallManager.NavIgnoreTag}': {excluded}"
			+ $"  — {walls.Count} wall(s) placed" );

		// ⚠️ THE PER-WALL SPLIT, because the global tag says nothing about a wall that
		// deliberately opted out of it. "Excluded: true" with a blocking wall present is
		// correct and would otherwise read as the wall being broken.
		Log.Info( $"[nz]   {walls.Count - blocking} player-only (horde walks through)"
			+ $"  ·  {blocking} blocking (baked in, horde routes around)" );
		for ( int i = 0; i < walls.Count; i++ )
			if ( walls[i].BlocksZombies )
				Log.Info( $"[nz]   #{i} blocks zombies at {walls[i].Position}" );

		if ( !excluded )
			Log.Warning( "[nz] ⚠ walls are NOT excluded — they will be baked into the "
				+ "navmesh the next time tiles regenerate near one. nz_wall_rebuild "
				+ "re-applies the tag." );
	}
}