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