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();