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: