NPC/NPC.Navigation.cs

NPC navigation component. Maintains A* pathfinding, follows or retraces paths, chooses direct chase when target visible, and asynchronously computes paths using AStarPathBuilder and GameTask threading.

NetworkingNative Interop
using System;
using System.Collections.Generic;
using System.Threading;
using Sandbox;
using GridAStar;

namespace BrickJam;

public partial class NPC
{
	public virtual float WalkSpeed { get; set; } = 120f;
	public virtual float RunSpeed { get; set; } = 380f;

	public Vector3 Direction { get; set; } = Vector3.Zero;
	public virtual float WishSpeed => Direction.IsNearlyZero() ? 0f : (HasArrivedDestination ? 0f : (Target.IsValid() ? RunSpeed : WalkSpeed));
	public Vector3 WishVelocity => Direction * WishSpeed;
	public Rotation WishRotation => Direction.IsNearlyZero() ? WorldRotation : Rotation.LookAt( Direction, Vector3.Up );

	private AStarPath currentPath;
	public AStarPath CurrentPath
	{
		get => currentPath;
		set
		{
			if ( !currentPath.IsEmpty && currentPath.Nodes == value.Nodes )
				return;
			currentPath = value;
			HasArrivedDestination = false;
		}
	}

	public virtual float PathRetraceFrequency { get; set; } = 0.1f;
	internal CancellationTokenSource CurrentPathToken { get; set; } = new();
	public AStarNode CurrentPathNode => IsFollowingPath ? CurrentPath.Nodes[0] : null;
	public AStarNode LastPathNode => IsFollowingPath ? CurrentPath.Nodes[^1] : null;
	public AStarNode NextPathNode => IsFollowingPath ? CurrentPath.Nodes[Math.Min( 1, CurrentPath.Count - 1 )] : null;
	public string NextMovementTag => IsFollowingPath ? NextPathNode.MovementTag : string.Empty;
	public bool IsFollowingPath => !HasArrivedDestination && CurrentPath.Count > 0;
	private Line CurrentPathLine => new( CurrentPathNode.EndPosition, NextPathNode.EndPosition );
	public float DistanceFromIdealPath => CurrentPathLine.Distance( WorldPosition );
	public Vector3 IdealDirection => IsFollowingPath ? (NextPathNode.EndPosition.WithZ( 0 ) - WorldPosition.WithZ( 0 )).Normal : Vector3.Zero;
	public bool HasArrivedDestination { get; private set; } = true;
	internal TimeUntil NextRetraceCheck { get; set; } = 0f;

	/// <summary>The cell we last tried to navigate to, and how long ago - used by the no-path fallback in
	/// <see cref="ComputeNavigation"/> so a momentarily disconnected grid (tight doorways) doesn't freeze us.</summary>
	public Cell DesiredCell { get; private set; }
	private TimeSince lastNavigateAttempt = 999f;

	/// <summary>Follow / maintain the A* path each tick. Scene-System port of legacy <c>ComputeNavigation</c>.</summary>
	public void ComputeNavigation()
	{
		if ( !Networking.IsHost ) return;
		if ( CurrentGrid is null ) return;

		if ( NextRetraceCheck )
		{
			var targetPathCell = GetTargetPathCell();

			if ( targetPathCell != null )
			{
				if ( IsFollowingPath )
				{
					if ( targetPathCell != LastPathNode.Current )
						NavigateTo( targetPathCell );
					else if ( IsOnGround )
					{
						var minimumDistanceUntilRetrace = CurrentGrid.CellSize * 1.42f + CurrentGrid.StepSize / 2f;
						if ( DistanceFromIdealPath > minimumDistanceUntilRetrace )
							NavigateTo( targetPathCell );
					}
				}
				else
					NavigateTo( targetPathCell );
			}

			NextRetraceCheck = PathRetraceFrequency;
		}

		if ( IsFollowingPath )
		{
			Direction = IdealDirection;

			var minimumDistanceUntilNext = CurrentGrid.CellSize;

			if ( WorldPosition.WithZ( 0 ).Distance( NextPathNode.EndPosition.WithZ( 0 ) ) <= minimumDistanceUntilNext )
				if ( Math.Abs( WorldPosition.z - NextPathNode.EndPosition.z ) <= CurrentGrid.StepSize )
				{
					CurrentPath.Nodes.RemoveAt( 0 );

					if ( CurrentPathNode == LastPathNode )
						HasArrivedDestination = true;
				}
		}
		else
		{
			// No usable grid path. If we tried to navigate somewhere very recently (e.g. the grid is
			// momentarily disconnected across a tight doorway), head STRAIGHT at the destination so we don't
			// freeze - the MoveHelper slides us along walls until we can re-path. Otherwise idle.
			if ( lastNavigateAttempt < 2f && DesiredCell != null
				&& DesiredCell.Position.WithZ( 0 ).Distance( WorldPosition.WithZ( 0 ) ) > CurrentGrid.CellSize * 1.5f )
				Direction = (DesiredCell.Position.WithZ( 0 ) - WorldPosition.WithZ( 0 )).Normal;
			else
				Direction = Vector3.Zero;
		}

		// Direct chase: if we can SEE the target right now, head STRAIGHT at it (the mover ignores "door"
		// colliders and slides along walls), bypassing the grid path - which can stall in tight doorways.
		// The grid path above is still maintained, so the moment line-of-sight breaks we fall back to it.
		if ( Target.IsValid() && InVision.TryGetValue( Target, out var seenAgo ) && seenAgo < 0.5f )
		{
			var toTarget = Target.WorldPosition.WithZ( 0 ) - WorldPosition.WithZ( 0 );
			Direction = toTarget.Length > 1f ? toTarget.Normal : Vector3.Zero;
		}
	}

	public void RecalculatePath() => NavigateTo( GetTargetPathCell() );

	public Cell GetTargetPathCell()
	{
		if ( Target.IsValid() )
			return CurrentGrid.GetCell( Target.WorldPosition ) ?? CurrentGrid.GetNearestCell( Target.WorldPosition );
		else if ( IsFollowingPath )
			return LastPathNode.Current;
		else
			return null;
	}

	public virtual AStarPathBuilder PathBuilder => new AStarPathBuilder( CurrentGrid )
		.WithPathCreator( this )
		.WithPartialEnabled()
		.WithMaxDistance( 2000f )
		.AvoidTag( "door", 400f )
		.AvoidTag( "edge", 50f )
		.AvoidTag( "outeredge", 40f )
		.AvoidTag( "inneredge", 30f );

	/// <summary>
	/// Compute a path to <paramref name="targetCell"/> on a background thread (A* only reads cell data, no
	/// scene traces, so this is safe). Scene-System port of legacy <c>NavigateTo</c>.
	/// </summary>
	public virtual void NavigateTo( Cell targetCell )
	{
		var grid = CurrentGrid;
		if ( targetCell is null || grid is null )
			return;

		DesiredCell = targetCell;
		lastNavigateAttempt = 0f;

		var pathBuilder = PathBuilder;
		var startingCell = grid.GetCell( WorldPosition ) ?? grid.GetNearestCell( WorldPosition );
		if ( startingCell is null || startingCell == targetCell )
			return;

		// Direct line of sight: trivial 2-node path, no need to thread.
		if ( grid.LineOfSight( startingCell, targetCell ) )
		{
			CurrentPath = AStarPath.From( pathBuilder, new List<AStarNode>() { new AStarNode( startingCell ), new AStarNode( targetCell ) } );
			return;
		}

		CurrentPathToken.Cancel();
		CurrentPathToken = new CancellationTokenSource();
		var token = CurrentPathToken.Token;

		_ = GameTask.RunInThreadAsync( async () =>
		{
			if ( !this.IsValid() )
				return;

			var computedPath = await pathBuilder.RunAsync( startingCell, targetCell, token );

			if ( computedPath.IsEmpty || computedPath.Length < 1 )
				return;

			computedPath.Simplify( 2, 3, "door", "edge", "inneredge", "outeredge" );

			CurrentPath = computedPath;
		} );
	}
}