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.
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;
} );
}
}