diff --git a/Content.Server/AI/Operators/Movement/BaseMover.cs b/Content.Server/AI/Operators/Movement/BaseMover.cs
deleted file mode 100644
index a491de14fb..0000000000
--- a/Content.Server/AI/Operators/Movement/BaseMover.cs
+++ /dev/null
@@ -1,297 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using Content.Server.GameObjects.Components.Access;
-using Content.Server.GameObjects.Components.Movement;
-using Content.Server.GameObjects.EntitySystems.AI.Pathfinding;
-using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders;
-using Content.Server.GameObjects.EntitySystems.JobQueues;
-using Content.Shared.GameObjects.EntitySystems;
-using Robust.Shared.GameObjects.Components;
-using Robust.Shared.Interfaces.GameObjects;
-using Robust.Shared.Interfaces.Map;
-using Robust.Shared.Interfaces.Random;
-using Robust.Shared.IoC;
-using Robust.Shared.Log;
-using Robust.Shared.Map;
-using Robust.Shared.Maths;
-using Timer = Robust.Shared.Timers.Timer;
-
-namespace Content.Server.AI.Operators.Movement
-{
- public abstract class BaseMover : AiOperator
- {
- ///
- /// Invoked every time we move across a tile
- ///
- public event Action MovedATile;
-
- ///
- /// How close the pathfinder needs to get before returning a route
- /// Set at 1.42f just in case there's rounding and diagonally adjacent tiles aren't counted.
- ///
- ///
- public float PathfindingProximity { get; set; } = 1.42f;
- protected Queue Route = new Queue();
- ///
- /// The final spot we're trying to get to
- ///
- protected GridCoordinates TargetGrid;
- ///
- /// As the pathfinder is tilebased we'll move to each tile's grid.
- ///
- protected GridCoordinates NextGrid;
- private const float TileTolerance = 0.2f;
-
- // Stuck checkers
- ///
- /// How long we're stuck in general before trying to unstuck
- ///
- private float _stuckTimerRemaining = 0.5f;
- private GridCoordinates _ourLastPosition;
-
- // Anti-stuck measures. See the AntiStuck() method for more details
- private bool _tryingAntiStuck;
- public bool IsStuck;
- private AntiStuckMethod _antiStuckMethod = AntiStuckMethod.Angle;
- private Angle _addedAngle = Angle.Zero;
- public event Action Stuck;
- private int _antiStuckAttempts = 0;
-
- private CancellationTokenSource _routeCancelToken;
- protected Job> RouteJob;
- private IMapManager _mapManager;
- private PathfindingSystem _pathfinder;
- private AiControllerComponent _controller;
-
- // Input
- protected IEntity Owner;
-
- protected void Setup(IEntity owner)
- {
- Owner = owner;
- _mapManager = IoCManager.Resolve();
- _pathfinder = IoCManager.Resolve().GetEntitySystem();
- if (!Owner.TryGetComponent(out AiControllerComponent controllerComponent))
- {
- throw new InvalidOperationException();
- }
-
- _controller = controllerComponent;
- }
-
- protected void NextTile()
- {
- MovedATile?.Invoke();
- }
-
- ///
- /// Will move the AI towards the next position
- ///
- /// true if movement to be done
- protected bool TryMove()
- {
- // Use collidable just so we don't get stuck on corners as much
- // var targetDiff = NextGrid.Position - _ownerCollidable.WorldAABB.Center;
- var targetDiff = NextGrid.Position - Owner.Transform.GridPosition.Position;
-
- // Check distance
- if (targetDiff.Length < TileTolerance)
- {
- return false;
- }
- // Move towards it
- if (_controller == null)
- {
- return false;
- }
- _controller.VelocityDir = _addedAngle.RotateVec(targetDiff).Normalized;
- return true;
-
- }
-
- ///
- /// Will try and get around obstacles if stuck
- ///
- protected void AntiStuck(float frameTime)
- {
- // TODO: More work because these are sketchy af
- // TODO: Check if a wall was spawned in front of us and then immediately dump route if it was
-
- // First check if we're still in a stuck state from last frame
- if (IsStuck && !_tryingAntiStuck)
- {
- switch (_antiStuckMethod)
- {
- case AntiStuckMethod.None:
- break;
- case AntiStuckMethod.Jiggle:
- var randomRange = IoCManager.Resolve().Next(0, 359);
- var angle = Angle.FromDegrees(randomRange);
- Owner.TryGetComponent(out AiControllerComponent mover);
- mover.VelocityDir = angle.ToVec().Normalized;
-
- break;
- case AntiStuckMethod.PhaseThrough:
- if (Owner.TryGetComponent(out CollidableComponent collidableComponent))
- {
- // TODO Fix this because they are yeeting themselves when they charge
- // TODO: If something updates this this will fuck it
- collidableComponent.CanCollide = false;
-
- Timer.Spawn(100, () =>
- {
- if (!collidableComponent.CanCollide)
- {
- collidableComponent.CanCollide = true;
- }
- });
- }
- break;
- case AntiStuckMethod.Teleport:
- Owner.Transform.DetachParent();
- Owner.Transform.GridPosition = NextGrid;
- break;
- case AntiStuckMethod.ReRoute:
- GetRoute();
- break;
- case AntiStuckMethod.Angle:
- var random = IoCManager.Resolve();
- _addedAngle = new Angle(random.Next(-60, 60));
- IsStuck = false;
- Timer.Spawn(100, () =>
- {
- _addedAngle = Angle.Zero;
- });
- break;
- default:
- throw new InvalidOperationException();
- }
- }
-
- _stuckTimerRemaining -= frameTime;
-
- // Stuck check cooldown
- if (_stuckTimerRemaining > 0.0f)
- {
- return;
- }
-
- _tryingAntiStuck = false;
- _stuckTimerRemaining = 0.5f;
-
- // Are we actually stuck
- if ((_ourLastPosition.Position - Owner.Transform.GridPosition.Position).Length < TileTolerance)
- {
- _antiStuckAttempts++;
-
- // Maybe it's just 1 tile that's borked so try next 1?
- if (_antiStuckAttempts >= 2 && _antiStuckAttempts < 5 && Route.Count > 1)
- {
- var nextTile = Route.Dequeue();
- NextGrid = _mapManager.GetGrid(nextTile.GridIndex).GridTileToLocal(nextTile.GridIndices);
- return;
- }
-
- if (_antiStuckAttempts >= 5 || Route.Count == 0)
- {
- Logger.DebugS("ai", $"{Owner} is stuck at {Owner.Transform.GridPosition}, trying new route");
- _antiStuckAttempts = 0;
- IsStuck = false;
- _ourLastPosition = Owner.Transform.GridPosition;
- GetRoute();
- return;
- }
- Stuck?.Invoke();
- IsStuck = true;
- return;
- }
-
- IsStuck = false;
-
- _ourLastPosition = Owner.Transform.GridPosition;
- }
-
- ///
- /// Tells us we don't need to keep moving and resets everything
- ///
- public void HaveArrived()
- {
- _routeCancelToken?.Cancel(); // oh thank god no more pathfinding
- Route.Clear();
- if (_controller == null) return;
- _controller.VelocityDir = Vector2.Zero;
- }
-
- protected void GetRoute()
- {
- _routeCancelToken?.Cancel();
- _routeCancelToken = new CancellationTokenSource();
- Route.Clear();
-
- int collisionMask;
- if (!Owner.TryGetComponent(out CollidableComponent collidableComponent))
- {
- collisionMask = 0;
- }
- else
- {
- collisionMask = collidableComponent.CollisionMask;
- }
-
- var startGrid = _mapManager.GetGrid(Owner.Transform.GridID).GetTileRef(Owner.Transform.GridPosition);
- var endGrid = _mapManager.GetGrid(TargetGrid.GridID).GetTileRef(TargetGrid);;
- var access = AccessReader.FindAccessTags(Owner);
-
- RouteJob = _pathfinder.RequestPath(new PathfindingArgs(
- Owner.Uid,
- access,
- collisionMask,
- startGrid,
- endGrid,
- PathfindingProximity
- ), _routeCancelToken.Token);
- }
-
- protected void ReceivedRoute()
- {
- Route = RouteJob.Result;
- RouteJob = null;
-
- if (Route == null)
- {
- Route = new Queue();
- // Couldn't find a route to target
- return;
- }
-
- // Because the entity may be half on 2 tiles we'll just cut out the first tile.
- // This may not be the best solution but sometimes if the AI is chasing for example it will
- // stutter backwards to the first tile again.
- Route.Dequeue();
-
- var nextTile = Route.Peek();
- NextGrid = _mapManager.GetGrid(nextTile.GridIndex).GridTileToLocal(nextTile.GridIndices);
- }
-
- public override Outcome Execute(float frameTime)
- {
- if (RouteJob != null && RouteJob.Status == JobStatus.Finished)
- {
- ReceivedRoute();
- }
-
- return !ActionBlockerSystem.CanMove(Owner) ? Outcome.Failed : Outcome.Continuing;
- }
- }
-
- public enum AntiStuckMethod
- {
- None,
- ReRoute,
- Jiggle, // Just pick a random direction for a bit and hope for the best
- Teleport, // The Half-Life 2 method
- PhaseThrough, // Just makes it non-collidable
- Angle, // Add a different angle for a bit
- }
-}
diff --git a/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs b/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs
index 317ef7d9e1..ed4a10e692 100644
--- a/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs
+++ b/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs
@@ -1,142 +1,67 @@
-using System.Collections.Generic;
-using Content.Server.GameObjects.EntitySystems.JobQueues;
+using System;
+using System.IO;
+using Content.Server.GameObjects.EntitySystems.AI.Steering;
+using Robust.Shared.GameObjects.Systems;
using Robust.Shared.Interfaces.GameObjects;
-using Robust.Shared.Interfaces.Map;
-using Robust.Shared.IoC;
-using Robust.Shared.Map;
+using Robust.Shared.Utility;
namespace Content.Server.AI.Operators.Movement
{
- public sealed class MoveToEntityOperator : BaseMover
+ public sealed class MoveToEntityOperator : AiOperator
{
- // Instance
- private GridCoordinates _lastTargetPosition;
- private IMapManager _mapManager;
+ // TODO: This and steering need to support InRangeUnobstructed now
+ private readonly IEntity _owner;
+ private EntityTargetSteeringRequest _request;
+ private readonly IEntity _target;
+ // For now we'll just get as close as we can because we're not doing LOS checks to be able to pick up at the max interaction range
+ public float ArrivalDistance { get; }
+ public float PathfindingProximity { get; }
- // Input
- public IEntity Target { get; }
- public float DesiredRange { get; set; }
-
- public MoveToEntityOperator(IEntity owner, IEntity target, float desiredRange = 1.5f)
+ public MoveToEntityOperator(IEntity owner, IEntity target, float arrivalDistance = 1.0f, float pathfindingProximity = 1.5f)
{
- Setup(owner);
- Target = target;
- _mapManager = IoCManager.Resolve();
- DesiredRange = desiredRange;
+ _owner = owner;
+ _target = target;
+ ArrivalDistance = arrivalDistance;
+ PathfindingProximity = pathfindingProximity;
+ }
+
+ public override bool TryStartup()
+ {
+ if (!base.TryStartup())
+ {
+ return true;
+ }
+
+ var steering = EntitySystem.Get();
+ _request = new EntityTargetSteeringRequest(_target, ArrivalDistance, PathfindingProximity);
+ steering.Register(_owner, _request);
+ return true;
+ }
+
+ public override void Shutdown(Outcome outcome)
+ {
+ base.Shutdown(outcome);
+ var steering = EntitySystem.Get();
+ steering.Unregister(_owner);
}
public override Outcome Execute(float frameTime)
{
- var baseOutcome = base.Execute(frameTime);
- // TODO: Given this is probably the most common operator whatever speed boosts you can do here will be gucci
- // Could also look at running it every other tick.
-
- if (baseOutcome == Outcome.Failed ||
- Target == null ||
- Target.Deleted ||
- Target.Transform.GridID != Owner.Transform.GridID)
+ switch (_request.Status)
{
- HaveArrived();
- return Outcome.Failed;
- }
-
- if (RouteJob != null)
- {
- if (RouteJob.Status != JobStatus.Finished)
- {
+ case SteeringStatus.Pending:
+ DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner));
return Outcome.Continuing;
- }
- ReceivedRoute();
- return Route.Count == 0 ? Outcome.Failed : Outcome.Continuing;
- }
-
- var targetRange = (Target.Transform.GridPosition.Position - Owner.Transform.GridPosition.Position).Length;
-
- // If they move near us
- if (targetRange <= DesiredRange)
- {
- HaveArrived();
- return Outcome.Success;
- }
-
- // If the target's moved we may need to re-route.
- // First we'll check if they're near another tile on the existing route and if so
- // we can trim up until that point.
- if (_lastTargetPosition != default &&
- (Target.Transform.GridPosition.Position - _lastTargetPosition.Position).Length > 1.5f)
- {
- var success = false;
- // Technically it should be Route.Count - 1 but if the route's empty it'll throw
- var newRoute = new Queue(Route.Count);
-
- for (var i = 0; i < Route.Count; i++)
- {
- var tile = Route.Dequeue();
- newRoute.Enqueue(tile);
- var tileGrid = _mapManager.GetGrid(tile.GridIndex).GridTileToLocal(tile.GridIndices);
-
- // Don't use DesiredRange here or above in case it's smaller than a tile;
- // when we get close we run straight at them anyway so it shooouullddd be okay...
- if ((Target.Transform.GridPosition.Position - tileGrid.Position).Length < 1.5f)
- {
- success = true;
- break;
- }
- }
-
- if (success)
- {
- Route = newRoute;
- _lastTargetPosition = Target.Transform.GridPosition;
- TargetGrid = Target.Transform.GridPosition;
- return Outcome.Continuing;
- }
-
- _lastTargetPosition = default;
- }
-
- // If they move too far or no route
- if (_lastTargetPosition == default)
- {
- // If they're further we could try pathfinding from the furthest tile potentially?
- _lastTargetPosition = Target.Transform.GridPosition;
- TargetGrid = Target.Transform.GridPosition;
- GetRoute();
- return Outcome.Continuing;
- }
-
- AntiStuck(frameTime);
-
- if (IsStuck)
- {
- return Outcome.Continuing;
- }
-
- if (TryMove())
- {
- return Outcome.Continuing;
- }
-
- // If we're really close just try bee-lining it?
- if (Route.Count == 0)
- {
- if (targetRange < 1.9f)
- {
- // TODO: If they have a phat hitbox they could block us
- NextGrid = TargetGrid;
- return Outcome.Continuing;
- }
- if (targetRange > DesiredRange)
- {
- HaveArrived();
+ case SteeringStatus.NoPath:
return Outcome.Failed;
- }
+ case SteeringStatus.Arrived:
+ return Outcome.Success;
+ case SteeringStatus.Moving:
+ DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner));
+ return Outcome.Continuing;
+ default:
+ throw new ArgumentOutOfRangeException();
}
-
- var nextTile = Route.Dequeue();
- NextTile();
- NextGrid = _mapManager.GetGrid(nextTile.GridIndex).GridTileToLocal(nextTile.GridIndices);
- return Outcome.Continuing;
}
}
-}
+}
\ No newline at end of file
diff --git a/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs b/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs
index ad866573ab..11d90389a7 100644
--- a/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs
+++ b/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs
@@ -1,94 +1,63 @@
-using Content.Server.GameObjects.EntitySystems.JobQueues;
+using System;
+using Content.Server.GameObjects.EntitySystems.AI.Steering;
+using Robust.Shared.GameObjects.Systems;
using Robust.Shared.Interfaces.GameObjects;
-using Robust.Shared.Interfaces.Map;
-using Robust.Shared.IoC;
using Robust.Shared.Map;
+using Robust.Shared.Utility;
namespace Content.Server.AI.Operators.Movement
{
- public class MoveToGridOperator : BaseMover
+ public sealed class MoveToGridOperator : AiOperator
{
- private IMapManager _mapManager;
- private float _desiredRange;
+ private readonly IEntity _owner;
+ private GridTargetSteeringRequest _request;
+ private readonly GridCoordinates _target;
+ public float DesiredRange { get; set; }
- public MoveToGridOperator(
- IEntity owner,
- GridCoordinates gridPosition,
- float desiredRange = 1.5f)
+ public MoveToGridOperator(IEntity owner, GridCoordinates target, float desiredRange = 1.5f)
{
- Setup(owner);
- TargetGrid = gridPosition;
- _mapManager = IoCManager.Resolve();
- PathfindingProximity = 0.2f; // Accept no substitutes
- _desiredRange = desiredRange;
+ _owner = owner;
+ _target = target;
+ DesiredRange = desiredRange;
}
- public void UpdateTarget(GridCoordinates newTarget)
+ public override bool TryStartup()
{
- TargetGrid = newTarget;
- HaveArrived();
- GetRoute();
+ if (!base.TryStartup())
+ {
+ return true;
+ }
+
+ var steering = EntitySystem.Get();
+ _request = new GridTargetSteeringRequest(_target, DesiredRange);
+ steering.Register(_owner, _request);
+ return true;
+ }
+
+ public override void Shutdown(Outcome outcome)
+ {
+ base.Shutdown(outcome);
+ var steering = EntitySystem.Get();
+ steering.Unregister(_owner);
}
public override Outcome Execute(float frameTime)
{
- var baseOutcome = base.Execute(frameTime);
-
- if (baseOutcome == Outcome.Failed ||
- TargetGrid.GridID != Owner.Transform.GridID)
+ switch (_request.Status)
{
- HaveArrived();
- return Outcome.Failed;
- }
-
- if (RouteJob != null)
- {
- if (RouteJob.Status != JobStatus.Finished)
- {
+ case SteeringStatus.Pending:
+ DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner));
return Outcome.Continuing;
- }
- ReceivedRoute();
- return Route.Count == 0 ? Outcome.Failed : Outcome.Continuing;
+ case SteeringStatus.NoPath:
+ return Outcome.Failed;
+ case SteeringStatus.Arrived:
+ return Outcome.Success;
+ case SteeringStatus.Moving:
+ DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner));
+ return Outcome.Continuing;
+ default:
+ throw new ArgumentOutOfRangeException();
}
-
- var targetRange = (TargetGrid.Position - Owner.Transform.GridPosition.Position).Length;
-
- // We there
- if (targetRange <= _desiredRange)
- {
- HaveArrived();
- return Outcome.Success;
- }
-
- // No route
- if (Route.Count == 0 && RouteJob == null)
- {
- GetRoute();
- return Outcome.Continuing;
- }
-
- AntiStuck(frameTime);
-
- if (IsStuck)
- {
- return Outcome.Continuing;
- }
-
- if (TryMove())
- {
- return Outcome.Continuing;
- }
-
- if (Route.Count == 0 && targetRange > 1.5f)
- {
- HaveArrived();
- return Outcome.Failed;
- }
-
- var nextTile = Route.Dequeue();
- NextTile();
- NextGrid = _mapManager.GetGrid(nextTile.GridIndex).GridTileToLocal(nextTile.GridIndices);
- return Outcome.Continuing;
}
}
-}
+}
\ No newline at end of file
diff --git a/Content.Server/GameObjects/EntitySystems/AI/Steering/AiSteeringSystem.cs b/Content.Server/GameObjects/EntitySystems/AI/Steering/AiSteeringSystem.cs
new file mode 100644
index 0000000000..ef8c673c51
--- /dev/null
+++ b/Content.Server/GameObjects/EntitySystems/AI/Steering/AiSteeringSystem.cs
@@ -0,0 +1,667 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Content.Server.GameObjects.Components.Access;
+using Content.Server.GameObjects.Components.Movement;
+using Content.Server.GameObjects.EntitySystems.AI.Pathfinding;
+using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders;
+using Content.Server.GameObjects.EntitySystems.JobQueues;
+using Content.Shared.GameObjects.EntitySystems;
+using Robust.Server.GameObjects;
+using Robust.Server.Interfaces.Timing;
+using Robust.Shared.GameObjects.Components;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Interfaces.GameObjects;
+using Robust.Shared.Interfaces.Map;
+using Robust.Shared.Interfaces.Timing;
+using Robust.Shared.IoC;
+using Robust.Shared.Map;
+using Robust.Shared.Maths;
+using Robust.Shared.Utility;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Server.GameObjects.EntitySystems.AI.Steering
+{
+ public sealed class AiSteeringSystem : EntitySystem
+ {
+ // http://www.red3d.com/cwr/papers/1999/gdc99steer.html for a steering overview
+
+#pragma warning disable 649
+ [Dependency] private IMapManager _mapManager;
+ [Dependency] private IEntityManager _entityManager;
+ [Dependency] private IPauseManager _pauseManager;
+#pragma warning restore 649
+ private PathfindingSystem _pathfindingSystem;
+
+ ///
+ /// Whether we try to avoid non-blocking physics objects
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public bool CollisionAvoidanceEnabled { get; set; } = true;
+
+ ///
+ /// How close we need to get to the center of each tile
+ ///
+ private const float TileTolerance = 0.8f;
+
+ private Dictionary RunningAgents => _agentLists[_listIndex];
+
+ // We'll cycle the running list every tick as all we're doing is getting a vector2 for the
+ // agent's steering. Should help a lot given this is the most expensive operator by far.
+ // The AI will keep moving, it's just it'll keep moving in its existing direction.
+ // If we change to 20/30 TPS you might want to change this but for now it's fine
+ private readonly List> _agentLists = new List>(AgentListCount);
+ private const int AgentListCount = 2;
+ private int _listIndex;
+
+ // Cache nextGrid
+ private readonly Dictionary _nextGrid = new Dictionary();
+
+ ///
+ /// Current live paths for AI
+ ///
+ private readonly Dictionary> _paths = new Dictionary>();
+
+ ///
+ /// Pathfinding request jobs we're waiting on
+ ///
+ private readonly Dictionary>)> _pathfindingRequests =
+ new Dictionary>)>();
+
+ ///
+ /// Keep track of how long we've been in 1 position and re-path if it's been too long
+ ///
+ private readonly Dictionary _stuckCounter = new Dictionary();
+
+ ///
+ /// Get a fixed position for the target entity; if they move then re-path
+ ///
+ private readonly Dictionary _entityTargetPosition = new Dictionary();
+
+ // Anti-Stuck
+ // Given the collision avoidance can lead to twitching need to store a reference position and check if we've been near this too long
+ private readonly Dictionary _stuckPositions = new Dictionary();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ _pathfindingSystem = Get();
+
+ for (var i = 0; i < AgentListCount; i++)
+ {
+ _agentLists.Add(new Dictionary());
+ }
+ }
+
+ ///
+ /// Adds the AI to the steering system to move towards a specific target
+ ///
+ /// We'll add it to the movement list that has the least number of agents
+ ///
+ ///
+ public void Register(IEntity entity, IAiSteeringRequest steeringRequest)
+ {
+ var lowestListCount = 1000;
+ var lowestListIndex = 0;
+
+ for (var i = 0; i < _agentLists.Count; i++)
+ {
+ var agentList = _agentLists[i];
+ // Register shouldn't be called twice; if it is then someone dun fucked up
+ DebugTools.Assert(!agentList.ContainsKey(entity));
+
+ if (agentList.Count < lowestListCount)
+ {
+ lowestListCount = agentList.Count;
+ lowestListIndex = i;
+ }
+ }
+
+ _agentLists[lowestListIndex].Add(entity, steeringRequest);
+ }
+
+ ///
+ /// Stops the steering behavior for the AI and cleans up
+ ///
+ ///
+ ///
+ public void Unregister(IEntity entity)
+ {
+ if (entity.TryGetComponent(out AiControllerComponent controller))
+ {
+ controller.VelocityDir = Vector2.Zero;
+ }
+
+ if (_pathfindingRequests.TryGetValue(entity, out var request))
+ {
+ request.Item1.Cancel();
+ _pathfindingRequests.Remove(entity);
+ }
+
+ if (_paths.ContainsKey(entity))
+ {
+ _paths.Remove(entity);
+ }
+
+ if (_nextGrid.ContainsKey(entity))
+ {
+ _nextGrid.Remove(entity);
+ }
+
+ if (_stuckCounter.ContainsKey(entity))
+ {
+ _stuckCounter.Remove(entity);
+ }
+
+ if (_entityTargetPosition.ContainsKey(entity))
+ {
+ _entityTargetPosition.Remove(entity);
+ }
+
+ foreach (var agentList in _agentLists)
+ {
+ if (agentList.ContainsKey(entity))
+ {
+ agentList.Remove(entity);
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Is the entity currently registered for steering?
+ ///
+ ///
+ ///
+ public bool IsRegistered(IEntity entity)
+ {
+ foreach (var agentList in _agentLists)
+ {
+ if (agentList.ContainsKey(entity))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ foreach (var (agent, steering) in RunningAgents)
+ {
+ var result = Steer(agent, steering);
+ steering.Status = result;
+
+ switch (result)
+ {
+ case SteeringStatus.Pending:
+ break;
+ case SteeringStatus.NoPath:
+ Unregister(agent);
+ break;
+ case SteeringStatus.Arrived:
+ Unregister(agent);
+ break;
+ case SteeringStatus.Moving:
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ _listIndex = (_listIndex + 1) % _agentLists.Count;
+ }
+
+ ///
+ /// Go through each steerer and combine their vectors
+ ///
+ ///
+ ///
+ ///
+ ///
+ private SteeringStatus Steer(IEntity entity, IAiSteeringRequest steeringRequest)
+ {
+ // Main optimisation to be done below is the redundant calls and adding more variables
+ if (!entity.TryGetComponent(out AiControllerComponent controller) || !ActionBlockerSystem.CanMove(entity))
+ {
+ return SteeringStatus.NoPath;
+ }
+
+ if (_pauseManager.IsGridPaused(entity.Transform.GridID))
+ {
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.Pending;
+ }
+
+ // Validation
+ // Check if we can even arrive -> Currently only samegrid movement supported
+ if (entity.Transform.GridID != steeringRequest.TargetGrid.GridID)
+ {
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.NoPath;
+ }
+
+ // Check if we have arrived
+ if ((entity.Transform.MapPosition.Position - steeringRequest.TargetMap.Position).Length <= steeringRequest.ArrivalDistance)
+ {
+ // TODO: If we need LOS and are moving to an entity then we may not be in range yet
+ // Chuck out a ray every half second or so and keep moving until we are?
+ // Alternatively could use tile-based LOS checks via the pathfindingsystem I guess
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.Arrived;
+ }
+
+ // Handle pathfinding job
+ // If we still have an existing path then keep following that until the new path arrives
+ if (_pathfindingRequests.TryGetValue(entity, out var pathRequest) && pathRequest.Item2.Status == JobStatus.Finished)
+ {
+ switch (pathRequest.Item2.Exception)
+ {
+ case null:
+ break;
+ // Currently nothing should be cancelling these except external factors
+ case TaskCanceledException _:
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.NoPath;
+ default:
+ throw pathRequest.Item2.Exception;
+ }
+ // No actual path
+ var path = _pathfindingRequests[entity].Item2.Result;
+ if (path == null || path.Count == 0)
+ {
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.NoPath;
+ }
+
+ // If we're closer to next tile then we don't want to walk backwards to our tile's center
+ UpdatePath(entity, path);
+
+ // If we're targeting entity get a fixed tile; if they move from it then re-path (at least til we get a better solution)
+ if (steeringRequest is EntityTargetSteeringRequest entitySteeringRequest)
+ {
+ _entityTargetPosition[entity] = entitySteeringRequest.TargetGrid;
+ }
+
+ // Move next tick
+ return SteeringStatus.Pending;
+ }
+
+ // Check if we even have a path to follow
+ // If the route's empty we could be close and may not need a re-path so we won't check if it is
+ if (!_paths.ContainsKey(entity) && !_pathfindingRequests.ContainsKey(entity))
+ {
+ RequestPath(entity, steeringRequest);
+ return SteeringStatus.Pending;
+ }
+
+ var ignoredCollision = new List();
+ // Check if the target entity has moved - If so then re-path
+ // TODO: Patch the path from the target's position back towards us, stopping if it ever intersects the current path
+ // Probably need a separate "PatchPath" job
+ if (steeringRequest is EntityTargetSteeringRequest entitySteer)
+ {
+ if (entitySteer.Target.Deleted)
+ {
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.NoPath;
+ }
+
+ // Check if target's moved too far
+ if (_entityTargetPosition.TryGetValue(entity, out var targetGrid) && (entitySteer.TargetGrid.Position - targetGrid.Position).Length >= entitySteer.TargetMaxMove)
+ {
+ // We'll just repath and keep following the existing one until we get a new one
+ RequestPath(entity, steeringRequest);
+ }
+
+ ignoredCollision.Add(entitySteer.Target);
+ }
+
+ HandleStuck(entity);
+
+ // TODO: Probably need a dedicated queuing solver (doorway congestion FML)
+ // Get the target grid (either next tile or target itself) and pass it in to the steering behaviors
+ // If there's nowhere to go then just stop and wait
+ var nextGrid = NextGrid(entity, steeringRequest);
+ if (!nextGrid.HasValue)
+ {
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.NoPath;
+ }
+
+ // Validate that we can even get to the next grid (could probably just check if we can use nextTile if we're not near the target grid)
+ if (!_pathfindingSystem.CanTraverse(entity, nextGrid.Value))
+ {
+ controller.VelocityDir = Vector2.Zero;
+ return SteeringStatus.NoPath;
+ }
+
+ // Now we can /finally/ move
+ var movementVector = Vector2.Zero;
+
+ // Originally I tried using interface steerers but ehhh each one kind of needs to do its own thing
+ // Plus there's not much point putting these in a separate class
+ // Each one just adds onto the final vector
+ movementVector += Seek(entity, nextGrid.Value);
+ if (CollisionAvoidanceEnabled)
+ {
+ movementVector += CollisionAvoidance(entity, movementVector, ignoredCollision);
+ }
+ // Group behaviors would also go here e.g. separation, cohesion, alignment
+
+ // Move towards it
+ DebugTools.Assert(movementVector != new Vector2(float.NaN, float.NaN));
+ controller.VelocityDir = movementVector.Normalized;
+ return SteeringStatus.Moving;
+ }
+
+ ///
+ /// Get a new job from the pathfindingsystem
+ ///
+ ///
+ ///
+ private void RequestPath(IEntity entity, IAiSteeringRequest steeringRequest)
+ {
+ if (_pathfindingRequests.ContainsKey(entity))
+ {
+ return;
+ }
+
+ var cancelToken = new CancellationTokenSource();
+ var gridManager = _mapManager.GetGrid(entity.Transform.GridID);
+ var startTile = gridManager.GetTileRef(entity.Transform.GridPosition);
+ var endTile = gridManager.GetTileRef(steeringRequest.TargetGrid);
+ var collisionMask = 0;
+ if (entity.TryGetComponent(out CollidableComponent collidableComponent))
+ {
+ collisionMask = collidableComponent.CollisionMask;
+ }
+
+ var access = AccessReader.FindAccessTags(entity);
+
+ var job = _pathfindingSystem.RequestPath(new PathfindingArgs(
+ entity.Uid,
+ access,
+ collisionMask,
+ startTile,
+ endTile,
+ steeringRequest.PathfindingProximity
+ ), cancelToken.Token);
+ _pathfindingRequests.Add(entity, (cancelToken, job));
+ }
+
+ ///
+ /// Given the pathfinding is timesliced we need to trim the first few(?) tiles so we don't walk backwards
+ ///
+ ///
+ ///
+ private void UpdatePath(IEntity entity, Queue path)
+ {
+ _pathfindingRequests.Remove(entity);
+
+ var entityTile = _mapManager.GetGrid(entity.Transform.GridID).GetTileRef(entity.Transform.GridPosition);
+ var tile = path.Dequeue();
+ var closestDistance = PathfindingHelpers.OctileDistance(entityTile, tile);
+
+ for (var i = 0; i < path.Count; i++)
+ {
+ tile = path.Peek();
+ var distance = PathfindingHelpers.OctileDistance(entityTile, tile);
+ if (distance < closestDistance)
+ {
+ path.Dequeue();
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ _paths[entity] = path;
+ }
+
+ ///
+ /// Get the next tile as GridCoordinates
+ ///
+ ///
+ ///
+ ///
+ private GridCoordinates? NextGrid(IEntity entity, IAiSteeringRequest steeringRequest)
+ {
+ // Remove the cached grid
+ if (!_paths.ContainsKey(entity) && _nextGrid.ContainsKey(entity))
+ {
+ _nextGrid.Remove(entity);
+ }
+
+ // If no tiles left just move towards the target (if we're close)
+ if (!_paths.ContainsKey(entity) || _paths[entity].Count == 0)
+ {
+ if ((steeringRequest.TargetGrid.Position - entity.Transform.GridPosition.Position).Length <= 1.5f)
+ {
+ return steeringRequest.TargetGrid;
+ }
+
+ // Too far so we need a re-path
+ return null;
+ }
+
+ if (!_nextGrid.TryGetValue(entity, out var nextGrid) ||
+ (nextGrid.Position - entity.Transform.GridPosition.Position).Length <= TileTolerance)
+ {
+ UpdateGridCache(entity);
+ nextGrid = _nextGrid[entity];
+ }
+
+ DebugTools.Assert(nextGrid != default);
+ return nextGrid;
+ }
+
+ ///
+ /// Rather than converting TileRef to GridCoordinates over and over we'll just cache it
+ ///
+ ///
+ ///
+ private void UpdateGridCache(IEntity entity, bool dequeue = true)
+ {
+ if (_paths[entity].Count == 0) return;
+ var nextTile = dequeue ? _paths[entity].Dequeue() : _paths[entity].Peek();
+ var nextGrid = _mapManager.GetGrid(entity.Transform.GridID).GridTileToLocal(nextTile.GridIndices);
+ _nextGrid[entity] = nextGrid;
+ }
+
+ ///
+ /// Check if we've been near our last GridCoordinates too long and try to fix it
+ ///
+ ///
+ private void HandleStuck(IEntity entity)
+ {
+ if (!_stuckPositions.TryGetValue(entity, out var stuckPosition))
+ {
+ _stuckPositions[entity] = entity.Transform.GridPosition;
+ _stuckCounter[entity] = 0;
+ return;
+ }
+
+ if ((entity.Transform.GridPosition.Position - stuckPosition.Position).Length <= 1.0f)
+ {
+ _stuckCounter.TryGetValue(entity, out var stuckCount);
+ _stuckCounter[entity] = stuckCount + 1;
+ }
+ else
+ {
+ // No longer stuck
+ _stuckPositions[entity] = entity.Transform.GridPosition;
+ _stuckCounter[entity] = 0;
+ return;
+ }
+
+ // Should probably be time-based
+ if (_stuckCounter[entity] < 30)
+ {
+ return;
+ }
+
+ // Okay now we're stuck
+ _paths.Remove(entity);
+ _stuckCounter[entity] = 0;
+ }
+
+ #region Steering
+ ///
+ /// Move straight to target position
+ ///
+ ///
+ ///
+ ///
+ private Vector2 Seek(IEntity entity, GridCoordinates grid)
+ {
+ // is-even much
+ var entityPos = entity.Transform.GridPosition;
+ return entityPos == grid ? Vector2.Zero : (grid.Position - entityPos.Position).Normalized;
+ }
+
+ ///
+ /// Like Seek but slows down when within distance
+ ///
+ ///
+ ///
+ ///
+ ///
+ private Vector2 Arrival(IEntity entity, GridCoordinates grid, float slowingDistance = 1.0f)
+ {
+ var entityPos = entity.Transform.GridPosition;
+ DebugTools.Assert(slowingDistance > 0.0f);
+ if (entityPos == grid)
+ {
+ return Vector2.Zero;
+ }
+ var targetDiff = grid.Position - entityPos.Position;
+ var rampedSpeed = targetDiff.Length / slowingDistance;
+ return targetDiff.Normalized * MathF.Min(1.0f, rampedSpeed);
+ }
+
+ ///
+ /// Like Seek but predicts target's future position
+ ///
+ ///
+ ///
+ ///
+ private Vector2 Pursuit(IEntity entity, IEntity target)
+ {
+ var entityPos = entity.Transform.GridPosition;
+ var targetPos = target.Transform.GridPosition;
+ if (entityPos == targetPos)
+ {
+ return Vector2.Zero;
+ }
+
+ if (target.TryGetComponent(out PhysicsComponent physicsComponent))
+ {
+ var targetDistance = (targetPos.Position - entityPos.Position);
+ targetPos = targetPos.Offset(physicsComponent.LinearVelocity * targetDistance);
+ }
+
+ return (targetPos.Position - entityPos.Position).Normalized;
+ }
+
+ ///
+ /// Checks for non-anchored physics objects that can block us
+ ///
+ ///
+ /// entity's travel direction
+ ///
+ ///
+ private Vector2 CollisionAvoidance(IEntity entity, Vector2 direction, ICollection ignoredTargets)
+ {
+ if (direction == Vector2.Zero || !entity.HasComponent())
+ {
+ return Vector2.Zero;
+ }
+
+ // We'll check tile-by-tile
+ // Rewriting this frequently so not many comments as they'll go stale
+ // I realise this is bad so please rewrite it ;-;
+ var avoidanceVector = Vector2.Zero;
+ var checkTiles = new HashSet();
+ var avoidTiles = new HashSet();
+ var entityGridCoords = entity.Transform.GridPosition;
+ var grid = _mapManager.GetGrid(entity.Transform.GridID);
+ var currentTile = grid.GetTileRef(entityGridCoords);
+ var halfwayTile = grid.GetTileRef(entityGridCoords.Offset(direction / 2));
+ var nextTile = grid.GetTileRef(entityGridCoords.Offset(direction));
+
+ checkTiles.Add(currentTile);
+ checkTiles.Add(halfwayTile);
+ checkTiles.Add(nextTile);
+
+ // Handling corners with collision avoidance is a real bitch
+ // TBH collision avoidance in general that doesn't run like arse is a real bitch
+ foreach (var tile in checkTiles)
+ {
+ var node = _pathfindingSystem.GetNode(tile);
+ // Assume the immovables have already been checked
+ foreach (var uid in node.PhysicsUids)
+ {
+ // Ignore myself / my target if applicable
+ if (uid == entity.Uid || ignoredTargets.Contains(entity)) continue;
+ // God there's so many ways to do this
+ // err for now we'll just assume the first entity is the center and just add a vector for it
+ var collisionEntity = _entityManager.GetEntity(uid);
+ // if we're moving in the same direction then ignore
+ // So if 2 entities are moving towards each other and both detect a collision they'll both move in the same direction
+ // i.e. towards the right
+ if (collisionEntity.TryGetComponent(out PhysicsComponent physicsComponent) &&
+ Vector2.Dot(physicsComponent.LinearVelocity, direction) > 0)
+ {
+ continue;
+ }
+ var centerGrid = collisionEntity.Transform.GridPosition;
+ // Check how close we are to center of tile and get the inverse; if we're closer this is stronger
+ var additionalVector = (centerGrid.Position - entityGridCoords.Position);
+ var distance = additionalVector.Length;
+ // If we're too far no point, if we're close then cap it at the normalized vector
+ distance = Math.Clamp(2.5f - distance, 0.0f, 1.0f);
+ additionalVector = new Angle(90 * distance).RotateVec(additionalVector);
+ avoidanceVector += additionalVector;
+ // if we do need to avoid that means we'll have to lookahead for the next tile
+ avoidTiles.Add(tile);
+ break;
+ }
+ }
+
+ // Dis ugly
+ if (_paths.TryGetValue(entity, out var path))
+ {
+ if (path.Count > 0)
+ {
+ var checkTile = path.Peek();
+ for (var i = 0; i < Math.Min(path.Count, avoidTiles.Count); i++)
+ {
+ if (avoidTiles.Contains(checkTile))
+ {
+ checkTile = path.Dequeue();
+ }
+ }
+
+ UpdateGridCache(entity, false);
+ }
+ }
+
+ return avoidanceVector == Vector2.Zero ? avoidanceVector : avoidanceVector.Normalized;
+ }
+ #endregion
+ }
+
+ public enum SteeringStatus
+ {
+ Pending,
+ NoPath,
+ Arrived,
+ Moving,
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameObjects/EntitySystems/AI/Steering/EntityTargetSteeringRequest.cs b/Content.Server/GameObjects/EntitySystems/AI/Steering/EntityTargetSteeringRequest.cs
new file mode 100644
index 0000000000..8b5c221a5b
--- /dev/null
+++ b/Content.Server/GameObjects/EntitySystems/AI/Steering/EntityTargetSteeringRequest.cs
@@ -0,0 +1,35 @@
+using Robust.Shared.Interfaces.GameObjects;
+using Robust.Shared.Map;
+
+namespace Content.Server.GameObjects.EntitySystems.AI.Steering
+{
+ public sealed class EntityTargetSteeringRequest : IAiSteeringRequest
+ {
+ public SteeringStatus Status { get; set; } = SteeringStatus.Pending;
+ public MapCoordinates TargetMap => _target.Transform.MapPosition;
+ public GridCoordinates TargetGrid => _target.Transform.GridPosition;
+ public IEntity Target => _target;
+ private IEntity _target;
+ ///
+ public float ArrivalDistance { get; }
+ ///
+ public float PathfindingProximity { get; }
+ ///
+ /// How far the target can move before we re-path
+ ///
+ public float TargetMaxMove { get; } = 1.5f;
+
+ ///
+ /// If we need LOS on the entity first before interaction
+ ///
+ public bool RequiresInRangeUnobstructed { get; }
+
+ public EntityTargetSteeringRequest(IEntity target, float arrivalDistance, float pathfindingProximity = 0.5f, bool requiresInRangeUnobstructed = false)
+ {
+ _target = target;
+ ArrivalDistance = arrivalDistance;
+ PathfindingProximity = pathfindingProximity;
+ RequiresInRangeUnobstructed = requiresInRangeUnobstructed;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameObjects/EntitySystems/AI/Steering/GridTargetSteeringRequest.cs b/Content.Server/GameObjects/EntitySystems/AI/Steering/GridTargetSteeringRequest.cs
new file mode 100644
index 0000000000..d054e77623
--- /dev/null
+++ b/Content.Server/GameObjects/EntitySystems/AI/Steering/GridTargetSteeringRequest.cs
@@ -0,0 +1,27 @@
+using Robust.Shared.Interfaces.Map;
+using Robust.Shared.IoC;
+using Robust.Shared.Map;
+
+namespace Content.Server.GameObjects.EntitySystems.AI.Steering
+{
+ public sealed class GridTargetSteeringRequest : IAiSteeringRequest
+ {
+ public SteeringStatus Status { get; set; } = SteeringStatus.Pending;
+ public MapCoordinates TargetMap { get; }
+ public GridCoordinates TargetGrid { get; }
+ ///
+ public float ArrivalDistance { get; }
+ ///
+ public float PathfindingProximity { get; }
+
+ public GridTargetSteeringRequest(GridCoordinates targetGrid, float arrivalDistance, float pathfindingProximity = 0.5f)
+ {
+ // Get it once up front so we the manager doesn't have to continuously get it
+ var mapManager = IoCManager.Resolve();
+ TargetMap = targetGrid.ToMap(mapManager);
+ TargetGrid = targetGrid;
+ ArrivalDistance = arrivalDistance;
+ PathfindingProximity = pathfindingProximity;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameObjects/EntitySystems/AI/Steering/IAiSteeringRequest.cs b/Content.Server/GameObjects/EntitySystems/AI/Steering/IAiSteeringRequest.cs
new file mode 100644
index 0000000000..da22b77b1c
--- /dev/null
+++ b/Content.Server/GameObjects/EntitySystems/AI/Steering/IAiSteeringRequest.cs
@@ -0,0 +1,20 @@
+using Robust.Shared.Map;
+
+namespace Content.Server.GameObjects.EntitySystems.AI.Steering
+{
+ public interface IAiSteeringRequest
+ {
+ SteeringStatus Status { get; set; }
+ MapCoordinates TargetMap { get; }
+ GridCoordinates TargetGrid { get; }
+ ///
+ /// How close we have to get before we've arrived
+ ///
+ float ArrivalDistance { get; }
+
+ ///
+ /// How close the pathfinder needs to get. Typically you want this set lower than ArrivalDistance
+ ///
+ float PathfindingProximity { get; }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameObjects/EntitySystems/JobQueues/Queues/JobQueue.cs b/Content.Server/GameObjects/EntitySystems/JobQueues/Queues/JobQueue.cs
index 08c04739c0..63330c4dbb 100644
--- a/Content.Server/GameObjects/EntitySystems/JobQueues/Queues/JobQueue.cs
+++ b/Content.Server/GameObjects/EntitySystems/JobQueues/Queues/JobQueue.cs
@@ -20,7 +20,7 @@ namespace Content.Server.GameObjects.EntitySystems.JobQueues.Queues
/// How long the job's allowed to run for before suspending
///
public virtual double MaxTime => 0.002;
-
+
private readonly Queue _pendingQueue = new Queue();
private readonly List _waitingJobs = new List();