diff --git a/OpenRA.Game/Map/Map.cs b/OpenRA.Game/Map/Map.cs
index 013e9d1471..8d91489854 100644
--- a/OpenRA.Game/Map/Map.cs
+++ b/OpenRA.Game/Map/Map.cs
@@ -151,6 +151,7 @@ namespace OpenRA
public class Map : IReadOnlyFileSystem
{
public const int SupportedMapFormat = 11;
+ const short InvalidCachedTerrainIndex = -1;
/// Defines the order of the fields in map.yaml
static readonly MapField[] YamlFields =
@@ -448,6 +449,19 @@ namespace OpenRA
}
AllEdgeCells = UpdateEdgeCells();
+
+ // Invalidate the entry for a cell if anything could cause the terrain index to change.
+ Action invalidateTerrainIndex = c =>
+ {
+ if (cachedTerrainIndexes != null)
+ cachedTerrainIndexes[c] = InvalidCachedTerrainIndex;
+ };
+
+ // Even though the cache is lazily initialized, we must attach these event handlers on init.
+ // This ensures our handler to invalidate the cache runs first,
+ // so other listeners to these same events will get correct data when calling GetTerrainIndex.
+ CustomTerrain.CellEntryChanged += invalidateTerrainIndex;
+ Tiles.CellEntryChanged += invalidateTerrainIndex;
}
void UpdateRamp(CPos cell)
@@ -1069,18 +1083,11 @@ namespace OpenRA
public byte GetTerrainIndex(CPos cell)
{
- const short InvalidCachedTerrainIndex = -1;
-
// Lazily initialize a cache for terrain indexes.
if (cachedTerrainIndexes == null)
{
cachedTerrainIndexes = new CellLayer(this);
cachedTerrainIndexes.Clear(InvalidCachedTerrainIndex);
-
- // Invalidate the entry for a cell if anything could cause the terrain index to change.
- Action invalidateTerrainIndex = c => cachedTerrainIndexes[c] = InvalidCachedTerrainIndex;
- CustomTerrain.CellEntryChanged += invalidateTerrainIndex;
- Tiles.CellEntryChanged += invalidateTerrainIndex;
}
var uv = cell.ToMPos(this);
diff --git a/OpenRA.Mods.Common/Activities/FindAndDeliverResources.cs b/OpenRA.Mods.Common/Activities/FindAndDeliverResources.cs
index 116650edd2..179389ca70 100644
--- a/OpenRA.Mods.Common/Activities/FindAndDeliverResources.cs
+++ b/OpenRA.Mods.Common/Activities/FindAndDeliverResources.cs
@@ -182,7 +182,7 @@ namespace OpenRA.Mods.Common.Activities
var harvPos = self.CenterPosition;
// Find any harvestable resources:
- var path = mobile.PathFinder.FindUnitPathToTargetCellByPredicate(
+ var path = mobile.PathFinder.FindPathToTargetCellByPredicate(
self,
new[] { searchFromLoc, self.Location },
loc =>
diff --git a/OpenRA.Mods.Common/Activities/Move/Move.cs b/OpenRA.Mods.Common/Activities/Move/Move.cs
index 7be501c655..943b716a4a 100644
--- a/OpenRA.Mods.Common/Activities/Move/Move.cs
+++ b/OpenRA.Mods.Common/Activities/Move/Move.cs
@@ -58,7 +58,7 @@ namespace OpenRA.Mods.Common.Activities
getPath = check =>
{
- return mobile.PathFinder.FindUnitPathToTargetCell(
+ return mobile.PathFinder.FindPathToTargetCell(
self, new[] { mobile.ToCell }, destination, check, laneBias: false);
};
@@ -78,7 +78,7 @@ namespace OpenRA.Mods.Common.Activities
if (!this.destination.HasValue)
return PathFinder.NoPath;
- return mobile.PathFinder.FindUnitPathToTargetCell(
+ return mobile.PathFinder.FindPathToTargetCell(
self, new[] { mobile.ToCell }, this.destination.Value, check, ignoreActor: ignoreActor);
};
diff --git a/OpenRA.Mods.Common/Activities/Move/MoveAdjacentTo.cs b/OpenRA.Mods.Common/Activities/Move/MoveAdjacentTo.cs
index 31991550ef..5e6562e98e 100644
--- a/OpenRA.Mods.Common/Activities/Move/MoveAdjacentTo.cs
+++ b/OpenRA.Mods.Common/Activities/Move/MoveAdjacentTo.cs
@@ -127,7 +127,7 @@ namespace OpenRA.Mods.Common.Activities
if (searchCells.Count == 0)
return PathFinder.NoPath;
- var path = Mobile.PathFinder.FindUnitPathToTargetCell(self, searchCells, loc, check);
+ var path = Mobile.PathFinder.FindPathToTargetCell(self, searchCells, loc, check);
path.Reverse();
return path;
}
diff --git a/OpenRA.Mods.Common/Pathfinder/DensePathGraph.cs b/OpenRA.Mods.Common/Pathfinder/DensePathGraph.cs
index 7272f7bf4a..cf9a8402cb 100644
--- a/OpenRA.Mods.Common/Pathfinder/DensePathGraph.cs
+++ b/OpenRA.Mods.Common/Pathfinder/DensePathGraph.cs
@@ -60,7 +60,7 @@ namespace OpenRA.Mods.Common.Pathfinder
/// allowable to be returned in a .
///
/// The candidate cell. This might not lie within map bounds.
- protected virtual bool NeighborAllowable(CPos neighbor)
+ protected virtual bool IsValidNeighbor(CPos neighbor)
{
return true;
}
@@ -125,7 +125,7 @@ namespace OpenRA.Mods.Common.Pathfinder
{
var dir = directions[i];
var neighbor = position + dir;
- if (!NeighborAllowable(neighbor))
+ if (!IsValidNeighbor(neighbor))
continue;
var pathCost = GetPathCostToNode(position, neighbor, dir);
@@ -142,7 +142,7 @@ namespace OpenRA.Mods.Common.Pathfinder
continue;
var layerPosition = new CPos(position.X, position.Y, cml.Index);
- if (!NeighborAllowable(layerPosition))
+ if (!IsValidNeighbor(layerPosition))
continue;
var entryCost = cml.EntryMovementCost(locomotor.Info, layerPosition);
@@ -155,7 +155,7 @@ namespace OpenRA.Mods.Common.Pathfinder
else
{
var groundPosition = new CPos(position.X, position.Y, 0);
- if (NeighborAllowable(groundPosition))
+ if (IsValidNeighbor(groundPosition))
{
var exitCost = CustomMovementLayers[layer].ExitMovementCost(locomotor.Info, groundPosition);
if (exitCost != PathGraph.MovementCostForUnreachableCell &&
diff --git a/OpenRA.Mods.Common/Pathfinder/Grid.cs b/OpenRA.Mods.Common/Pathfinder/Grid.cs
new file mode 100644
index 0000000000..d313868284
--- /dev/null
+++ b/OpenRA.Mods.Common/Pathfinder/Grid.cs
@@ -0,0 +1,94 @@
+#region Copyright & License Information
+/*
+ * Copyright 2007-2022 The OpenRA Developers (see AUTHORS)
+ * This file is part of OpenRA, which is free software. It is made
+ * available to you under the terms of the GNU General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version. For more
+ * information, see COPYING.
+ */
+#endregion
+
+using System;
+
+namespace OpenRA.Mods.Common.Pathfinder
+{
+ ///
+ /// Represents a simplistic grid of cells, where everything in the
+ /// top-to-bottom and left-to-right range is within the grid.
+ /// The grid can be restricted to a single layer, or allowed to span all layers.
+ ///
+ ///
+ /// This means in some cells within a grid may lay off the map.
+ /// Contrast this with which maintains the simplistic grid in map space -
+ /// ensuring the cells are therefore always within the map area.
+ /// The advantage of Grid is that it has straight edges, making logic for adjacent grids easy.
+ /// A CellRegion has jagged edges in RectangularIsometric, which makes that more difficult.
+ ///
+ public readonly struct Grid
+ {
+ ///
+ /// Inclusive.
+ ///
+ public readonly CPos TopLeft;
+
+ ///
+ /// Exclusive.
+ ///
+ public readonly CPos BottomRight;
+
+ ///
+ /// When true, the grid spans only the single layer given by the cells. When false, it spans all layers.
+ ///
+ public readonly bool SingleLayer;
+
+ public Grid(CPos topLeft, CPos bottomRight, bool singleLayer)
+ {
+ if (topLeft.Layer != bottomRight.Layer)
+ throw new ArgumentException($"{nameof(topLeft)} and {nameof(bottomRight)} must have the same {nameof(CPos.Layer)}");
+
+ TopLeft = topLeft;
+ BottomRight = bottomRight;
+ SingleLayer = singleLayer;
+ }
+
+ public int Width => BottomRight.X - TopLeft.X;
+ public int Height => BottomRight.Y - TopLeft.Y;
+
+ ///
+ /// Checks if the cell X and Y lie within the grid bounds. The cell layer must also match.
+ ///
+ public bool Contains(CPos cell)
+ {
+ return
+ cell.X >= TopLeft.X && cell.X < BottomRight.X &&
+ cell.Y >= TopLeft.Y && cell.Y < BottomRight.Y &&
+ (!SingleLayer || cell.Layer == TopLeft.Layer);
+ }
+
+ ///
+ /// Checks if the line segment from to
+ /// passes through the grid boundary. The cell layers are ignored.
+ /// A line contained wholly within the grid that doesn't cross the boundary is not counted as intersecting.
+ ///
+ public bool IntersectsLine(CPos start, CPos end)
+ {
+ var s = new int2(start.X, start.Y);
+ var e = new int2(end.X, end.Y);
+ var tl = new int2(TopLeft.X, TopLeft.Y);
+ var tr = new int2(BottomRight.X, TopLeft.Y);
+ var bl = new int2(TopLeft.X, BottomRight.Y);
+ var br = new int2(BottomRight.X, BottomRight.Y);
+ return
+ Exts.LinesIntersect(s, e, tl, tr) ||
+ Exts.LinesIntersect(s, e, tl, bl) ||
+ Exts.LinesIntersect(s, e, bl, br) ||
+ Exts.LinesIntersect(s, e, tr, br);
+ }
+
+ public override string ToString()
+ {
+ return $"{TopLeft}->{BottomRight}";
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/Pathfinder/GridPathGraph.cs b/OpenRA.Mods.Common/Pathfinder/GridPathGraph.cs
new file mode 100644
index 0000000000..3e670d25d9
--- /dev/null
+++ b/OpenRA.Mods.Common/Pathfinder/GridPathGraph.cs
@@ -0,0 +1,54 @@
+#region Copyright & License Information
+/*
+ * Copyright 2007-2022 The OpenRA Developers (see AUTHORS)
+ * This file is part of OpenRA, which is free software. It is made
+ * available to you under the terms of the GNU General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version. For more
+ * information, see COPYING.
+ */
+#endregion
+
+using System;
+using OpenRA.Mods.Common.Traits;
+
+namespace OpenRA.Mods.Common.Pathfinder
+{
+ ///
+ /// A dense pathfinding graph that supports a search over all cells within a .
+ /// Cells outside the grid area are deemed unreachable and will not be considered.
+ /// It implements the ability to cost and get connections for cells, and supports .
+ ///
+ sealed class GridPathGraph : DensePathGraph
+ {
+ readonly CellInfo[] infos;
+ readonly Grid grid;
+
+ public GridPathGraph(Locomotor locomotor, Actor actor, World world, BlockedByActor check,
+ Func customCost, Actor ignoreActor, bool laneBias, bool inReverse, Grid grid)
+ : base(locomotor, actor, world, check, customCost, ignoreActor, laneBias, inReverse)
+ {
+ infos = new CellInfo[grid.Width * grid.Height];
+ this.grid = grid;
+ }
+
+ protected override bool IsValidNeighbor(CPos neighbor)
+ {
+ // Enforce that we only search within the grid bounds.
+ return grid.Contains(neighbor);
+ }
+
+ int InfoIndex(CPos pos)
+ {
+ return
+ (pos.Y - grid.TopLeft.Y) * grid.Width +
+ (pos.X - grid.TopLeft.X);
+ }
+
+ public override CellInfo this[CPos pos]
+ {
+ get => infos[InfoIndex(pos)];
+ set => infos[InfoIndex(pos)] = value;
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/Pathfinder/HierarchicalPathFinder.cs b/OpenRA.Mods.Common/Pathfinder/HierarchicalPathFinder.cs
new file mode 100644
index 0000000000..ddfe4f5451
--- /dev/null
+++ b/OpenRA.Mods.Common/Pathfinder/HierarchicalPathFinder.cs
@@ -0,0 +1,904 @@
+#region Copyright & License Information
+/*
+ * Copyright 2007-2022 The OpenRA Developers (see AUTHORS)
+ * This file is part of OpenRA, which is free software. It is made
+ * available to you under the terms of the GNU General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version. For more
+ * information, see COPYING.
+ */
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using OpenRA.Mods.Common.Traits;
+
+namespace OpenRA.Mods.Common.Pathfinder
+{
+ ///
+ /// Provides pathfinding abilities for actors that use a specific .
+ /// Maintains a hierarchy of abstract graphs that provide a more accurate heuristic function during
+ /// A* pathfinding than the one available from .
+ /// This allows for faster pathfinding.
+ ///
+ ///
+ /// The goal of this pathfinder is to increase performance of path searches. is used
+ /// to perform a path search as usual, but a different heuristic function is provided that is more accurate. This
+ /// means fewer nodes have to be explored during the search, resulting in a performance increase.
+ ///
+ /// When an A* path search is performed, the search expands outwards from the source location until the
+ /// target is found. The heuristic controls how this expansion occurs. When the heuristic of h(n) = 0 is given, we
+ /// get Dijkstra's algorithm. The search grows outwards from the source node in an expanding circle with no sense
+ /// of direction. This will find the shortest path by brute force. It will explore many nodes during the search,
+ /// including lots of nodes in the opposite direction to the target.
+ ///
+ /// provides heuristic for searching a 2D grid. It
+ /// estimates the cost as the straight-line distance between the source and target nodes. The search grows in a
+ /// straight line towards the target node. This is a vast improvement over Dijkstra's algorithm as we now
+ /// prioritize exploring nodes that lie closer to the target, rather than exploring nodes that take us away from
+ /// the target.
+ ///
+ /// This default straight-line heuristic still has drawbacks - it is unaware of the obstacles on the grid. If
+ /// the route to be found requires steering around obstacles then this heuristic can perform badly. Imagine a path
+ /// that must steer around a lake, or move back on itself to get out of a dead end. In these cases the straight-line
+ /// heuristic moves blindly towards the target, when actually the path requires that we move sidewards or even
+ /// backwards to find a route. When this occurs then the straight-line heuristic ends up exploring nodes that
+ /// aren't useful - they lead us into dead ends or directly into an obstacle that we need to go around instead.
+ ///
+ ///
+ /// The improves the heuristic by making it aware of unreachable map
+ /// terrain. A "low-resolution" version of the map is maintained, and used to provide an initial route. When the
+ /// search is conducted it explores along this initial route. This allows the search to "know" it needs to go
+ /// sideways around the lake or backwards out of the dead-end, meaning we can explore even fewer nodes.
+ ///
+ /// The "low-resolution" version of the map is referred to as the abstract graph. The abstract graph is
+ /// created by dividing the map up into a series of grids, of say 10x10 nodes. Within each grid, we determine the
+ /// connected regions of nodes within that grid. If all the nodes within the grid connect to each other, we have
+ /// one such region. If they are split up by impassable terrain then we may have two or more regions within the
+ /// grid. Every region will be represented by one node in the abstract graph (an abstract node, for short).
+ ///
+ /// When a path search is to be performed, we first perform a A* search on the abstract graph with the
+ /// . This graph is much smaller than the full map, so
+ /// this search is quick. The resulting path gives us the initial route between each abstract node. We can then use
+ /// this to create the improved heuristic for use on the path search on the full resolution map. When determining
+ /// the cost for the node, we can use the straight-line distance towards the next abstract node as our estimate.
+ /// Our search is therefore guided along the initial route.
+ ///
+ /// This implementation only maintains one level of abstract graph, but a hierarchy of such graphs is
+ /// possible. This allows the top-level and lowest resolution graph to be as small as possible - important because
+ /// it will be searched using the dumbest heuristic. Each level underneath is higher-resolution and contains more
+ /// nodes, but uses a heuristic informed from the previous level to guide the search in the right direction.
+ ///
+ /// This implementation is aware of movement costs over terrain given by
+ /// . It is aware of changes to the costs in terrain and able to
+ /// update the abstract graph when this occurs. It is able to search the abstract graph as if
+ /// had been specified. It is not aware of actors on the map. So blocking actors
+ /// will not be accounted for in the heuristic.
+ ///
+ /// If the obstacle on the map is from terrain (e.g. a cliff or lake) the heuristic will work well. If the
+ /// obstacle is from a blocking actor (trees, walls, buildings, units) the heuristic is unaware of these. Therefore
+ /// the same problem where the search goes in the wrong direction is possible, e.g. through a choke-point that has
+ /// been walled off. In this scenario the performance benefit will be lost, as the search will have to explore more
+ /// nodes until it can get around the obstacle.
+ ///
+ /// In summary, the reduces the performance impact of path searches that
+ /// must go around terrain, but does not improve performance of searches that must go around actors.
+ ///
+ public sealed class HierarchicalPathFinder
+ {
+ // This value determined via empiric testing as the best performance trade-off.
+ const int GridSize = 10;
+
+ readonly World world;
+ readonly Locomotor locomotor;
+ readonly Func costEstimator;
+ readonly HashSet dirtyGridIndexes = new HashSet();
+ Grid mapBounds;
+ int gridXs;
+ int gridYs;
+
+ ///
+ /// Index by a .
+ ///
+ GridInfo[] gridInfos;
+
+ ///
+ /// The abstract graph is represented here.
+ /// An abstract node is the key, and costs to other abstract nodes are then available.
+ /// Abstract nodes with no connections are NOT present in the graph.
+ /// A lookup will fail, rather than return an empty list.
+ ///
+ Dictionary> abstractGraph;
+
+ ///
+ /// Knows about the abstract nodes within a grid. Can map a local cell to its abstract node.
+ ///
+ readonly struct GridInfo
+ {
+ readonly CPos?[] singleAbstractCellForLayer;
+ readonly Dictionary localCellToAbstractCell;
+
+ public GridInfo(CPos?[] singleAbstractCellForLayer, Dictionary localCellToAbstractCell)
+ {
+ this.singleAbstractCellForLayer = singleAbstractCellForLayer;
+ this.localCellToAbstractCell = localCellToAbstractCell;
+ }
+
+ ///
+ /// Maps a local cell to a abstract node in the graph.
+ /// Returns null when the local cell is unreachable.
+ ///
+ public CPos? AbstractCellForLocalCell(CPos localCell)
+ {
+ var abstractCell = singleAbstractCellForLayer[localCell.Layer];
+ if (abstractCell != null)
+ return abstractCell;
+ if (localCellToAbstractCell.TryGetValue(localCell, out var abstractCellFromMap))
+ return abstractCellFromMap;
+ return null;
+ }
+
+ public void CopyAbstractCellsInto(HashSet set)
+ {
+ foreach (var single in singleAbstractCellForLayer)
+ if (single != null)
+ set.Add(single.Value);
+ foreach (var cell in localCellToAbstractCell.Values)
+ set.Add(cell);
+ }
+ }
+
+ ///
+ /// Represents an abstract graph with some extra edges inserted.
+ /// Instead of building a new dictionary with the edges added, we build a supplemental dictionary of changes.
+ /// This is to avoid copying the entire abstract graph.
+ ///
+ sealed class AbstractGraphWithInsertedEdges
+ {
+ readonly Dictionary> abstractEdges;
+ readonly Dictionary> changedEdges;
+
+ public AbstractGraphWithInsertedEdges(
+ Dictionary> abstractEdges,
+ IList sourceEdges,
+ GraphEdge? targetEdge,
+ Func costEstimator)
+ {
+ this.abstractEdges = abstractEdges;
+ changedEdges = new Dictionary>(sourceEdges.Count * 9 + (targetEdge != null ? 9 : 0));
+ foreach (var sourceEdge in sourceEdges)
+ InsertEdgeAsBidirectional(sourceEdge, costEstimator);
+ if (targetEdge != null)
+ InsertEdgeAsBidirectional(targetEdge.Value, costEstimator);
+ }
+
+ void InsertEdgeAsBidirectional(GraphEdge edge, Func costEstimator)
+ {
+ InsertConnections(edge.Source, edge.Destination, costEstimator);
+ }
+
+ void InsertConnections(CPos localCell, CPos abstractCell, Func costEstimator)
+ {
+ if (!abstractEdges.TryGetValue(abstractCell, out var edges))
+ edges = new List();
+ changedEdges[localCell] = edges
+ .Select(e => new GraphConnection(e.Destination, costEstimator(localCell, e.Destination)))
+ .Append(new GraphConnection(abstractCell, costEstimator(localCell, abstractCell)));
+
+ IEnumerable abstractChangedEdges = edges;
+ if (changedEdges.TryGetValue(abstractCell, out var existingEdges))
+ abstractChangedEdges = existingEdges;
+ changedEdges[abstractCell] = abstractChangedEdges
+ .Append(new GraphConnection(localCell, costEstimator(abstractCell, localCell)));
+
+ foreach (var conn in edges)
+ {
+ IEnumerable connChangedEdges;
+ if (changedEdges.TryGetValue(conn.Destination, out var existingConnEdges))
+ connChangedEdges = existingConnEdges;
+ else
+ connChangedEdges = abstractEdges[conn.Destination];
+
+ changedEdges[conn.Destination] = connChangedEdges
+ .Append(new GraphConnection(localCell, costEstimator(conn.Destination, localCell)));
+ }
+ }
+
+ public List GetConnections(CPos position)
+ {
+ if (changedEdges.TryGetValue(position, out var changedEdge))
+ return changedEdge.ToList();
+ if (abstractEdges.TryGetValue(position, out var abstractEdge))
+ return abstractEdge;
+ return new List();
+ }
+ }
+
+ public HierarchicalPathFinder(World world, Locomotor locomotor)
+ {
+ this.world = world;
+ this.locomotor = locomotor;
+ if (locomotor.Info.TerrainSpeeds.Count == 0)
+ return;
+
+ costEstimator = PathSearch.DefaultCostEstimator(locomotor);
+
+ BuildGrids();
+ BuildCostTable();
+
+ // When we build the cost table, it depends on the movement costs of the cells at that time.
+ // When this changes, we must update the cost table.
+ locomotor.CellCostChanged += RequireCostRefreshInCell;
+ }
+
+ public IReadOnlyDictionary> GetOverlayData()
+ {
+ if (costEstimator == null)
+ return default;
+
+ // Ensure the abstract graph is up to date when using the overlay.
+ RebuildDirtyGrids();
+ return new ReadOnlyDictionary>(abstractGraph);
+ }
+
+ ///
+ /// Divides the map area up into a series of grids.
+ ///
+ void BuildGrids()
+ {
+ Grid GetCPosBounds(Map map)
+ {
+ if (map.Grid.Type == MapGridType.RectangularIsometric)
+ {
+ var bottomRight = map.AllCells.BottomRight;
+ var bottomRightU = bottomRight.ToMPos(map).U;
+ return new Grid(
+ new CPos(0, -bottomRightU),
+ new CPos(bottomRight.X + 1, bottomRight.Y + bottomRightU + 1),
+ false);
+ }
+
+ return new Grid(CPos.Zero, (CPos)map.MapSize, false);
+ }
+
+ mapBounds = GetCPosBounds(world.Map);
+ gridXs = Exts.IntegerDivisionRoundingAwayFromZero(mapBounds.Width, GridSize);
+ gridYs = Exts.IntegerDivisionRoundingAwayFromZero(mapBounds.Height, GridSize);
+
+ var customMovementLayers = world.GetCustomMovementLayers();
+ gridInfos = new GridInfo[gridXs * gridYs];
+ for (var gridX = mapBounds.TopLeft.X; gridX < mapBounds.BottomRight.X; gridX += GridSize)
+ for (var gridY = mapBounds.TopLeft.Y; gridY < mapBounds.BottomRight.Y; gridY += GridSize)
+ gridInfos[GridIndex(new CPos(gridX, gridY))] = BuildGrid(gridX, gridY, customMovementLayers);
+ }
+
+ ///
+ /// Determines the abstract nodes within a single grid. One abstract node will be created for each set of cells
+ /// that are reachable from each other within the grid area. A grid with open terrain will commonly have one
+ /// abstract node. If impassable terrain such as cliffs or water divides the cells into 2 or more distinct
+ /// regions, one abstract node is created for each region. We also remember which cells belong to which
+ /// abstract node. Given a local cell, this allows us to determine which abstract node it belongs to.
+ ///
+ GridInfo BuildGrid(int gridX, int gridY, ICustomMovementLayer[] customMovementLayers)
+ {
+ var singleAbstractCellForLayer = new CPos?[customMovementLayers.Length];
+ var localCellToAbstractCell = new Dictionary();
+ for (byte gridLayer = 0; gridLayer < customMovementLayers.Length; gridLayer++)
+ {
+ if (gridLayer != 0 &&
+ (customMovementLayers[gridLayer] == null ||
+ !customMovementLayers[gridLayer].EnabledForLocomotor(locomotor.Info)))
+ continue;
+
+ var grid = GetGrid(new CPos(gridX, gridY, gridLayer), mapBounds);
+ var accessibleCells = new HashSet();
+ for (var y = gridY; y < grid.BottomRight.Y; y++)
+ {
+ for (var x = gridX; x < grid.BottomRight.X; x++)
+ {
+ var cell = new CPos(x, y, gridLayer);
+ if (locomotor.MovementCostForCell(cell) != PathGraph.MovementCostForUnreachableCell)
+ accessibleCells.Add(cell);
+ }
+ }
+
+ CPos AbstractCellForLocalCells(List cells, byte layer)
+ {
+ var minX = int.MaxValue;
+ var minY = int.MaxValue;
+ var maxX = int.MinValue;
+ var maxY = int.MinValue;
+ foreach (var cell in cells)
+ {
+ minX = Math.Min(cell.X, minX);
+ minY = Math.Min(cell.Y, minY);
+ maxX = Math.Max(cell.X, maxX);
+ maxY = Math.Max(cell.Y, maxY);
+ }
+
+ var regionWidth = maxX - minX;
+ var regionHeight = maxY - minY;
+ var desired = new CPos(minX + regionWidth / 2, minY + regionHeight / 2, layer);
+
+ // Make sure the abstract cell is one of the available local cells.
+ // This ensures each abstract cell we generate is unique.
+ // We'll choose an abstract node as close to the middle of the region as possible.
+ var abstractCell = desired;
+ var distance = int.MaxValue;
+ foreach (var cell in cells)
+ {
+ var newDistance = (cell - desired).LengthSquared;
+ if (distance > newDistance)
+ {
+ distance = newDistance;
+ abstractCell = cell;
+ }
+ }
+
+ return abstractCell;
+ }
+
+ // Flood fill the search area from one of the accessible cells.
+ // We can use this to determine the connected regions within the grid.
+ // Each region we discover will be represented by an abstract node.
+ // Repeat this process until all disjoint regions are discovered.
+ var hasPopulatedAbstractCellForLayer = false;
+ while (accessibleCells.Count > 0)
+ {
+ var src = accessibleCells.First();
+ using (var search = GetLocalPathSearch(
+ null, new[] { src }, src, null, null, BlockedByActor.None, false, grid, 100))
+ {
+ var localCellsInRegion = search.ExpandAll();
+ var abstractCell = AbstractCellForLocalCells(localCellsInRegion, gridLayer);
+ accessibleCells.ExceptWith(localCellsInRegion);
+
+ // PERF: If there is only one distinct region of cells,
+ // we can represent this grid with one abstract cell.
+ // We don't need to remember how to map back from a local cell to an abstract cell.
+ if (!hasPopulatedAbstractCellForLayer && accessibleCells.Count == 0)
+ singleAbstractCellForLayer[gridLayer] = abstractCell;
+ else
+ {
+ // When there is more than one region within the grid
+ // (imagine a wall or stream separating the grid into disjoint areas)
+ // then we will remember a mapping from local cells to each of their abstract cells.
+ hasPopulatedAbstractCellForLayer = true;
+ foreach (var localCell in localCellsInRegion)
+ localCellToAbstractCell.Add(localCell, abstractCell);
+ }
+ }
+ }
+ }
+
+ return new GridInfo(singleAbstractCellForLayer, localCellToAbstractCell);
+ }
+
+ ///
+ /// Builds the abstract graph in entirety. The abstract graph contains edges between all the abstract nodes
+ /// that represent the costs to move between them.
+ ///
+ void BuildCostTable()
+ {
+ abstractGraph = new Dictionary>(gridXs * gridYs);
+ var customMovementLayers = world.GetCustomMovementLayers();
+ for (var gridX = mapBounds.TopLeft.X; gridX < mapBounds.BottomRight.X; gridX += GridSize)
+ for (var gridY = mapBounds.TopLeft.Y; gridY < mapBounds.BottomRight.Y; gridY += GridSize)
+ foreach (var edges in GetAbstractEdgesForGrid(gridX, gridY, customMovementLayers))
+ abstractGraph.Add(edges.Key, edges.Value);
+ }
+
+ ///
+ /// For a given grid, determines the edges between the abstract nodes within the grid and the abstract nodes
+ /// within adjacent grids on the same layer. Also determines any edges available to grids on other layers via
+ /// custom movement layers.
+ ///
+ IEnumerable>> GetAbstractEdgesForGrid(int gridX, int gridY, ICustomMovementLayer[] customMovementLayers)
+ {
+ var abstractEdges = new HashSet<(CPos Src, CPos Dst)>();
+ for (byte gridLayer = 0; gridLayer < customMovementLayers.Length; gridLayer++)
+ {
+ if (gridLayer != 0 &&
+ (customMovementLayers[gridLayer] == null ||
+ !customMovementLayers[gridLayer].EnabledForLocomotor(locomotor.Info)))
+ continue;
+
+ // Searches along edges of all grids within a layer.
+ // Checks for the local edge cell if we can traverse to any of the three adjacent cells in the next grid.
+ // Builds connections in the abstract graph when any local cells have connections.
+ void AddAbstractEdges(int xIncrement, int yIncrement, CVec adjacentVec, int2 offset)
+ {
+ var startY = gridY + offset.Y;
+ var startX = gridX + offset.X;
+ for (var y = startY; y < startY + GridSize; y += yIncrement)
+ {
+ for (var x = startX; x < startX + GridSize; x += xIncrement)
+ {
+ var cell = new CPos(x, y, gridLayer);
+ if (locomotor.MovementCostForCell(cell) == PathGraph.MovementCostForUnreachableCell)
+ continue;
+
+ var adjacentCell = cell + adjacentVec;
+ for (var i = -1; i <= 1; i++)
+ {
+ var candidateCell = adjacentCell + i * new CVec(adjacentVec.Y, adjacentVec.X);
+ if (locomotor.MovementCostToEnterCell(null, cell, candidateCell, BlockedByActor.None, null) !=
+ PathGraph.MovementCostForUnreachableCell)
+ {
+ var gridInfo = gridInfos[GridIndex(cell)];
+ var abstractCell = gridInfo.AbstractCellForLocalCell(cell);
+ if (abstractCell == null)
+ continue;
+
+ var gridInfoAdjacent = gridInfos[GridIndex(candidateCell)];
+ var abstractCellAdjacent = gridInfoAdjacent.AbstractCellForLocalCell(candidateCell);
+ if (abstractCellAdjacent == null)
+ continue;
+
+ abstractEdges.Add((abstractCell.Value, abstractCellAdjacent.Value));
+ }
+ }
+ }
+ }
+ }
+
+ // Searches all cells within a layer.
+ // Checks for the local cell if we can traverse from/to a custom movement layer.
+ // Builds connections in the abstract graph when any local cells have connections.
+ void AddAbstractCustomLayerEdges()
+ {
+ var gridCml = customMovementLayers[gridLayer];
+ for (byte candidateLayer = 0; candidateLayer < customMovementLayers.Length; candidateLayer++)
+ {
+ if (gridLayer == candidateLayer)
+ continue;
+
+ var candidateCml = customMovementLayers[candidateLayer];
+ if (candidateLayer != 0 && (candidateCml == null || !candidateCml.EnabledForLocomotor(locomotor.Info)))
+ continue;
+
+ for (var y = gridY; y < gridY + GridSize; y++)
+ {
+ for (var x = gridX; x < gridX + GridSize; x++)
+ {
+ var cell = new CPos(x, y, gridLayer);
+ if (locomotor.MovementCostForCell(cell) == PathGraph.MovementCostForUnreachableCell)
+ continue;
+
+ CPos candidateCell;
+ if (gridLayer == 0)
+ {
+ candidateCell = new CPos(cell.X, cell.Y, candidateLayer);
+ if (candidateCml.EntryMovementCost(locomotor.Info, candidateCell) == PathGraph.MovementCostForUnreachableCell)
+ continue;
+ }
+ else
+ {
+ candidateCell = new CPos(cell.X, cell.Y, 0);
+ if (gridCml.ExitMovementCost(locomotor.Info, candidateCell) == PathGraph.MovementCostForUnreachableCell)
+ continue;
+ }
+
+ if (locomotor.MovementCostToEnterCell(null, cell, candidateCell, BlockedByActor.None, null) ==
+ PathGraph.MovementCostForUnreachableCell)
+ continue;
+
+ var gridInfo = gridInfos[GridIndex(cell)];
+ var abstractCell = gridInfo.AbstractCellForLocalCell(cell);
+ if (abstractCell == null)
+ continue;
+
+ var gridInfoAdjacent = gridInfos[GridIndex(candidateCell)];
+ var abstractCellAdjacent = gridInfoAdjacent.AbstractCellForLocalCell(candidateCell);
+ if (abstractCellAdjacent == null)
+ continue;
+
+ abstractEdges.Add((abstractCell.Value, abstractCellAdjacent.Value));
+ }
+ }
+ }
+ }
+
+ // Top, Left, Bottom, Right
+ AddAbstractEdges(1, GridSize, new CVec(0, -1), new int2(0, 0));
+ AddAbstractEdges(GridSize, 1, new CVec(-1, 0), new int2(0, 0));
+ AddAbstractEdges(1, GridSize, new CVec(0, 1), new int2(0, GridSize - 1));
+ AddAbstractEdges(GridSize, 1, new CVec(1, 0), new int2(GridSize - 1, 0));
+
+ AddAbstractCustomLayerEdges();
+ }
+
+ return abstractEdges
+ .GroupBy(edge => edge.Src)
+ .Select(group => KeyValuePair.Create(
+ group.Key,
+ group.Select(edge => new GraphConnection(edge.Dst, costEstimator(edge.Src, edge.Dst))).ToList()));
+ }
+
+ ///
+ /// When reachability changes for a cell, marks the grid it belongs to as out of date.
+ ///
+ void RequireCostRefreshInCell(CPos cell, short oldCost, short newCost)
+ {
+ // We don't care about the specific cost of the cell, just whether it is reachable or not.
+ // This is good because updating the table is expensive, so only having to update it when
+ // the reachability changes rather than for all costs changes saves us a lot of time.
+ if (oldCost == PathGraph.MovementCostForUnreachableCell ^ newCost == PathGraph.MovementCostForUnreachableCell)
+ dirtyGridIndexes.Add(GridIndex(cell));
+ }
+
+ int GridIndex(CPos cellInGrid)
+ {
+ return
+ (cellInGrid.Y - mapBounds.TopLeft.Y) / GridSize * gridXs +
+ (cellInGrid.X - mapBounds.TopLeft.X) / GridSize;
+ }
+
+ CPos GetGridTopLeft(int gridIndex, byte layer)
+ {
+ return new CPos(
+ gridIndex % gridXs * GridSize + mapBounds.TopLeft.X,
+ gridIndex / gridXs * GridSize + mapBounds.TopLeft.Y,
+ layer);
+ }
+
+ static CPos GetGridTopLeft(CPos cellInGrid, Grid mapBounds)
+ {
+ return new CPos(
+ ((cellInGrid.X - mapBounds.TopLeft.X) / GridSize * GridSize) + mapBounds.TopLeft.X,
+ ((cellInGrid.Y - mapBounds.TopLeft.Y) / GridSize * GridSize) + mapBounds.TopLeft.Y,
+ cellInGrid.Layer);
+ }
+
+ static Grid GetGrid(CPos cellInGrid, Grid mapBounds)
+ {
+ var gridTopLeft = GetGridTopLeft(cellInGrid, mapBounds);
+ var width = Math.Min(mapBounds.BottomRight.X - gridTopLeft.X, GridSize);
+ var height = Math.Min(mapBounds.BottomRight.Y - gridTopLeft.Y, GridSize);
+
+ return new Grid(
+ gridTopLeft,
+ gridTopLeft + new CVec(width, height),
+ true);
+ }
+
+ ///
+ /// Calculates a path for the actor from multiple possible sources to target, using a unidirectional search.
+ /// Returned path is *reversed* and given target to source.
+ /// The actor must use the same as this .
+ ///
+ public List FindPath(Actor self, IReadOnlyCollection sources, CPos target,
+ BlockedByActor check, int heuristicWeightPercentage, Func customCost,
+ Actor ignoreActor, bool laneBias)
+ {
+ if (costEstimator == null)
+ return PathFinder.NoPath;
+
+ RebuildDirtyGrids();
+
+ var targetAbstractCell = AbstractCellForLocalCell(target);
+ if (targetAbstractCell == null)
+ return PathFinder.NoPath;
+
+ var sourcesWithReachableNodes = new List(sources.Count);
+ var sourceEdges = new List(sources.Count);
+ foreach (var source in sources)
+ {
+ var sourceAbstractCell = AbstractCellForLocalCell(source);
+ if (sourceAbstractCell == null)
+ continue;
+
+ sourcesWithReachableNodes.Add(source);
+ var sourceEdge = EdgeFromLocalToAbstract(source, sourceAbstractCell.Value);
+ if (sourceEdge != null)
+ sourceEdges.Add(sourceEdge.Value);
+ }
+
+ if (sourcesWithReachableNodes.Count == 0)
+ return PathFinder.NoPath;
+
+ var targetEdge = EdgeFromLocalToAbstract(target, targetAbstractCell.Value);
+
+ // The new edges will be treated as bi-directional.
+ var fullGraph = new AbstractGraphWithInsertedEdges(abstractGraph, sourceEdges, targetEdge, costEstimator);
+
+ // Determine an abstract path to all sources, for use in a unidirectional search.
+ var estimatedSearchSize = (abstractGraph.Count + 2) / 8;
+ using (var reverseAbstractSearch = PathSearch.ToTargetCellOverGraph(
+ fullGraph.GetConnections, locomotor, target, target, estimatedSearchSize))
+ {
+ var sourcesWithPathableNodes = new List(sourcesWithReachableNodes.Count);
+ foreach (var source in sourcesWithReachableNodes)
+ {
+ reverseAbstractSearch.TargetPredicate = cell => cell == source;
+ if (reverseAbstractSearch.ExpandToTarget())
+ sourcesWithPathableNodes.Add(source);
+ }
+
+ if (sourcesWithPathableNodes.Count == 0)
+ return PathFinder.NoPath;
+
+ using (var fromSrc = GetLocalPathSearch(
+ self, sourcesWithPathableNodes, target, customCost, ignoreActor, check, laneBias, null, heuristicWeightPercentage,
+ heuristic: Heuristic(reverseAbstractSearch, estimatedSearchSize)))
+ return fromSrc.FindPath();
+ }
+ }
+
+ ///
+ /// Calculates a path for the actor from source to target, using a bidirectional search.
+ /// Returned path is *reversed* and given target to source.
+ /// The actor must use the same as this .
+ ///
+ public List FindPath(Actor self, CPos source, CPos target,
+ BlockedByActor check, int heuristicWeightPercentage, Func customCost,
+ Actor ignoreActor, bool laneBias)
+ {
+ if (costEstimator == null)
+ return PathFinder.NoPath;
+
+ // If the source and target are close, see if they can be reached locally.
+ // This avoids the cost of an abstract search unless we need one.
+ const int CloseGridDistance = 2;
+ if ((target - source).LengthSquared < GridSize * GridSize * CloseGridDistance * CloseGridDistance && source.Layer == target.Layer)
+ {
+ var gridToSearch = new Grid(
+ new CPos(
+ Math.Min(source.X, target.X) - GridSize / 2,
+ Math.Min(source.Y, target.Y) - GridSize / 2,
+ source.Layer),
+ new CPos(
+ Math.Max(source.X, target.X) + GridSize / 2,
+ Math.Max(source.Y, target.Y) + GridSize / 2,
+ source.Layer),
+ false);
+
+ List localPath;
+ using (var search = GetLocalPathSearch(
+ self, new[] { source }, target, customCost, ignoreActor, check, laneBias, gridToSearch, heuristicWeightPercentage))
+ localPath = search.FindPath();
+
+ if (localPath.Count > 0)
+ return localPath;
+ }
+
+ RebuildDirtyGrids();
+
+ var targetAbstractCell = AbstractCellForLocalCell(target);
+ if (targetAbstractCell == null)
+ return PathFinder.NoPath;
+ var sourceAbstractCell = AbstractCellForLocalCell(source);
+ if (sourceAbstractCell == null)
+ return PathFinder.NoPath;
+ var targetEdge = EdgeFromLocalToAbstract(target, targetAbstractCell.Value);
+ var sourceEdge = EdgeFromLocalToAbstract(source, sourceAbstractCell.Value);
+
+ // The new edges will be treated as bi-directional.
+ var fullGraph = new AbstractGraphWithInsertedEdges(
+ abstractGraph, sourceEdge != null ? new[] { sourceEdge.Value } : Array.Empty(), targetEdge, costEstimator);
+
+ // Determine an abstract path in both directions, for use in a bidirectional search.
+ var estimatedSearchSize = (abstractGraph.Count + 2) / 8;
+ using (var forwardAbstractSearch = PathSearch.ToTargetCellOverGraph(
+ fullGraph.GetConnections, locomotor, source, target, estimatedSearchSize))
+ {
+ if (!forwardAbstractSearch.ExpandToTarget())
+ return PathFinder.NoPath;
+
+ using (var reverseAbstractSearch = PathSearch.ToTargetCellOverGraph(
+ fullGraph.GetConnections, locomotor, target, source, estimatedSearchSize))
+ {
+ reverseAbstractSearch.ExpandToTarget();
+
+ using (var fromSrc = GetLocalPathSearch(
+ self, new[] { source }, target, customCost, ignoreActor, check, laneBias, null, heuristicWeightPercentage,
+ heuristic: Heuristic(reverseAbstractSearch, estimatedSearchSize)))
+ using (var fromDest = GetLocalPathSearch(
+ self, new[] { target }, source, customCost, ignoreActor, check, laneBias, null, heuristicWeightPercentage,
+ heuristic: Heuristic(forwardAbstractSearch, estimatedSearchSize),
+ inReverse: true))
+ return PathSearch.FindBidiPath(fromDest, fromSrc);
+ }
+ }
+ }
+
+ ///
+ /// The abstract graph can become out of date when reachability costs for terrain change.
+ /// When this occurs, we must rebuild any affected parts of the abstract graph so it remains correct.
+ ///
+ void RebuildDirtyGrids()
+ {
+ if (dirtyGridIndexes.Count == 0)
+ return;
+
+ var customMovementLayers = world.GetCustomMovementLayers();
+ foreach (var gridIndex in dirtyGridIndexes)
+ {
+ var oldGrid = gridInfos[gridIndex];
+ var gridTopLeft = GetGridTopLeft(gridIndex, 0);
+ gridInfos[gridIndex] = BuildGrid(gridTopLeft.X, gridTopLeft.Y, customMovementLayers);
+ RebuildCostTable(gridTopLeft.X, gridTopLeft.Y, oldGrid, customMovementLayers);
+ }
+
+ dirtyGridIndexes.Clear();
+ }
+
+ ///
+ /// Updates the abstract graph to account for changes in a specific grid. Any nodes and edges related to that
+ /// grid will be removed, new nodes and edges will be determined and then inserted into the graph.
+ ///
+ void RebuildCostTable(int gridX, int gridY, GridInfo oldGrid, ICustomMovementLayer[] customMovementLayers)
+ {
+ // For this grid, it is possible the abstract nodes have changed.
+ // Remove the old abstract nodes for this grid.
+ // This is important as GetAbstractEdgesForGrid will look at the *current* grids.
+ // So it won't be aware of any nodes that disappeared before we updated the grid.
+ var abstractNodes = new HashSet();
+ oldGrid.CopyAbstractCellsInto(abstractNodes);
+ foreach (var oldAbstractNode in abstractNodes)
+ abstractGraph.Remove(oldAbstractNode);
+ abstractNodes.Clear();
+
+ // Add new abstract edges for this grid, since we cleared out the old nodes everything should be new.
+ foreach (var edges in GetAbstractEdgesForGrid(gridX, gridY, customMovementLayers))
+ abstractGraph.Add(edges.Key, edges.Value);
+
+ foreach (var direction in CVec.Directions)
+ {
+ var adjacentGrid = new CPos(gridX, gridY) + GridSize * direction;
+ if (!mapBounds.Contains(adjacentGrid))
+ continue;
+
+ // For all adjacent grids, their abstract nodes will not have changed, but the connections may have done.
+ // Update the connections, and keep track of which nodes we have updated.
+ gridInfos[GridIndex(adjacentGrid)].CopyAbstractCellsInto(abstractNodes);
+ foreach (var edges in GetAbstractEdgesForGrid(adjacentGrid.X, adjacentGrid.Y, customMovementLayers))
+ {
+ abstractGraph[edges.Key] = edges.Value;
+ abstractNodes.Remove(edges.Key);
+ }
+
+ // If any nodes were left over they have no connections now, so we can remove them from the graph.
+ foreach (var unconnectedNode in abstractNodes)
+ abstractGraph.Remove(unconnectedNode);
+ abstractNodes.Clear();
+ }
+ }
+
+ ///
+ /// Maps a local cell to a abstract node in the graph.
+ /// Returns null when the local cell is unreachable.
+ ///
+ CPos? AbstractCellForLocalCell(CPos localCell) =>
+ gridInfos[GridIndex(localCell)].AbstractCellForLocalCell(localCell);
+
+ ///
+ /// Creates a from the to the .
+ /// Return null when no edge is required, because the cells match.
+ ///
+ GraphEdge? EdgeFromLocalToAbstract(CPos localCell, CPos abstractCell)
+ {
+ if (localCell == abstractCell)
+ return null;
+
+ return new GraphEdge(localCell, abstractCell, costEstimator(localCell, abstractCell));
+ }
+
+ ///
+ /// Uses the provided abstract search to provide an estimate of the distance remaining to the target
+ /// (the heuristic) for a local path search. The abstract search must run in the opposite direction to the
+ /// local search. So when searching from source to target, the abstract search must be from target to source.
+ ///
+ Func Heuristic(PathSearch abstractSearch, int estimatedSearchSize)
+ {
+ var nodeForCostLookup = new Dictionary(estimatedSearchSize);
+ var graph = (SparsePathGraph)abstractSearch.Graph;
+ return cell =>
+ {
+ // All cells searched by the heuristic are guaranteed to be reachable.
+ // So we don't need to handle an abstract cell lookup failing, or the search failing to expand.
+ // Cells added as initial starting points for the search are filtered out if they aren't reachable.
+ // The search only explores accessible cells from then on.
+ var gridInfo = gridInfos[GridIndex(cell)];
+ var abstractCell = gridInfo.AbstractCellForLocalCell(cell).Value;
+ var info = graph[abstractCell];
+
+ // Expand the abstract search if we have not visited the abstract cell.
+ if (info.Status == CellStatus.Unvisited)
+ {
+ abstractSearch.TargetPredicate = c => c == abstractCell;
+ if (!abstractSearch.ExpandToTarget())
+ throw new Exception("The abstract path should never be searched for an unreachable point.");
+ info = graph[abstractCell];
+ }
+
+ var abstractNode = info.PreviousNode;
+
+ // When transitioning between layers, the XY will be the same and only the layer changes.
+ // If we have transitioned layers then we need the next node
+ // along otherwise we're measuring from our current location.
+ if (abstractCell.Layer != abstractNode.Layer)
+ abstractNode = graph[abstractNode].PreviousNode;
+
+ // Now we have an abstract node to target, determine if there is one further along the path we can use.
+ // This will provide a better estimate. Cache these results as they are expensive to calculate.
+ if (!nodeForCostLookup.TryGetValue(abstractNode, out var abstractNodeForCost))
+ {
+ abstractNodeForCost = AbstractNodeForCost(graph, abstractCell, abstractNode);
+ nodeForCostLookup.Add(abstractNode, abstractNodeForCost);
+ }
+
+ var cost = graph[abstractNodeForCost].CostSoFar + costEstimator(cell, abstractNodeForCost);
+ return cost;
+ };
+ }
+
+ ///
+ /// Determines an abstract node further along the path which can be reached directly without deviating from the
+ /// abstract path from the abstract cell of the source location.
+ /// As this node can be reached directly we can target it instead
+ /// of the original node to provide a better cost estimate.
+ ///
+ CPos AbstractNodeForCost(SparsePathGraph graph, CPos abstractCell, CPos abstractNode)
+ {
+ // We currently have the next abstract node along our path.
+ // This means we can create a distance estimate for our current
+ // cell to this next abstract node, plus then the cost provided by the abstract path.
+ // | AC --- AN ---------------------------- D |
+ // This means the lowest cost estimate follows the abstract path.
+ // The abstract path becomes a sort of highway, units want to
+ // move to it as soon as possible and follow it.
+ // This causes issues when a unit can move in a straight line to the destination.
+ // Instead of moving in a straight line, they move to join the highway, travel the highway,
+ // then leave the highway at the other end.
+ // We can combat this by delaying them from joining the highway until the abstract paths turns.
+ // | AC ----------------- AN -------------- D |
+ // This means the unit will move in a direct path whilst it can, and only "join the highway"
+ // when the highway is going to navigate it around an obstacle.
+ // When the heuristic weight is >100%, this greatly improves the resulting path.
+ // As when the weight is higher we may never get a chance to check for the shorter direct path.
+ var abstractNodesAlongPath = new List();
+ while (true)
+ {
+ var previousAbstractNode = graph[abstractNode].PreviousNode;
+
+ // The whole abstract path has been travelled, can't go further.
+ if (previousAbstractNode == abstractNode)
+ break;
+
+ // Check if we can move directly to the new node whilst staying
+ // within the boundary of the abstract path so far.
+ var intersectsAllNodes = true;
+ abstractNodesAlongPath.Add(abstractNode);
+ foreach (var node in abstractNodesAlongPath)
+ {
+ if (!GetGrid(node, mapBounds).IntersectsLine(abstractCell, previousAbstractNode))
+ {
+ intersectsAllNodes = false;
+ break;
+ }
+ }
+
+ if (!intersectsAllNodes)
+ break;
+
+ abstractNode = previousAbstractNode;
+ }
+
+ return abstractNode;
+ }
+
+ PathSearch GetLocalPathSearch(
+ Actor self, IEnumerable srcs, CPos dst, Func customCost,
+ Actor ignoreActor, BlockedByActor check, bool laneBias, Grid? grid, int heuristicWeightPercentage,
+ Func heuristic = null,
+ bool inReverse = false)
+ {
+ return PathSearch.ToTargetCell(
+ world, locomotor, self, srcs, dst, check, heuristicWeightPercentage,
+ customCost, ignoreActor, laneBias, inReverse, heuristic, grid);
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/Pathfinder/IPathGraph.cs b/OpenRA.Mods.Common/Pathfinder/IPathGraph.cs
index b8a6a33c17..259de4b243 100644
--- a/OpenRA.Mods.Common/Pathfinder/IPathGraph.cs
+++ b/OpenRA.Mods.Common/Pathfinder/IPathGraph.cs
@@ -24,6 +24,9 @@ namespace OpenRA.Mods.Common.Pathfinder
///
/// Given a source node, returns connections to all reachable destination nodes with their cost.
///
+ /// PERF: Returns a rather than an as enumerating
+ /// this efficiently is important for pathfinding performance. Callers should interact with this as an
+ /// and not mutate the result.
List GetConnections(CPos source);
///
@@ -38,6 +41,37 @@ namespace OpenRA.Mods.Common.Pathfinder
public const short MovementCostForUnreachableCell = short.MaxValue;
}
+ ///
+ /// Represents a full edge in a graph, giving the cost to traverse between two nodes.
+ ///
+ public readonly struct GraphEdge
+ {
+ public readonly CPos Source;
+ public readonly CPos Destination;
+ public readonly int Cost;
+
+ public GraphEdge(CPos source, CPos destination, int cost)
+ {
+ if (source == destination)
+ throw new ArgumentException($"{nameof(source)} and {nameof(destination)} must refer to different cells");
+ if (cost < 0)
+ throw new ArgumentOutOfRangeException(nameof(cost), $"{nameof(cost)} cannot be negative");
+ if (cost == PathGraph.PathCostForInvalidPath)
+ throw new ArgumentOutOfRangeException(nameof(cost), $"{nameof(cost)} cannot be used for an unreachable path");
+
+ Source = source;
+ Destination = destination;
+ Cost = cost;
+ }
+
+ public GraphConnection ToConnection()
+ {
+ return new GraphConnection(Destination, Cost);
+ }
+
+ public override string ToString() => $"{Source} -> {Destination} = {Cost}";
+ }
+
///
/// Represents part of an edge in a graph, giving the cost to traverse to a node.
///
@@ -69,6 +103,11 @@ namespace OpenRA.Mods.Common.Pathfinder
Cost = cost;
}
+ public GraphEdge ToEdge(CPos source)
+ {
+ return new GraphEdge(source, Destination, Cost);
+ }
+
public override string ToString() => $"-> {Destination} = {Cost}";
}
}
diff --git a/OpenRA.Mods.Common/Pathfinder/PathSearch.cs b/OpenRA.Mods.Common/Pathfinder/PathSearch.cs
index f4a92f752e..4ba2e30455 100644
--- a/OpenRA.Mods.Common/Pathfinder/PathSearch.cs
+++ b/OpenRA.Mods.Common/Pathfinder/PathSearch.cs
@@ -20,12 +20,6 @@ namespace OpenRA.Mods.Common.Pathfinder
{
public sealed class PathSearch : IDisposable
{
- ///
- /// When searching for paths, use a default weight of 125% to reduce
- /// computation effort - even if this means paths may be sub-optimal.
- ///
- public const int DefaultHeuristicWeightPercentage = 125;
-
// PERF: Maintain a pool of layers used for paths searches for each world. These searches are performed often
// so we wish to avoid the high cost of initializing a new search space every time by reusing the old ones.
static readonly ConditionalWeakTable LayerPoolTable = new ConditionalWeakTable();
@@ -37,13 +31,14 @@ namespace OpenRA.Mods.Common.Pathfinder
}
public static PathSearch ToTargetCellByPredicate(
- World world, Locomotor locomotor, Actor self, IEnumerable froms, Func targetPredicate, BlockedByActor check,
+ World world, Locomotor locomotor, Actor self,
+ IEnumerable froms, Func targetPredicate, BlockedByActor check,
Func customCost = null,
Actor ignoreActor = null,
bool laneBias = true)
{
var graph = new MapPathGraph(LayerPoolForWorld(world), locomotor, self, world, check, customCost, ignoreActor, laneBias, false);
- var search = new PathSearch(graph, loc => 0, DefaultHeuristicWeightPercentage, targetPredicate);
+ var search = new PathSearch(graph, loc => 0, 0, targetPredicate);
foreach (var sl in froms)
if (world.Map.Contains(sl))
@@ -53,15 +48,20 @@ namespace OpenRA.Mods.Common.Pathfinder
}
public static PathSearch ToTargetCell(
- World world, Locomotor locomotor, Actor self, IEnumerable froms, CPos target, BlockedByActor check,
+ World world, Locomotor locomotor, Actor self,
+ IEnumerable froms, CPos target, BlockedByActor check, int heuristicWeightPercentage,
Func customCost = null,
Actor ignoreActor = null,
bool laneBias = true,
bool inReverse = false,
Func heuristic = null,
- int heuristicWeightPercentage = DefaultHeuristicWeightPercentage)
+ Grid? grid = null)
{
- var graph = new MapPathGraph(LayerPoolForWorld(world), locomotor, self, world, check, customCost, ignoreActor, laneBias, inReverse);
+ IPathGraph graph;
+ if (grid != null)
+ graph = new GridPathGraph(locomotor, self, world, check, customCost, ignoreActor, laneBias, inReverse, grid.Value);
+ else
+ graph = new MapPathGraph(LayerPoolForWorld(world), locomotor, self, world, check, customCost, ignoreActor, laneBias, inReverse);
heuristic = heuristic ?? DefaultCostEstimator(locomotor, target);
var search = new PathSearch(graph, heuristic, heuristicWeightPercentage, loc => loc == target);
@@ -73,6 +73,18 @@ namespace OpenRA.Mods.Common.Pathfinder
return search;
}
+ public static PathSearch ToTargetCellOverGraph(
+ Func> edges, Locomotor locomotor, CPos from, CPos target,
+ int estimatedSearchSize = 0)
+ {
+ var graph = new SparsePathGraph(edges, estimatedSearchSize);
+ var search = new PathSearch(graph, DefaultCostEstimator(locomotor, target), 100, loc => loc == target);
+
+ search.AddInitialCell(from);
+
+ return search;
+ }
+
///
/// Default: Diagonal distance heuristic. More information:
/// http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html
@@ -113,9 +125,9 @@ namespace OpenRA.Mods.Common.Pathfinder
}
public IPathGraph Graph { get; }
+ public Func TargetPredicate { get; set; }
readonly Func heuristic;
readonly int heuristicWeightPercentage;
- readonly Func targetPredicate;
readonly IPriorityQueue openQueue;
///
@@ -137,7 +149,7 @@ namespace OpenRA.Mods.Common.Pathfinder
Graph = graph;
this.heuristic = heuristic;
this.heuristicWeightPercentage = heuristicWeightPercentage;
- this.targetPredicate = targetPredicate;
+ TargetPredicate = targetPredicate;
openQueue = new PriorityQueue(GraphConnection.ConnectionCostComparer);
}
@@ -215,6 +227,30 @@ namespace OpenRA.Mods.Common.Pathfinder
return currentMinNode;
}
+ ///
+ /// Expands the path search until a path is found, and returns whether a path is found successfully.
+ ///
+ public bool ExpandToTarget()
+ {
+ while (CanExpand())
+ if (TargetPredicate(Expand()))
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Expands the path search over the whole search space.
+ /// Returns the cells that were visited during the search.
+ ///
+ public List ExpandAll()
+ {
+ var consideredCells = new List();
+ while (CanExpand())
+ consideredCells.Add(Expand());
+ return consideredCells;
+ }
+
///
/// Expands the path search until a path is found, and returns that path.
/// Returned path is *reversed* and given target to source.
@@ -224,7 +260,7 @@ namespace OpenRA.Mods.Common.Pathfinder
while (CanExpand())
{
var p = Expand();
- if (targetPredicate(p))
+ if (TargetPredicate(p))
return MakePath(Graph, p);
}
diff --git a/OpenRA.Mods.Common/Pathfinder/SparsePathGraph.cs b/OpenRA.Mods.Common/Pathfinder/SparsePathGraph.cs
new file mode 100644
index 0000000000..8b5e0d7334
--- /dev/null
+++ b/OpenRA.Mods.Common/Pathfinder/SparsePathGraph.cs
@@ -0,0 +1,53 @@
+#region Copyright & License Information
+/*
+ * Copyright 2007-2022 The OpenRA Developers (see AUTHORS)
+ * This file is part of OpenRA, which is free software. It is made
+ * available to you under the terms of the GNU General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version. For more
+ * information, see COPYING.
+ */
+#endregion
+
+using System;
+using System.Collections.Generic;
+
+namespace OpenRA.Mods.Common.Pathfinder
+{
+ ///
+ /// A sparse pathfinding graph that supports a search over provided cells.
+ /// This is a classic graph that supports an arbitrary graph of nodes and edges,
+ /// and does not require a dense grid of cells.
+ /// Costs and any desired connections to a
+ /// must be provided as input.
+ ///
+ sealed class SparsePathGraph : IPathGraph
+ {
+ readonly Func> edges;
+ readonly Dictionary info;
+
+ public SparsePathGraph(Func> edges, int estimatedSearchSize = 0)
+ {
+ this.edges = edges;
+ info = new Dictionary(estimatedSearchSize);
+ }
+
+ public List GetConnections(CPos position)
+ {
+ return edges(position) ?? new List();
+ }
+
+ public CellInfo this[CPos pos]
+ {
+ get
+ {
+ if (info.TryGetValue(pos, out var cellInfo))
+ return cellInfo;
+ return default;
+ }
+ set => info[pos] = value;
+ }
+
+ public void Dispose() { }
+ }
+}
diff --git a/OpenRA.Mods.Common/Traits/BotModules/HarvesterBotModule.cs b/OpenRA.Mods.Common/Traits/BotModules/HarvesterBotModule.cs
index 99da17cb9b..01b5f0a211 100644
--- a/OpenRA.Mods.Common/Traits/BotModules/HarvesterBotModule.cs
+++ b/OpenRA.Mods.Common/Traits/BotModules/HarvesterBotModule.cs
@@ -142,7 +142,7 @@ namespace OpenRA.Mods.Common.Traits
harv.Harvester.CanHarvestCell(cell) &&
claimLayer.CanClaimCell(actor, cell);
- var path = harv.Mobile.PathFinder.FindUnitPathToTargetCellByPredicate(
+ var path = harv.Mobile.PathFinder.FindPathToTargetCellByPredicate(
actor, new[] { actor.Location }, isValidResource, BlockedByActor.Stationary,
loc => world.FindActorsInCircle(world.Map.CenterOfCell(loc), Info.HarvesterEnemyAvoidanceRadius)
.Where(u => !u.IsDead && actor.Owner.RelationshipWith(u.Owner) == PlayerRelationship.Enemy)
diff --git a/OpenRA.Mods.Common/Traits/Harvester.cs b/OpenRA.Mods.Common/Traits/Harvester.cs
index 044df3f29f..163421cc52 100644
--- a/OpenRA.Mods.Common/Traits/Harvester.cs
+++ b/OpenRA.Mods.Common/Traits/Harvester.cs
@@ -193,7 +193,7 @@ namespace OpenRA.Mods.Common.Traits
}).ToLookup(r => r.Location);
// Start a search from each refinery's delivery location:
- var path = mobile.PathFinder.FindUnitPathToTargetCell(
+ var path = mobile.PathFinder.FindPathToTargetCell(
self, refineries.Select(r => r.Key), self.Location, BlockedByActor.None,
location =>
{
diff --git a/OpenRA.Mods.Common/Traits/Mobile.cs b/OpenRA.Mods.Common/Traits/Mobile.cs
index f5c5e8c2ee..f776b1703a 100644
--- a/OpenRA.Mods.Common/Traits/Mobile.cs
+++ b/OpenRA.Mods.Common/Traits/Mobile.cs
@@ -825,7 +825,7 @@ namespace OpenRA.Mods.Common.Traits
if (CanEnterCell(above))
return above;
- var path = PathFinder.FindUnitPathToTargetCellByPredicate(
+ var path = PathFinder.FindPathToTargetCellByPredicate(
self, new[] { self.Location }, loc => loc.Layer == 0 && CanEnterCell(loc), BlockedByActor.All);
if (path.Count > 0)
diff --git a/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs b/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs
new file mode 100644
index 0000000000..d7cf89014e
--- /dev/null
+++ b/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs
@@ -0,0 +1,137 @@
+#region Copyright & License Information
+/*
+ * Copyright 2007-2022 The OpenRA Developers (see AUTHORS)
+ * This file is part of OpenRA, which is free software. It is made
+ * available to you under the terms of the GNU General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version. For more
+ * information, see COPYING.
+ */
+#endregion
+
+using System.Collections.Generic;
+using System.Linq;
+using OpenRA.Graphics;
+using OpenRA.Mods.Common.Commands;
+using OpenRA.Mods.Common.Graphics;
+using OpenRA.Primitives;
+using OpenRA.Traits;
+
+namespace OpenRA.Mods.Common.Traits
+{
+ [TraitLocation(SystemActors.World)]
+ [Desc("Renders a debug overlay showing the abstract graph of the hierarchical pathfinder. Attach this to the world actor.")]
+ public class HierarchicalPathFinderOverlayInfo : TraitInfo, Requires
+ {
+ public readonly string Font = "TinyBold";
+ public readonly Color GroundLayerColor = Color.DarkOrange;
+ public readonly Color CustomLayerColor = Color.Blue;
+ public readonly Color GroundToCustomLayerColor = Color.Purple;
+ public readonly Color AbstractNodeColor = Color.Red;
+
+ public override object Create(ActorInitializer init) { return new HierarchicalPathFinderOverlay(this); }
+ }
+
+ public class HierarchicalPathFinderOverlay : IRenderAnnotations, IWorldLoaded, IChatCommand
+ {
+ const string CommandName = "hpf";
+ const string CommandDesc = "toggles the hierarchical pathfinder overlay.";
+
+ readonly HierarchicalPathFinderOverlayInfo info;
+ readonly SpriteFont font;
+
+ public bool Enabled { get; private set; }
+
+ ///
+ /// The Locomotor selected in the UI which the overlay will display.
+ /// If null, will show the overlays for the currently selected units.
+ ///
+ public Locomotor Locomotor { get; set; }
+
+ public HierarchicalPathFinderOverlay(HierarchicalPathFinderOverlayInfo info)
+ {
+ this.info = info;
+ font = Game.Renderer.Fonts[info.Font];
+ }
+
+ void IWorldLoaded.WorldLoaded(World w, WorldRenderer wr)
+ {
+ var console = w.WorldActor.TraitOrDefault();
+ var help = w.WorldActor.TraitOrDefault();
+
+ if (console == null || help == null)
+ return;
+
+ console.RegisterCommand(CommandName, this);
+ help.RegisterHelp(CommandName, CommandDesc);
+ }
+
+ void IChatCommand.InvokeCommand(string name, string arg)
+ {
+ if (name == CommandName)
+ Enabled ^= true;
+ }
+
+ IEnumerable IRenderAnnotations.RenderAnnotations(Actor self, WorldRenderer wr)
+ {
+ if (!Enabled)
+ yield break;
+
+ var pathFinder = self.Trait();
+ var visibleRegion = wr.Viewport.AllVisibleCells;
+ var locomotors = Locomotor == null
+ ? self.World.Selection.Actors
+ .Where(a => !a.Disposed)
+ .Select(a => a.TraitOrDefault()?.Locomotor)
+ .Where(l => l != null)
+ .Distinct()
+ : new[] { Locomotor };
+ foreach (var locomotor in locomotors)
+ {
+ var abstractGraph = pathFinder.GetOverlayDataForLocomotor(locomotor);
+
+ // Locomotor doesn't allow movement, nothing to display.
+ if (abstractGraph == null)
+ continue;
+
+ foreach (var connectionsFromOneNode in abstractGraph)
+ {
+ var nodeCell = connectionsFromOneNode.Key;
+ var srcUv = (PPos)nodeCell.ToMPos(self.World.Map);
+ foreach (var cost in connectionsFromOneNode.Value)
+ {
+ var destUv = (PPos)cost.Destination.ToMPos(self.World.Map);
+ if (!visibleRegion.Contains(destUv) && !visibleRegion.Contains(srcUv))
+ continue;
+
+ var connection = new WPos[]
+ {
+ self.World.Map.CenterOfSubCell(cost.Destination, SubCell.FullCell),
+ self.World.Map.CenterOfSubCell(nodeCell, SubCell.FullCell),
+ };
+
+ // Connections on the ground layer given in ground color.
+ // Connections on any custom layers given in custom color.
+ // Connections that allow a transition between layers given in transition color.
+ Color lineColor;
+ if (nodeCell.Layer == 0 && cost.Destination.Layer == 0)
+ lineColor = info.GroundLayerColor;
+ else if (nodeCell.Layer == cost.Destination.Layer)
+ lineColor = info.CustomLayerColor;
+ else
+ lineColor = info.GroundToCustomLayerColor;
+ yield return new TargetLineRenderable(connection, lineColor, 1, 2);
+
+ var centerCell = new CPos(
+ (cost.Destination.X + nodeCell.X) / 2,
+ (cost.Destination.Y + nodeCell.Y) / 2);
+ var centerPos = self.World.Map.CenterOfSubCell(centerCell, SubCell.FullCell);
+ yield return new TextAnnotationRenderable(font, centerPos, 0, lineColor, cost.Cost.ToString());
+ }
+ }
+ }
+ }
+
+ bool IRenderAnnotations.SpatiallyPartitionable => false;
+ }
+}
diff --git a/OpenRA.Mods.Common/Traits/World/PathFinder.cs b/OpenRA.Mods.Common/Traits/World/PathFinder.cs
index 26d57bb8c1..cdff4f46fd 100644
--- a/OpenRA.Mods.Common/Traits/World/PathFinder.cs
+++ b/OpenRA.Mods.Common/Traits/World/PathFinder.cs
@@ -12,6 +12,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using OpenRA.Graphics;
using OpenRA.Mods.Common.Pathfinder;
using OpenRA.Traits;
@@ -23,19 +24,39 @@ namespace OpenRA.Mods.Common.Traits
{
public override object Create(ActorInitializer init)
{
- return new PathFinder(init.World);
+ return new PathFinder(init.Self);
}
}
- public class PathFinder : IPathFinder
+ public class PathFinder : IPathFinder, IWorldLoaded
{
public static readonly List NoPath = new List(0);
- readonly World world;
+ ///
+ /// When searching for paths, use a default weight of 125% to reduce
+ /// computation effort - even if this means paths may be sub-optimal.
+ ///
+ const int DefaultHeuristicWeightPercentage = 125;
- public PathFinder(World world)
+ readonly World world;
+ Dictionary hierarchicalPathFindersByLocomotor;
+
+ public PathFinder(Actor self)
{
- this.world = world;
+ world = self.World;
+ }
+
+ public IReadOnlyDictionary> GetOverlayDataForLocomotor(Locomotor locomotor)
+ {
+ return hierarchicalPathFindersByLocomotor[locomotor].GetOverlayData();
+ }
+
+ public void WorldLoaded(World w, WorldRenderer wr)
+ {
+ // Requires ensures all Locomotors have been initialized.
+ hierarchicalPathFindersByLocomotor = w.WorldActor.TraitsImplementing().ToDictionary(
+ locomotor => locomotor,
+ locomotor => new HierarchicalPathFinder(world, locomotor));
}
///
@@ -48,7 +69,7 @@ namespace OpenRA.Mods.Common.Traits
/// as optimizations are possible for the single source case. Use searches from multiple source cells
/// sparingly.
///
- public List FindUnitPathToTargetCell(
+ public List FindPathToTargetCell(
Actor self, IEnumerable sources, CPos target, BlockedByActor check,
Func customCost = null,
Actor ignoreActor = null,
@@ -58,13 +79,13 @@ namespace OpenRA.Mods.Common.Traits
if (sourcesList.Count == 0)
return NoPath;
- var locomotor = GetLocomotor(self);
+ var locomotor = GetActorLocomotor(self);
// If the target cell is inaccessible, bail early.
var inaccessible =
!world.Map.Contains(target) ||
!locomotor.CanMoveFreelyInto(self, target, check, ignoreActor) ||
- (!(customCost is null) && customCost(target) == PathGraph.PathCostForInvalidPath);
+ (customCost != null && customCost(target) == PathGraph.PathCostForInvalidPath);
if (inaccessible)
return NoPath;
@@ -81,18 +102,14 @@ namespace OpenRA.Mods.Common.Traits
return new List(2) { target, source };
}
- // With one starting point, we can use a bidirectional search.
- using (var fromTarget = PathSearch.ToTargetCell(
- world, locomotor, self, new[] { target }, source, check, ignoreActor: ignoreActor))
- using (var fromSource = PathSearch.ToTargetCell(
- world, locomotor, self, new[] { source }, target, check, ignoreActor: ignoreActor, inReverse: true))
- return PathSearch.FindBidiPath(fromTarget, fromSource);
+ // Use a hierarchical path search, which performs a guided bidirectional search.
+ return hierarchicalPathFindersByLocomotor[locomotor].FindPath(
+ self, source, target, check, DefaultHeuristicWeightPercentage, customCost, ignoreActor, laneBias);
}
- // With multiple starting points, we can only use a unidirectional search.
- using (var search = PathSearch.ToTargetCell(
- world, locomotor, self, sourcesList, target, check, customCost, ignoreActor, laneBias))
- return search.FindPath();
+ // Use a hierarchical path search, which performs a guided unidirectional search.
+ return hierarchicalPathFindersByLocomotor[locomotor].FindPath(
+ self, sourcesList, target, check, DefaultHeuristicWeightPercentage, customCost, ignoreActor, laneBias);
}
///
@@ -101,10 +118,10 @@ namespace OpenRA.Mods.Common.Traits
/// The shortest path between a source and a discovered target is returned.
///
///
- /// Searches with this method are slower than due to the need to search for
+ /// Searches with this method are slower than due to the need to search for
/// and discover an acceptable target cell. Use this search sparingly.
///
- public List FindUnitPathToTargetCellByPredicate(
+ public List FindPathToTargetCellByPredicate(
Actor self, IEnumerable sources, Func targetPredicate, BlockedByActor check,
Func customCost = null,
Actor ignoreActor = null,
@@ -112,11 +129,11 @@ namespace OpenRA.Mods.Common.Traits
{
// With no pre-specified target location, we can only use a unidirectional search.
using (var search = PathSearch.ToTargetCellByPredicate(
- world, GetLocomotor(self), self, sources, targetPredicate, check, customCost, ignoreActor, laneBias))
+ world, GetActorLocomotor(self), self, sources, targetPredicate, check, customCost, ignoreActor, laneBias))
return search.FindPath();
}
- static Locomotor GetLocomotor(Actor self)
+ static Locomotor GetActorLocomotor(Actor self)
{
// PERF: This PathFinder trait requires the use of Mobile, so we can be sure that is in use.
// We can save some performance by avoiding querying for the Locomotor trait and retrieving it from Mobile.
diff --git a/OpenRA.Mods.Common/TraitsInterfaces.cs b/OpenRA.Mods.Common/TraitsInterfaces.cs
index 5d2536a3c8..785789e258 100644
--- a/OpenRA.Mods.Common/TraitsInterfaces.cs
+++ b/OpenRA.Mods.Common/TraitsInterfaces.cs
@@ -799,7 +799,7 @@ namespace OpenRA.Mods.Common.Traits
/// Returned path is *reversed* and given target to source.
/// The shortest path between a source and the target is returned.
///
- List FindUnitPathToTargetCell(
+ List FindPathToTargetCell(
Actor self, IEnumerable sources, CPos target, BlockedByActor check,
Func customCost = null,
Actor ignoreActor = null,
@@ -810,7 +810,7 @@ namespace OpenRA.Mods.Common.Traits
/// Returned path is *reversed* and given target to source.
/// The shortest path between a source and a discovered target is returned.
///
- List FindUnitPathToTargetCellByPredicate(
+ List FindPathToTargetCellByPredicate(
Actor self, IEnumerable sources, Func targetPredicate, BlockedByActor check,
Func customCost = null,
Actor ignoreActor = null,
diff --git a/OpenRA.Mods.Common/Widgets/Logic/Ingame/HierarchicalPathFinderOverlayLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Ingame/HierarchicalPathFinderOverlayLogic.cs
new file mode 100644
index 0000000000..a022b2ee31
--- /dev/null
+++ b/OpenRA.Mods.Common/Widgets/Logic/Ingame/HierarchicalPathFinderOverlayLogic.cs
@@ -0,0 +1,47 @@
+#region Copyright & License Information
+/*
+ * Copyright 2007-2022 The OpenRA Developers (see AUTHORS)
+ * This file is part of OpenRA, which is free software. It is made
+ * available to you under the terms of the GNU General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version. For more
+ * information, see COPYING.
+ */
+#endregion
+
+using System;
+using System.Linq;
+using OpenRA.Mods.Common.Traits;
+using OpenRA.Widgets;
+
+namespace OpenRA.Mods.Common.Widgets.Logic
+{
+ public class HierarchicalPathFinderOverlayLogic : ChromeLogic
+ {
+ [ObjectCreator.UseCtor]
+ public HierarchicalPathFinderOverlayLogic(Widget widget, World world)
+ {
+ var hpfOverlay = world.WorldActor.Trait();
+ widget.IsVisible = () => hpfOverlay.Enabled;
+
+ var locomotors = new Locomotor[] { null }.Concat(
+ world.WorldActor.TraitsImplementing().OrderBy(l => l.Info.Name))
+ .ToArray();
+ var locomotorSelector = widget.Get("HPF_OVERLAY_LOCOMOTOR");
+ locomotorSelector.OnMouseDown = _ =>
+ {
+ Func setupItem = (option, template) =>
+ {
+ var item = ScrollItemWidget.Setup(
+ template,
+ () => hpfOverlay.Locomotor == option,
+ () => hpfOverlay.Locomotor = option);
+ item.Get("LABEL").GetText = () => option?.Info.Name ?? "(Selected Units)";
+ return item;
+ };
+
+ locomotorSelector.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", locomotors.Length * 30, locomotors, setupItem);
+ };
+ }
+ }
+}
diff --git a/mods/cnc/chrome/ingame.yaml b/mods/cnc/chrome/ingame.yaml
index 59ae68267f..95e920a893 100644
--- a/mods/cnc/chrome/ingame.yaml
+++ b/mods/cnc/chrome/ingame.yaml
@@ -1860,6 +1860,18 @@ Container@PLAYER_WIDGETS:
Y: 299
Width: 194
Height: 20
+ Container@HPF_OVERLAY:
+ Logic: HierarchicalPathFinderOverlayLogic
+ X: WINDOW_RIGHT - WIDTH - 240
+ Y: 40
+ Width: 150
+ Height: 25
+ Children:
+ DropDownButton@HPF_OVERLAY_LOCOMOTOR:
+ Width: PARENT_RIGHT
+ Height: PARENT_BOTTOM
+ Text: Select Locomotor
+ Font: Regular
Background@FMVPLAYER:
Width: WINDOW_RIGHT
diff --git a/mods/cnc/rules/world.yaml b/mods/cnc/rules/world.yaml
index 61699bcee3..f65c682107 100644
--- a/mods/cnc/rules/world.yaml
+++ b/mods/cnc/rules/world.yaml
@@ -153,6 +153,7 @@ World:
ChatCommands:
DevCommands:
DebugVisualizationCommands:
+ HierarchicalPathFinderOverlay:
PlayerCommands:
HelpCommand:
ScreenShaker:
diff --git a/mods/d2k/chrome/ingame-player.yaml b/mods/d2k/chrome/ingame-player.yaml
index 2b1634935c..c6532e20f8 100644
--- a/mods/d2k/chrome/ingame-player.yaml
+++ b/mods/d2k/chrome/ingame-player.yaml
@@ -641,3 +641,15 @@ Container@PLAYER_WIDGETS:
Y: 5
ImageCollection: scrollpanel-decorations
ImageName: down
+ Container@HPF_OVERLAY:
+ Logic: HierarchicalPathFinderOverlayLogic
+ X: WINDOW_RIGHT - WIDTH - 231
+ Y: 40
+ Width: 150
+ Height: 25
+ Children:
+ DropDownButton@HPF_OVERLAY_LOCOMOTOR:
+ Width: PARENT_RIGHT
+ Height: PARENT_BOTTOM
+ Text: Select Locomotor
+ Font: Regular
diff --git a/mods/d2k/rules/world.yaml b/mods/d2k/rules/world.yaml
index ebe873cdff..a68fc70ba5 100644
--- a/mods/d2k/rules/world.yaml
+++ b/mods/d2k/rules/world.yaml
@@ -120,6 +120,7 @@ World:
ChatCommands:
DevCommands:
DebugVisualizationCommands:
+ HierarchicalPathFinderOverlay:
PlayerCommands:
HelpCommand:
ScreenShaker:
diff --git a/mods/ra/chrome/ingame-player.yaml b/mods/ra/chrome/ingame-player.yaml
index c0b16945ab..0894249fa9 100644
--- a/mods/ra/chrome/ingame-player.yaml
+++ b/mods/ra/chrome/ingame-player.yaml
@@ -649,3 +649,16 @@ Container@PLAYER_WIDGETS:
Y: 4
ImageCollection: power-icons
ImageName: power-normal
+ Container@HPF_OVERLAY:
+ Logic: HierarchicalPathFinderOverlayLogic
+ X: WINDOW_RIGHT - WIDTH - 260
+ Y: 40
+ Width: 150
+ Height: 25
+ Children:
+ DropDownButton@HPF_OVERLAY_LOCOMOTOR:
+ Width: PARENT_RIGHT
+ Height: PARENT_BOTTOM
+ Text: Select Locomotor
+ Font: Regular
+
diff --git a/mods/ra/rules/world.yaml b/mods/ra/rules/world.yaml
index 192c236ce0..3f1f03e732 100644
--- a/mods/ra/rules/world.yaml
+++ b/mods/ra/rules/world.yaml
@@ -165,6 +165,7 @@ World:
ChatCommands:
DevCommands:
DebugVisualizationCommands:
+ HierarchicalPathFinderOverlay:
PlayerCommands:
HelpCommand:
ScreenShaker:
diff --git a/mods/ts/chrome/ingame-player.yaml b/mods/ts/chrome/ingame-player.yaml
index 357eeca592..202048864c 100644
--- a/mods/ts/chrome/ingame-player.yaml
+++ b/mods/ts/chrome/ingame-player.yaml
@@ -619,3 +619,15 @@ Container@PLAYER_WIDGETS:
Background: scrolldown-buttons
TooltipText: Scroll down
TooltipContainer: TOOLTIP_CONTAINER
+ Container@HPF_OVERLAY:
+ Logic: HierarchicalPathFinderOverlayLogic
+ X: WINDOW_RIGHT - WIDTH - 245
+ Y: 40
+ Width: 150
+ Height: 25
+ Children:
+ DropDownButton@HPF_OVERLAY_LOCOMOTOR:
+ Width: PARENT_RIGHT
+ Height: PARENT_BOTTOM
+ Text: Select Locomotor
+ Font: Regular
diff --git a/mods/ts/rules/world.yaml b/mods/ts/rules/world.yaml
index ccc64bded8..45267b928e 100644
--- a/mods/ts/rules/world.yaml
+++ b/mods/ts/rules/world.yaml
@@ -235,6 +235,7 @@ World:
ChatCommands:
DevCommands:
DebugVisualizationCommands:
+ HierarchicalPathFinderOverlay:
PlayerCommands:
HelpCommand:
BuildingInfluence: