Add utility AI (#806)

Co-authored-by: Pieter-Jan Briers <pieterjan.briers@gmail.com>
Co-authored-by: Metal Gear Sloth <metalgearsloth@gmail.com>
Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
This commit is contained in:
metalgearsloth
2020-06-18 22:52:44 +10:00
committed by GitHub
parent 9b8cedf6c6
commit 5391d3c72a
211 changed files with 10335 additions and 527 deletions

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using Content.Server.AI.Utility.AiLogic;
using Content.Server.GameObjects.Components.Movement;
using Content.Server.Interfaces.GameObjects.Components.Movement;
using JetBrains.Annotations;
using Robust.Server.AI;
using Robust.Server.Interfaces.Console;
using Robust.Server.Interfaces.Player;
@@ -13,8 +15,9 @@ using Robust.Shared.Interfaces.Reflection;
using Robust.Shared.IoC;
using Robust.Shared.Utility;
namespace Content.Server.GameObjects.EntitySystems
namespace Content.Server.GameObjects.EntitySystems.AI
{
[UsedImplicitly]
internal class AiSystem : EntitySystem
{
#pragma warning disable 649
@@ -47,6 +50,7 @@ namespace Content.Server.GameObjects.EntitySystems
/// <inheritdoc />
public override void Update(float frameTime)
{
var entities = EntityManager.GetEntities(EntityQuery);
foreach (var entity in entities)
{
@@ -56,12 +60,7 @@ namespace Content.Server.GameObjects.EntitySystems
}
var aiComp = entity.GetComponent<AiControllerComponent>();
if (aiComp.Processor == null)
{
aiComp.Processor = CreateProcessor(aiComp.LogicName);
aiComp.Processor.SelfEntity = entity;
aiComp.Processor.VisionRadius = aiComp.VisionRadius;
}
ProcessorInitialize(aiComp);
var processor = aiComp.Processor;
@@ -69,11 +68,24 @@ namespace Content.Server.GameObjects.EntitySystems
}
}
private AiLogicProcessor CreateProcessor(string name)
/// <summary>
/// Will start up the controller's processor if not already done so
/// </summary>
/// <param name="controller"></param>
public void ProcessorInitialize(AiControllerComponent controller)
{
if (controller.Processor != null) return;
controller.Processor = CreateProcessor(controller.LogicName);
controller.Processor.SelfEntity = controller.Owner;
controller.Processor.VisionRadius = controller.VisionRadius;
controller.Processor.Setup();
}
private UtilityAi CreateProcessor(string name)
{
if (_processorTypes.TryGetValue(name, out var type))
{
return (AiLogicProcessor)_typeFactory.CreateInstance(type);
return (UtilityAi)_typeFactory.CreateInstance(type);
}
// processor needs to inherit AiLogicProcessor, and needs an AiLogicProcessorAttribute to define the YAML name

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using Content.Server.AI.Utility.Actions;
using Content.Server.AI.WorldState;
using Robust.Shared.GameObjects;
namespace Content.Server.GameObjects.EntitySystems.AI.LoadBalancer
{
public class AiActionRequest
{
public EntityUid EntityUid { get; }
public Blackboard Context { get; }
public IEnumerable<IAiUtility> Actions { get; }
public AiActionRequest(EntityUid uid, Blackboard context, IEnumerable<IAiUtility> actions)
{
EntityUid = uid;
Context = context;
Actions = actions;
}
}
}

View File

@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.AI.Utility.Actions;
using Content.Server.AI.Utility.ExpandableActions;
using Content.Server.AI.WorldState.States;
using Content.Server.AI.WorldState.States.Utility;
using Content.Server.GameObjects.Components.Movement;
using Content.Server.GameObjects.EntitySystems.JobQueues;
using Content.Shared.AI;
namespace Content.Server.GameObjects.EntitySystems.AI.LoadBalancer
{
public class AiActionRequestJob : Job<UtilityAction>
{
#if DEBUG
public static event Action<SharedAiDebug.UtilityAiDebugMessage> FoundAction;
#endif
private readonly AiActionRequest _request;
public AiActionRequestJob(
double maxTime,
AiActionRequest request,
CancellationToken cancellationToken = default) : base(maxTime, cancellationToken)
{
_request = request;
}
protected override async Task<UtilityAction> Process()
{
if (_request.Context == null)
{
return null;
}
var entity = _request.Context.GetState<SelfState>().GetValue();
if (entity == null || !entity.HasComponent<AiControllerComponent>())
{
return null;
}
if (_request.Actions == null || _request.Context == null)
{
return null;
}
var consideredTaskCount = 0;
// Actions are pre-sorted
var actions = new Stack<IAiUtility>(_request.Actions);
// So essentially we go through and once we have a valid score that score becomes the cutoff;
// once the bonus of new tasks is below the cutoff we can stop evaluating.
// Use last action as the basis for the cutoff
var cutoff = _request.Context.GetState<LastUtilityScoreState>().GetValue();
UtilityAction foundAction = null;
// To see what I was trying to do watch these 2 videos about Infinite Axis Utility System (IAUS):
// Architecture Tricks: Managing Behaviors in Time, Space, and Depth
// Building a Better Centaur
// We'll want to cap the considered entities at some point, e.g. if 500 guns are in a stack cap it at 256 or whatever
while (actions.Count > 0)
{
if (consideredTaskCount > 0 && consideredTaskCount % 5 == 0)
{
await SuspendIfOutOfTime();
// If this happens then that means something changed when we resumed so ABORT
if (actions.Count == 0 || _request.Context == null)
{
return null;
}
}
var action = actions.Pop();
switch (action)
{
case ExpandableUtilityAction expandableUtilityAction:
foreach (var expanded in expandableUtilityAction.GetActions(_request.Context))
{
actions.Push(expanded);
}
break;
case UtilityAction utilityAction:
consideredTaskCount++;
var bonus = (float) utilityAction.Bonus;
if (bonus < cutoff)
{
// We know none of the other actions can beat this as they're pre-sorted
actions.Clear();
break;
}
var score = utilityAction.GetScore(_request.Context, cutoff);
if (score > cutoff)
{
foundAction = utilityAction;
cutoff = score;
}
break;
default:
throw new ArgumentOutOfRangeException();
}
}
_request.Context.GetState<LastUtilityScoreState>().SetValue(cutoff);
#if DEBUG
if (foundAction != null)
{
FoundAction?.Invoke(new SharedAiDebug.UtilityAiDebugMessage(
_request.Context.GetState<SelfState>().GetValue().Uid,
DebugTime,
cutoff,
foundAction.GetType().Name,
consideredTaskCount));
}
#endif
_request.Context.ResetPlanning();
return foundAction;
}
}
}

View File

@@ -0,0 +1,28 @@
using System.Threading;
using Content.Server.GameObjects.EntitySystems.JobQueues.Queues;
using Robust.Shared.GameObjects.Systems;
namespace Content.Server.GameObjects.EntitySystems.AI.LoadBalancer
{
/// <summary>
/// This will queue up an AI's request for an action and give it one when possible
/// </summary>
public class AiActionSystem : EntitySystem
{
private readonly AiActionJobQueue _aiRequestQueue = new AiActionJobQueue();
public AiActionRequestJob RequestAction(AiActionRequest request, CancellationTokenSource cancellationToken)
{
var job = new AiActionRequestJob(0.002, request, cancellationToken.Token);
// AI should already know if it shouldn't request again
_aiRequestQueue.EnqueueJob(job);
return job;
}
public override void Update(float frameTime)
{
base.Update(frameTime);
_aiRequestQueue.Process();
}
}
}

View File

@@ -0,0 +1,14 @@
using Robust.Shared.GameObjects.Components.Transform;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.GraphUpdates
{
public struct CollidableMove : IPathfindingGraphUpdate
{
public MoveEvent MoveEvent { get; }
public CollidableMove(MoveEvent moveEvent)
{
MoveEvent = moveEvent;
}
}
}

View File

@@ -0,0 +1,16 @@
using Robust.Shared.Interfaces.GameObjects;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.GraphUpdates
{
public class CollisionChange : IPathfindingGraphUpdate
{
public IEntity Owner { get; }
public bool Value { get; }
public CollisionChange(IEntity owner, bool value)
{
Owner = owner;
Value = value;
}
}
}

View File

@@ -0,0 +1,14 @@
using Robust.Shared.Map;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.GraphUpdates
{
public struct GridRemoval : IPathfindingGraphUpdate
{
public GridId GridId { get; }
public GridRemoval(GridId gridId)
{
GridId = gridId;
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.GraphUpdates
{
public interface IPathfindingGraphUpdate
{
}
}

View File

@@ -0,0 +1,14 @@
using Robust.Shared.Map;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.GraphUpdates
{
public struct TileUpdate : IPathfindingGraphUpdate
{
public TileUpdate(TileRef tile)
{
Tile = tile;
}
public TileRef Tile { get; }
}
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.GameObjects.EntitySystems.JobQueues;
using Content.Server.GameObjects.EntitySystems.Pathfinding;
using Content.Shared.AI;
using Robust.Shared.Map;
using Robust.Shared.Utility;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders
{
public class AStarPathfindingJob : Job<Queue<TileRef>>
{
public static event Action<SharedAiDebug.AStarRouteDebug> DebugRoute;
private PathfindingNode _startNode;
private PathfindingNode _endNode;
private PathfindingArgs _pathfindingArgs;
public AStarPathfindingJob(
double maxTime,
PathfindingNode startNode,
PathfindingNode endNode,
PathfindingArgs pathfindingArgs,
CancellationToken cancellationToken) : base(maxTime, cancellationToken)
{
_startNode = startNode;
_endNode = endNode;
_pathfindingArgs = pathfindingArgs;
}
protected override async Task<Queue<TileRef>> Process()
{
if (_startNode == null ||
_endNode == null ||
Status == JobStatus.Finished)
{
return null;
}
// If we couldn't get a nearby node that's good enough
if (!Utils.TryEndNode(ref _endNode, _pathfindingArgs))
{
return null;
}
var openTiles = new PriorityQueue<ValueTuple<float, PathfindingNode>>(new PathfindingComparer());
var gScores = new Dictionary<PathfindingNode, float>();
var cameFrom = new Dictionary<PathfindingNode, PathfindingNode>();
var closedTiles = new HashSet<PathfindingNode>();
PathfindingNode currentNode = null;
openTiles.Add((0.0f, _startNode));
gScores[_startNode] = 0.0f;
var routeFound = false;
var count = 0;
while (openTiles.Count > 0)
{
count++;
if (count % 20 == 0 && count > 0)
{
await SuspendIfOutOfTime();
}
if (_startNode == null || _endNode == null)
{
return null;
}
(_, currentNode) = openTiles.Take();
if (currentNode.Equals(_endNode))
{
routeFound = true;
break;
}
closedTiles.Add(currentNode);
foreach (var (direction, nextNode) in currentNode.Neighbors)
{
if (closedTiles.Contains(nextNode))
{
continue;
}
// If tile is untraversable it'll be null
var tileCost = Utils.GetTileCost(_pathfindingArgs, currentNode, nextNode);
if (tileCost == null || !Utils.DirectionTraversable(_pathfindingArgs.CollisionMask, currentNode, direction))
{
continue;
}
var gScore = gScores[currentNode] + tileCost.Value;
if (gScores.TryGetValue(nextNode, out var nextValue) && gScore >= nextValue)
{
continue;
}
cameFrom[nextNode] = currentNode;
gScores[nextNode] = gScore;
// pFactor is tie-breaker where the fscore is otherwise equal.
// See http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html#breaking-ties
// There's other ways to do it but future consideration
var fScore = gScores[nextNode] + Utils.OctileDistance(_endNode, nextNode) * (1.0f + 1.0f / 1000.0f);
openTiles.Add((fScore, nextNode));
}
}
if (!routeFound)
{
return null;
}
var route = Utils.ReconstructPath(cameFrom, currentNode);
if (route.Count == 1)
{
return null;
}
#if DEBUG
// Need to get data into an easier format to send to the relevant clients
if (DebugRoute != null && route.Count > 0)
{
var debugCameFrom = new Dictionary<TileRef, TileRef>(cameFrom.Count);
var debugGScores = new Dictionary<TileRef, float>(gScores.Count);
var debugClosedTiles = new HashSet<TileRef>(closedTiles.Count);
foreach (var (node, parent) in cameFrom)
{
debugCameFrom.Add(node.TileRef, parent.TileRef);
}
foreach (var (node, score) in gScores)
{
debugGScores.Add(node.TileRef, score);
}
foreach (var node in closedTiles)
{
debugClosedTiles.Add(node.TileRef);
}
var debugRoute = new SharedAiDebug.AStarRouteDebug(
_pathfindingArgs.Uid,
route,
debugCameFrom,
debugGScores,
debugClosedTiles,
DebugTime);
DebugRoute.Invoke(debugRoute);
}
#endif
return route;
}
}
}

View File

@@ -0,0 +1,389 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.GameObjects.EntitySystems.JobQueues;
using Content.Server.GameObjects.EntitySystems.Pathfinding;
using Content.Shared.AI;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders
{
public class JpsPathfindingJob : Job<Queue<TileRef>>
{
public static event Action<SharedAiDebug.JpsRouteDebug> DebugRoute;
private PathfindingNode _startNode;
private PathfindingNode _endNode;
private PathfindingArgs _pathfindingArgs;
public JpsPathfindingJob(double maxTime,
PathfindingNode startNode,
PathfindingNode endNode,
PathfindingArgs pathfindingArgs,
CancellationToken cancellationToken) : base(maxTime, cancellationToken)
{
_startNode = startNode;
_endNode = endNode;
_pathfindingArgs = pathfindingArgs;
}
protected override async Task<Queue<TileRef>> Process()
{
// VERY similar to A*; main difference is with the neighbor tiles you look for jump nodes instead
if (_startNode == null ||
_endNode == null)
{
return null;
}
// If we couldn't get a nearby node that's good enough
if (!Utils.TryEndNode(ref _endNode, _pathfindingArgs))
{
return null;
}
var openTiles = new PriorityQueue<ValueTuple<float, PathfindingNode>>(new PathfindingComparer());
var gScores = new Dictionary<PathfindingNode, float>();
var cameFrom = new Dictionary<PathfindingNode, PathfindingNode>();
var closedTiles = new HashSet<PathfindingNode>();
#if DEBUG
var jumpNodes = new HashSet<PathfindingNode>();
#endif
PathfindingNode currentNode = null;
openTiles.Add((0, _startNode));
gScores[_startNode] = 0.0f;
var routeFound = false;
var count = 0;
while (openTiles.Count > 0)
{
count++;
// JPS probably getting a lot fewer nodes than A* is
if (count % 5 == 0 && count > 0)
{
await SuspendIfOutOfTime();
}
(_, currentNode) = openTiles.Take();
if (currentNode.Equals(_endNode))
{
routeFound = true;
break;
}
foreach (var (direction, _) in currentNode.Neighbors)
{
var jumpNode = GetJumpPoint(currentNode, direction, _endNode);
if (jumpNode != null && !closedTiles.Contains(jumpNode))
{
closedTiles.Add(jumpNode);
#if DEBUG
jumpNodes.Add(jumpNode);
#endif
// GetJumpPoint should already check if we can traverse to the node
var tileCost = Utils.GetTileCost(_pathfindingArgs, currentNode, jumpNode);
if (tileCost == null)
{
throw new InvalidOperationException();
}
var gScore = gScores[currentNode] + tileCost.Value;
if (gScores.TryGetValue(jumpNode, out var nextValue) && gScore >= nextValue)
{
continue;
}
cameFrom[jumpNode] = currentNode;
gScores[jumpNode] = gScore;
// pFactor is tie-breaker where the fscore is otherwise equal.
// See http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html#breaking-ties
// There's other ways to do it but future consideration
var fScore = gScores[jumpNode] + Utils.OctileDistance(_endNode, jumpNode) * (1.0f + 1.0f / 1000.0f);
openTiles.Add((fScore, jumpNode));
}
}
}
if (!routeFound)
{
return null;
}
var route = Utils.ReconstructJumpPath(cameFrom, currentNode);
if (route.Count == 1)
{
return null;
}
#if DEBUG
// Need to get data into an easier format to send to the relevant clients
if (DebugRoute != null && route.Count > 0)
{
var debugJumpNodes = new HashSet<TileRef>(jumpNodes.Count);
foreach (var node in jumpNodes)
{
debugJumpNodes.Add(node.TileRef);
}
var debugRoute = new SharedAiDebug.JpsRouteDebug(
_pathfindingArgs.Uid,
route,
debugJumpNodes,
DebugTime);
DebugRoute.Invoke(debugRoute);
}
#endif
return route;
}
private PathfindingNode GetJumpPoint(PathfindingNode currentNode, Direction direction, PathfindingNode endNode)
{
var count = 0;
while (count < 1000)
{
count++;
var nextNode = currentNode.GetNeighbor(direction);
// We'll do opposite DirectionTraversable just because of how the method's setup
// Nodes should be 2-way anyway.
if (nextNode == null ||
Utils.GetTileCost(_pathfindingArgs, currentNode, nextNode) == null)
{
return null;
}
if (nextNode == endNode)
{
return endNode;
}
// Horizontal and vertical are treated the same i.e.
// They only check in their specific direction
// (So Going North means you check NorthWest and NorthEast to see if we're a jump point)
// Diagonals also check the cardinal directions at the same time at the same time
// See https://harablog.wordpress.com/2011/09/07/jump-point-search/ for original description
switch (direction)
{
case Direction.East:
if (IsCardinalJumpPoint(direction, nextNode))
{
return nextNode;
}
break;
case Direction.NorthEast:
if (IsDiagonalJumpPoint(direction, nextNode))
{
return nextNode;
}
if (GetJumpPoint(nextNode, Direction.North, endNode) != null || GetJumpPoint(nextNode, Direction.East, endNode) != null)
{
return nextNode;
}
break;
case Direction.North:
if (IsCardinalJumpPoint(direction, nextNode))
{
return nextNode;
}
break;
case Direction.NorthWest:
if (IsDiagonalJumpPoint(direction, nextNode))
{
return nextNode;
}
if (GetJumpPoint(nextNode, Direction.North, endNode) != null || GetJumpPoint(nextNode, Direction.West, endNode) != null)
{
return nextNode;
}
break;
case Direction.West:
if (IsCardinalJumpPoint(direction, nextNode))
{
return nextNode;
}
break;
case Direction.SouthWest:
if (IsDiagonalJumpPoint(direction, nextNode))
{
return nextNode;
}
if (GetJumpPoint(nextNode, Direction.South, endNode) != null || GetJumpPoint(nextNode, Direction.West, endNode) != null)
{
return nextNode;
}
break;
case Direction.South:
if (IsCardinalJumpPoint(direction, nextNode))
{
return nextNode;
}
break;
case Direction.SouthEast:
if (IsDiagonalJumpPoint(direction, nextNode))
{
return nextNode;
}
if (GetJumpPoint(nextNode, Direction.South, endNode) != null || GetJumpPoint(nextNode, Direction.East, endNode) != null)
{
return nextNode;
}
break;
default:
throw new ArgumentOutOfRangeException(nameof(direction), direction, null);
}
currentNode = nextNode;
}
Logger.WarningS("pathfinding", "Recursion found in JPS pathfinder");
return null;
}
private bool IsDiagonalJumpPoint(Direction direction, PathfindingNode currentNode)
{
// If we're going diagonally need to check all cardinals.
// I just just using casts int casts and offset to make it smaller but brain no workyand it wasn't working.
// From NorthEast we check (Closed / Open) S - SE, W - NW
PathfindingNode openNeighborOne;
PathfindingNode closedNeighborOne;
PathfindingNode openNeighborTwo;
PathfindingNode closedNeighborTwo;
switch (direction)
{
case Direction.NorthEast:
openNeighborOne = currentNode.GetNeighbor(Direction.SouthEast);
closedNeighborOne = currentNode.GetNeighbor(Direction.South);
openNeighborTwo = currentNode.GetNeighbor(Direction.NorthWest);
closedNeighborTwo = currentNode.GetNeighbor(Direction.West);
break;
case Direction.SouthEast:
openNeighborOne = currentNode.GetNeighbor(Direction.NorthEast);
closedNeighborOne = currentNode.GetNeighbor(Direction.North);
openNeighborTwo = currentNode.GetNeighbor(Direction.SouthWest);
closedNeighborTwo = currentNode.GetNeighbor(Direction.West);
break;
case Direction.SouthWest:
openNeighborOne = currentNode.GetNeighbor(Direction.NorthWest);
closedNeighborOne = currentNode.GetNeighbor(Direction.North);
openNeighborTwo = currentNode.GetNeighbor(Direction.SouthEast);
closedNeighborTwo = currentNode.GetNeighbor(Direction.East);
break;
case Direction.NorthWest:
openNeighborOne = currentNode.GetNeighbor(Direction.SouthWest);
closedNeighborOne = currentNode.GetNeighbor(Direction.South);
openNeighborTwo = currentNode.GetNeighbor(Direction.NorthEast);
closedNeighborTwo = currentNode.GetNeighbor(Direction.East);
break;
default:
throw new ArgumentOutOfRangeException();
}
if ((closedNeighborOne == null || Utils.GetTileCost(_pathfindingArgs, currentNode, closedNeighborOne) == null)
&& openNeighborOne != null && Utils.GetTileCost(_pathfindingArgs, currentNode, openNeighborOne) != null)
{
return true;
}
if ((closedNeighborTwo == null || Utils.GetTileCost(_pathfindingArgs, currentNode, closedNeighborTwo) == null)
&& openNeighborTwo != null && Utils.GetTileCost(_pathfindingArgs, currentNode, openNeighborTwo) != null)
{
return true;
}
return false;
}
/// <summary>
/// Check to see if the node is a jump point (only works for cardinal directions)
/// </summary>
private bool IsCardinalJumpPoint(Direction direction, PathfindingNode currentNode)
{
PathfindingNode openNeighborOne;
PathfindingNode closedNeighborOne;
PathfindingNode openNeighborTwo;
PathfindingNode closedNeighborTwo;
switch (direction)
{
case Direction.North:
openNeighborOne = currentNode.GetNeighbor(Direction.NorthEast);
closedNeighborOne = currentNode.GetNeighbor(Direction.East);
openNeighborTwo = currentNode.GetNeighbor(Direction.NorthWest);
closedNeighborTwo = currentNode.GetNeighbor(Direction.West);
break;
case Direction.East:
openNeighborOne = currentNode.GetNeighbor(Direction.NorthEast);
closedNeighborOne = currentNode.GetNeighbor(Direction.North);
openNeighborTwo = currentNode.GetNeighbor(Direction.SouthEast);
closedNeighborTwo = currentNode.GetNeighbor(Direction.South);
break;
case Direction.South:
openNeighborOne = currentNode.GetNeighbor(Direction.SouthEast);
closedNeighborOne = currentNode.GetNeighbor(Direction.East);
openNeighborTwo = currentNode.GetNeighbor(Direction.SouthWest);
closedNeighborTwo = currentNode.GetNeighbor(Direction.West);
break;
case Direction.West:
openNeighborOne = currentNode.GetNeighbor(Direction.NorthWest);
closedNeighborOne = currentNode.GetNeighbor(Direction.North);
openNeighborTwo = currentNode.GetNeighbor(Direction.SouthWest);
closedNeighborTwo = currentNode.GetNeighbor(Direction.South);
break;
default:
throw new ArgumentOutOfRangeException();
}
if ((closedNeighborOne == null || !Utils.Traversable(_pathfindingArgs.CollisionMask, closedNeighborOne.CollisionMask)) &&
(openNeighborOne != null && Utils.Traversable(_pathfindingArgs.CollisionMask, openNeighborOne.CollisionMask)))
{
return true;
}
if ((closedNeighborTwo == null || !Utils.Traversable(_pathfindingArgs.CollisionMask, closedNeighborTwo.CollisionMask)) &&
(openNeighborTwo != null && Utils.Traversable(_pathfindingArgs.CollisionMask, openNeighborTwo.CollisionMask)))
{
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,41 @@
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders
{
public struct PathfindingArgs
{
public EntityUid Uid { get; }
public int CollisionMask { get; }
public TileRef Start { get; }
public TileRef End { get; }
// How close we need to get to the endpoint to be 'done'
public float Proximity { get; }
// Whether we use cardinal only or not
public bool AllowDiagonals { get; }
// Can we go through walls
public bool NoClip { get; }
// Can we traverse space tiles
public bool AllowSpace { get; }
public PathfindingArgs(
EntityUid entityUid,
int collisionMask,
TileRef start,
TileRef end,
float proximity = 0.0f,
bool allowDiagonals = true,
bool noClip = false,
bool allowSpace = false)
{
Uid = entityUid;
CollisionMask = collisionMask;
Start = start;
End = end;
Proximity = proximity;
AllowDiagonals = allowDiagonals;
NoClip = noClip;
AllowSpace = allowSpace;
}
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using Content.Server.GameObjects.EntitySystems.Pathfinding;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders
{
public class PathfindingComparer : IComparer<ValueTuple<float, PathfindingNode>>
{
public int Compare((float, PathfindingNode) x, (float, PathfindingNode) y)
{
return y.Item1.CompareTo(x.Item1);
}
}
}

View File

@@ -0,0 +1,323 @@
using System;
using System.Collections.Generic;
using Content.Server.GameObjects.EntitySystems.Pathfinding;
using Robust.Shared.Interfaces.Map;
using Robust.Shared.IoC;
using Robust.Shared.Map;
using Robust.Shared.Maths;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
{
public class PathfindingChunk
{
public GridId GridId { get; }
public MapIndices Indices => _indices;
private readonly MapIndices _indices;
// Nodes per chunk row
public static int ChunkSize => 16;
public PathfindingNode[,] Nodes => _nodes;
private PathfindingNode[,] _nodes = new PathfindingNode[ChunkSize,ChunkSize];
public Dictionary<Direction, PathfindingChunk> Neighbors { get; } = new Dictionary<Direction, PathfindingChunk>(8);
public PathfindingChunk(GridId gridId, MapIndices indices)
{
GridId = gridId;
_indices = indices;
}
public void Initialize()
{
var grid = IoCManager.Resolve<IMapManager>().GetGrid(GridId);
for (var x = 0; x < ChunkSize; x++)
{
for (var y = 0; y < ChunkSize; y++)
{
var tileRef = grid.GetTileRef(new MapIndices(x + _indices.X, y + _indices.Y));
CreateNode(tileRef);
}
}
RefreshNodeNeighbors();
}
/// <summary>
/// Updates all internal nodes with references to every other internal node
/// </summary>
private void RefreshNodeNeighbors()
{
for (var x = 0; x < ChunkSize; x++)
{
for (var y = 0; y < ChunkSize; y++)
{
var node = _nodes[x, y];
// West
if (x != 0)
{
if (y != ChunkSize - 1)
{
node.AddNeighbor(Direction.NorthWest, _nodes[x - 1, y + 1]);
}
node.AddNeighbor(Direction.West, _nodes[x - 1, y]);
if (y != 0)
{
node.AddNeighbor(Direction.SouthWest, _nodes[x - 1, y - 1]);
}
}
// Same column
if (y != ChunkSize - 1)
{
node.AddNeighbor(Direction.North, _nodes[x, y + 1]);
}
if (y != 0)
{
node.AddNeighbor(Direction.South, _nodes[x, y - 1]);
}
// East
if (x != ChunkSize - 1)
{
if (y != ChunkSize - 1)
{
node.AddNeighbor(Direction.NorthEast, _nodes[x + 1, y + 1]);
}
node.AddNeighbor(Direction.East, _nodes[x + 1, y]);
if (y != 0)
{
node.AddNeighbor(Direction.SouthEast, _nodes[x + 1, y - 1]);
}
}
}
}
}
/// <summary>
/// This will work both ways
/// </summary>
/// <param name="chunk"></param>
/// <exception cref="InvalidOperationException"></exception>
public void AddNeighbor(PathfindingChunk chunk)
{
if (chunk == this) return;
if (Neighbors.ContainsValue(chunk))
{
return;
}
Direction direction;
if (chunk.Indices.X < _indices.X)
{
if (chunk.Indices.Y > _indices.Y)
{
direction = Direction.NorthWest;
} else if (chunk.Indices.Y < _indices.Y)
{
direction = Direction.SouthWest;
}
else
{
direction = Direction.West;
}
}
else if (chunk.Indices.X > _indices.X)
{
if (chunk.Indices.Y > _indices.Y)
{
direction = Direction.NorthEast;
} else if (chunk.Indices.Y < _indices.Y)
{
direction = Direction.SouthEast;
}
else
{
direction = Direction.East;
}
}
else
{
if (chunk.Indices.Y > _indices.Y)
{
direction = Direction.North;
} else if (chunk.Indices.Y < _indices.Y)
{
direction = Direction.South;
}
else
{
throw new InvalidOperationException();
}
}
Neighbors.TryAdd(direction, chunk);
foreach (var node in GetBorderNodes(direction))
{
foreach (var counter in chunk.GetCounterpartNodes(direction))
{
var xDiff = node.TileRef.X - counter.TileRef.X;
var yDiff = node.TileRef.Y - counter.TileRef.Y;
if (Math.Abs(xDiff) <= 1 && Math.Abs(yDiff) <= 1)
{
node.AddNeighbor(counter);
counter.AddNeighbor(node);
}
}
}
chunk.Neighbors.TryAdd(OppositeDirection(direction), this);
if (Neighbors.Count > 8)
{
throw new InvalidOperationException();
}
}
private Direction OppositeDirection(Direction direction)
{
return (Direction) (((int) direction + 4) % 8);
}
// TODO I was too tired to think of an easier system. Could probably just google an array wraparound
private IEnumerable<PathfindingNode> GetCounterpartNodes(Direction direction)
{
switch (direction)
{
case Direction.West:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[ChunkSize - 1, i];
}
break;
case Direction.SouthWest:
yield return _nodes[ChunkSize - 1, ChunkSize - 1];
break;
case Direction.South:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[i, ChunkSize - 1];
}
break;
case Direction.SouthEast:
yield return _nodes[0, ChunkSize - 1];
break;
case Direction.East:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[0, i];
}
break;
case Direction.NorthEast:
yield return _nodes[0, 0];
break;
case Direction.North:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[i, 0];
}
break;
case Direction.NorthWest:
yield return _nodes[ChunkSize - 1, 0];
break;
default:
throw new ArgumentOutOfRangeException(nameof(direction), direction, null);
}
}
public IEnumerable<PathfindingNode> GetBorderNodes(Direction direction)
{
switch (direction)
{
case Direction.East:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[ChunkSize - 1, i];
}
break;
case Direction.NorthEast:
yield return _nodes[ChunkSize - 1, ChunkSize - 1];
break;
case Direction.North:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[i, ChunkSize - 1];
}
break;
case Direction.NorthWest:
yield return _nodes[0, ChunkSize - 1];
break;
case Direction.West:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[0, i];
}
break;
case Direction.SouthWest:
yield return _nodes[0, 0];
break;
case Direction.South:
for (var i = 0; i < ChunkSize; i++)
{
yield return _nodes[i, 0];
}
break;
case Direction.SouthEast:
yield return _nodes[ChunkSize - 1, 0];
break;
default:
throw new ArgumentOutOfRangeException(nameof(direction), direction, null);
}
}
public bool InBounds(TileRef tile)
{
if (tile.X < _indices.X || tile.Y < _indices.Y) return false;
if (tile.X >= _indices.X + ChunkSize || tile.Y >= _indices.Y + ChunkSize) return false;
return true;
}
/// <summary>
/// Returns true if the tile is on the outer edge
/// </summary>
/// <param name="node"></param>
/// <returns></returns>
public bool OnEdge(PathfindingNode node)
{
if (node.TileRef.X == _indices.X) return true;
if (node.TileRef.Y == _indices.Y) return true;
if (node.TileRef.X == _indices.X + ChunkSize - 1) return true;
if (node.TileRef.Y == _indices.Y + ChunkSize - 1) return true;
return false;
}
public PathfindingNode GetNode(TileRef tile)
{
var chunkX = tile.X - _indices.X;
var chunkY = tile.Y - _indices.Y;
return _nodes[chunkX, chunkY];
}
public void UpdateNode(TileRef tile)
{
var node = GetNode(tile);
node.UpdateTile(tile);
}
private void CreateNode(TileRef tile, PathfindingChunk parent = null)
{
if (parent == null)
{
parent = this;
}
var node = new PathfindingNode(parent, tile);
var offsetX = tile.X - Indices.X;
var offsetY = tile.Y - Indices.Y;
_nodes[offsetX, offsetY] = node;
}
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding;
using Robust.Shared.Map;
using Robust.Shared.Maths;
namespace Content.Server.GameObjects.EntitySystems.Pathfinding
{
public class PathfindingNode
{
// TODO: Add access ID here
public PathfindingChunk ParentChunk => _parentChunk;
private readonly PathfindingChunk _parentChunk;
public TileRef TileRef { get; private set; }
public List<int> CollisionLayers { get; }
public int CollisionMask { get; private set; }
public Dictionary<Direction, PathfindingNode> Neighbors => _neighbors;
private Dictionary<Direction, PathfindingNode> _neighbors = new Dictionary<Direction, PathfindingNode>();
public PathfindingNode(PathfindingChunk parent, TileRef tileRef, List<int> collisionLayers = null)
{
_parentChunk = parent;
TileRef = tileRef;
if (collisionLayers == null)
{
CollisionLayers = new List<int>();
}
else
{
CollisionLayers = collisionLayers;
}
GenerateMask();
}
public void AddNeighbor(Direction direction, PathfindingNode node)
{
_neighbors.Add(direction, node);
}
public void AddNeighbor(PathfindingNode node)
{
if (node.TileRef.GridIndex != TileRef.GridIndex)
{
throw new InvalidOperationException();
}
Direction direction;
if (node.TileRef.X < TileRef.X)
{
if (node.TileRef.Y > TileRef.Y)
{
direction = Direction.NorthWest;
} else if (node.TileRef.Y < TileRef.Y)
{
direction = Direction.SouthWest;
}
else
{
direction = Direction.West;
}
}
else if (node.TileRef.X > TileRef.X)
{
if (node.TileRef.Y > TileRef.Y)
{
direction = Direction.NorthEast;
} else if (node.TileRef.Y < TileRef.Y)
{
direction = Direction.SouthEast;
}
else
{
direction = Direction.East;
}
}
else
{
if (node.TileRef.Y > TileRef.Y)
{
direction = Direction.North;
}
else
{
direction = Direction.South;
}
}
if (_neighbors.ContainsKey(direction))
{
// Should we verify that they align?
return;
}
_neighbors.Add(direction, node);
}
public PathfindingNode GetNeighbor(Direction direction)
{
_neighbors.TryGetValue(direction, out var node);
return node;
}
public void UpdateTile(TileRef newTile)
{
TileRef = newTile;
}
public void AddCollisionLayer(int layer)
{
CollisionLayers.Add(layer);
GenerateMask();
}
public void RemoveCollisionLayer(int layer)
{
CollisionLayers.Remove(layer);
GenerateMask();
}
private void GenerateMask()
{
CollisionMask = 0x0;
foreach (var layer in CollisionLayers)
{
CollisionMask |= layer;
}
}
}
}

View File

@@ -0,0 +1,357 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Content.Server.GameObjects.Components.Doors;
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.GraphUpdates;
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders;
using Content.Server.GameObjects.EntitySystems.JobQueues;
using Content.Server.GameObjects.EntitySystems.JobQueues.Queues;
using Content.Server.GameObjects.EntitySystems.Pathfinding;
using Robust.Shared.GameObjects.Components;
using Robust.Shared.GameObjects.Components.Transform;
using Robust.Shared.GameObjects.Systems;
using Robust.Shared.Interfaces.GameObjects;
using Robust.Shared.Interfaces.Map;
using Robust.Shared.IoC;
using Robust.Shared.Map;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
{
/*
// TODO: IMO use rectangular symmetry reduction on the nodes with collision at all., or
alternatively store all rooms and have an alternative graph for humanoid mobs (same collision mask, needs access etc). You could also just path from room to room as needed.
// TODO: Longer term -> Handle collision layer changes?
*/
/// <summary>
/// This system handles pathfinding graph updates as well as dispatches to the pathfinder
/// (90% of what it's doing is graph updates so not much point splitting the 2 roles)
/// </summary>
public class PathfindingSystem : EntitySystem
{
#pragma warning disable 649
[Dependency] private readonly IMapManager _mapManager;
#pragma warning restore 649
public IReadOnlyDictionary<GridId, Dictionary<MapIndices, PathfindingChunk>> Graph => _graph;
private readonly Dictionary<GridId, Dictionary<MapIndices, PathfindingChunk>> _graph = new Dictionary<GridId, Dictionary<MapIndices, PathfindingChunk>>();
// Every tick we queue up all the changes and do them at once
private readonly Queue<IPathfindingGraphUpdate> _queuedGraphUpdates = new Queue<IPathfindingGraphUpdate>();
private readonly PathfindingJobQueue _pathfindingQueue = new PathfindingJobQueue();
// Need to store previously known entity positions for collidables for when they move
private readonly Dictionary<IEntity, TileRef> _lastKnownPositions = new Dictionary<IEntity, TileRef>();
/// <summary>
/// Ask for the pathfinder to gimme somethin
/// </summary>
/// <param name="pathfindingArgs"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Job<Queue<TileRef>> RequestPath(PathfindingArgs pathfindingArgs, CancellationToken cancellationToken)
{
var startNode = GetNode(pathfindingArgs.Start);
var endNode = GetNode(pathfindingArgs.End);
var job = new AStarPathfindingJob(0.003, startNode, endNode, pathfindingArgs, cancellationToken);
_pathfindingQueue.EnqueueJob(job);
return job;
}
public override void Update(float frameTime)
{
base.Update(frameTime);
// Make sure graph is updated, then get pathfinders
ProcessGraphUpdates();
_pathfindingQueue.Process();
}
private void ProcessGraphUpdates()
{
for (var i = 0; i < Math.Min(50, _queuedGraphUpdates.Count); i++)
{
var update = _queuedGraphUpdates.Dequeue();
switch (update)
{
case CollidableMove move:
HandleCollidableMove(move);
break;
case CollisionChange change:
if (change.Value)
{
HandleCollidableAdd(change.Owner);
}
else
{
HandleCollidableRemove(change.Owner);
}
break;
case GridRemoval removal:
HandleGridRemoval(removal);
break;
case TileUpdate tile:
HandleTileUpdate(tile);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
}
private void HandleGridRemoval(GridRemoval removal)
{
if (!_graph.ContainsKey(removal.GridId))
{
throw new InvalidOperationException();
}
_graph.Remove(removal.GridId);
}
private void HandleTileUpdate(TileUpdate tile)
{
var chunk = GetChunk(tile.Tile);
chunk.UpdateNode(tile.Tile);
}
public PathfindingChunk GetChunk(TileRef tile)
{
var chunkX = (int) (Math.Floor((float) tile.X / PathfindingChunk.ChunkSize) * PathfindingChunk.ChunkSize);
var chunkY = (int) (Math.Floor((float) tile.Y / PathfindingChunk.ChunkSize) * PathfindingChunk.ChunkSize);
var mapIndices = new MapIndices(chunkX, chunkY);
if (_graph.TryGetValue(tile.GridIndex, out var chunks))
{
if (!chunks.ContainsKey(mapIndices))
{
CreateChunk(tile.GridIndex, mapIndices);
}
return chunks[mapIndices];
}
var newChunk = CreateChunk(tile.GridIndex, mapIndices);
return newChunk;
}
private PathfindingChunk CreateChunk(GridId gridId, MapIndices indices)
{
var newChunk = new PathfindingChunk(gridId, indices);
newChunk.Initialize();
if (_graph.TryGetValue(gridId, out var chunks))
{
for (var x = -1; x < 2; x++)
{
for (var y = -1; y < 2; y++)
{
if (x == 0 && y == 0) continue;
var neighborIndices = new MapIndices(
indices.X + x * PathfindingChunk.ChunkSize,
indices.Y + y * PathfindingChunk.ChunkSize);
if (chunks.TryGetValue(neighborIndices, out var neighborChunk))
{
neighborChunk.AddNeighbor(newChunk);
}
}
}
}
else
{
_graph.Add(gridId, new Dictionary<MapIndices, PathfindingChunk>());
}
_graph[gridId].Add(indices, newChunk);
return newChunk;
}
public PathfindingNode GetNode(TileRef tile)
{
var chunk = GetChunk(tile);
var node = chunk.GetNode(tile);
return node;
}
public override void Initialize()
{
IoCManager.InjectDependencies(this);
SubscribeLocalEvent<CollisionChangeEvent>(QueueCollisionEnabledEvent);
SubscribeLocalEvent<MoveEvent>(QueueCollidableMove);
// Handle all the base grid changes
// Anything that affects traversal (i.e. collision layer) is handled separately.
_mapManager.OnGridRemoved += QueueGridRemoval;
_mapManager.GridChanged += QueueGridChange;
_mapManager.TileChanged += QueueTileChange;
}
public override void Shutdown()
{
base.Shutdown();
_mapManager.OnGridRemoved -= QueueGridRemoval;
_mapManager.GridChanged -= QueueGridChange;
_mapManager.TileChanged -= QueueTileChange;
}
private void QueueGridRemoval(GridId gridId)
{
_queuedGraphUpdates.Enqueue(new GridRemoval(gridId));
}
private void QueueGridChange(object sender, GridChangedEventArgs eventArgs)
{
foreach (var (position, _) in eventArgs.Modified)
{
_queuedGraphUpdates.Enqueue(new TileUpdate(eventArgs.Grid.GetTileRef(position)));
}
}
private void QueueTileChange(object sender, TileChangedEventArgs eventArgs)
{
_queuedGraphUpdates.Enqueue(new TileUpdate(eventArgs.NewTile));
}
#region collidable
/// <summary>
/// If an entity's collision gets turned on then we need to update its current position
/// </summary>
/// <param name="entity"></param>
private void HandleCollidableAdd(IEntity entity)
{
// It's a grid / gone / a door / we already have it (which probably shouldn't happen)
if (entity.Prototype == null ||
entity.Deleted ||
entity.HasComponent<ServerDoorComponent>() ||
entity.HasComponent<AirlockComponent>() ||
_lastKnownPositions.ContainsKey(entity))
{
return;
}
var grid = _mapManager.GetGrid(entity.Transform.GridID);
var tileRef = grid.GetTileRef(entity.Transform.GridPosition);
var collisionLayer = entity.GetComponent<CollidableComponent>().CollisionLayer;
var chunk = GetChunk(tileRef);
var node = chunk.GetNode(tileRef);
node.AddCollisionLayer(collisionLayer);
_lastKnownPositions.Add(entity, tileRef);
}
/// <summary>
/// If an entity's collision is removed then stop tracking it from the graph
/// </summary>
/// <param name="entity"></param>
private void HandleCollidableRemove(IEntity entity)
{
if (entity.Prototype == null ||
entity.Deleted ||
entity.HasComponent<ServerDoorComponent>() ||
entity.HasComponent<AirlockComponent>() ||
!_lastKnownPositions.ContainsKey(entity))
{
return;
}
_lastKnownPositions.Remove(entity);
var grid = _mapManager.GetGrid(entity.Transform.GridID);
var tileRef = grid.GetTileRef(entity.Transform.GridPosition);
if (!entity.TryGetComponent(out CollidableComponent collidableComponent))
{
return;
}
var collisionLayer = collidableComponent.CollisionLayer;
var chunk = GetChunk(tileRef);
var node = chunk.GetNode(tileRef);
node.RemoveCollisionLayer(collisionLayer);
}
private void QueueCollidableMove(MoveEvent moveEvent)
{
_queuedGraphUpdates.Enqueue(new CollidableMove(moveEvent));
}
private void HandleCollidableMove(CollidableMove move)
{
if (!_lastKnownPositions.ContainsKey(move.MoveEvent.Sender))
{
return;
}
// The pathfinding graph is tile-based so first we'll check if they're on a different tile and if we need to update.
// If you get entities bigger than 1 tile wide you'll need some other system so god help you.
var moveEvent = move.MoveEvent;
if (moveEvent.Sender.Deleted)
{
HandleCollidableRemove(moveEvent.Sender);
return;
}
_lastKnownPositions.TryGetValue(moveEvent.Sender, out var oldTile);
var newTile = _mapManager.GetGrid(moveEvent.NewPosition.GridID).GetTileRef(moveEvent.NewPosition);
if (oldTile == newTile)
{
return;
}
_lastKnownPositions[moveEvent.Sender] = newTile;
if (!moveEvent.Sender.TryGetComponent(out CollidableComponent collidableComponent))
{
HandleCollidableRemove(moveEvent.Sender);
return;
}
var collisionLayer = collidableComponent.CollisionLayer;
var gridIds = new HashSet<GridId>(2) {oldTile.GridIndex, newTile.GridIndex};
foreach (var gridId in gridIds)
{
if (oldTile.GridIndex == gridId)
{
var oldChunk = GetChunk(oldTile);
var oldNode = oldChunk.GetNode(oldTile);
oldNode.RemoveCollisionLayer(collisionLayer);
}
if (newTile.GridIndex == gridId)
{
var newChunk = GetChunk(newTile);
var newNode = newChunk.GetNode(newTile);
newNode.RemoveCollisionLayer(collisionLayer);
}
}
}
private void QueueCollisionEnabledEvent(CollisionChangeEvent collisionEvent)
{
// TODO: Handle containers
var entityManager = IoCManager.Resolve<IEntityManager>();
var entity = entityManager.GetEntity(collisionEvent.Owner);
switch (collisionEvent.CanCollide)
{
case true:
_queuedGraphUpdates.Enqueue(new CollisionChange(entity, true));
break;
case false:
_queuedGraphUpdates.Enqueue(new CollisionChange(entity, false));
break;
}
}
#endregion
}
}

View File

@@ -0,0 +1,136 @@
using System.Collections.Generic;
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders;
using Content.Shared.AI;
using JetBrains.Annotations;
using Robust.Shared.GameObjects.Systems;
using Robust.Shared.Interfaces.Map;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
{
#if DEBUG
[UsedImplicitly]
public class ServerPathfindingDebugSystem : EntitySystem
{
public override void Initialize()
{
base.Initialize();
AStarPathfindingJob.DebugRoute += DispatchAStarDebug;
JpsPathfindingJob.DebugRoute += DispatchJpsDebug;
SubscribeNetworkEvent<SharedAiDebug.RequestPathfindingGraphMessage>(DispatchGraph);
}
public override void Shutdown()
{
base.Shutdown();
AStarPathfindingJob.DebugRoute -= DispatchAStarDebug;
JpsPathfindingJob.DebugRoute -= DispatchJpsDebug;
}
private void DispatchAStarDebug(SharedAiDebug.AStarRouteDebug routeDebug)
{
var mapManager = IoCManager.Resolve<IMapManager>();
var route = new List<Vector2>();
foreach (var tile in routeDebug.Route)
{
var tileGrid = mapManager.GetGrid(tile.GridIndex).GridTileToLocal(tile.GridIndices);
route.Add(mapManager.GetGrid(tile.GridIndex).LocalToWorld(tileGrid).Position);
}
var cameFrom = new Dictionary<Vector2, Vector2>();
foreach (var (from, to) in routeDebug.CameFrom)
{
var tileOneGrid = mapManager.GetGrid(from.GridIndex).GridTileToLocal(from.GridIndices);
var tileOneWorld = mapManager.GetGrid(from.GridIndex).LocalToWorld(tileOneGrid).Position;
var tileTwoGrid = mapManager.GetGrid(to.GridIndex).GridTileToLocal(to.GridIndices);
var tileTwoWorld = mapManager.GetGrid(to.GridIndex).LocalToWorld(tileTwoGrid).Position;
cameFrom.Add(tileOneWorld, tileTwoWorld);
}
var gScores = new Dictionary<Vector2, float>();
foreach (var (tile, score) in routeDebug.GScores)
{
var tileGrid = mapManager.GetGrid(tile.GridIndex).GridTileToLocal(tile.GridIndices);
gScores.Add(mapManager.GetGrid(tile.GridIndex).LocalToWorld(tileGrid).Position, score);
}
var closedTiles = new List<Vector2>();
foreach (var tile in routeDebug.ClosedTiles)
{
var tileGrid = mapManager.GetGrid(tile.GridIndex).GridTileToLocal(tile.GridIndices);
closedTiles.Add(mapManager.GetGrid(tile.GridIndex).LocalToWorld(tileGrid).Position);
}
var systemMessage = new SharedAiDebug.AStarRouteMessage(
routeDebug.EntityUid,
route,
cameFrom,
gScores,
closedTiles,
routeDebug.TimeTaken
);
EntityManager.EntityNetManager.SendSystemNetworkMessage(systemMessage);
}
private void DispatchJpsDebug(SharedAiDebug.JpsRouteDebug routeDebug)
{
var mapManager = IoCManager.Resolve<IMapManager>();
var route = new List<Vector2>();
foreach (var tile in routeDebug.Route)
{
var tileGrid = mapManager.GetGrid(tile.GridIndex).GridTileToLocal(tile.GridIndices);
route.Add(mapManager.GetGrid(tile.GridIndex).LocalToWorld(tileGrid).Position);
}
var jumpNodes = new List<Vector2>();
foreach (var tile in routeDebug.JumpNodes)
{
var tileGrid = mapManager.GetGrid(tile.GridIndex).GridTileToLocal(tile.GridIndices);
jumpNodes.Add(mapManager.GetGrid(tile.GridIndex).LocalToWorld(tileGrid).Position);
}
var systemMessage = new SharedAiDebug.JpsRouteMessage(
routeDebug.EntityUid,
route,
jumpNodes,
routeDebug.TimeTaken
);
EntityManager.EntityNetManager.SendSystemNetworkMessage(systemMessage);
}
private void DispatchGraph(SharedAiDebug.RequestPathfindingGraphMessage message)
{
var pathfindingSystem = EntitySystemManager.GetEntitySystem<PathfindingSystem>();
var mapManager = IoCManager.Resolve<IMapManager>();
var result = new Dictionary<int, List<Vector2>>();
var idx = 0;
foreach (var (gridId, chunks) in pathfindingSystem.Graph)
{
var gridManager = mapManager.GetGrid(gridId);
foreach (var chunk in chunks.Values)
{
var nodes = new List<Vector2>();
foreach (var node in chunk.Nodes)
{
var worldTile = gridManager.GridTileToWorldPos(node.TileRef.GridIndices);
nodes.Add(worldTile);
}
result.Add(idx, nodes);
idx++;
}
}
var systemMessage = new SharedAiDebug.PathfindingGraphMessage(result);
EntityManager.EntityNetManager.SendSystemNetworkMessage(systemMessage);
}
}
#endif
}

View File

@@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders;
using Content.Server.GameObjects.EntitySystems.Pathfinding;
using Robust.Shared.Interfaces.GameObjects;
using Robust.Shared.Interfaces.Map;
using Robust.Shared.IoC;
using Robust.Shared.Map;
using Robust.Shared.Maths;
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
{
public static class Utils
{
public static bool TryEndNode(ref PathfindingNode endNode, PathfindingArgs pathfindingArgs)
{
if (!Traversable(pathfindingArgs.CollisionMask, endNode.CollisionMask))
{
if (pathfindingArgs.Proximity > 0.0f)
{
// TODO: Should make this account for proximities,
// probably some kind of breadth-first search to find a valid one
foreach (var (direction, node) in endNode.Neighbors)
{
if (Traversable(pathfindingArgs.CollisionMask, node.CollisionMask))
{
endNode = node;
return true;
}
}
}
return false;
}
return true;
}
public static bool DirectionTraversable(int collisionMask, PathfindingNode currentNode, Direction direction)
{
// If it's a diagonal we need to check NSEW to see if we can get to it and stop corner cutting, NE needs N and E etc.
// Given there's different collision layers stored for each node in the graph it's probably not worth it to cache this
// Also this will help with corner-cutting
currentNode.Neighbors.TryGetValue(Direction.North, out var northNeighbor);
currentNode.Neighbors.TryGetValue(Direction.South, out var southNeighbor);
currentNode.Neighbors.TryGetValue(Direction.East, out var eastNeighbor);
currentNode.Neighbors.TryGetValue(Direction.West, out var westNeighbor);
switch (direction)
{
case Direction.NorthEast:
if (northNeighbor == null || eastNeighbor == null) return false;
if (!Traversable(collisionMask, northNeighbor.CollisionMask) ||
!Traversable(collisionMask, eastNeighbor.CollisionMask))
{
return false;
}
break;
case Direction.NorthWest:
if (northNeighbor == null || westNeighbor == null) return false;
if (!Traversable(collisionMask, northNeighbor.CollisionMask) ||
!Traversable(collisionMask, westNeighbor.CollisionMask))
{
return false;
}
break;
case Direction.SouthWest:
if (southNeighbor == null || westNeighbor == null) return false;
if (!Traversable(collisionMask, southNeighbor.CollisionMask) ||
!Traversable(collisionMask, westNeighbor.CollisionMask))
{
return false;
}
break;
case Direction.SouthEast:
if (southNeighbor == null || eastNeighbor == null) return false;
if (!Traversable(collisionMask, southNeighbor.CollisionMask) ||
!Traversable(collisionMask, eastNeighbor.CollisionMask))
{
return false;
}
break;
}
return true;
}
public static bool Traversable(int collisionMask, int nodeMask)
{
return (collisionMask & nodeMask) == 0;
}
public static Queue<TileRef> ReconstructPath(Dictionary<PathfindingNode, PathfindingNode> cameFrom, PathfindingNode current)
{
var running = new Stack<TileRef>();
running.Push(current.TileRef);
while (cameFrom.ContainsKey(current))
{
var previousCurrent = current;
current = cameFrom[current];
cameFrom.Remove(previousCurrent);
running.Push(current.TileRef);
}
var result = new Queue<TileRef>(running);
return result;
}
/// <summary>
/// This will reconstruct the path and fill in the tile holes as well
/// </summary>
/// <param name="cameFrom"></param>
/// <param name="current"></param>
/// <returns></returns>
public static Queue<TileRef> ReconstructJumpPath(Dictionary<PathfindingNode, PathfindingNode> cameFrom, PathfindingNode current)
{
var running = new Stack<TileRef>();
running.Push(current.TileRef);
while (cameFrom.ContainsKey(current))
{
var previousCurrent = current;
current = cameFrom[current];
var intermediate = previousCurrent;
cameFrom.Remove(previousCurrent);
var pathfindingSystem = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<PathfindingSystem>();
var mapManager = IoCManager.Resolve<IMapManager>();
var grid = mapManager.GetGrid(current.TileRef.GridIndex);
// Get all the intermediate nodes
while (true)
{
var xOffset = 0;
var yOffset = 0;
if (intermediate.TileRef.X < current.TileRef.X)
{
xOffset += 1;
}
else if (intermediate.TileRef.X > current.TileRef.X)
{
xOffset -= 1;
}
else
{
xOffset = 0;
}
if (intermediate.TileRef.Y < current.TileRef.Y)
{
yOffset += 1;
}
else if (intermediate.TileRef.Y > current.TileRef.Y)
{
yOffset -= 1;
}
else
{
yOffset = 0;
}
intermediate = pathfindingSystem.GetNode(grid.GetTileRef(
new MapIndices(intermediate.TileRef.X + xOffset, intermediate.TileRef.Y + yOffset)));
if (intermediate.TileRef != current.TileRef)
{
// Hacky corner cut fix
running.Push(intermediate.TileRef);
continue;
}
break;
}
running.Push(current.TileRef);
}
var result = new Queue<TileRef>(running);
return result;
}
public static float OctileDistance(PathfindingNode endNode, PathfindingNode currentNode)
{
// "Fast Euclidean" / octile.
// This implementation is written down in a few sources; it just saves doing sqrt.
int dstX = Math.Abs(currentNode.TileRef.X - endNode.TileRef.X);
int dstY = Math.Abs(currentNode.TileRef.Y - endNode.TileRef.Y);
if (dstX > dstY)
{
return 1.4f * dstY + (dstX - dstY);
}
return 1.4f * dstX + (dstY - dstX);
}
public static float ManhattanDistance(PathfindingNode endNode, PathfindingNode currentNode)
{
return Math.Abs(currentNode.TileRef.X - endNode.TileRef.X) + Math.Abs(currentNode.TileRef.Y - endNode.TileRef.Y);
}
public static float? GetTileCost(PathfindingArgs pathfindingArgs, PathfindingNode start, PathfindingNode end)
{
if (!pathfindingArgs.NoClip && !Traversable(pathfindingArgs.CollisionMask, end.CollisionMask))
{
return null;
}
if (!pathfindingArgs.AllowSpace && end.TileRef.Tile.IsEmpty)
{
return null;
}
var cost = 1.0f;
switch (pathfindingArgs.AllowDiagonals)
{
case true:
cost *= OctileDistance(end, start);
break;
// Manhattan distance
case false:
cost *= ManhattanDistance(end, start);
break;
}
return cost;
}
}
}

View File

@@ -0,0 +1,30 @@
using Content.Server.GameObjects.EntitySystems.AI.LoadBalancer;
using Content.Shared.AI;
using JetBrains.Annotations;
using Robust.Shared.GameObjects.Systems;
namespace Content.Server.GameObjects.EntitySystems.AI
{
#if DEBUG
[UsedImplicitly]
public class ServerAiDebugSystem : EntitySystem
{
public override void Initialize()
{
base.Initialize();
AiActionRequestJob.FoundAction += NotifyActionJob;
}
public override void Shutdown()
{
base.Shutdown();
AiActionRequestJob.FoundAction -= NotifyActionJob;
}
private void NotifyActionJob(SharedAiDebug.UtilityAiDebugMessage message)
{
EntityManager.EntityNetManager.SendSystemNetworkMessage(message);
}
}
#endif
}

View File

@@ -8,6 +8,7 @@ using Content.Shared.GameObjects.Components.Inventory;
using Content.Shared.GameObjects.EntitySystems;
using Content.Shared.Input;
using JetBrains.Annotations;
using Robust.Server.GameObjects;
using Robust.Server.GameObjects.EntitySystems;
using Robust.Server.Interfaces.Player;
using Robust.Shared.GameObjects;
@@ -319,7 +320,7 @@ namespace Content.Server.GameObjects.EntitySystems
{
CommandBinds.Builder
.Bind(EngineKeyFunctions.Use,
new PointerInputCmdHandler(HandleUseItemInHand))
new PointerInputCmdHandler(HandleClientUseItemInHand))
.Bind(ContentKeyFunctions.WideAttack,
new PointerInputCmdHandler(HandleWideAttack))
.Bind(ContentKeyFunctions.ActivateItemInWorld,
@@ -421,7 +422,31 @@ namespace Content.Server.GameObjects.EntitySystems
return true;
}
private bool HandleUseItemInHand(ICommonSession session, GridCoordinates coords, EntityUid uid)
/// <summary>
/// Entity will try and use their active hand at the target location.
/// Don't use for players
/// </summary>
/// <param name="entity"></param>
/// <param name="coords"></param>
/// <param name="uid"></param>
internal void UseItemInHand(IEntity entity, GridCoordinates coords, EntityUid uid)
{
if (entity.HasComponent<BasicActorComponent>())
{
throw new InvalidOperationException();
}
if (entity.TryGetComponent(out CombatModeComponent combatMode) && combatMode.IsInCombatMode)
{
DoAttack(entity, coords);
}
else
{
UserInteraction(entity, coords, uid);
}
}
private bool HandleClientUseItemInHand(ICommonSession session, GridCoordinates coords, EntityUid uid)
{
// client sanitization
if (!_mapManager.GridExists(coords.GridID))

View File

@@ -0,0 +1,10 @@
using System.Collections;
namespace Content.Server.GameObjects.EntitySystems.JobQueues
{
public interface IJob
{
JobStatus Status { get; }
void Run();
}
}

View File

@@ -0,0 +1,232 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Robust.Shared.Log;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.GameObjects.EntitySystems.JobQueues
{
/// <summary>
/// CPU-intensive job that can be suspended and resumed on the main thread
/// </summary>
/// <remarks>
/// Implementations should overload <see cref="Process"/>.
/// Inside <see cref="Process"/>, implementations should only await on <see cref="SuspendNow"/>,
/// <see cref="SuspendIfOutOfTime"/>, or <see cref="WaitAsyncTask"/>.
/// </remarks>
/// <typeparam name="T">The type of result this job generates</typeparam>
public abstract class Job<T> : IJob
{
public JobStatus Status { get; private set; } = JobStatus.Pending;
/// <summary>
/// Represents the status of this job as a regular task.
/// </summary>
public Task<T> AsTask { get; }
public T Result { get; private set; }
public Exception Exception { get; private set; }
protected CancellationToken Cancellation { get; }
public double DebugTime { get; private set; }
private readonly double _maxTime;
protected readonly IStopwatch StopWatch;
// TCS for the Task property.
private readonly TaskCompletionSource<T> _taskTcs;
// TCS to call to resume the suspended job.
private TaskCompletionSource<object> _resume;
private Task _workInProgress;
protected Job(double maxTime, CancellationToken cancellation = default)
: this(maxTime, new Stopwatch(), cancellation)
{
}
protected Job(double maxTime, IStopwatch stopwatch, CancellationToken cancellation = default)
{
_maxTime = maxTime;
StopWatch = stopwatch;
Cancellation = cancellation;
_taskTcs = new TaskCompletionSource<T>();
AsTask = _taskTcs.Task;
}
/// <summary>
/// Suspends the current task immediately, yielding to other running jobs.
/// </summary>
/// <remarks>
/// This does not stop the job queue from un-suspending the current task immediately again,
/// if there is still time left over.
/// </remarks>
protected Task SuspendNow()
{
DebugTools.AssertNull(_resume);
_resume = new TaskCompletionSource<object>();
Status = JobStatus.Paused;
DebugTime += StopWatch.Elapsed.TotalSeconds;
return _resume.Task;
}
protected ValueTask SuspendIfOutOfTime()
{
DebugTools.AssertNull(_resume);
// ReSharper disable once CompareOfFloatsByEqualityOperator
if (StopWatch.Elapsed.TotalSeconds <= _maxTime || _maxTime == 0.0)
{
return new ValueTask();
}
return new ValueTask(SuspendNow());
}
/// <summary>
/// Wrapper to await on an external task.
/// </summary>
protected async Task<TTask> WaitAsyncTask<TTask>(Task<TTask> task)
{
DebugTools.AssertNull(_resume);
Status = JobStatus.Waiting;
DebugTime += StopWatch.Elapsed.TotalSeconds;
var result = await task;
// Immediately block on resume so that everything stays correct.
Status = JobStatus.Paused;
_resume = new TaskCompletionSource<object>();
await _resume.Task;
return result;
}
/// <summary>
/// Wrapper to safely await on an external task.
/// </summary>
protected async Task WaitAsyncTask(Task task)
{
DebugTools.AssertNull(_resume);
Status = JobStatus.Waiting;
DebugTime += StopWatch.Elapsed.TotalSeconds;
await task;
// Immediately block on resume so that everything stays correct.
_resume = new TaskCompletionSource<object>();
Status = JobStatus.Paused;
await _resume.Task;
}
public void Run()
{
_workInProgress ??= ProcessWrap();
if (Status == JobStatus.Finished)
{
return;
}
DebugTools.Assert(_resume != null,
"Run() called without resume. Was this called while the job is in Waiting state?");
var resume = _resume;
_resume = null;
Status = JobStatus.Running;
StopWatch.Restart();
if (Cancellation.IsCancellationRequested)
{
resume.TrySetCanceled();
}
else
{
resume.SetResult(null);
}
if (Status != JobStatus.Finished && Status != JobStatus.Waiting)
{
DebugTools.Assert(_resume != null,
"Job suspended without _resume set. Did you await on an external task without using WaitAsyncTask?");
}
}
protected abstract Task<T> Process();
private async Task ProcessWrap()
{
try
{
Cancellation.ThrowIfCancellationRequested();
// Making sure that the task starts inside the Running block,
// where the stopwatch is correctly set and such.
await SuspendNow();
Result = await Process();
// TODO: not sure if it makes sense to connect Task directly up
// to the return value of this method/Process.
// Maybe?
_taskTcs.TrySetResult(Result);
}
catch (TaskCanceledException)
{
_taskTcs.TrySetCanceled();
}
catch (Exception e)
{
// TODO: Should this be exposed differently?
// I feel that people might forget to check whether the job failed.
Logger.ErrorS("job", "Job failed on exception:\n{0}", e);
Exception = e;
_taskTcs.TrySetException(e);
}
finally
{
if (Status != JobStatus.Waiting)
{
// If we're blocked on waiting and the waiting task goes cancel/exception,
// this timing info would not be correct.
DebugTime += StopWatch.Elapsed.TotalSeconds;
}
Status = JobStatus.Finished;
}
}
}
public enum JobStatus
{
/// <summary>
/// Job has been created and has not been ran yet.
/// </summary>
Pending,
/// <summary>
/// Job is currently (yes, right now!) executing.
/// </summary>
Running,
/// <summary>
/// Job is paused due to CPU limits.
/// </summary>
Paused,
/// <summary>
/// Job is paused because of waiting on external task.
/// </summary>
Waiting,
/// <summary>
/// Job is done.
/// </summary>
// TODO: Maybe have a different status code for cancelled/failed on exception?
Finished,
}
}

View File

@@ -0,0 +1,4 @@
namespace Content.Server.GameObjects.EntitySystems.JobQueues.Queues
{
public sealed class AiActionJobQueue : JobQueue {}
}

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using Robust.Shared.Timing;
namespace Content.Server.GameObjects.EntitySystems.JobQueues.Queues
{
public class JobQueue
{
private readonly IStopwatch _stopwatch;
public JobQueue() : this(new Stopwatch())
{
}
public JobQueue(IStopwatch stopwatch)
{
_stopwatch = stopwatch;
}
/// <summary>
/// How long the job's allowed to run for before suspending
/// </summary>
public virtual double MaxTime => 0.002;
private readonly Queue<IJob> _pendingQueue = new Queue<IJob>();
private readonly List<IJob> _waitingJobs = new List<IJob>();
public void EnqueueJob(IJob job)
{
_pendingQueue.Enqueue(job);
}
public void Process()
{
// Move all finished waiting jobs back into the regular queue.
foreach (var waitingJob in _waitingJobs)
{
if (waitingJob.Status != JobStatus.Waiting)
{
_pendingQueue.Enqueue(waitingJob);
}
}
_waitingJobs.RemoveAll(p => p.Status != JobStatus.Waiting);
// At one point I tried making the pathfinding queue multi-threaded but ehhh didn't go great
// Could probably try it again at some point
// it just seemed slow af but I was probably doing something dumb with semaphores
_stopwatch.Restart();
// Although the jobs can stop themselves we might be able to squeeze more of them in the allotted time
while (_stopwatch.Elapsed.TotalSeconds < MaxTime && _pendingQueue.TryDequeue(out var job))
{
// Deque and re-enqueue these to cycle them through to avoid starvation if we've got a lot of jobs.
job.Run();
switch (job.Status)
{
case JobStatus.Finished:
continue;
case JobStatus.Waiting:
// If this job goes into waiting we have to move it into a separate list.
// Otherwise we'd just be spinning like mad here for external IO or such.
_waitingJobs.Add(job);
break;
default:
_pendingQueue.Enqueue(job);
break;
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Content.Server.GameObjects.EntitySystems.JobQueues.Queues
{
public sealed class PathfindingJobQueue : JobQueue
{
public override double MaxTime => 0.003;
}
}