NPC Steering refactor (#10190)
Co-authored-by: metalgearsloth <metalgearsloth@gmail.com>
This commit is contained in:
@@ -68,6 +68,13 @@ namespace Content.Server.AI.Operators.Combat.Melee
|
|||||||
|
|
||||||
public override Outcome Execute(float frameTime)
|
public override Outcome Execute(float frameTime)
|
||||||
{
|
{
|
||||||
|
if (_unarmedCombat == null ||
|
||||||
|
!_entMan.GetComponent<TransformComponent>(_target).Coordinates.TryDistance(_entMan, _entMan.GetComponent<TransformComponent>(_owner).Coordinates, out var distance) || distance >
|
||||||
|
_unarmedCombat.Range)
|
||||||
|
{
|
||||||
|
return Outcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
if (_burstTime <= _elapsedTime)
|
if (_burstTime <= _elapsedTime)
|
||||||
{
|
{
|
||||||
return Outcome.Success;
|
return Outcome.Success;
|
||||||
@@ -78,12 +85,6 @@ namespace Content.Server.AI.Operators.Combat.Melee
|
|||||||
return Outcome.Failed;
|
return Outcome.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((_entMan.GetComponent<TransformComponent>(_target).Coordinates.Position - _entMan.GetComponent<TransformComponent>(_owner).Coordinates.Position).Length >
|
|
||||||
_unarmedCombat.Range)
|
|
||||||
{
|
|
||||||
return Outcome.Failed;
|
|
||||||
}
|
|
||||||
|
|
||||||
var interactionSystem = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<InteractionSystem>();
|
var interactionSystem = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<InteractionSystem>();
|
||||||
interactionSystem.AiUseInteraction(_owner, _entMan.GetComponent<TransformComponent>(_target).Coordinates, _target);
|
interactionSystem.AiUseInteraction(_owner, _entMan.GetComponent<TransformComponent>(_target).Coordinates, _target);
|
||||||
_elapsedTime += frameTime;
|
_elapsedTime += frameTime;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Content.Server.AI.Steering;
|
using Content.Server.AI.Steering;
|
||||||
|
using Robust.Shared.Map;
|
||||||
using Robust.Shared.Utility;
|
using Robust.Shared.Utility;
|
||||||
|
|
||||||
namespace Content.Server.AI.Operators.Movement
|
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
|
// TODO: This and steering need to support InRangeUnobstructed now
|
||||||
private readonly EntityUid _owner;
|
private readonly EntityUid _owner;
|
||||||
private EntityTargetSteeringRequest? _request;
|
|
||||||
private readonly EntityUid _target;
|
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
|
// 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 ArrivalDistance { get; }
|
||||||
@@ -36,9 +36,9 @@ namespace Content.Server.AI.Operators.Movement
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var steering = EntitySystem.Get<AiSteeringSystem>();
|
var steering = EntitySystem.Get<NPCSteeringSystem>();
|
||||||
_request = new EntityTargetSteeringRequest(_target, ArrivalDistance, PathfindingProximity, _requiresInRangeUnobstructed);
|
var comp = steering.Register(_owner, new EntityCoordinates(_target, Vector2.Zero));
|
||||||
steering.Register(_owner, _request);
|
comp.Range = ArrivalDistance;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,24 +47,23 @@ namespace Content.Server.AI.Operators.Movement
|
|||||||
if (!base.Shutdown(outcome))
|
if (!base.Shutdown(outcome))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var steering = EntitySystem.Get<AiSteeringSystem>();
|
var steering = EntitySystem.Get<NPCSteeringSystem>();
|
||||||
steering.Unregister(_owner);
|
steering.Unregister(_owner);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Outcome Execute(float frameTime)
|
public override Outcome Execute(float frameTime)
|
||||||
{
|
{
|
||||||
switch (_request?.Status)
|
if (!IoCManager.Resolve<IEntityManager>().TryGetComponent<NPCSteeringComponent>(_owner, out var steering))
|
||||||
|
return Outcome.Failed;
|
||||||
|
|
||||||
|
switch (steering.Status)
|
||||||
{
|
{
|
||||||
case SteeringStatus.Pending:
|
|
||||||
DebugTools.Assert(EntitySystem.Get<AiSteeringSystem>().IsRegistered(_owner));
|
|
||||||
return Outcome.Continuing;
|
|
||||||
case SteeringStatus.NoPath:
|
case SteeringStatus.NoPath:
|
||||||
return Outcome.Failed;
|
return Outcome.Failed;
|
||||||
case SteeringStatus.Arrived:
|
case SteeringStatus.InRange:
|
||||||
return Outcome.Success;
|
return Outcome.Success;
|
||||||
case SteeringStatus.Moving:
|
case SteeringStatus.Moving:
|
||||||
DebugTools.Assert(EntitySystem.Get<AiSteeringSystem>().IsRegistered(_owner));
|
|
||||||
return Outcome.Continuing;
|
return Outcome.Continuing;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace Content.Server.AI.Operators.Movement
|
|||||||
public sealed class MoveToGridOperator : AiOperator
|
public sealed class MoveToGridOperator : AiOperator
|
||||||
{
|
{
|
||||||
private readonly EntityUid _owner;
|
private readonly EntityUid _owner;
|
||||||
private GridTargetSteeringRequest? _request;
|
|
||||||
private readonly EntityCoordinates _target;
|
private readonly EntityCoordinates _target;
|
||||||
public float DesiredRange { get; set; }
|
public float DesiredRange { get; set; }
|
||||||
|
|
||||||
@@ -25,9 +24,9 @@ namespace Content.Server.AI.Operators.Movement
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var steering = EntitySystem.Get<AiSteeringSystem>();
|
var steering = EntitySystem.Get<NPCSteeringSystem>();
|
||||||
_request = new GridTargetSteeringRequest(_target, DesiredRange);
|
var comp = steering.Register(_owner, _target);
|
||||||
steering.Register(_owner, _request);
|
comp.Range = DesiredRange;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,24 +35,23 @@ namespace Content.Server.AI.Operators.Movement
|
|||||||
if (!base.Shutdown(outcome))
|
if (!base.Shutdown(outcome))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var steering = EntitySystem.Get<AiSteeringSystem>();
|
var steering = EntitySystem.Get<NPCSteeringSystem>();
|
||||||
steering.Unregister(_owner);
|
steering.Unregister(_owner);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Outcome Execute(float frameTime)
|
public override Outcome Execute(float frameTime)
|
||||||
{
|
{
|
||||||
switch (_request?.Status)
|
if (!IoCManager.Resolve<IEntityManager>().TryGetComponent<NPCSteeringComponent>(_owner, out var steering))
|
||||||
|
return Outcome.Failed;
|
||||||
|
|
||||||
|
switch (steering.Status)
|
||||||
{
|
{
|
||||||
case SteeringStatus.Pending:
|
|
||||||
DebugTools.Assert(EntitySystem.Get<AiSteeringSystem>().IsRegistered(_owner));
|
|
||||||
return Outcome.Continuing;
|
|
||||||
case SteeringStatus.NoPath:
|
case SteeringStatus.NoPath:
|
||||||
return Outcome.Failed;
|
return Outcome.Failed;
|
||||||
case SteeringStatus.Arrived:
|
case SteeringStatus.InRange:
|
||||||
return Outcome.Success;
|
return Outcome.Success;
|
||||||
case SteeringStatus.Moving:
|
case SteeringStatus.Moving:
|
||||||
DebugTools.Assert(EntitySystem.Get<AiSteeringSystem>().IsRegistered(_owner));
|
|
||||||
return Outcome.Continuing;
|
return Outcome.Continuing;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
|
|||||||
@@ -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!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether we try to avoid non-blocking physics objects
|
|
||||||
/// </summary>
|
|
||||||
[ViewVariables(VVAccess.ReadWrite)]
|
|
||||||
public bool CollisionAvoidanceEnabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How close we need to get to the center of each tile
|
|
||||||
/// </summary>
|
|
||||||
private const float TileTolerance = 0.8f;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How long to wait between checks (if necessary).
|
|
||||||
/// </summary>
|
|
||||||
private const float InRangeUnobstructedCooldown = 0.25f;
|
|
||||||
|
|
||||||
private Dictionary<EntityUid, IAiSteeringRequest> 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<Dictionary<EntityUid, IAiSteeringRequest>> _agentLists = new(AgentListCount);
|
|
||||||
private const int AgentListCount = 2;
|
|
||||||
private int _listIndex;
|
|
||||||
|
|
||||||
// Cache nextGrid
|
|
||||||
private readonly Dictionary<EntityUid, EntityCoordinates> _nextGrid = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current live paths for AI
|
|
||||||
/// </summary>
|
|
||||||
private readonly Dictionary<EntityUid, Queue<TileRef>> _paths = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pathfinding request jobs we're waiting on
|
|
||||||
/// </summary>
|
|
||||||
private readonly Dictionary<EntityUid, (CancellationTokenSource CancelToken, CPUJob.JobQueues.Job<Queue<TileRef>> Job)> _pathfindingRequests =
|
|
||||||
new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Keep track of how long we've been in 1 position and re-path if it's been too long
|
|
||||||
/// </summary>
|
|
||||||
private readonly Dictionary<EntityUid, int> _stuckCounter = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get a fixed position for the target entity; if they move then re-path
|
|
||||||
/// </summary>
|
|
||||||
private readonly Dictionary<EntityUid, EntityCoordinates> _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<EntityUid, EntityCoordinates> _stuckPositions = new();
|
|
||||||
|
|
||||||
public override void Initialize()
|
|
||||||
{
|
|
||||||
base.Initialize();
|
|
||||||
|
|
||||||
for (var i = 0; i < AgentListCount; i++)
|
|
||||||
{
|
|
||||||
_agentLists.Add(new Dictionary<EntityUid, IAiSteeringRequest>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds the AI to the steering system to move towards a specific target
|
|
||||||
/// </summary>
|
|
||||||
/// We'll add it to the movement list that has the least number of agents
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="steeringRequest"></param>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the steering behavior for the AI and cleans up
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <exception cref="InvalidOperationException"></exception>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is the entity currently registered for steering?
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Go through each steerer and combine their vectors
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="steeringRequest"></param>
|
|
||||||
/// <param name="frameTime"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
/// <exception cref="NotImplementedException"></exception>
|
|
||||||
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<MetaDataComponent>(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<EntityUid>();
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get a new job from the pathfindingsystem
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="steeringRequest"></param>
|
|
||||||
private void RequestPath(EntityUid entity, IAiSteeringRequest steeringRequest)
|
|
||||||
{
|
|
||||||
if (_pathfindingRequests.ContainsKey(entity))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var xform = EntityManager.GetComponent<TransformComponent>(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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Given the pathfinding is timesliced we need to trim the first few(?) tiles so we don't walk backwards
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="path"></param>
|
|
||||||
private void UpdatePath(EntityUid entity, Queue<TileRef> path)
|
|
||||||
{
|
|
||||||
_pathfindingRequests.Remove(entity);
|
|
||||||
|
|
||||||
var xform = EntityManager.GetComponent<TransformComponent>(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the next tile as EntityCoordinates
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="steeringRequest"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
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<TransformComponent>(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rather than converting TileRef to EntityCoordinates over and over we'll just cache it
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="dequeue"></param>
|
|
||||||
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<TransformComponent>(entity);
|
|
||||||
if (xform.GridUid == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var nextGrid = _mapManager.GetGrid(xform.GridUid.Value).GridTileToLocal(nextTile.GridIndices);
|
|
||||||
_nextGrid[entity] = nextGrid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if we've been near our last EntityCoordinates too long and try to fix it
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
private void HandleStuck(EntityUid entity)
|
|
||||||
{
|
|
||||||
if (!_stuckPositions.TryGetValue(entity, out var stuckPosition))
|
|
||||||
{
|
|
||||||
_stuckPositions[entity] = EntityManager.GetComponent<TransformComponent>(entity).Coordinates;
|
|
||||||
_stuckCounter[entity] = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((EntityManager.GetComponent<TransformComponent>(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<TransformComponent>(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
|
|
||||||
/// <summary>
|
|
||||||
/// Move straight to target position
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="grid"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private Vector2 Seek(EntityUid entity, EntityCoordinates grid)
|
|
||||||
{
|
|
||||||
// is-even much
|
|
||||||
var entityPos = EntityManager.GetComponent<TransformComponent>(entity).Coordinates;
|
|
||||||
return entityPos == grid
|
|
||||||
? Vector2.Zero
|
|
||||||
: (grid.Position - entityPos.Position).Normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Like Seek but slows down when within distance
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="grid"></param>
|
|
||||||
/// <param name="slowingDistance"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private Vector2 Arrival(EntityUid entity, EntityCoordinates grid, float slowingDistance = 1.0f)
|
|
||||||
{
|
|
||||||
var entityPos = EntityManager.GetComponent<TransformComponent>(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Like Seek but predicts target's future position
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="target"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private Vector2 Pursuit(EntityUid entity, EntityUid target)
|
|
||||||
{
|
|
||||||
var entityPos = EntityManager.GetComponent<TransformComponent>(entity).Coordinates;
|
|
||||||
var targetPos = EntityManager.GetComponent<TransformComponent>(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks for non-anchored physics objects that can block us
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entity"></param>
|
|
||||||
/// <param name="direction">entity's travel direction</param>
|
|
||||||
/// <param name="ignoredTargets"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private Vector2 CollisionAvoidance(EntityUid entity, Vector2 direction, ICollection<EntityUid> 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<TileRef>();
|
|
||||||
var avoidTiles = new HashSet<TileRef>();
|
|
||||||
|
|
||||||
var xform = EntityManager.GetComponent<TransformComponent>(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<TransformComponent>(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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<IEntityManager>().GetComponent<TransformComponent>(_target).MapPosition;
|
|
||||||
public EntityCoordinates TargetGrid => IoCManager.Resolve<IEntityManager>().GetComponent<TransformComponent>(_target).Coordinates;
|
|
||||||
public EntityUid Target => _target;
|
|
||||||
private readonly EntityUid _target;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public float ArrivalDistance { get; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public float PathfindingProximity { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How far the target can move before we re-path
|
|
||||||
/// </summary>
|
|
||||||
public float TargetMaxMove { get; } = 1.5f;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool RequiresInRangeUnobstructed { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// To avoid spamming InRangeUnobstructed we'll apply a cd to it.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
/// <inheritdoc />
|
|
||||||
public float ArrivalDistance { get; }
|
|
||||||
/// <inheritdoc />
|
|
||||||
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<IEntityManager>();
|
|
||||||
TargetMap = targetGrid.ToMap(entityManager);
|
|
||||||
TargetGrid = targetGrid;
|
|
||||||
ArrivalDistance = arrivalDistance;
|
|
||||||
PathfindingProximity = pathfindingProximity;
|
|
||||||
RequiresInRangeUnobstructed = requiresInRangeUnobstructed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
56
Content.Server/AI/Steering/NPCSteeringComponent.cs
Normal file
56
Content.Server/AI/Steering/NPCSteeringComponent.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Added to NPCs that are moving.
|
||||||
|
/// </summary>
|
||||||
|
[RegisterComponent]
|
||||||
|
public sealed class NPCSteeringComponent : Component
|
||||||
|
{
|
||||||
|
[ViewVariables] public Job<Queue<TileRef>>? Pathfind = null;
|
||||||
|
[ViewVariables] public CancellationTokenSource? PathfindToken = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current path we're following to our coordinates.
|
||||||
|
/// </summary>
|
||||||
|
[ViewVariables] public Queue<TileRef> CurrentPath = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Target that we're trying to move to.
|
||||||
|
/// </summary>
|
||||||
|
[ViewVariables(VVAccess.ReadWrite)] public EntityCoordinates Coordinates;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How close are we trying to get to the coordinates before being considered in range.
|
||||||
|
/// </summary>
|
||||||
|
[ViewVariables(VVAccess.ReadWrite)] public float Range = 0.2f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How far does the last node in the path need to be before considering re-pathfinding.
|
||||||
|
/// </summary>
|
||||||
|
[ViewVariables(VVAccess.ReadWrite)] public float RepathRange = 1.5f;
|
||||||
|
|
||||||
|
[ViewVariables] public SteeringStatus Status = SteeringStatus.Moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SteeringStatus : byte
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// If we can't reach the target (e.g. different map).
|
||||||
|
/// </summary>
|
||||||
|
NoPath,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Are we moving towards our target
|
||||||
|
/// </summary>
|
||||||
|
Moving,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Are we currently in range of our target.
|
||||||
|
/// </summary>
|
||||||
|
InRange,
|
||||||
|
}
|
||||||
398
Content.Server/AI/Steering/NPCSteeringSystem.cs
Normal file
398
Content.Server/AI/Steering/NPCSteeringSystem.cs
Normal file
@@ -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<NPCSteeringComponent, ComponentShutdown>(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<NPCSteeringComponent, InputMoverComponent>())
|
||||||
|
{
|
||||||
|
mover.CurTickSprintMovement = Vector2.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_enabled = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Shutdown()
|
||||||
|
{
|
||||||
|
base.Shutdown();
|
||||||
|
_configManager.UnsubValueChanged(CCVars.NPCEnabled, SetNPCEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the AI to the steering system to move towards a specific target
|
||||||
|
/// </summary>
|
||||||
|
public NPCSteeringComponent Register(EntityUid entity, EntityCoordinates coordinates)
|
||||||
|
{
|
||||||
|
if (TryComp<NPCSteeringComponent>(entity, out var comp))
|
||||||
|
{
|
||||||
|
comp.PathfindToken?.Cancel();
|
||||||
|
comp.PathfindToken = null;
|
||||||
|
comp.CurrentPath.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
comp = AddComp<NPCSteeringComponent>(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
comp.Coordinates = coordinates;
|
||||||
|
return comp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the steering behavior for the AI and cleans up.
|
||||||
|
/// </summary>
|
||||||
|
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<NPCSteeringComponent>(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<PhysicsComponent>();
|
||||||
|
var modifierQuery = GetEntityQuery<MovementSpeedModifierComponent>();
|
||||||
|
|
||||||
|
foreach (var (steering, _, mover, xform) in EntityQuery<NPCSteeringComponent, ActiveNPCComponent, InputMoverComponent, TransformComponent>())
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Go through each steerer and combine their vectors
|
||||||
|
/// </summary>
|
||||||
|
private void Steer(
|
||||||
|
NPCSteeringComponent steering,
|
||||||
|
InputMoverComponent mover,
|
||||||
|
TransformComponent xform,
|
||||||
|
EntityQuery<MovementSpeedModifierComponent> modifierQuery,
|
||||||
|
EntityQuery<PhysicsComponent> 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.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// We may be pathfinding and moving at the same time in which case early nodes may be out of date.
|
||||||
|
/// </summary>
|
||||||
|
private void PrunePath(EntityCoordinates coordinates, Queue<TileRef> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the coordinates we should be heading towards.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a new job from the pathfindingsystem
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user