Tools/ToolCommands.cs

Console command helper for the NZombies map editor. Exposes ConCmds to set game mode, arm tools, place/remove objects, toggle preview, list/clear/place spawns, and helper placement offsets and random placement routines that call into the MapEditor and ActiveConfig systems.

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// TOOL/COMMANDS — drive the map editor without a mouse.
///
/// Every one of these calls the SAME method the mouse buttons call, so a
/// passing test here is evidence about the real tool rather than about a
/// parallel code path built for testing.
/// </summary>
public static class ToolCommands
{
	static MapEditor Editor
	{
		get
		{
			var p = NZPlayer.Local;
			return p?.Components.GetOrCreate<MapEditor>();
		}
	}

	/// <summary>Creative / Survival / Lobby. Tools only work in creative.</summary>
	[ConCmd( "nz_mode" )]
	public static void SetMode( string mode = "creative" )
	{
		var m = mode.ToLowerInvariant() switch
		{
			"creative" => GameMode.Creative,
			"survival" => GameMode.Survival,
			_ => GameMode.Lobby,
		};
		NZGame.SetMode( m );
	}

	/// <summary>Arm a tool, as clicking it in the Q menu does. Empty to holster.</summary>
	[ConCmd( "nz_tool" )]
	public static void Tool( string tool = "" )
	{
		var e = Editor;
		if ( e is null ) { Log.Warning( "[nz] no player" ); return; }

		e.ActiveTool = tool;
		Log.Info( string.IsNullOrEmpty( tool )
			? "[nz] tool holstered"
			: $"[nz] tool armed: {NZTools.NameFor( tool )}" );
	}

	/// <summary>
	/// What LMB does. Optionally cap the reach first.
	///
	/// ⚠️ The distance argument exists because nobody can nudge the crosshair
	/// over MCP — without it, a placement always lands on whatever the camera
	/// happens to be pointing at, which is usually a distant wall. Capping the
	/// reach puts it on the floor in front instead, and the reach is restored
	/// afterwards so the tool behaves normally for a person holding the mouse.
	/// </summary>
	[ConCmd( "nz_place" )]
	public static void PlaceCmd( float distance = 0f )
	{
		var e = Editor;
		if ( e is null ) { Log.Warning( "[nz] no player" ); return; }
		if ( !e.HasTool ) { Log.Warning( "[nz] no tool armed — nz_tool player_spawn" ); return; }
		if ( !NZGame.IsCreative ) { Log.Warning( "[nz] not in creative — nz_mode creative" ); return; }

		if ( distance <= 0f ) { e.Place(); return; }

		// Point along the aim ray at the requested distance. PlaceAt drops it to
		// the floor, so this lands in front of you rather than in mid-air.
		var cam = Game.ActiveScene?.Camera;
		if ( !cam.IsValid() ) { Log.Warning( "[nz] no camera" ); return; }

		e.PlaceAt( cam.WorldPosition + cam.WorldRotation.Forward * distance );
	}

	/// <summary>
	/// Place at a forward/right offset from the camera.
	///
	/// ⚠️ Exists for the same reason nz_place takes a distance: without a mouse
	/// there is no way to aim slightly left of where you are looking, so
	/// "put one beside that" is otherwise impossible to express.
	/// </summary>
	/// <summary>
	/// Toggle preview: nz_preview, or nz_preview 1 / nz_preview 0.
	///
	/// Shows the map the way a player sees it — no spawn markers, no barrier
	/// outlines — without leaving Creative, so nothing is reset and the tools
	/// still work.
	/// </summary>
	[ConCmd( "nz_preview" )]
	public static void Preview( int on = -1 )
	{
		NZGame.PreviewMode = on < 0 ? !NZGame.PreviewMode : on != 0;

		Log.Info( NZGame.PreviewMode
			? "[nz] PREVIEW on — authoring visuals hidden (nz_preview to restore)"
			: "[nz] preview off — markers and outlines back" );
	}

	[ConCmd( "nz_place_offset" )]
	public static void PlaceOffset( float forward = 150f, float right = 80f )
	{
		var e = Editor;
		if ( e is null ) { Log.Warning( "[nz] no player" ); return; }
		if ( !e.HasTool ) { Log.Warning( "[nz] no tool armed" ); return; }
		if ( !NZGame.IsCreative ) { Log.Warning( "[nz] not in creative" ); return; }

		// ⚠️ Anchored to the PLAYER, not Scene.Camera. Scene.Camera is a separate
		// object that trails the player, so a command batched right after a
		// teleport measured from where the view USED to be — placements landed
		// somewhere else entirely and every offset became guesswork. The player's
		// own transform is authoritative the instant it moves.
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation()
			?? p.WorldRotation;

		// Flatten the axes — a slightly downward view would otherwise push the
		// point into the floor and shorten the offset.
		var fwd = rot.Forward.WithZ( 0 ).Normal;
		var rgt = rot.Right.WithZ( 0 ).Normal;

		e.PlaceAt( p.WorldPosition + fwd * forward + rgt * right );
	}

	/// <summary>What RMB does.</summary>
	[ConCmd( "nz_remove" )]
	public static void RemoveCmd()
	{
		var e = Editor;
		if ( e is null ) { Log.Warning( "[nz] no player" ); return; }
		e.RemoveAimed();
	}

	/// <summary>What is currently placed, and where.</summary>
	[ConCmd( "nz_spawns" )]
	public static void Spawns()
	{
		var c = ActiveConfig.Current;
		Log.Info( $"[nz] mode {NZGame.Mode}   config '{c.Name}'" );
		Log.Info( $"[nz] player spawns: {c.PlayerSpawns.Count}" );

		for ( int i = 0; i < c.PlayerSpawns.Count; i++ )
			Log.Info( $"[nz]   [{i}] {c.PlayerSpawns[i].Position}  yaw {c.PlayerSpawns[i].Yaw:0}" );

		Log.Info( $"[nz] zombie spawns: {c.ZombieSpawns.Count}" );
		for ( int i = 0; i < c.ZombieSpawns.Count; i++ )
			Log.Info( $"[nz]   [{i}] {c.ZombieSpawns[i].Position}  yaw {c.ZombieSpawns[i].Yaw:0}" );
	}

	/// <summary>
	/// Place spawns at random points around the player, so the system can be
	/// exercised without anyone aiming at anything.
	///
	/// ⚠️ Traces DOWN from above each point to find the floor, rather than
	/// dropping markers at the player's own height — otherwise they float, and
	/// a spawn point in mid-air is worse than useless.
	/// </summary>
	[ConCmd( "nz_place_random" )]
	public static void PlaceRandom( int count = 4, float radius = 500f )
	{
		var scene = Game.ActiveScene;
		var p = NZPlayer.Local;
		if ( p is null ) { Log.Warning( "[nz] no player" ); return; }

		int placed = 0;
		for ( int i = 0; i < count; i++ )
		{
			var angle = (i / (float)count) * MathF.PI * 2f;
			var at = p.WorldPosition + new Vector3(
				MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0f );

			var tr = scene.Trace
				.Ray( at + Vector3.Up * 500f, at - Vector3.Up * 2000f )
				.Run();

			if ( !tr.Hit ) continue;

			// Face the player, matching what the tool does.
			var yaw = Rotation.LookAt( (p.WorldPosition - tr.HitPosition).WithZ( 0 ) ).Yaw();
			ActiveConfig.Current.PlayerSpawns.Add( new SpawnPoint( tr.HitPosition, yaw ) );
			placed++;
		}

		Log.Info( $"[nz] placed {placed}/{count} player spawns "
			+ $"({ActiveConfig.Current.PlayerSpawns.Count} total)" );
	}

	/// <summary>Wipe them, for starting a placement test over.</summary>
	/// <summary>Wipe spawns. Defaults to both — pass "player" or "zombie" for one.</summary>
	[ConCmd( "nz_clear_spawns" )]
	public static void ClearSpawns( string which = "all" )
	{
		var c = ActiveConfig.Current;
		var w = which.ToLowerInvariant();

		if ( w is "all" or "player" )
		{
			Log.Info( $"[nz] cleared {c.PlayerSpawns.Count} player spawns" );
			c.PlayerSpawns.Clear();
		}

		if ( w is "all" or "zombie" )
		{
			Log.Info( $"[nz] cleared {c.ZombieSpawns.Count} zombie spawns" );
			c.ZombieSpawns.Clear();
		}
	}
}