AI/Agents/AgentController.cs
using HC3.Terrain;
using Sandbox.Diagnostics;
using System;
namespace HC3;
public abstract class AgentController : Component
{
[RequireComponent] public Agent Agent { get; set; }
[ConVar( "hc3.debug.ai" )] public static bool UseDebugDraw { get; set; }
[Property] public float WalkSpeed { get; set; } = 40.0f;
[Property] public float RunSpeed { get; set; } = 80.0f;
[Sync( SyncFlags.FromHost ), Property, ReadOnly] public bool IsGrounded { get; set; } = true;
[Sync( SyncFlags.FromHost )] private bool _isRunning { get; set; }
public bool IsOnStairs { get; set; } = false;
/// <summary>
/// Are we running?
/// </summary>
public new virtual bool IsRunning
{
get
{
return _isRunning;
}
set
{
_isRunning = value;
}
}
public bool IsNavigating => NetworkedPath.Count > 0;
public float MoveSpeed
{
get
{
var spd = IsRunning ? RunSpeed : WalkSpeed;
spd += speedOffset;
return spd;
}
}
/// <summary>
/// How fast should this agent steer?
/// </summary>
[Property] public float SteerSpeed { get; set; } = 3f;
[Sync( SyncFlags.FromHost )] NetList<Vector3> NetworkedPath { get; set; } = new();
NavFlags _currentFlags;
Vector3? _currentTarget;
float _pathTotalLength = 0f;
float speedOffset;
Vector3 _gravity => Scene.PhysicsWorld.Gravity;
// Cache the current navigation path so we're not reconstructing every frame
List<Vector3> _currentPath;
int _currentPathIndex = 0;
/// <summary>
/// The NavigationVersion when this route was computed. Used for cheap invalidation detection.
/// </summary>
uint _routeNavVersion;
/// <summary>
/// Cached remaining path distance, updated incrementally as waypoints are consumed.
/// </summary>
float _cachedRemainingDistance = 0f;
/// <summary>
/// Last position where CheckPlatform ran its ray trace fallback.
/// </summary>
Vector3 _lastPlatformCheckPos;
protected override void OnEnabled()
{
speedOffset = Game.Random.Int( -10, 10 );
}
public IEnumerable<Vector3> GetNetworkedPath() => NetworkedPath;
public List<Vector3> GetCurrentPath() => _currentPath;
public void BeginRide() => Enabled = false;
public void EndRide() => Enabled = true;
/// <summary>
/// Navigate to a point in the world.
/// </summary>
public bool Navigate( Vector3 target, NavFlags flags = NavFlags.Default )
{
using var _ = Performance.Scope( "Guest::Navigation" );
if ( WorldPosition.Distance( target ) <= (GridManager.GridSize / 2.0f) )
{
ClearNavigation();
return true; // arrived
}
if ( _currentPath != null && _currentPath.Count > 0 )
{
var targetGrid = GridManager.WorldToGridPosition( target );
var lastTargetGrid = GridManager.WorldToGridPosition( _currentTarget.Value );
// don't repath if our destination is the same
if ( targetGrid == lastTargetGrid )
{
return false;
}
}
_currentTarget = target;
_currentFlags = flags;
var path = GridNavigation.Instance.FindWorldPath( WorldPosition, target, flags );
if ( path is not null && path.Count > 0 )
{
_currentPath = path;
_currentPathIndex = 0;
_routeNavVersion = GridManager.Instance.NavigationVersion;
ComputePathLength();
_cachedRemainingDistance = _pathTotalLength;
UpdateNetworkedPath();
}
else if ( path is null )
{
Log.Warning( $"{this.GameObject} couldn't path ({Agent.ActionController?.CurrentAction})" );
}
return false;
}
private GridCell _currentCell;
public void UpdateAgentCell()
{
var gridPos = GridManager.WorldToGridPosition( WorldPosition );
var newCell = GridManager.Instance.GetCell( gridPos );
if ( newCell == null || newCell == _currentCell ) return;
if ( _currentCell != null )
AgentCellCache.Unregister( this, _currentCell );
AgentCellCache.Register( this, newCell );
_currentCell = newCell;
}
private void ComputePathLength()
{
_pathTotalLength = 0f;
if ( _currentPath == null || _currentPath.Count < 2 )
return;
for ( int i = 1; i < _currentPath.Count; i++ )
_pathTotalLength += _currentPath[i - 1].Distance( _currentPath[i] );
}
/// <summary>
/// Navigate among a pre-defined path
/// </summary>
/// <param name="path"></param>
public void Navigate( List<Vector3> path )
{
if ( _currentPath != null && _currentPath.Count > 0 )
{
var finalTarget = _currentPath[^1];
if ( finalTarget.Distance( path.Last() ) <= (GridManager.GridSize / 2.0f) )
return;
}
_currentFlags = NavFlags.IncludeUnwalkable;
_currentPath = path;
_currentTarget = path.Last();
_currentPathIndex = 0;
_routeNavVersion = GridManager.Instance.NavigationVersion;
ComputePathLength();
_cachedRemainingDistance = _pathTotalLength;
UpdateNetworkedPath();
}
public Vector3Int TilePosition
{
get
{
var pos2d = GridManager.WorldToGridPosition( WorldPosition );
int level = (int)MathF.Floor( (WorldPosition.z + GridNavigation.Z_BIAS) / GridManager.HeightStep );
return new Vector3Int( pos2d.x, pos2d.y, level );
}
}
public bool IsRouteValid()
{
if ( _currentFlags.HasFlag( NavFlags.IncludeUnwalkable ) ) return true;
// Fast path: if the navigation graph hasn't changed since we computed this route, it's still valid
if ( _routeNavVersion == GridManager.Instance.NavigationVersion )
return true;
// Navigation graph changed — do a full revalidation
var currentGrid = GridManager.WorldToGridPosition3D( _currentPath[_currentPathIndex] );
var destPos = GridManager.WorldToGridPosition3D( _currentPath[^1] );
// Quick region check first
if ( !GridManager.IsWalkable( currentGrid, destPos ) )
{
Log.Info( $"InvalidRoute: WorldPosition {currentGrid} not walkable to destination {destPos} ({Agent.FullName} doing {Agent.ActionController.CurrentAction})" );
return false;
}
// Spot-check a few upcoming waypoints + destination instead of entire path
int remaining = _currentPath.Count - _currentPathIndex;
int step = Math.Max( 1, remaining / 5 );
for ( int i = _currentPathIndex; i < _currentPath.Count; i += step )
{
var worldPos = _currentPath[i];
var gridPos = GridManager.WorldToGridPosition( worldPos );
if ( GridManager.Instance.GetCell( gridPos ) is not { } cell )
{
Log.Info( $"InvalidRoute: cell missing at {gridPos}" );
return false;
}
if ( !GridNavigation.Instance.CanEnter( gridPos, _currentFlags ) )
{
Log.Info( $"InvalidRoute: can't enter cell at {gridPos}" );
return false;
}
}
// Always check the final waypoint
if ( remaining > 1 )
{
var lastPos = GridManager.WorldToGridPosition( _currentPath[^1] );
if ( GridManager.Instance.GetCell( lastPos ) is not { } lastCell ||
!GridNavigation.Instance.CanEnter( lastPos, _currentFlags ) )
{
Log.Info( $"InvalidRoute: destination unreachable at {lastPos}" );
return false;
}
}
// Route is still valid despite graph change — update our version stamp
_routeNavVersion = GridManager.Instance.NavigationVersion;
return true;
}
/// <summary>
/// Should we try to path around decorations (expensive)
/// </summary>
[Property]
public bool AvoidDecorations { get; set; } = false;
/// <summary>
/// Called every update to move our guests.
/// </summary>
void TryMoveToTarget()
{
if ( _currentPath is null || _currentPathIndex >= _currentPath.Count )
{
ClearNavigation();
return;
}
if ( !IsRouteValid() )
{
ClearNavigation();
return;
}
if ( !IsGrounded )
{
// if we're not grounded, we can't move
return;
}
var currentTarget = _currentPath[_currentPathIndex];
var toTarget = currentTarget - WorldPosition;
var distanceToTarget = toTarget.Length;
var desiredDirection = toTarget.Normal.WithZ( 0 );
var forward = WorldRotation.Forward.WithZ( 0 ).Normal;
float moveStep = MoveSpeed * Time.Delta;
// Snap to point if close
if ( distanceToTarget <= moveStep )
{
// Update cached remaining distance incrementally
_cachedRemainingDistance -= distanceToTarget;
_currentPathIndex++;
if ( _currentPathIndex >= _currentPath.Count )
_currentPath = null;
WorldPosition = currentTarget;
return;
}
float alignment = forward.Dot( desiredDirection ).Clamp( -1.0f, 1.0f );
float speedFactor = MathF.Max( 0.2f, alignment ); // prevent full stall
if ( alignment < -0.99f )
{
desiredDirection = Vector3.Cross( Vector3.Up, forward ).Normal;
}
WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( desiredDirection ), Time.Delta * SteerSpeed );
// clamp XY move to not overshoot
var move = desiredDirection * MathF.Min( moveStep * speedFactor, distanceToTarget );
// move on Z if needed
float zMove = toTarget.Normal.z * moveStep;
WorldPosition += move.WithZ( zMove );
if ( IsOnStairs )
{
// Local avoidance
var avoidanceOffset = Vector3.Zero;
var localAgents = AgentCellCache.GetNeighbouring( _currentCell );
if ( localAgents is not null )
{
foreach ( var other in localAgents )
{
if ( other == this ) continue;
if ( other.IsOnStairs ) continue;
var toOther = other.WorldPosition - WorldPosition;
var dist = toOther.Length;
if ( dist < GridManager.GridSize * 0.25f )
{
avoidanceOffset -= toOther.Normal * (GridManager.GridSize - dist) * 0.2f;
}
}
}
if ( AvoidDecorations )
{
const float avoidanceRadius = 64f;
foreach ( var hit in Scene.FindInPhysics( new Sphere( WorldPosition, avoidanceRadius ) ) )
{
var deco = hit;
if ( deco == null || !deco.Tags.Has( "decoration" ) )
continue;
var toDeco = deco.WorldPosition - WorldPosition;
float dist = toDeco.Length;
if ( dist < GridManager.GridSize * 0.75f )
{
avoidanceOffset -= toDeco.Normal * (GridManager.GridSize - dist) * 0.25f;
}
}
}
// prevent avoidance pushing people off cell (should we do this only for raised platforms?)
Vector3 attemptedPos = WorldPosition + (avoidanceOffset.WithZ( 0 ) * Time.Delta);
Vector3Int gridPos = GridManager.WorldToGridPosition3D( WorldPosition );
Vector3 cellWorldMin = GridManager.GridToWorldPosition( gridPos );
Vector3 cellWorldMax = GridManager.GridToWorldPosition( gridPos + 1 );
WorldPosition = attemptedPos.Clamp( cellWorldMin, cellWorldMax );
}
}
public float GetRemainingPathDistance()
{
if ( _currentPath == null || _currentPathIndex >= _currentPath.Count )
return 0f;
// Cached distance covers waypoint[currentIndex] → end.
// Add distance from agent position to current waypoint.
return WorldPosition.Distance( _currentPath[_currentPathIndex] ) + _cachedRemainingDistance;
}
public float GetPathProgress()
{
if ( _pathTotalLength <= 0f )
return 0f;
float remaining = _cachedRemainingDistance;
return 1f - (remaining / _pathTotalLength).Clamp( 0f, 1f );
}
void CheckPlatform()
{
var gridPos = GridManager.WorldToGridPosition3D( WorldPosition );
var cell = GridManager.Instance.GetCell( new Vector2Int( gridPos ) );
if ( cell is not null && cell.GetComponents<Path>( gridPos.z, 1 ).FirstOrDefault() is Path path )
{
if ( WorldPosition.z > path.WorldPosition.z || Math.Abs( path.WorldPosition.z - WorldPosition.z ) < GridNavigation.Z_BIAS )
{
IsGrounded = true;
if ( path.IsStairs )
{
bool ascending = Vector2.Dot( WorldRotation.Forward, path.StairDirection ) > 0;
if ( !ascending )
{
WorldPosition = WorldPosition.WithZ( Math.Min( WorldPosition.z, path.GetHeightAt( WorldPosition ) ) );
}
}
IsOnStairs = path.IsStairs;
return;
}
}
// AreaTile (enclosure floor) also counts as ground
if ( cell is not null && cell.GetComponents<AreaTile>( gridPos.z, 1 ).Any() )
{
IsGrounded = true;
IsOnStairs = false;
return;
}
// Ray trace fallback — skip if we haven't moved since last check
if ( WorldPosition.AlmostEqual( _lastPlatformCheckPos, 1f ) )
return;
_lastPlatformCheckPos = WorldPosition;
var tr = Scene.Trace.Ray( WorldPosition + Vector3.Up * 4f, WorldPosition + Vector3.Down * 8f )
.WithTag( "ground" )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
IsGrounded = tr.Hit;
}
void SnapOnZ()
{
// we only really want to snap if we're NOT on stairs
// otherwise we might get pushed to the level above a stair - which is likely a void
if ( IsOnStairs )
return;
int biasedLevel = (int)((WorldPosition.z + GridNavigation.Z_BIAS) / GridManager.HeightStep);
WorldPosition = WorldPosition.WithZ( biasedLevel * GridManager.HeightStep );
}
Vector3Int previousTile;
public void Tick()
{
DebugDraw();
// Don't do any actions if we're dead as hell
if ( Agent == null || !Agent.IsAlive )
return;
// If we're in a building, don't bother moving
if ( Agent.Building.IsValid() )
{
return;
}
TryMoveToTarget();
var currentTile = TilePosition;
if ( currentTile.x != previousTile.x || currentTile.y != previousTile.y )
{
if ( IsGrounded )
{
// entered a new tile, make sure we've not just clipped under
SnapOnZ();
}
}
previousTile = TilePosition;
CheckPlatform();
if ( !IsGrounded )
{
var nextPos = WorldPosition;
// falling!
nextPos += (_gravity / 5.0f /* this looks fun ok */) * Time.Delta;
var terrain = GridManager.Instance.Terrain;
if ( terrain.IsValid() )
{
// don't fall thru terrain
var gridPos = GridManager.WorldToGridPosition( nextPos ).Clamp( terrain.Bounds.Shrink( 0, 0, 1, 1 ) );
nextPos.z = Math.Max( nextPos.z, terrain.GetHeight( gridPos ) * GridManager.HeightStep );
}
WorldPosition = nextPos;
ClearNavigation();
}
UpdateAgentCell();
}
void DebugDraw()
{
if ( !UseDebugDraw ) return;
using ( Gizmo.Scope( "GuestPath" ) )
{
if ( _currentPath != null && _currentPath.Count > 1 )
{
var offset = Vector3.Up * 16f;
for ( int i = 0; i < _currentPath.Count - 1; i++ )
{
var from = _currentPath[i] + offset;
var to = _currentPath[i + 1] + offset;
var col = _currentPathIndex > i ? Color.Green : Color.Red;
Gizmo.Draw.Color = col.WithAlpha( 0.5f );
Gizmo.Draw.LineSphere( new Sphere( from, 4 ) );
Gizmo.Draw.Color = Color.White.WithAlpha( 0.5f );
var spacer = ((to - from).Normal * 8f);
Gizmo.Draw.Line( from + spacer, to - spacer );
}
Gizmo.Draw.Color = Color.Red.WithAlpha( 0.5f );
Gizmo.Draw.LineSphere( new Sphere( _currentPath.Last() + offset, 4 ) );
}
}
}
public void ClearNavigation()
{
_currentPath = null;
_currentFlags = NavFlags.None;
_currentTarget = null;
_currentPathIndex = 0;
_cachedRemainingDistance = 0f;
_routeNavVersion = 0;
UpdateNetworkedPath();
SnapOnZ();
}
void UpdateNetworkedPath()
{
NetworkedPath.Clear();
if ( _currentPath is null || _currentPath.Count <= 0 )
return;
foreach ( var v in _currentPath )
{
NetworkedPath.Add( v );
}
}
protected override void OnDestroy()
{
if ( _currentCell != null )
AgentCellCache.Unregister( this, _currentCell );
}
}