diff --git a/Content.Server/AI/Operators/Combat/Melee/UnarmedCombatOperator.cs b/Content.Server/AI/Operators/Combat/Melee/UnarmedCombatOperator.cs index 6ee0ec1fbe..d7dccf07c3 100644 --- a/Content.Server/AI/Operators/Combat/Melee/UnarmedCombatOperator.cs +++ b/Content.Server/AI/Operators/Combat/Melee/UnarmedCombatOperator.cs @@ -68,6 +68,13 @@ namespace Content.Server.AI.Operators.Combat.Melee public override Outcome Execute(float frameTime) { + if (_unarmedCombat == null || + !_entMan.GetComponent(_target).Coordinates.TryDistance(_entMan, _entMan.GetComponent(_owner).Coordinates, out var distance) || distance > + _unarmedCombat.Range) + { + return Outcome.Failed; + } + if (_burstTime <= _elapsedTime) { return Outcome.Success; @@ -78,12 +85,6 @@ namespace Content.Server.AI.Operators.Combat.Melee return Outcome.Failed; } - if ((_entMan.GetComponent(_target).Coordinates.Position - _entMan.GetComponent(_owner).Coordinates.Position).Length > - _unarmedCombat.Range) - { - return Outcome.Failed; - } - var interactionSystem = IoCManager.Resolve().GetEntitySystem(); interactionSystem.AiUseInteraction(_owner, _entMan.GetComponent(_target).Coordinates, _target); _elapsedTime += frameTime; diff --git a/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs b/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs index 667e961f4c..ff43245fff 100644 --- a/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs +++ b/Content.Server/AI/Operators/Movement/MoveToEntityOperator.cs @@ -1,4 +1,5 @@ using Content.Server.AI.Steering; +using Robust.Shared.Map; using Robust.Shared.Utility; namespace Content.Server.AI.Operators.Movement @@ -7,7 +8,6 @@ namespace Content.Server.AI.Operators.Movement { // TODO: This and steering need to support InRangeUnobstructed now private readonly EntityUid _owner; - private EntityTargetSteeringRequest? _request; private readonly EntityUid _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; } @@ -36,9 +36,9 @@ namespace Content.Server.AI.Operators.Movement return true; } - var steering = EntitySystem.Get(); - _request = new EntityTargetSteeringRequest(_target, ArrivalDistance, PathfindingProximity, _requiresInRangeUnobstructed); - steering.Register(_owner, _request); + var steering = EntitySystem.Get(); + var comp = steering.Register(_owner, new EntityCoordinates(_target, Vector2.Zero)); + comp.Range = ArrivalDistance; return true; } @@ -47,24 +47,23 @@ namespace Content.Server.AI.Operators.Movement if (!base.Shutdown(outcome)) return false; - var steering = EntitySystem.Get(); + var steering = EntitySystem.Get(); steering.Unregister(_owner); return true; } public override Outcome Execute(float frameTime) { - switch (_request?.Status) + if (!IoCManager.Resolve().TryGetComponent(_owner, out var steering)) + return Outcome.Failed; + + switch (steering.Status) { - case SteeringStatus.Pending: - DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner)); - return Outcome.Continuing; case SteeringStatus.NoPath: return Outcome.Failed; - case SteeringStatus.Arrived: + case SteeringStatus.InRange: return Outcome.Success; case SteeringStatus.Moving: - DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner)); return Outcome.Continuing; default: throw new ArgumentOutOfRangeException(); diff --git a/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs b/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs index 032320f0a7..03d276ee3c 100644 --- a/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs +++ b/Content.Server/AI/Operators/Movement/MoveToGridOperator.cs @@ -7,7 +7,6 @@ namespace Content.Server.AI.Operators.Movement public sealed class MoveToGridOperator : AiOperator { private readonly EntityUid _owner; - private GridTargetSteeringRequest? _request; private readonly EntityCoordinates _target; public float DesiredRange { get; set; } @@ -25,9 +24,9 @@ namespace Content.Server.AI.Operators.Movement return true; } - var steering = EntitySystem.Get(); - _request = new GridTargetSteeringRequest(_target, DesiredRange); - steering.Register(_owner, _request); + var steering = EntitySystem.Get(); + var comp = steering.Register(_owner, _target); + comp.Range = DesiredRange; return true; } @@ -36,24 +35,23 @@ namespace Content.Server.AI.Operators.Movement if (!base.Shutdown(outcome)) return false; - var steering = EntitySystem.Get(); + var steering = EntitySystem.Get(); steering.Unregister(_owner); return true; } public override Outcome Execute(float frameTime) { - switch (_request?.Status) + if (!IoCManager.Resolve().TryGetComponent(_owner, out var steering)) + return Outcome.Failed; + + switch (steering.Status) { - case SteeringStatus.Pending: - DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner)); - return Outcome.Continuing; case SteeringStatus.NoPath: return Outcome.Failed; - case SteeringStatus.Arrived: + case SteeringStatus.InRange: return Outcome.Success; case SteeringStatus.Moving: - DebugTools.Assert(EntitySystem.Get().IsRegistered(_owner)); return Outcome.Continuing; default: throw new ArgumentOutOfRangeException(); diff --git a/Content.Server/AI/Steering/AiSteeringSystem.cs b/Content.Server/AI/Steering/AiSteeringSystem.cs deleted file mode 100644 index 0269e8d7ac..0000000000 --- a/Content.Server/AI/Steering/AiSteeringSystem.cs +++ /dev/null @@ -1,741 +0,0 @@ -using System.Runtime.ExceptionServices; -using System.Threading; -using System.Threading.Tasks; -using Content.Server.AI.Components; -using Content.Server.AI.Pathfinding; -using Content.Server.AI.Pathfinding.Pathfinders; -using Content.Server.CPUJob.JobQueues; -using Content.Shared.Access.Systems; -using Content.Shared.Doors.Components; -using Content.Shared.Interaction; -using Content.Shared.Movement.Components; -using Robust.Shared.Map; -using Robust.Shared.Physics; -using Robust.Shared.Timing; -using Robust.Shared.Utility; - -namespace Content.Server.AI.Steering -{ - public sealed class AiSteeringSystem : EntitySystem - { - // http://www.red3d.com/cwr/papers/1999/gdc99steer.html for a steering overview - [Dependency] private readonly IGameTiming _timing = default!; - [Dependency] private readonly IMapManager _mapManager = default!; - [Dependency] private readonly PathfindingSystem _pathfindingSystem = default!; - [Dependency] private readonly AccessReaderSystem _accessReader = default!; - [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; - - /// - /// 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; - - /// - /// How long to wait between checks (if necessary). - /// - private const float InRangeUnobstructedCooldown = 0.25f; - - 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(AgentListCount); - private const int AgentListCount = 2; - private int _listIndex; - - // Cache nextGrid - private readonly Dictionary _nextGrid = new(); - - /// - /// Current live paths for AI - /// - private readonly Dictionary> _paths = new(); - - /// - /// Pathfinding request jobs we're waiting on - /// - private readonly Dictionary> Job)> _pathfindingRequests = - new(); - - /// - /// 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(); - - /// - /// Get a fixed position for the target entity; if they move then re-path - /// - private readonly Dictionary _entityTargetPosition = new(); - - // 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(); - - public override void Initialize() - { - base.Initialize(); - - 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(EntityUid 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(EntityUid entity) - { - if (EntityManager.TryGetComponent(entity, out InputMoverComponent? controller)) - { - controller.CurTickSprintMovement = Vector2.Zero; - } - - if (_pathfindingRequests.TryGetValue(entity, out var request)) - { - switch (request.Job.Status) - { - case JobStatus.Pending: - case JobStatus.Finished: - break; - case JobStatus.Running: - case JobStatus.Paused: - case JobStatus.Waiting: - request.CancelToken.Cancel(); - break; - } - - switch (request.Job.Exception) - { - case null: - break; - default: - ExceptionDispatchInfo.Capture(request.Job.Exception).Throw(); - throw request.Job.Exception; - } - _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(EntityUid 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) - { - // Yeah look it's not true frametime but good enough. - var result = Steer(agent, steering, frameTime * RunningAgents.Count); - 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; - } - - private void SetDirection(InputMoverComponent component, Vector2 value) - { - component.CurTickSprintMovement = value; - component.LastInputTick = _timing.CurTick; - component.LastInputSubTick = ushort.MaxValue; - } - - /// - /// Go through each steerer and combine their vectors - /// - /// - /// - /// - /// - /// - private SteeringStatus Steer(EntityUid entity, IAiSteeringRequest steeringRequest, float frameTime) - { - // Main optimisation to be done below is the redundant calls and adding more variables - if (Deleted(entity) || - !EntityManager.TryGetComponent(entity, out InputMoverComponent? controller) || - !controller.CanMove || - !TryComp(entity, out TransformComponent? xform) || - xform.GridUid == null) - { - return SteeringStatus.NoPath; - } - - var entitySteering = steeringRequest as EntityTargetSteeringRequest; - - if (entitySteering != null && (!EntityManager.EntityExists(entitySteering.Target) ? EntityLifeStage.Deleted : EntityManager.GetComponent(entitySteering.Target).EntityLifeStage) >= EntityLifeStage.Deleted) - { - controller.CurTickSprintMovement = Vector2.Zero; - return SteeringStatus.NoPath; - } - - if (_mapManager.IsGridPaused(xform.GridUid.Value)) - { - SetDirection(controller, Vector2.Zero); - return SteeringStatus.Pending; - } - - // Validation - // Check if we can even arrive -> Currently only samegrid movement supported - if (xform.GridUid != steeringRequest.TargetGrid.GetGridUid(EntityManager)) - { - SetDirection(controller, Vector2.Zero); - return SteeringStatus.NoPath; - } - - // Check if we have arrived - var targetDistance = (xform.MapPosition.Position - steeringRequest.TargetMap.Position).Length; - steeringRequest.TimeUntilInteractionCheck -= frameTime; - - if (targetDistance <= steeringRequest.ArrivalDistance && steeringRequest.TimeUntilInteractionCheck <= 0.0f) - { - if (!steeringRequest.RequiresInRangeUnobstructed || - _interactionSystem.InRangeUnobstructed(entity, steeringRequest.TargetMap, steeringRequest.ArrivalDistance, popup: true)) - { - // TODO: Need cruder LOS checks for ranged weaps - SetDirection(controller, Vector2.Zero); - return SteeringStatus.Arrived; - } - - steeringRequest.TimeUntilInteractionCheck = InRangeUnobstructedCooldown; - // Welp, we'll keep on moving. - } - - // If we're really close don't swiggity swoogity back and forth and just wait for the interaction check maybe? - if (steeringRequest.TimeUntilInteractionCheck > 0.0f && targetDistance <= 0.1f) - { - SetDirection(controller, Vector2.Zero); - return SteeringStatus.Moving; - } - - // 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.Job.Status == JobStatus.Finished) - { - switch (pathRequest.Job.Exception) - { - case null: - break; - // Currently nothing should be cancelling these except external factors - case TaskCanceledException _: - SetDirection(controller, Vector2.Zero); - return SteeringStatus.NoPath; - default: - throw pathRequest.Job.Exception; - } - // No actual path - var path = _pathfindingRequests[entity].Job.Result; - if (path == null || path.Count == 0) - { - SetDirection(controller, 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 (entitySteering != null) - { - _entityTargetPosition[entity] = entitySteering.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) && targetDistance > 1.5f) - { - SetDirection(controller, Vector2.Zero); - 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 (entitySteering != null) - { - // Check if target's moved too far - if (_entityTargetPosition.TryGetValue(entity, out var targetGrid) && - (entitySteering.TargetGrid.Position - targetGrid.Position).Length >= entitySteering.TargetMaxMove) - { - // We'll just repath and keep following the existing one until we get a new one - RequestPath(entity, steeringRequest); - } - - ignoredCollision.Add(entitySteering.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) - { - SetDirection(controller, 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)) - { - SetDirection(controller, 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)); - SetDirection(controller, movementVector.Normalized); - return SteeringStatus.Moving; - } - - /// - /// Get a new job from the pathfindingsystem - /// - /// - /// - private void RequestPath(EntityUid entity, IAiSteeringRequest steeringRequest) - { - if (_pathfindingRequests.ContainsKey(entity)) - { - return; - } - - var xform = EntityManager.GetComponent(entity); - if (xform.GridUid == null) - return; - - var cancelToken = new CancellationTokenSource(); - var gridManager = _mapManager.GetGrid(xform.GridUid.Value); - var startTile = gridManager.GetTileRef(xform.Coordinates); - var endTile = gridManager.GetTileRef(steeringRequest.TargetGrid); - var collisionMask = 0; - if (EntityManager.TryGetComponent(entity, out IPhysBody? physics)) - { - collisionMask = physics.CollisionMask; - } - - var access = _accessReader.FindAccessTags(entity); - - var job = _pathfindingSystem.RequestPath(new PathfindingArgs( - entity, - 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(EntityUid entity, Queue path) - { - _pathfindingRequests.Remove(entity); - - var xform = EntityManager.GetComponent(entity); - if (xform.GridUid == null) - return; - var entityTile = _mapManager.GetGrid(xform.GridUid.Value).GetTileRef(xform.Coordinates); - 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 EntityCoordinates - /// - /// - /// - /// - private EntityCoordinates? NextGrid(EntityUid entity, IAiSteeringRequest steeringRequest) - { - // Remove the cached grid - if (!_paths.ContainsKey(entity) && _nextGrid.ContainsKey(entity)) - { - _nextGrid.Remove(entity); - } - - var xform = EntityManager.GetComponent(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 - xform.Coordinates.Position).Length <= 2.0f) - { - return steeringRequest.TargetGrid; - } - - // Too far so we need a re-path - return null; - } - - if (!_nextGrid.TryGetValue(entity, out var nextGrid) || - (nextGrid.Position - xform.Coordinates.Position).Length <= TileTolerance) - { - UpdateGridCache(entity); - nextGrid = _nextGrid[entity]; - } - - DebugTools.Assert(nextGrid != default); - return nextGrid; - } - - /// - /// Rather than converting TileRef to EntityCoordinates over and over we'll just cache it - /// - /// - /// - private void UpdateGridCache(EntityUid entity, bool dequeue = true) - { - if (_paths[entity].Count == 0) return; - var nextTile = dequeue ? _paths[entity].Dequeue() : _paths[entity].Peek(); - - var xform = EntityManager.GetComponent(entity); - if (xform.GridUid == null) - return; - - var nextGrid = _mapManager.GetGrid(xform.GridUid.Value).GridTileToLocal(nextTile.GridIndices); - _nextGrid[entity] = nextGrid; - } - - /// - /// Check if we've been near our last EntityCoordinates too long and try to fix it - /// - /// - private void HandleStuck(EntityUid entity) - { - if (!_stuckPositions.TryGetValue(entity, out var stuckPosition)) - { - _stuckPositions[entity] = EntityManager.GetComponent(entity).Coordinates; - _stuckCounter[entity] = 0; - return; - } - - if ((EntityManager.GetComponent(entity).Coordinates.Position - stuckPosition.Position).Length <= 1.0f) - { - _stuckCounter.TryGetValue(entity, out var stuckCount); - _stuckCounter[entity] = stuckCount + 1; - } - else - { - // No longer stuck - _stuckPositions[entity] = EntityManager.GetComponent(entity).Coordinates; - _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(EntityUid entity, EntityCoordinates grid) - { - // is-even much - var entityPos = EntityManager.GetComponent(entity).Coordinates; - return entityPos == grid - ? Vector2.Zero - : (grid.Position - entityPos.Position).Normalized; - } - - /// - /// Like Seek but slows down when within distance - /// - /// - /// - /// - /// - private Vector2 Arrival(EntityUid entity, EntityCoordinates grid, float slowingDistance = 1.0f) - { - var entityPos = EntityManager.GetComponent(entity).Coordinates; - 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(EntityUid entity, EntityUid target) - { - var entityPos = EntityManager.GetComponent(entity).Coordinates; - var targetPos = EntityManager.GetComponent(target).Coordinates; - if (entityPos == targetPos) - { - return Vector2.Zero; - } - - if (EntityManager.TryGetComponent(target, out IPhysBody? physics)) - { - var targetDistance = (targetPos.Position - entityPos.Position); - targetPos = targetPos.Offset(physics.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(EntityUid entity, Vector2 direction, ICollection ignoredTargets) - { - if (direction == Vector2.Zero || !EntityManager.TryGetComponent(entity, out IPhysBody? physics)) - { - 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 entityCollisionMask = physics.CollisionMask; - var avoidanceVector = Vector2.Zero; - var checkTiles = new HashSet(); - var avoidTiles = new HashSet(); - - var xform = EntityManager.GetComponent(entity); - if (xform.GridUid == null) - return default; - - var entityGridCoords = xform.Coordinates; - var grid = _mapManager.GetGrid(xform.GridUid.Value); - 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 (physicsEntity, layer) in node.PhysicsLayers) - { - // Ignore myself / my target if applicable / if my mask doesn't collide - if (physicsEntity == entity || ignoredTargets.Contains(physicsEntity) || (entityCollisionMask & layer) == 0) 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 - - //Pathfinding updates are deferred so this may not be done yet. - if (Deleted(physicsEntity)) continue; - - // 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 (EntityManager.TryGetComponent(physicsEntity, out IPhysBody? otherPhysics) && - (!otherPhysics.Hard || - Vector2.Dot(otherPhysics.LinearVelocity, direction) > 0)) - { - continue; - } - - var centerGrid = EntityManager.GetComponent(physicsEntity).Coordinates; - // 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 = MathHelper.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, - } -} diff --git a/Content.Server/AI/Steering/EntityTargetSteeringRequest.cs b/Content.Server/AI/Steering/EntityTargetSteeringRequest.cs deleted file mode 100644 index 8cb5ef43e4..0000000000 --- a/Content.Server/AI/Steering/EntityTargetSteeringRequest.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Robust.Shared.Map; - -namespace Content.Server.AI.Steering -{ - public sealed class EntityTargetSteeringRequest : IAiSteeringRequest - { - public SteeringStatus Status { get; set; } = SteeringStatus.Pending; - public MapCoordinates TargetMap => IoCManager.Resolve().GetComponent(_target).MapPosition; - public EntityCoordinates TargetGrid => IoCManager.Resolve().GetComponent(_target).Coordinates; - public EntityUid Target => _target; - private readonly EntityUid _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; - - /// - public bool RequiresInRangeUnobstructed { get; } - - /// - /// To avoid spamming InRangeUnobstructed we'll apply a cd to it. - /// - public float TimeUntilInteractionCheck { get; set; } - - public EntityTargetSteeringRequest(EntityUid target, float arrivalDistance, float pathfindingProximity = 0.5f, bool requiresInRangeUnobstructed = false) - { - _target = target; - ArrivalDistance = arrivalDistance; - PathfindingProximity = pathfindingProximity; - RequiresInRangeUnobstructed = requiresInRangeUnobstructed; - } - } -} diff --git a/Content.Server/AI/Steering/GridTargetSteeringRequest.cs b/Content.Server/AI/Steering/GridTargetSteeringRequest.cs deleted file mode 100644 index 226da887b2..0000000000 --- a/Content.Server/AI/Steering/GridTargetSteeringRequest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Robust.Shared.Map; - -namespace Content.Server.AI.Steering -{ - public sealed class GridTargetSteeringRequest : IAiSteeringRequest - { - public SteeringStatus Status { get; set; } = SteeringStatus.Pending; - public MapCoordinates TargetMap { get; } - public EntityCoordinates TargetGrid { get; } - /// - public float ArrivalDistance { get; } - /// - public float PathfindingProximity { get; } - - public bool RequiresInRangeUnobstructed { get; } - - public float TimeUntilInteractionCheck { get; set; } = 0.0f; - - - public GridTargetSteeringRequest(EntityCoordinates targetGrid, float arrivalDistance, float pathfindingProximity = 0.5f, bool requiresInRangeUnobstructed = false) - { - // Get it once up front so we the manager doesn't have to continuously get it - var entityManager = IoCManager.Resolve(); - TargetMap = targetGrid.ToMap(entityManager); - TargetGrid = targetGrid; - ArrivalDistance = arrivalDistance; - PathfindingProximity = pathfindingProximity; - RequiresInRangeUnobstructed = requiresInRangeUnobstructed; - } - } -} diff --git a/Content.Server/AI/Steering/NPCSteeringComponent.cs b/Content.Server/AI/Steering/NPCSteeringComponent.cs new file mode 100644 index 0000000000..08c0db087d --- /dev/null +++ b/Content.Server/AI/Steering/NPCSteeringComponent.cs @@ -0,0 +1,56 @@ +using System.Threading; +using Content.Server.AI.Pathfinding.Pathfinders; +using Content.Server.CPUJob.JobQueues; +using Robust.Shared.Map; + +namespace Content.Server.AI.Steering; + +/// +/// Added to NPCs that are moving. +/// +[RegisterComponent] +public sealed class NPCSteeringComponent : Component +{ + [ViewVariables] public Job>? Pathfind = null; + [ViewVariables] public CancellationTokenSource? PathfindToken = null; + + /// + /// Current path we're following to our coordinates. + /// + [ViewVariables] public Queue CurrentPath = new(); + + /// + /// Target that we're trying to move to. + /// + [ViewVariables(VVAccess.ReadWrite)] public EntityCoordinates Coordinates; + + /// + /// How close are we trying to get to the coordinates before being considered in range. + /// + [ViewVariables(VVAccess.ReadWrite)] public float Range = 0.2f; + + /// + /// How far does the last node in the path need to be before considering re-pathfinding. + /// + [ViewVariables(VVAccess.ReadWrite)] public float RepathRange = 1.5f; + + [ViewVariables] public SteeringStatus Status = SteeringStatus.Moving; +} + +public enum SteeringStatus : byte +{ + /// + /// If we can't reach the target (e.g. different map). + /// + NoPath, + + /// + /// Are we moving towards our target + /// + Moving, + + /// + /// Are we currently in range of our target. + /// + InRange, +} diff --git a/Content.Server/AI/Steering/NPCSteeringSystem.cs b/Content.Server/AI/Steering/NPCSteeringSystem.cs new file mode 100644 index 0000000000..13ef9dd70b --- /dev/null +++ b/Content.Server/AI/Steering/NPCSteeringSystem.cs @@ -0,0 +1,398 @@ +using System.Linq; +using System.Threading; +using Content.Server.AI.Components; +using Content.Server.AI.Pathfinding; +using Content.Server.AI.Pathfinding.Pathfinders; +using Content.Server.CPUJob.JobQueues; +using Content.Shared.Access.Systems; +using Content.Shared.CCVar; +using Content.Shared.Movement.Components; +using Robust.Shared.Configuration; +using Robust.Shared.Map; +using Robust.Shared.Physics; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Server.AI.Steering +{ + public sealed class NPCSteeringSystem : EntitySystem + { + // http://www.red3d.com/cwr/papers/1999/gdc99steer.html for a steering overview + [Dependency] private readonly IConfigurationManager _configManager = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IMapManager _mapManager = default!; + [Dependency] private readonly PathfindingSystem _pathfindingSystem = default!; + [Dependency] private readonly AccessReaderSystem _accessReader = default!; + + // This will likely get moved onto an abstract pathfinding node that specifies the max distance allowed from the coordinate. + private const float TileTolerance = 0.1f; + + private bool _enabled; + + public override void Initialize() + { + base.Initialize(); + _configManager.OnValueChanged(CCVars.NPCEnabled, SetNPCEnabled, true); + + SubscribeLocalEvent(OnSteeringShutdown); + } + + private void OnSteeringShutdown(EntityUid uid, NPCSteeringComponent component, ComponentShutdown args) + { + component.PathfindToken?.Cancel(); + } + + private void SetNPCEnabled(bool obj) + { + if (!obj) + { + foreach (var (_, mover) in EntityQuery()) + { + mover.CurTickSprintMovement = Vector2.Zero; + } + } + + _enabled = obj; + } + + public override void Shutdown() + { + base.Shutdown(); + _configManager.UnsubValueChanged(CCVars.NPCEnabled, SetNPCEnabled); + } + + /// + /// Adds the AI to the steering system to move towards a specific target + /// + public NPCSteeringComponent Register(EntityUid entity, EntityCoordinates coordinates) + { + if (TryComp(entity, out var comp)) + { + comp.PathfindToken?.Cancel(); + comp.PathfindToken = null; + comp.CurrentPath.Clear(); + } + else + { + comp = AddComp(entity); + } + + comp.Coordinates = coordinates; + return comp; + } + + /// + /// Stops the steering behavior for the AI and cleans up. + /// + public void Unregister(EntityUid uid, NPCSteeringComponent? component = null) + { + if (!Resolve(uid, ref component, false)) + return; + + if (EntityManager.TryGetComponent(component.Owner, out InputMoverComponent? controller)) + { + controller.CurTickSprintMovement = Vector2.Zero; + } + + component.PathfindToken?.Cancel(); + component.PathfindToken = null; + component.Pathfind = null; + RemComp(uid); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + if (!_enabled) + return; + + // Not every mob has the modifier component so do it as a separate query. + var bodyQuery = GetEntityQuery(); + var modifierQuery = GetEntityQuery(); + + foreach (var (steering, _, mover, xform) in EntityQuery()) + { + Steer(steering, mover, xform, modifierQuery, bodyQuery, frameTime); + } + } + + private void SetDirection(InputMoverComponent component, Vector2 value) + { + component.CurTickSprintMovement = value; + component.LastInputTick = _timing.CurTick; + component.LastInputSubTick = ushort.MaxValue; + } + + /// + /// Go through each steerer and combine their vectors + /// + private void Steer( + NPCSteeringComponent steering, + InputMoverComponent mover, + TransformComponent xform, + EntityQuery modifierQuery, + EntityQuery bodyQuery, + float frameTime) + { + var ourCoordinates = xform.Coordinates; + var destinationCoordinates = steering.Coordinates; + + // We've arrived, nothing else matters. + if (xform.Coordinates.TryDistance(EntityManager, destinationCoordinates, out var distance) && + distance <= steering.Range) + { + SetDirection(mover, Vector2.Zero); + steering.Status = SteeringStatus.InRange; + return; + } + + // Can't move at all, just noop input. + if (!mover.CanMove) + { + SetDirection(mover, Vector2.Zero); + steering.Status = SteeringStatus.Moving; + return; + } + + // If we were pathfinding then try to update our path. + if (steering.Pathfind != null) + { + switch (steering.Pathfind.Status) + { + case JobStatus.Waiting: + case JobStatus.Running: + case JobStatus.Pending: + case JobStatus.Paused: + break; + case JobStatus.Finished: + steering.CurrentPath.Clear(); + + if (steering.Pathfind.Result != null) + { + PrunePath(ourCoordinates, steering.Pathfind.Result); + + foreach (var node in steering.Pathfind.Result) + { + steering.CurrentPath.Enqueue(node); + } + } + + steering.Pathfind = null; + steering.PathfindToken = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + // Grab the target position, either the path or our end goal. + // TODO: Some situations we may not want to move at our target without a path. + var targetCoordinates = GetTargetCoordinates(steering); + var arrivalDistance = TileTolerance; + + if (targetCoordinates.Equals(steering.Coordinates)) + { + // What's our tolerance for arrival. + // If it's a pathfinding node it might be different to the destination. + arrivalDistance = steering.Range; + } + + // Check if mapids match. + var targetMap = targetCoordinates.ToMap(EntityManager); + var ourMap = ourCoordinates.ToMap(EntityManager); + + if (targetMap.MapId != ourMap.MapId) + { + SetDirection(mover, Vector2.Zero); + steering.Status = SteeringStatus.NoPath; + return; + } + + var direction = targetMap.Position - ourMap.Position; + + // Are we in range + if (direction.Length <= arrivalDistance) + { + // It was just a node, not the target, so grab the next destination (either the target or next node). + if (steering.CurrentPath.Count > 0) + { + steering.CurrentPath.Dequeue(); + + // Alright just adjust slightly and grab the next node so we don't stop moving for a tick. + // TODO: If it's the last node just grab the target instead. + targetCoordinates = GetTargetCoordinates(steering); + targetMap = targetCoordinates.ToMap(EntityManager); + + // Can't make it again. + if (ourMap.MapId != targetMap.MapId) + { + SetDirection(mover, Vector2.Zero); + steering.Status = SteeringStatus.NoPath; + return; + } + + // Gonna resume now business as usual + direction = targetMap.Position - ourMap.Position; + } + else + { + // This probably shouldn't happen as we check above but eh. + SetDirection(mover, Vector2.Zero); + steering.Status = SteeringStatus.InRange; + return; + } + } + + // Do we have no more nodes to follow OR has the target moved sufficiently? If so then re-path. + var needsPath = steering.CurrentPath.Count == 0; + + // TODO: Probably need partial planning support i.e. patch from the last node to where the target moved to. + + if (!needsPath) + { + var lastNode = steering.CurrentPath.Last(); + // I know this is bad and doesn't account for tile size + // However with the path I'm going to change it to return pathfinding nodes which include coordinates instead. + var lastCoordinate = new EntityCoordinates(lastNode.GridUid, (Vector2) lastNode.GridIndices + 0.5f); + + if (lastCoordinate.TryDistance(EntityManager, steering.Coordinates, out var lastDistance) && + lastDistance > steering.RepathRange) + { + needsPath = true; + } + } + + // Request the new path. + if (needsPath && bodyQuery.TryGetComponent(steering.Owner, out var body)) + { + RequestPath(steering, xform, body); + } + + modifierQuery.TryGetComponent(steering.Owner, out var modifier); + var moveSpeed = GetSprintSpeed(modifier); + + var input = direction.Normalized; + + // If we're going to overshoot then... don't. + // TODO: For tile / movement we don't need to get bang on, just need to make sure we don't overshoot the far end. + var tickMovement = moveSpeed * frameTime; + + if (tickMovement.Equals(0f)) + { + SetDirection(mover, Vector2.Zero); + steering.Status = SteeringStatus.NoPath; + return; + } + + // We may overshoot slightly but still be in the arrival distance which is okay. + var maxDistance = direction.Length + arrivalDistance; + + if (tickMovement > maxDistance) + { + input *= maxDistance / tickMovement; + } + + // TODO: This isn't going to work for space. + if (_mapManager.TryGetGrid(xform.GridUid, out var grid)) + { + input = (-grid.WorldRotation).RotateVec(input); + } + + SetDirection(mover, input); + + // todo: Need a console command to make an NPC steer to a specific spot. + + // TODO: Actual steering behaviours and collision avoidance. + // TODO: Need to handle path invalidation if nodes change. + } + + /// + /// We may be pathfinding and moving at the same time in which case early nodes may be out of date. + /// + private void PrunePath(EntityCoordinates coordinates, Queue nodes) + { + // Right now the pathfinder gives EVERY TILE back but ideally it won't someday, it'll just give straightline ones. + // For now, we just prune up until the closest node + 1 extra. + var closest = ((Vector2) nodes.Peek().GridIndices + 0.5f - coordinates.Position).Length; + // TODO: Need to handle multi-grid and stuff. + + while (nodes.TryPeek(out var node)) + { + // TODO: Tile size + var nodePosition = (Vector2) node.GridIndices + 0.5f; + var length = (coordinates.Position - nodePosition).Length; + + if (length < closest) + { + closest = length; + nodes.Dequeue(); + continue; + } + + nodes.Dequeue(); + break; + } + } + + /// + /// Get the coordinates we should be heading towards. + /// + private EntityCoordinates GetTargetCoordinates(NPCSteeringComponent steering) + { + // Depending on what's going on we may return the target or a pathfind node. + + // If it's the last node then just head to the target. + if (steering.CurrentPath.Count > 1 && steering.CurrentPath.TryPeek(out var nextTarget)) + { + return new EntityCoordinates(nextTarget.GridUid, (Vector2) nextTarget.GridIndices + 0.5f); + } + + return steering.Coordinates; + } + + /// + /// Get a new job from the pathfindingsystem + /// + private void RequestPath(NPCSteeringComponent steering, TransformComponent xform, PhysicsComponent? body) + { + // If we already have a pathfinding request then don't grab another. + if (steering.Pathfind != null) + return; + + if (!_mapManager.TryGetGrid(xform.GridUid, out var grid)) + return; + + steering.PathfindToken = new CancellationTokenSource(); + var startTile = grid.GetTileRef(xform.Coordinates); + var endTile = grid.GetTileRef(steering.Coordinates); + var collisionMask = 0; + + if (body != null) + { + collisionMask = body.CollisionMask; + } + + var access = _accessReader.FindAccessTags(steering.Owner); + + steering.Pathfind = _pathfindingSystem.RequestPath(new PathfindingArgs( + steering.Owner, + access, + collisionMask, + startTile, + endTile, + steering.Range + ), steering.PathfindToken.Token); + } + + private float GetSprintSpeed(MovementSpeedModifierComponent? modifier) + { + return modifier?.CurrentSprintSpeed ?? MovementSpeedModifierComponent.DefaultBaseSprintSpeed; + } + + private float GetWalkSpeed(MovementSpeedModifierComponent? modifier) + { + return modifier?.CurrentWalkSpeed ?? MovementSpeedModifierComponent.DefaultBaseWalkSpeed; + } + } +}