AI Reachable system (#1342)
Co-authored-by: Metal Gear Sloth <metalgearsloth@gmail.com>
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Content.Server.GameObjects.Components.Access;
|
||||
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Pathfinders;
|
||||
using Content.Server.GameObjects.EntitySystems.Pathfinding;
|
||||
using Content.Shared.AI;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Server.GameObjects;
|
||||
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;
|
||||
|
||||
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Accessible
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether an AI has access to a specific pathfinding node.
|
||||
/// </summary>
|
||||
/// Long-term can be used to do hierarchical pathfinding
|
||||
[UsedImplicitly]
|
||||
public sealed class AiReachableSystem : EntitySystem
|
||||
{
|
||||
/*
|
||||
* The purpose of this is to provide a higher-level / hierarchical abstraction of the actual pathfinding graph
|
||||
* The goal is so that we can more quickly discern if a specific node is reachable or not rather than
|
||||
* Pathfinding the entire graph.
|
||||
*
|
||||
* There's a lot of different implementations of hierarchical or some variation of it: HPA*, PRA, HAA*, etc.
|
||||
* (HPA* technically caches the edge nodes of each chunk), e.g. Rimworld, Factorio, etc.
|
||||
* so we'll just write one with SS14's requirements in mind.
|
||||
*
|
||||
* There's probably a better data structure to use though you'd need to benchmark multiple ones to compare,
|
||||
* at the very least on the memory side it could definitely be better.
|
||||
*/
|
||||
|
||||
#pragma warning disable 649
|
||||
[Dependency] private IMapManager _mapmanager;
|
||||
[Dependency] private IGameTiming _gameTiming;
|
||||
#pragma warning restore 649
|
||||
private PathfindingSystem _pathfindingSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Queued region updates
|
||||
/// </summary>
|
||||
private HashSet<PathfindingChunk> _queuedUpdates = new HashSet<PathfindingChunk>();
|
||||
|
||||
// Oh god the nesting. Shouldn't need to go beyond this
|
||||
/// <summary>
|
||||
/// The corresponding regions for each PathfindingChunk.
|
||||
/// Regions are groups of nodes with the same profile (for pathfinding purposes)
|
||||
/// i.e. same collision, not-space, same access, etc.
|
||||
/// </summary>
|
||||
private Dictionary<GridId, Dictionary<PathfindingChunk, HashSet<PathfindingRegion>>> _regions =
|
||||
new Dictionary<GridId, Dictionary<PathfindingChunk, HashSet<PathfindingRegion>>>();
|
||||
|
||||
/// <summary>
|
||||
/// Minimum time for the cached reachable regions to be stored
|
||||
/// </summary>
|
||||
private const float MinCacheTime = 1.0f;
|
||||
|
||||
// Cache what regions are accessible from this region. Cached per ReachableArgs
|
||||
// so multiple entities in the same region with the same args should all be able to share their reachable lookup
|
||||
// Also need to store when we cached it to know if it's stale if the chunks have updated
|
||||
|
||||
// TODO: There's probably a more memory-efficient way to cache this
|
||||
// Then again, there's likely also a more memory-efficient way to implement regions.
|
||||
|
||||
// Also, didn't use a dictionary because there didn't seem to be a clean way to do the lookup
|
||||
// Plus this way we can check if everything is equal except for vision so an entity with a lower vision radius can use an entity with a higher vision radius' cached result
|
||||
private Dictionary<ReachableArgs, Dictionary<PathfindingRegion, (TimeSpan CacheTime, HashSet<PathfindingRegion> Regions)>> _cachedAccessible =
|
||||
new Dictionary<ReachableArgs, Dictionary<PathfindingRegion, (TimeSpan, HashSet<PathfindingRegion>)>>();
|
||||
|
||||
#if DEBUG
|
||||
private int _runningCacheIdx = 0;
|
||||
#endif
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
_pathfindingSystem = Get<PathfindingSystem>();
|
||||
SubscribeLocalEvent<PathfindingChunkUpdateMessage>(RecalculateNodeRegions);
|
||||
#if DEBUG
|
||||
SubscribeLocalEvent<PlayerAttachSystemMessage>(SendDebugMessage);
|
||||
#endif
|
||||
}
|
||||
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
foreach (var chunk in _queuedUpdates)
|
||||
{
|
||||
GenerateRegions(chunk);
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if (_queuedUpdates.Count > 0)
|
||||
{
|
||||
foreach (var (gridId, regs) in _regions)
|
||||
{
|
||||
if (regs.Count > 0)
|
||||
{
|
||||
SendRegionsDebugMessage(gridId);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
_queuedUpdates.Clear();
|
||||
}
|
||||
|
||||
public override void Shutdown()
|
||||
{
|
||||
base.Shutdown();
|
||||
_queuedUpdates.Clear();
|
||||
_regions.Clear();
|
||||
_cachedAccessible.Clear();
|
||||
}
|
||||
|
||||
private void RecalculateNodeRegions(PathfindingChunkUpdateMessage message)
|
||||
{
|
||||
// TODO: Only need to do changed nodes ideally
|
||||
// For now this is fine but it's a low-hanging fruit optimisation
|
||||
_queuedUpdates.Add(message.Chunk);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Can the entity reach the target?
|
||||
/// </summary>
|
||||
/// First it does a quick check to see if there are any traversable nodes in range.
|
||||
/// Then it will go through the regions to try and see if there's a region connection between the target and itself
|
||||
/// Will used a cached region if available
|
||||
/// <param name="entity"></param>
|
||||
/// <param name="target"></param>
|
||||
/// <param name="range"></param>
|
||||
/// <returns></returns>
|
||||
public bool CanAccess(IEntity entity, IEntity target, float range = 0.0f)
|
||||
{
|
||||
var targetTile = _mapmanager.GetGrid(target.Transform.GridID).GetTileRef(target.Transform.GridPosition);
|
||||
var targetNode = _pathfindingSystem.GetNode(targetTile);
|
||||
|
||||
var collisionMask = 0;
|
||||
if (entity.TryGetComponent(out CollidableComponent collidableComponent))
|
||||
{
|
||||
collisionMask = collidableComponent.CollisionMask;
|
||||
}
|
||||
|
||||
var access = AccessReader.FindAccessTags(entity);
|
||||
|
||||
// We'll do a quick traversable check before going through regions
|
||||
// If we can't access it we'll try to get a valid node in range (this is essentially an early-out)
|
||||
if (!PathfindingHelpers.Traversable(collisionMask, access, targetNode))
|
||||
{
|
||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||
if (range == 0.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var pathfindingArgs = new PathfindingArgs(entity.Uid, access, collisionMask, default, targetTile, range);
|
||||
foreach (var node in BFSPathfinder.GetNodesInRange(pathfindingArgs, false))
|
||||
{
|
||||
targetNode = node;
|
||||
}
|
||||
}
|
||||
|
||||
return CanAccess(entity, targetNode);
|
||||
}
|
||||
|
||||
public bool CanAccess(IEntity entity, PathfindingNode targetNode)
|
||||
{
|
||||
if (entity.Transform.GridID != targetNode.TileRef.GridIndex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entityTile = _mapmanager.GetGrid(entity.Transform.GridID).GetTileRef(entity.Transform.GridPosition);
|
||||
var entityNode = _pathfindingSystem.GetNode(entityTile);
|
||||
var entityRegion = GetRegion(entityNode);
|
||||
var targetRegion = GetRegion(targetNode);
|
||||
// TODO: Regional pathfind from target to entity
|
||||
// Early out
|
||||
if (entityRegion == targetRegion)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// We'll go from target's position to us because most of the time it's probably in a locked room rather than vice versa
|
||||
var reachableArgs = ReachableArgs.GetArgs(entity);
|
||||
var reachableRegions = GetReachableRegions(reachableArgs, targetRegion);
|
||||
|
||||
return reachableRegions.Contains(entityRegion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve the reachable regions
|
||||
/// </summary>
|
||||
/// <param name="reachableArgs"></param>
|
||||
/// <param name="region"></param>
|
||||
/// <returns></returns>
|
||||
public HashSet<PathfindingRegion> GetReachableRegions(ReachableArgs reachableArgs, PathfindingRegion region)
|
||||
{
|
||||
// if we're on a node that's not tracked at all atm then region will be null
|
||||
if (region == null)
|
||||
{
|
||||
return new HashSet<PathfindingRegion>();
|
||||
}
|
||||
|
||||
var cachedArgs = GetCachedArgs(reachableArgs);
|
||||
(TimeSpan CacheTime, HashSet<PathfindingRegion> Regions) cached;
|
||||
|
||||
if (!IsCacheValid(cachedArgs, region))
|
||||
{
|
||||
cached = GetVisionReachable(cachedArgs, region);
|
||||
_cachedAccessible[cachedArgs][region] = cached;
|
||||
#if DEBUG
|
||||
SendRegionCacheMessage(region.ParentChunk.GridId, cached.Regions, false);
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
cached = _cachedAccessible[cachedArgs][region];
|
||||
#if DEBUG
|
||||
SendRegionCacheMessage(region.ParentChunk.GridId, cached.Regions, true);
|
||||
#endif
|
||||
}
|
||||
|
||||
return cached.Regions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get any adequate cached args if possible, otherwise just use ours
|
||||
/// </summary>
|
||||
/// Essentially any args that have the same access AND >= our vision radius can be used
|
||||
/// <param name="accessibleArgs"></param>
|
||||
/// <returns></returns>
|
||||
private ReachableArgs GetCachedArgs(ReachableArgs accessibleArgs)
|
||||
{
|
||||
ReachableArgs foundArgs = null;
|
||||
|
||||
foreach (var (cachedAccessible, _) in _cachedAccessible)
|
||||
{
|
||||
if (Equals(cachedAccessible.Access, accessibleArgs.Access) &&
|
||||
cachedAccessible.CollisionMask == accessibleArgs.CollisionMask &&
|
||||
cachedAccessible.VisionRadius <= accessibleArgs.VisionRadius)
|
||||
{
|
||||
foundArgs = cachedAccessible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return foundArgs ?? accessibleArgs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether there's a valid cache for our accessibility args.
|
||||
/// Most regular mobs can share their cached accessibility with each other
|
||||
/// </summary>
|
||||
/// Will also remove it from the cache if it is invalid
|
||||
/// <param name="accessibleArgs"></param>
|
||||
/// <param name="region"></param>
|
||||
/// <returns></returns>
|
||||
private bool IsCacheValid(ReachableArgs accessibleArgs, PathfindingRegion region)
|
||||
{
|
||||
if (!_cachedAccessible.TryGetValue(accessibleArgs, out var cachedArgs))
|
||||
{
|
||||
_cachedAccessible.Add(accessibleArgs, new Dictionary<PathfindingRegion, (TimeSpan, HashSet<PathfindingRegion>)>());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!cachedArgs.TryGetValue(region, out var regionCache))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Just so we don't invalidate the cache every tick we'll store it for a minimum amount of time
|
||||
var currentTime = _gameTiming.CurTime;
|
||||
if ((currentTime - regionCache.CacheTime).TotalSeconds < MinCacheTime)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var checkedAccess = new HashSet<PathfindingRegion>();
|
||||
// Check if cache is stale
|
||||
foreach (var accessibleRegion in regionCache.Regions)
|
||||
{
|
||||
if (checkedAccess.Contains(accessibleRegion)) continue;
|
||||
|
||||
// Any applicable chunk has been invalidated OR one of our neighbors has been invalidated (i.e. new connections)
|
||||
// TODO: Could look at storing the TimeSpan directly on the region so our neighbor can tell us straight-up
|
||||
if (accessibleRegion.ParentChunk.LastUpdate > regionCache.CacheTime)
|
||||
{
|
||||
// Remove the stale cache, to be updated later
|
||||
_cachedAccessible[accessibleArgs].Remove(region);
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var neighbor in accessibleRegion.Neighbors)
|
||||
{
|
||||
if (checkedAccess.Contains(neighbor)) continue;
|
||||
if (neighbor.ParentChunk.LastUpdate > regionCache.CacheTime)
|
||||
{
|
||||
_cachedAccessible[accessibleArgs].Remove(region);
|
||||
return false;
|
||||
}
|
||||
checkedAccess.Add(neighbor);
|
||||
}
|
||||
checkedAccess.Add(accessibleRegion);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches the entity's nearby accessible regions in vision radius
|
||||
/// </summary>
|
||||
/// Longer-term TODO: Hierarchical pathfinding in which case this function would probably get bulldozed, BRRRTT
|
||||
/// <param name="reachableArgs"></param>
|
||||
/// <param name="entityRegion"></param>
|
||||
private (TimeSpan, HashSet<PathfindingRegion>) GetVisionReachable(ReachableArgs reachableArgs, PathfindingRegion entityRegion)
|
||||
{
|
||||
var openSet = new Queue<PathfindingRegion>();
|
||||
openSet.Enqueue(entityRegion);
|
||||
var closedSet = new HashSet<PathfindingRegion>();
|
||||
var accessible = new HashSet<PathfindingRegion> {entityRegion};
|
||||
|
||||
while (openSet.Count > 0)
|
||||
{
|
||||
var region = openSet.Dequeue();
|
||||
closedSet.Add(region);
|
||||
|
||||
foreach (var neighbor in region.Neighbors)
|
||||
{
|
||||
if (closedSet.Contains(neighbor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Distance is an approximation here so we'll be generous with it
|
||||
// TODO: Could do better; the fewer nodes the better it is.
|
||||
if (!neighbor.RegionTraversable(reachableArgs) ||
|
||||
neighbor.Distance(entityRegion) > reachableArgs.VisionRadius + 1)
|
||||
{
|
||||
closedSet.Add(neighbor);
|
||||
continue;
|
||||
}
|
||||
|
||||
openSet.Enqueue(neighbor);
|
||||
accessible.Add(neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
return (_gameTiming.CurTime, accessible);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grab the related cardinal nodes and if they're in different regions then add to our edge and their edge
|
||||
/// </summary>
|
||||
/// Implicitly they would've already been merged if possible
|
||||
/// <param name="region"></param>
|
||||
/// <param name="node"></param>
|
||||
private void UpdateRegionEdge(PathfindingRegion region, PathfindingNode node)
|
||||
{
|
||||
DebugTools.Assert(region.Nodes.Contains(node));
|
||||
// Originally I tried just doing bottom and left but that doesn't work as the chunk update order is not guaranteed
|
||||
|
||||
var checkDirections = new[] {Direction.East, Direction.South, Direction.West, Direction.North};
|
||||
foreach (var direction in checkDirections)
|
||||
{
|
||||
var directionNode = node.GetNeighbor(direction);
|
||||
if (directionNode == null) continue;
|
||||
|
||||
var directionRegion = GetRegion(directionNode);
|
||||
if (directionRegion == null || directionRegion == region) continue;
|
||||
|
||||
region.Neighbors.Add(directionRegion);
|
||||
directionRegion.Neighbors.Add(region);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current region for this entity
|
||||
/// </summary>
|
||||
/// <param name="entity"></param>
|
||||
/// <returns></returns>
|
||||
public PathfindingRegion GetRegion(IEntity entity)
|
||||
{
|
||||
var entityTile = _mapmanager.GetGrid(entity.Transform.GridID).GetTileRef(entity.Transform.GridPosition);
|
||||
var entityNode = _pathfindingSystem.GetNode(entityTile);
|
||||
return GetRegion(entityNode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current region for this node
|
||||
/// </summary>
|
||||
/// <param name="node"></param>
|
||||
/// <returns></returns>
|
||||
public PathfindingRegion GetRegion(PathfindingNode node)
|
||||
{
|
||||
// Not sure on the best way to optimise this
|
||||
// On the one hand, just storing each node's region is faster buuutttt muh memory
|
||||
// On the other hand, you might need O(n) lookups on regions for each chunk, though it's probably not too bad with smaller chunk sizes?
|
||||
// Someone smarter than me will know better
|
||||
var parentChunk = node.ParentChunk;
|
||||
|
||||
// No guarantee the node even has a region yet (if we're doing neighbor lookups)
|
||||
if (!_regions[parentChunk.GridId].TryGetValue(parentChunk, out var regions))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
if (region.Nodes.Contains(node))
|
||||
{
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
// Longer term this will probably be guaranteed a region but for now space etc. are no region
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add this node to the relevant region.
|
||||
/// </summary>
|
||||
/// <param name="node"></param>
|
||||
/// <param name="existingRegions"></param>
|
||||
/// <param name="x">This is already calculated in advance so may as well re-use it</param>
|
||||
/// <param name="y">This is already calculated in advance so may as well re-use it</param>
|
||||
/// <returns></returns>
|
||||
private PathfindingRegion CalculateNode(PathfindingNode node, Dictionary<PathfindingNode, PathfindingRegion> existingRegions, int x, int y)
|
||||
{
|
||||
DebugTools.Assert(_regions.ContainsKey(node.ParentChunk.GridId));
|
||||
DebugTools.Assert(_regions[node.ParentChunk.GridId].ContainsKey(node.ParentChunk));
|
||||
// TODO For now we don't have these regions but longer-term yeah sure
|
||||
if (node.BlockedCollisionMask != 0x0 || node.TileRef.Tile.IsEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parentChunk = node.ParentChunk;
|
||||
// Doors will be their own separate region
|
||||
// We won't store them in existingRegions so they don't show up and can't be connected to (at least for now)
|
||||
if (node.AccessReaders.Count > 0)
|
||||
{
|
||||
var region = new PathfindingRegion(node, new HashSet<PathfindingNode>(1) {node}, true);
|
||||
_regions[parentChunk.GridId][parentChunk].Add(region);
|
||||
UpdateRegionEdge(region, node);
|
||||
return region;
|
||||
}
|
||||
|
||||
// Relative x and y of the chunk
|
||||
// If one of our bottom / left neighbors are in a region try to join them
|
||||
// Otherwise, make our own region.
|
||||
var leftNeighbor = x > 0 ? parentChunk.Nodes[x - 1, y] : null;
|
||||
var bottomNeighbor = y > 0 ? parentChunk.Nodes[x, y - 1] : null;
|
||||
PathfindingRegion leftRegion;
|
||||
PathfindingRegion bottomRegion;
|
||||
|
||||
// We'll check if our left or down neighbors are already in a region and join them
|
||||
|
||||
// Is left node valid to connect to
|
||||
if (leftNeighbor != null &&
|
||||
existingRegions.TryGetValue(leftNeighbor, out leftRegion) &&
|
||||
!leftRegion.IsDoor)
|
||||
{
|
||||
// We'll try and connect the left node's region to the bottom region if they're separate (yay merge)
|
||||
if (bottomNeighbor != null && existingRegions.TryGetValue(bottomNeighbor, out bottomRegion) &&
|
||||
!bottomRegion.IsDoor)
|
||||
{
|
||||
bottomRegion.Add(node);
|
||||
existingRegions.Add(node, bottomRegion);
|
||||
MergeInto(leftRegion, bottomRegion);
|
||||
return bottomRegion;
|
||||
}
|
||||
|
||||
leftRegion.Add(node);
|
||||
existingRegions.Add(node, leftRegion);
|
||||
UpdateRegionEdge(leftRegion, node);
|
||||
return leftRegion;
|
||||
}
|
||||
|
||||
//Is bottom node valid to connect to
|
||||
if (bottomNeighbor != null &&
|
||||
existingRegions.TryGetValue(bottomNeighbor, out bottomRegion) &&
|
||||
!bottomRegion.IsDoor)
|
||||
{
|
||||
bottomRegion.Add(node);
|
||||
existingRegions.Add(node, bottomRegion);
|
||||
UpdateRegionEdge(bottomRegion, node);
|
||||
return bottomRegion;
|
||||
}
|
||||
|
||||
// If we can't join an existing region then we'll make our own
|
||||
var newRegion = new PathfindingRegion(node, new HashSet<PathfindingNode> {node}, node.AccessReaders.Count > 0);
|
||||
_regions[parentChunk.GridId][parentChunk].Add(newRegion);
|
||||
existingRegions.Add(node, newRegion);
|
||||
UpdateRegionEdge(newRegion, node);
|
||||
|
||||
return newRegion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines the two regions into one bigger region
|
||||
/// </summary>
|
||||
/// <param name="source"></param>
|
||||
/// <param name="target"></param>
|
||||
private void MergeInto(PathfindingRegion source, PathfindingRegion target)
|
||||
{
|
||||
DebugTools.AssertNotNull(source);
|
||||
DebugTools.AssertNotNull(target);
|
||||
foreach (var node in source.Nodes)
|
||||
{
|
||||
target.Add(node);
|
||||
}
|
||||
|
||||
source.Shutdown();
|
||||
_regions[source.ParentChunk.GridId][source.ParentChunk].Remove(source);
|
||||
|
||||
foreach (var node in target.Nodes)
|
||||
{
|
||||
UpdateRegionEdge(target, node);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate all of the regions within a chunk
|
||||
/// </summary>
|
||||
/// These can't across over into another chunk and doors are their own region
|
||||
/// <param name="chunk"></param>
|
||||
private void GenerateRegions(PathfindingChunk chunk)
|
||||
{
|
||||
if (!_regions.ContainsKey(chunk.GridId))
|
||||
{
|
||||
_regions.Add(chunk.GridId, new Dictionary<PathfindingChunk, HashSet<PathfindingRegion>>());
|
||||
}
|
||||
|
||||
if (_regions[chunk.GridId].TryGetValue(chunk, out var regions))
|
||||
{
|
||||
foreach (var region in regions)
|
||||
{
|
||||
region.Shutdown();
|
||||
}
|
||||
_regions[chunk.GridId].Remove(chunk);
|
||||
}
|
||||
|
||||
// Temporarily store the corresponding region for each node
|
||||
// Makes merging regions or adding nodes to existing regions neater.
|
||||
var nodeRegions = new Dictionary<PathfindingNode, PathfindingRegion>();
|
||||
var chunkRegions = new HashSet<PathfindingRegion>();
|
||||
_regions[chunk.GridId].Add(chunk, chunkRegions);
|
||||
|
||||
for (var y = 0; y < PathfindingChunk.ChunkSize; y++)
|
||||
{
|
||||
for (var x = 0; x < PathfindingChunk.ChunkSize; x++)
|
||||
{
|
||||
var node = chunk.Nodes[x, y];
|
||||
var region = CalculateNode(node, nodeRegions, x, y);
|
||||
// Currently we won't store a separate region for each mask / space / whatever because muh effort
|
||||
// Long-term you'll want to account for it probably
|
||||
if (region == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
chunkRegions.Add(region);
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
SendRegionsDebugMessage(chunk.GridId);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private void SendDebugMessage(PlayerAttachSystemMessage message)
|
||||
{
|
||||
var playerGrid = message.Entity.Transform.GridID;
|
||||
SendRegionsDebugMessage(playerGrid);
|
||||
}
|
||||
|
||||
private void SendRegionsDebugMessage(GridId gridId)
|
||||
{
|
||||
var grid = _mapmanager.GetGrid(gridId);
|
||||
// Chunk / Regions / Nodes
|
||||
var debugResult = new Dictionary<int, Dictionary<int, List<Vector2>>>();
|
||||
var chunkIdx = 0;
|
||||
var regionIdx = 0;
|
||||
|
||||
foreach (var (_, regions) in _regions[gridId])
|
||||
{
|
||||
var debugRegions = new Dictionary<int, List<Vector2>>();
|
||||
debugResult.Add(chunkIdx, debugRegions);
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
var debugRegionNodes = new List<Vector2>(region.Nodes.Count);
|
||||
debugResult[chunkIdx].Add(regionIdx, debugRegionNodes);
|
||||
|
||||
foreach (var node in region.Nodes)
|
||||
{
|
||||
var nodeVector = grid.GridTileToLocal(node.TileRef.GridIndices).ToMapPos(_mapmanager);
|
||||
debugRegionNodes.Add(nodeVector);
|
||||
}
|
||||
|
||||
regionIdx++;
|
||||
}
|
||||
|
||||
chunkIdx++;
|
||||
}
|
||||
RaiseNetworkEvent(new SharedAiDebug.ReachableChunkRegionsDebugMessage(gridId, debugResult));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sent whenever the reachable cache for a particular mob is built or retrieved
|
||||
/// </summary>
|
||||
/// <param name="gridId"></param>
|
||||
/// <param name="regions"></param>
|
||||
/// <param name="cached"></param>
|
||||
private void SendRegionCacheMessage(GridId gridId, IEnumerable<PathfindingRegion> regions, bool cached)
|
||||
{
|
||||
var grid = _mapmanager.GetGrid(gridId);
|
||||
var debugResult = new Dictionary<int, List<Vector2>>();
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
debugResult.Add(_runningCacheIdx, new List<Vector2>());
|
||||
|
||||
foreach (var node in region.Nodes)
|
||||
{
|
||||
var nodeVector = grid.GridTileToLocal(node.TileRef.GridIndices).ToMapPos(_mapmanager);
|
||||
|
||||
debugResult[_runningCacheIdx].Add(nodeVector);
|
||||
}
|
||||
|
||||
_runningCacheIdx++;
|
||||
}
|
||||
|
||||
RaiseNetworkEvent(new SharedAiDebug.ReachableCacheDebugMessage(gridId, debugResult, cached));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Content.Server.GameObjects.EntitySystems.Pathfinding;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Accessible
|
||||
{
|
||||
/// <summary>
|
||||
/// A group of homogenous PathfindingNodes inside a single chunk
|
||||
/// </summary>
|
||||
/// Makes the graph smaller and quicker to traverse
|
||||
public class PathfindingRegion : IEquatable<PathfindingRegion>
|
||||
{
|
||||
/// <summary>
|
||||
/// Bottom-left reference node of the region
|
||||
/// </summary>
|
||||
public PathfindingNode OriginNode { get; }
|
||||
|
||||
// The shape may be anything within the bounds of a chunk, this is just a quick way to do a bounds-check
|
||||
|
||||
/// <summary>
|
||||
/// Maximum width of the nodes
|
||||
/// </summary>
|
||||
public int Height { get; private set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum width of the nodes
|
||||
/// </summary>
|
||||
public int Width { get; private set; } = 1;
|
||||
|
||||
public PathfindingChunk ParentChunk => OriginNode.ParentChunk;
|
||||
public HashSet<PathfindingRegion> Neighbors { get; } = new HashSet<PathfindingRegion>();
|
||||
|
||||
public bool IsDoor { get; }
|
||||
public HashSet<PathfindingNode> Nodes => _nodes;
|
||||
private HashSet<PathfindingNode> _nodes;
|
||||
|
||||
public PathfindingRegion(PathfindingNode originNode, HashSet<PathfindingNode> nodes, bool isDoor = false)
|
||||
{
|
||||
OriginNode = originNode;
|
||||
_nodes = nodes;
|
||||
IsDoor = isDoor;
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
// Tell our neighbors we no longer exist ;-/
|
||||
var neighbors = new List<PathfindingRegion>(Neighbors);
|
||||
|
||||
for (var i = 0; i < neighbors.Count; i++)
|
||||
{
|
||||
var neighbor = neighbors[i];
|
||||
neighbor.Neighbors.Remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roughly how far away another region is by nearest node
|
||||
/// </summary>
|
||||
/// <param name="otherRegion"></param>
|
||||
/// <returns></returns>
|
||||
public float Distance(PathfindingRegion otherRegion)
|
||||
{
|
||||
// JANK
|
||||
var xDistance = otherRegion.OriginNode.TileRef.X - OriginNode.TileRef.X;
|
||||
var yDistance = otherRegion.OriginNode.TileRef.Y - OriginNode.TileRef.Y;
|
||||
|
||||
if (xDistance > 0)
|
||||
{
|
||||
xDistance -= Width;
|
||||
}
|
||||
else if (xDistance < 0)
|
||||
{
|
||||
xDistance = Math.Abs(xDistance + otherRegion.Width);
|
||||
}
|
||||
|
||||
if (yDistance > 0)
|
||||
{
|
||||
yDistance -= Height;
|
||||
}
|
||||
else if (yDistance < 0)
|
||||
{
|
||||
yDistance = Math.Abs(yDistance + otherRegion.Height);
|
||||
}
|
||||
|
||||
return PathfindingHelpers.OctileDistance(xDistance, yDistance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Can the given args can traverse this region?
|
||||
/// </summary>
|
||||
/// <param name="reachableArgs"></param>
|
||||
/// <returns></returns>
|
||||
public bool RegionTraversable(ReachableArgs reachableArgs)
|
||||
{
|
||||
// The assumption is that all nodes in a region have the same pathfinding traits
|
||||
// As such we can just use the origin node for checking.
|
||||
return PathfindingHelpers.Traversable(reachableArgs.CollisionMask, reachableArgs.Access,
|
||||
OriginNode);
|
||||
}
|
||||
|
||||
public void Add(PathfindingNode node)
|
||||
{
|
||||
var xWidth = Math.Abs(node.TileRef.X - OriginNode.TileRef.X);
|
||||
var yHeight = Math.Abs(node.TileRef.Y - OriginNode.TileRef.Y);
|
||||
|
||||
if (xWidth > Width)
|
||||
{
|
||||
Width = xWidth;
|
||||
}
|
||||
|
||||
if (yHeight > Height)
|
||||
{
|
||||
Height = yHeight;
|
||||
}
|
||||
|
||||
_nodes.Add(node);
|
||||
}
|
||||
|
||||
// HashSet wasn't working correctly so uhh we got this.
|
||||
public bool Equals(PathfindingRegion other)
|
||||
{
|
||||
if (other == null) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
return GetHashCode() == other.GetHashCode();
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return OriginNode.GetHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using Content.Server.GameObjects.Components.Access;
|
||||
using Content.Server.GameObjects.Components.Movement;
|
||||
using Robust.Shared.GameObjects.Components;
|
||||
using Robust.Shared.Interfaces.GameObjects;
|
||||
|
||||
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Accessible
|
||||
{
|
||||
public sealed class ReachableArgs
|
||||
{
|
||||
public float VisionRadius { get; set; }
|
||||
public ICollection<string> Access { get; }
|
||||
public int CollisionMask { get; }
|
||||
|
||||
public ReachableArgs(float visionRadius, ICollection<string> access, int collisionMask)
|
||||
{
|
||||
VisionRadius = visionRadius;
|
||||
Access = access;
|
||||
CollisionMask = collisionMask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get appropriate args for a particular entity
|
||||
/// </summary>
|
||||
/// <param name="entity"></param>
|
||||
/// <returns></returns>
|
||||
public static ReachableArgs GetArgs(IEntity entity)
|
||||
{
|
||||
var collisionMask = 0;
|
||||
if (entity.TryGetComponent(out CollidableComponent collidableComponent))
|
||||
{
|
||||
collisionMask = collidableComponent.CollisionMask;
|
||||
}
|
||||
|
||||
var access = AccessReader.FindAccessTags(entity);
|
||||
var visionRadius = entity.GetComponent<AiControllerComponent>().VisionRadius;
|
||||
|
||||
return new ReachableArgs(visionRadius, access, collisionMask);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,37 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Content.Server.GameObjects.EntitySystems.Pathfinding;
|
||||
using Robust.Shared.GameObjects;
|
||||
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;
|
||||
|
||||
namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
|
||||
{
|
||||
public class PathfindingChunkUpdateMessage : EntitySystemMessage
|
||||
{
|
||||
public PathfindingChunk Chunk { get; }
|
||||
|
||||
public PathfindingChunkUpdateMessage(PathfindingChunk chunk)
|
||||
{
|
||||
Chunk = chunk;
|
||||
}
|
||||
}
|
||||
|
||||
public class PathfindingChunk
|
||||
{
|
||||
public TimeSpan LastUpdate { get; private set; }
|
||||
public GridId GridId { get; }
|
||||
|
||||
public MapIndices Indices => _indices;
|
||||
private readonly MapIndices _indices;
|
||||
|
||||
// Nodes per chunk row
|
||||
public static int ChunkSize => 16;
|
||||
public static int ChunkSize => 8;
|
||||
public PathfindingNode[,] Nodes => _nodes;
|
||||
private PathfindingNode[,] _nodes = new PathfindingNode[ChunkSize,ChunkSize];
|
||||
|
||||
@@ -29,16 +43,28 @@ namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
|
||||
_indices = indices;
|
||||
}
|
||||
|
||||
public void Initialize(IMapGrid grid)
|
||||
public void Initialize(IMapGrid mapGrid)
|
||||
{
|
||||
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));
|
||||
var tileRef = mapGrid.GetTileRef(new MapIndices(x + _indices.X, y + _indices.Y));
|
||||
CreateNode(tileRef);
|
||||
}
|
||||
}
|
||||
|
||||
Dirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only called when blockers change (i.e. un-anchored physics objects don't trigger)
|
||||
/// </summary>
|
||||
public void Dirty()
|
||||
{
|
||||
LastUpdate = IoCManager.Resolve<IGameTiming>().CurTime;
|
||||
IoCManager.Resolve<IEntityManager>().EventBus
|
||||
.RaiseEvent(EventSource.Local, new PathfindingChunkUpdateMessage(this));
|
||||
}
|
||||
|
||||
public IEnumerable<PathfindingChunk> GetNeighbors()
|
||||
|
||||
@@ -221,6 +221,16 @@ namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
|
||||
return result;
|
||||
}
|
||||
|
||||
public static float OctileDistance(int dstX, int dstY)
|
||||
{
|
||||
if (dstX > dstY)
|
||||
{
|
||||
return 1.4f * dstY + (dstX - dstY);
|
||||
}
|
||||
|
||||
return 1.4f * dstX + (dstY - dstX);
|
||||
}
|
||||
|
||||
public static float OctileDistance(PathfindingNode endNode, PathfindingNode currentNode)
|
||||
{
|
||||
// "Fast Euclidean" / octile.
|
||||
|
||||
@@ -88,9 +88,159 @@ namespace Content.Server.GameObjects.EntitySystems.Pathfinding
|
||||
}
|
||||
}
|
||||
|
||||
public PathfindingNode GetNeighbor(Direction direction)
|
||||
{
|
||||
var chunkXOffset = TileRef.X - ParentChunk.Indices.X;
|
||||
var chunkYOffset = TileRef.Y - ParentChunk.Indices.Y;
|
||||
MapIndices neighborMapIndices;
|
||||
|
||||
switch (direction)
|
||||
{
|
||||
case Direction.East:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset + 1, chunkYOffset];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X + 1, TileRef.Y);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case Direction.NorthEast:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset + 1, chunkYOffset + 1];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X + 1, TileRef.Y + 1);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case Direction.North:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset, chunkYOffset + 1];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X, TileRef.Y + 1);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case Direction.NorthWest:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset - 1, chunkYOffset + 1];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X - 1, TileRef.Y + 1);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case Direction.West:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset - 1, chunkYOffset];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X - 1, TileRef.Y);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case Direction.SouthWest:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset - 1, chunkYOffset - 1];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X - 1, TileRef.Y - 1);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case Direction.South:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset, chunkYOffset - 1];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X, TileRef.Y - 1);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case Direction.SouthEast:
|
||||
if (!ParentChunk.OnEdge(this))
|
||||
{
|
||||
return ParentChunk.Nodes[chunkXOffset + 1, chunkYOffset - 1];
|
||||
}
|
||||
|
||||
neighborMapIndices = new MapIndices(TileRef.X + 1, TileRef.Y - 1);
|
||||
foreach (var neighbor in ParentChunk.GetNeighbors())
|
||||
{
|
||||
if (neighbor.InBounds(neighborMapIndices))
|
||||
{
|
||||
return neighbor.Nodes[neighborMapIndices.X - neighbor.Indices.X,
|
||||
neighborMapIndices.Y - neighbor.Indices.Y];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(direction), direction, null);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateTile(TileRef newTile)
|
||||
{
|
||||
TileRef = newTile;
|
||||
ParentChunk.Dirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -111,6 +261,7 @@ namespace Content.Server.GameObjects.EntitySystems.Pathfinding
|
||||
if (entity.TryGetComponent(out AccessReader accessReader) && !_accessReaders.ContainsKey(entity.Uid))
|
||||
{
|
||||
_accessReaders.Add(entity.Uid, accessReader);
|
||||
ParentChunk.Dirty();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -126,6 +277,7 @@ namespace Content.Server.GameObjects.EntitySystems.Pathfinding
|
||||
{
|
||||
_blockedCollidables.TryAdd(entity.Uid, collidableComponent.CollisionLayer);
|
||||
GenerateMask();
|
||||
ParentChunk.Dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,11 +299,13 @@ namespace Content.Server.GameObjects.EntitySystems.Pathfinding
|
||||
else if (_accessReaders.ContainsKey(entity.Uid))
|
||||
{
|
||||
_accessReaders.Remove(entity.Uid);
|
||||
ParentChunk.Dirty();
|
||||
}
|
||||
else if (_blockedCollidables.ContainsKey(entity.Uid))
|
||||
{
|
||||
_blockedCollidables.Remove(entity.Uid);
|
||||
GenerateMask();
|
||||
ParentChunk.Dirty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ namespace Content.Server.GameObjects.EntitySystems.AI.Pathfinding
|
||||
return newChunk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the entity's tile position, then get the corresponding node
|
||||
/// </summary>
|
||||
/// <param name="entity"></param>
|
||||
/// <returns></returns>
|
||||
public PathfindingNode GetNode(IEntity entity)
|
||||
{
|
||||
var tile = _mapManager.GetGrid(entity.Transform.GridID).GetTileRef(entity.Transform.GridPosition);
|
||||
return GetNode(tile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the corresponding PathfindingNode for this tile
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user