Player/Noclip.cs

A Player Component that implements a creative-mode noclip (fly-through-walls) for map authoring. It toggles via V or console, disables physics/input/collider while active, moves the player transform directly based on eye angles and input, and restores the saved state when turned off or destroyed.

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

namespace NZombies;

/// <summary>
/// NOCLIP — fly through the map while building it. V toggles it, creative only.
///
/// Authoring a map means getting to places the map does not let a player go: the
/// top of a doorway to click a wall's height, the far side of a barrier you just
/// sealed, a rooftop you are fencing off. Walking there is often impossible by
/// construction — you are building the very thing that stops you.
///
/// ⛔ CREATIVE ONLY, AND FORCED OFF ON THE WAY OUT. A player who flies in
/// Survival is not playing nZombies, and leaving the mode with the collider
/// still disabled would hand them a body that falls through the world.
/// </summary>
public sealed class Noclip : Component
{
	/// <summary>Units per second. 600 is a brisk walk-through-walls; the sprint
	/// key multiplies it.</summary>
	[Property] public float Speed { get; set; } = 600f;

	/// <summary>How much faster the run key makes it.</summary>
	[Property] public float SprintMultiplier { get; set; } = 3f;

	/// <summary>Flying right now?</summary>
	public bool Flying { get; private set; }

	// ⚠️ WHAT THE CONTROLLER LOOKED LIKE BEFORE WE TOUCHED IT. Restoring
	// hardcoded values instead would quietly overwrite anything else that had
	// configured the body — and "noclip once, then gravity is subtly wrong
	// forever" is a horrible bug to track down later.
	bool _savedGravity, _savedMotion, _savedInput, _savedCollider, _saved;

	/// <summary>The player's noclip, or null. For the console command.</summary>
	public static Noclip Find()
	{
		var p = NZPlayer.Local;
		return p?.Components.GetOrCreate<Noclip>();
	}

	protected override void OnUpdate()
	{
		// ⛔ CHECKED EVERY FRAME, not hooked onto the mode change. NZGame.Mode is
		// static and survives a play restart, and PlayerPresence moves the body
		// on its own — so there is no single event that reliably means "creative
		// ended". Asking every frame cannot be missed.
		if ( !NZGame.IsCreative )
		{
			if ( Flying ) SetFlying( false );
			return;
		}

		// ⚠️ Not while a menu is up. The Q menu and the lobby both have text
		// fields, and typing a config name containing a "v" would otherwise
		// toggle noclip under the player. Mouse visibility is the one signal both
		// menus already set, so this needs no new seam into the razor types.
		if ( Input.Keyboard.Pressed( "V" ) && Mouse.Visibility != MouseVisibility.Visible )
			SetFlying( !Flying );

		if ( Flying ) Fly();
	}

	/// <summary>
	/// Drive the transform directly.
	///
	/// ⚠️ NOT via WishVelocity. The controller's movement is ground-based — it
	/// would still refuse to go up, still stick to surfaces, and still be the
	/// thing deciding where we end up. Writing the position is the whole point:
	/// nothing gets a vote.
	/// </summary>
	void Fly()
	{
		var c = Components.Get<PlayerController>();
		if ( !c.IsValid() ) return;

		// ⚠️ From the EYE ANGLES, not the body's rotation. The body yaws to
		// follow the camera but never pitches, so using it would give a noclip
		// that cannot fly up or down at the one moment you want to — lining up
		// the top of a doorway.
		// ⛔ NOT WHILE A CURSOR MENU IS UP. `Input.AnalogMove` holds its last value while the cursor
		// is visible, so a key released with a panel open stays held as far as this is concerned —
		// and this writes the position directly every frame, so the player keeps flying in that
		// direction until noclip is switched off. Reported exactly that way.
		//
		// ⚠️ THE SAME TEST THE V KEY ABOVE ALREADY USES, and the same one `NZPlayer` uses to keep
		// the scroll wheel out of the wallbuy picker: one check covers DevMenu, LobbyMenu, the
		// tuner panels and the next menu for free. ⚠️ `Auto` is NOT `Visible` — that is the in-game
		// state menus restore on close, so testing for it would disable noclip permanently.
		if ( Mouse.Visibility == MouseVisibility.Visible ) return;

		var rot = c.EyeAngles.ToRotation();
		var move = Input.AnalogMove;

		var dir = (rot.Forward * move.x) + (rot.Left * move.y);

		if ( Input.Down( "Jump" ) ) dir += Vector3.Up;
		if ( Input.Down( "Duck" ) ) dir += Vector3.Down;

		// ⛔ NOTHING ELSE MAY CARRY THE PLAYER WHILE FLYING, AND SAYING SO EVERY FRAME IS THE FIX.
		// `Detach` zeroes the velocity and disables motion ONCE, at the moment noclip starts — so
		// anything that writes velocity afterwards (a boss's pull, a shove, a residual integration
		// on a body whose `MotionEnabled` did not take) is carried for as long as noclip lasts and
		// stops the instant it ends, because `Reattach` zeroes again. That is exactly the reported
		// shape: "I keep moving in that direction until I drop noclip".
		//
		// ⚠️ CHEAP AND UNCONDITIONAL. Two writes a frame while a dev tool is on, against a class of
		// bug that needs the one component that moved you to be identified first.
		var body = c.Body;
		if ( body.IsValid() )
		{
			body.Velocity = Vector3.Zero;
			body.AngularVelocity = Vector3.Zero;
		}
		c.WishVelocity = Vector3.Zero;

		if ( dir.IsNearZeroLength ) return;

		float speed = Speed * (Input.Down( "Run" ) ? SprintMultiplier : 1f);
		WorldPosition += dir.Normal * speed * Time.Delta;
	}

	/// <summary>Turn it on or off. Safe to call with it already in that state.</summary>
	public void SetFlying( bool on )
	{
		if ( Flying == on ) return;

		var c = Components.Get<PlayerController>();
		if ( !c.IsValid() )
		{
			Log.Warning( "[nz] noclip: no PlayerController on this player" );
			return;
		}

		if ( on ) Detach( c );
		else Reattach( c );

		Flying = on;
		Log.Info( $"[nz] noclip {(on ? "ON — WASD + jump/duck, run to go faster" : "off")}" );
	}

	/// <summary>
	/// Take the body out of the physics simulation.
	///
	/// Four separate things, because each one alone leaves a different half of
	/// the problem: gravity still pulls, the controller still steers, physics
	/// still owns the transform, and the collider still stops you at a wall.
	/// </summary>
	void Detach( PlayerController c )
	{
		var body = c.Body;
		var col = c.ColliderObject;

		_savedGravity = body.IsValid() && body.Gravity;
		_savedMotion = body.IsValid() && body.MotionEnabled;
		_savedInput = c.UseInputControls;
		_savedCollider = col.IsValid() && col.Enabled;
		_saved = true;

		if ( body.IsValid() )
		{
			// Zeroed BEFORE motion is disabled — a body frozen mid-fall keeps its
			// velocity and hands it straight back the moment noclip ends, so you
			// would drop out of the sky at whatever speed you were falling when
			// you turned it on.
			body.Velocity = Vector3.Zero;
			body.AngularVelocity = Vector3.Zero;
			body.Gravity = false;
			body.MotionEnabled = false;
		}

		c.UseInputControls = false;

		// ⛔ AND THE CONTROLLER'S OWN WISH, WHICH THE BODY'S VELOCITY IS NOT.
		// Zeroing body.Velocity stops the PHYSICS momentum; the controller keeps a
		// separate WishVelocity — where it WANTS to go — and `UseInputControls =
		// false` only stops that being refreshed from input. It does not clear it.
		// So toggling noclip while walking froze the last wish in place and the
		// controller kept applying it forever: "if i am moving the moment i click
		// noclip, i maintain the movement permanently".
		//
		// ⚠️ Reported as a NOCLIP bug and it is really a handover bug — the same
		// shape as the vault's transform ownership. Two systems both believe they
		// are moving the body, and taking one out of the loop is not the same as
		// telling it to stop.
		c.WishVelocity = Vector3.Zero;

		if ( col.IsValid() ) col.Enabled = false;
	}

	/// <summary>Put it back exactly as it was.</summary>
	void Reattach( PlayerController c )
	{
		if ( !_saved ) return;

		// ⚠️ Cleared on the way OUT as well. Flying moves the transform directly,
		// so by the time control is handed back the old wish describes a direction
		// from wherever noclip started — and the player would set off in it the
		// instant input control returned.
		c.WishVelocity = Vector3.Zero;

		var body = c.Body;
		var col = c.ColliderObject;

		if ( col.IsValid() ) col.Enabled = _savedCollider;

		if ( body.IsValid() )
		{
			body.Gravity = _savedGravity;
			body.MotionEnabled = _savedMotion;

			// Land, do not launch. Whatever velocity the body had before it was
			// frozen is meaningless now that we have flown somewhere else.
			body.Velocity = Vector3.Zero;
			body.AngularVelocity = Vector3.Zero;
		}

		c.UseInputControls = _savedInput;
		_saved = false;
	}

	// ⛔ BOTH OF THESE, OR A DISABLED NOCLIP LEAVES A BROKEN PLAYER. The state
	// this component changes lives on OTHER components, so if it stops running
	// while flying — hotload, component toggled off, player destroyed — nothing
	// else will ever put gravity and the collider back, and the body falls
	// through the world with no clue as to why.
	protected override void OnDisabled() => SetFlying( false );
	protected override void OnDestroy() => SetFlying( false );

	// ── console ──────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_noclip_report` — every input and every carrier that could be moving you, right now.
	/// </summary>
	///
	/// ⚠️ IT EXISTS BECAUSE TWO GUESSES AT THIS WERE WRONG. A drifting player can be the analog
	/// stick latched by a visible cursor, a physics body that kept its velocity, a `WishVelocity`
	/// nobody cleared, or the flags that were supposed to stop all three. They are indistinguishable
	/// from the outside and each needs a different fix. Run it while drifting.
	[ConCmd( "nz_noclip_report" )]
	public static void ReportCmd()
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-noclip] no player" ); return; }

		var n = p.Components.Get<Noclip>( FindMode.EverythingInSelfAndDescendants );
		var c = p.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
		var body = c.IsValid() ? c.Body : null;

		Log.Info( $"[nz-noclip] flying {(n.IsValid() && n.Flying)}"
			+ $" · cursor {Mouse.Visibility}"
			+ $" · analog {Input.AnalogMove}" );

		Log.Info( $"[nz-noclip] wish {(c.IsValid() ? c.WishVelocity.ToString() : "-")}"
			+ $" · input {(c.IsValid() ? c.UseInputControls.ToString() : "-")}"
			+ $" · body {(body.IsValid() ? $"vel {body.Velocity} motion {body.MotionEnabled} gravity {body.Gravity}" : "none")}" );
	}

	/// <summary>Toggle noclip, or set it: nz_noclip [0|1]. The V key equivalent.</summary>
	[ConCmd( "nz_noclip" )]
	public static void Toggle( int on = -1 )
	{
		var n = Find();
		if ( n is null ) { Log.Warning( "[nz] no player" ); return; }

		if ( !NZGame.IsCreative )
		{
			Log.Warning( "[nz] noclip is creative only — nz_mode creative first" );
			return;
		}

		n.SetFlying( on < 0 ? !n.Flying : on != 0 );
	}

	/// <summary>How fast noclip flies: nz_noclip_speed &lt;units/sec&gt; [sprint].</summary>
	[ConCmd( "nz_noclip_speed" )]
	public static void SetSpeed( float speed = 600f, float sprint = 3f )
	{
		var n = Find();
		if ( n is null ) { Log.Warning( "[nz] no player" ); return; }

		n.Speed = MathF.Max( 1f, speed );
		n.SprintMultiplier = MathF.Max( 1f, sprint );

		Log.Info( $"[nz] noclip speed {n.Speed:0}/s, {n.SprintMultiplier:0.#}x sprinting" );
	}
}