NPC/NPC.Controller.cs

NPC controller partial for movement and physics. Computes desired velocity and rotation, applies separation from other pushables, performs capsule sweep collision via Scene.Trace, uses MoveHelper to move with stepping and unstucking, handles ground detection/gravity, and performs stuck checks to recompute path and nudge the NPC.

Native InteropNetworking
using Sandbox;

namespace BrickJam;

public partial class NPC
{
	private static readonly string[] moveIgnoreTags = { "player", "npc", "door", "loot", "nocollide" };

	/// <summary>Capsule sweep used by the <see cref="MoveHelper"/> (collides with world geometry only).</summary>
	private SceneTraceResult Sweep( Vector3 from, Vector3 to )
		=> Scene.Trace.Capsule( CollisionCapsule, from, to )
			.IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( moveIgnoreTags )
			.Run();

	/// <summary>
	/// Move along <see cref="Direction"/> via the custom <see cref="MoveHelper"/>. Scene-System port of the
	/// legacy <c>ComputeMotion</c> (which used Entity-System MoveHelper + NavMesh agent velocity).
	/// </summary>
	public virtual void ComputeMotion()
	{
		if ( !Blocked )
			Velocity = Vector3.Lerp( Velocity, WishVelocity, Time.Delta * 15f ).WithZ( Velocity.z );
		else
			Velocity = Vector3.Zero.WithZ( Velocity.z );

		// Face the victim while attacking, otherwise turn toward the travel direction.
		if ( !CurrentlyMurdering.IsValid() )
		{
			WorldRotation = Rotation.Lerp( WorldRotation, WishRotation, Time.Delta * 5f );
		}
		else
		{
			var look = (CurrentlyMurdering.WorldPosition - WorldPosition).WithZ( 0 );
			if ( look.Length > 0.01f )
				WorldRotation = Rotation.LookAt( look, Vector3.Up );
		}

		// Soft separation from other pushables (players/NPCs).
		Velocity += Pushables.ComputeSeparation( this, WorldPosition, CollisionRadius, PushForce ) * Time.Delta;

		var helper = new MoveHelper( WorldPosition, Velocity, Sweep ) { MaxStandableAngle = 70f };
		helper.TryMoveWithStep( Time.Delta, 24f );
		helper.TryUnstuck();

		WorldPosition = helper.Position;
		Velocity = helper.Velocity;

		// Ground + gravity.
		var down = Sweep( WorldPosition, WorldPosition + Vector3.Down * 2f );
		if ( down.Hit && Vector3.GetAngle( Vector3.Up, down.Normal ) <= 70f )
		{
			IsOnGround = true;
			WorldPosition = down.EndPosition;
			Velocity = Velocity.WithZ( 0 );
		}
		else
		{
			IsOnGround = false;
			Velocity += Scene.PhysicsWorld.Gravity * Time.Delta;
		}
	}

	private Vector3 stuckCheckPos;
	private TimeUntil nextStuckCheck = StuckCheckWindow;
	private const float StuckCheckWindow = 0.5f;

	/// <summary>
	/// If we're trying to move but haven't made real progress over <see cref="StuckCheckWindow"/>, recompute
	/// the path and nudge sideways/up to break contact. Handles wedging in tight doorways and on the mansion
	/// spiral staircase, where the grid path is technically valid but the capsule snags on geometry.
	/// </summary>
	protected void CheckStuck()
	{
		// Only meaningful while actively pathing on the ground and not pinned by an attack/knockback.
		if ( Blocked || CurrentlyMurdering.IsValid() || !IsOnGround || WishSpeed <= 1f )
		{
			stuckCheckPos = WorldPosition;
			nextStuckCheck = StuckCheckWindow;
			return;
		}

		if ( !nextStuckCheck )
			return;

		var moved = WorldPosition.WithZ( 0 ).Distance( stuckCheckPos.WithZ( 0 ) );
		var expected = WishSpeed * StuckCheckWindow;

		// Covered under ~20% of the distance we should have at wish speed => wedged.
		if ( moved < expected * 0.2f )
		{
			RecalculatePath();

			var side = MansionGame.Random.NextSingle() > 0.5f ? 1f : -1f;
			Velocity += WorldRotation.Right * side * WishSpeed + Vector3.Up * 80f; // sidestep + small hop
		}

		stuckCheckPos = WorldPosition;
		nextStuckCheck = StuckCheckWindow;
	}
}