Initial commit: OpenRA game engine
Some checks failed
Continuous Integration / Linux (.NET 8.0) (push) Has been cancelled
Continuous Integration / Windows (.NET 8.0) (push) Has been cancelled

Fork from OpenRA/OpenRA with one-click launch script (start-ra.cmd)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
let5sne.win10
2026-01-10 21:46:54 +08:00
commit 9cf6ebb986
4065 changed files with 635973 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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
{
/// <summary>
/// Describes the three states that a node in the graph can have.
/// Based on A* algorithm specification.
/// </summary>
public enum CellStatus : byte
{
Unvisited,
Open,
Closed
}
/// <summary>
/// Stores information about nodes in the pathfinding graph.
/// The default value of this struct represents an <see cref="CellStatus.Unvisited"/> location.
/// </summary>
public readonly struct CellInfo
{
/// <summary>
/// The status of this node. Accessing other fields is only valid when the status is not <see cref="CellStatus.Unvisited"/>.
/// </summary>
public readonly CellStatus Status;
/// <summary>
/// The cost to move from the start up to this node.
/// </summary>
public readonly int CostSoFar;
/// <summary>
/// The estimation of how far this node is from our target.
/// </summary>
public readonly int EstimatedTotalCost;
/// <summary>
/// The previous node of this one that follows the shortest path.
/// </summary>
public readonly CPos PreviousNode;
public CellInfo(CellStatus status, int costSoFar, int estimatedTotalCost, CPos previousNode)
{
if (status == CellStatus.Unvisited)
throw new ArgumentException(
$"The default {nameof(CellInfo)} is the only such {nameof(CellInfo)} allowed for representing an {nameof(CellStatus.Unvisited)} location.",
nameof(status));
Status = status;
CostSoFar = costSoFar;
EstimatedTotalCost = estimatedTotalCost;
PreviousNode = previousNode;
}
public override string ToString()
{
if (Status == CellStatus.Unvisited)
return Status.ToString();
return
$"{Status} {nameof(CostSoFar)}={CostSoFar} " +
$"{nameof(EstimatedTotalCost)}={EstimatedTotalCost} {nameof(PreviousNode)}={PreviousNode}";
}
}
}

View File

@@ -0,0 +1,86 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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
{
sealed class CellInfoLayerPool
{
const int MaxPoolSize = 4;
readonly Stack<CellLayer<CellInfo>> pool = new(MaxPoolSize);
readonly Map map;
public CellInfoLayerPool(Map map)
{
this.map = map;
}
public PooledCellInfoLayer Get()
{
return new PooledCellInfoLayer(this);
}
CellLayer<CellInfo> GetLayer()
{
CellLayer<CellInfo> layer = null;
lock (pool)
if (pool.Count > 0)
layer = pool.Pop();
// As the default value of CellInfo represents an Unvisited location,
// we don't need to initialize the values in the layer,
// we can just clear them to the defaults.
if (layer == null)
layer = new CellLayer<CellInfo>(map);
else
layer.Clear();
return layer;
}
void ReturnLayer(CellLayer<CellInfo> layer)
{
lock (pool)
if (pool.Count < MaxPoolSize)
pool.Push(layer);
}
public sealed class PooledCellInfoLayer : IDisposable
{
CellInfoLayerPool layerPool;
List<CellLayer<CellInfo>> layers = [];
public PooledCellInfoLayer(CellInfoLayerPool layerPool)
{
this.layerPool = layerPool;
}
public CellLayer<CellInfo> GetLayer()
{
var layer = layerPool.GetLayer();
layers.Add(layer);
return layer;
}
public void Dispose()
{
if (layerPool != null)
foreach (var layer in layers)
layerPool.ReturnLayer(layer);
layers = null;
layerPool = null;
}
}
}
}

View File

@@ -0,0 +1,235 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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.Linq;
using OpenRA.Mods.Common.Traits;
namespace OpenRA.Mods.Common.Pathfinder
{
/// <summary>
/// A dense pathfinding graph that implements the ability to cost and get connections for cells,
/// and supports <see cref="ICustomMovementLayer"/>. Allows searching over a dense grid of cells.
/// Derived classes are required to provide backing storage for the pathfinding information.
/// </summary>
abstract class DensePathGraph : IPathGraph
{
const int LaneBiasCost = 1;
protected readonly ICustomMovementLayer[] CustomMovementLayers;
readonly int customMovementLayersEnabledForLocomotor;
readonly Locomotor locomotor;
readonly Actor actor;
readonly World world;
readonly BlockedByActor check;
readonly Func<CPos, int> customCost;
readonly Actor ignoreActor;
readonly bool laneBias;
readonly bool inReverse;
readonly bool checkTerrainHeight;
protected DensePathGraph(Locomotor locomotor, Actor actor, World world, BlockedByActor check,
Func<CPos, int> customCost, Actor ignoreActor, bool laneBias, bool inReverse)
{
CustomMovementLayers = world.GetCustomMovementLayers();
customMovementLayersEnabledForLocomotor = CustomMovementLayers.Count(cml => cml != null && cml.EnabledForLocomotor(locomotor.Info));
this.locomotor = locomotor;
this.world = world;
this.actor = actor;
this.check = check;
this.customCost = customCost;
this.ignoreActor = ignoreActor;
this.laneBias = laneBias;
this.inReverse = inReverse;
checkTerrainHeight = world.Map.Grid.MaximumTerrainHeight > 0;
}
public abstract CellInfo this[CPos node] { get; set; }
/// <summary>
/// Determines if a candidate neighbouring position is
/// allowable to be returned in a <see cref="GraphConnection"/>.
/// </summary>
/// <param name="neighbor">The candidate cell. This might not lie within map bounds.</param>
protected virtual bool IsValidNeighbor(CPos neighbor)
{
return true;
}
// Sets of neighbors for each incoming direction. These exclude the neighbors which are guaranteed
// to be reached more cheaply by a path through our parent cell which does not include the current cell.
// For horizontal/vertical directions, the set is the three cells 'ahead'. For diagonal directions, the set
// is the three cells ahead, plus the two cells to the side. Effectively, these are the cells left over
// if you ignore the ones reachable from the parent cell.
// We can do this because for any cell in range of both the current and parent location,
// if we can reach it from one we are guaranteed to be able to reach it from the other.
static readonly CVec[][] DirectedNeighbors =
[
[new CVec(-1, -1), new CVec(0, -1), new CVec(1, -1), new CVec(-1, 0), new CVec(-1, 1)], // TL
[new CVec(-1, -1), new CVec(0, -1), new CVec(1, -1)], // T
[new CVec(-1, -1), new CVec(0, -1), new CVec(1, -1), new CVec(1, 0), new CVec(1, 1)], // TR
[new CVec(-1, -1), new CVec(-1, 0), new CVec(-1, 1)], // L
CVec.Directions,
[new CVec(1, -1), new CVec(1, 0), new CVec(1, 1)], // R
[new CVec(-1, -1), new CVec(-1, 0), new CVec(-1, 1), new CVec(0, 1), new CVec(1, 1)], // BL
[new CVec(-1, 1), new CVec(0, 1), new CVec(1, 1)], // B
[new CVec(1, -1), new CVec(1, 0), new CVec(-1, 1), new CVec(0, 1), new CVec(1, 1)], // BR
];
// With height discontinuities between the parent and current cell, we cannot optimize the possible neighbors.
// It is no longer true that for any cell in range of both the current and parent location,
// if we can reach it from one we are guaranteed to be able to reach it from the other.
// This is because a height discontinuity may have prevented the parent location from reaching,
// but our current cell on a new height may be able to reach as the height difference may be small enough.
// Therefore, we can only exclude the parent cell in each set of directions.
static readonly CVec[][] DirectedNeighborsConservative =
[
CVec.Directions.Exclude(new CVec(1, 1)).ToArray(), // TL
CVec.Directions.Exclude(new CVec(0, 1)).ToArray(), // T
CVec.Directions.Exclude(new CVec(-1, 1)).ToArray(), // TR
CVec.Directions.Exclude(new CVec(1, 0)).ToArray(), // L
CVec.Directions,
CVec.Directions.Exclude(new CVec(-1, 0)).ToArray(), // R
CVec.Directions.Exclude(new CVec(1, -1)).ToArray(), // BL
CVec.Directions.Exclude(new CVec(0, -1)).ToArray(), // B
CVec.Directions.Exclude(new CVec(-1, -1)).ToArray(), // BR
];
public List<GraphConnection> GetConnections(CPos position, Func<CPos, bool> targetPredicate)
{
var layer = position.Layer;
var info = this[position];
var previousNode = info.PreviousNode;
var dx = position.X - previousNode.X;
var dy = position.Y - previousNode.Y;
var index = dy * 3 + dx + 4;
var heightLayer = world.Map.Height;
var directions =
(checkTerrainHeight && layer == 0 && previousNode.Layer == 0 && heightLayer[position] != heightLayer[previousNode]
? DirectedNeighborsConservative
: DirectedNeighbors)[index];
var validNeighbors = new List<GraphConnection>(directions.Length + (layer == 0 ? customMovementLayersEnabledForLocomotor : 1));
for (var i = 0; i < directions.Length; i++)
{
var dir = directions[i];
var neighbor = position + dir;
if (!IsValidNeighbor(neighbor))
continue;
var pathCost = GetPathCostToNode(position, neighbor, dir, targetPredicate);
if (pathCost != PathGraph.PathCostForInvalidPath &&
this[neighbor].Status != CellStatus.Closed)
validNeighbors.Add(new GraphConnection(neighbor, pathCost));
}
if (layer == 0)
{
if (customMovementLayersEnabledForLocomotor > 0)
{
foreach (var cml in CustomMovementLayers)
{
if (cml == null || !cml.EnabledForLocomotor(locomotor.Info))
continue;
var layerPosition = new CPos(position.X, position.Y, cml.Index);
if (!IsValidNeighbor(layerPosition))
continue;
var entryCost = cml.EntryMovementCost(locomotor.Info, layerPosition);
if (entryCost != PathGraph.MovementCostForUnreachableCell &&
CanEnterNode(position, layerPosition, targetPredicate) &&
this[layerPosition].Status != CellStatus.Closed)
validNeighbors.Add(new GraphConnection(layerPosition, entryCost));
}
}
}
else
{
var groundPosition = new CPos(position.X, position.Y, 0);
if (IsValidNeighbor(groundPosition))
{
var exitCost = CustomMovementLayers[layer].ExitMovementCost(locomotor.Info, groundPosition);
if (exitCost != PathGraph.MovementCostForUnreachableCell &&
CanEnterNode(position, groundPosition, targetPredicate) &&
this[groundPosition].Status != CellStatus.Closed)
validNeighbors.Add(new GraphConnection(groundPosition, exitCost));
}
}
return validNeighbors;
}
bool CanEnterNode(CPos srcNode, CPos destNode, Func<CPos, bool> targetPredicate)
{
return
locomotor.MovementCostToEnterCell(actor, srcNode, destNode, check, ignoreActor)
!= PathGraph.MovementCostForUnreachableCell ||
(inReverse && targetPredicate(destNode));
}
int GetPathCostToNode(CPos srcNode, CPos destNode, CVec direction, Func<CPos, bool> targetPredicate)
{
var movementCost = locomotor.MovementCostToEnterCell(actor, srcNode, destNode, check, ignoreActor);
// When doing searches in reverse, we must allow movement onto an inaccessible target location.
// Because when reversed this is actually the source, and it is allowed to move out from an inaccessible source.
if (movementCost == PathGraph.MovementCostForUnreachableCell && inReverse && targetPredicate(destNode))
movementCost = 0;
if (movementCost != PathGraph.MovementCostForUnreachableCell)
return CalculateCellPathCost(destNode, direction, movementCost);
return PathGraph.PathCostForInvalidPath;
}
int CalculateCellPathCost(CPos neighborCPos, CVec direction, short movementCost)
{
var cellCost = direction.X * direction.Y != 0
? Exts.MultiplyBySqrtTwo(movementCost)
: movementCost;
if (customCost != null)
{
var customCellCost = customCost(neighborCPos);
if (customCellCost == PathGraph.PathCostForInvalidPath)
return PathGraph.PathCostForInvalidPath;
cellCost += customCellCost;
}
// Directional bonuses for smoother flow!
if (laneBias)
{
var ux = neighborCPos.X + (inReverse ? 1 : 0) & 1;
var uy = neighborCPos.Y + (inReverse ? 1 : 0) & 1;
if ((ux == 0 && direction.Y < 0) || (ux == 1 && direction.Y > 0))
cellCost += LaneBiasCost;
if ((uy == 0 && direction.X < 0) || (uy == 1 && direction.X > 0))
cellCost += LaneBiasCost;
}
return cellCost;
}
protected virtual void Dispose(bool disposing) { }
public void Dispose()
{
Dispose(true);
}
}
}

View File

@@ -0,0 +1,94 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This means in <see cref="MapGridType.RectangularIsometric"/> some cells within a grid may lay off the map.
/// Contrast this with <see cref="CellRegion"/> 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.
/// </remarks>
public readonly struct Grid
{
/// <summary>
/// Inclusive.
/// </summary>
public readonly CPos TopLeft;
/// <summary>
/// Exclusive.
/// </summary>
public readonly CPos BottomRight;
/// <summary>
/// When true, the grid spans only the single layer given by the cells. When false, it spans all layers.
/// </summary>
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;
/// <summary>
/// Checks if the cell X and Y lie within the grid bounds. The cell layer must also match.
/// </summary>
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);
}
/// <summary>
/// Checks if the line segment from <paramref name="start"/> to <paramref name="end"/>
/// 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.
/// </summary>
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}";
}
}
}

View File

@@ -0,0 +1,54 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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
{
/// <summary>
/// A dense pathfinding graph that supports a search over all cells within a <see cref="Grid"/>.
/// 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 <see cref="ICustomMovementLayer"/>.
/// </summary>
sealed class GridPathGraph : DensePathGraph
{
readonly CellInfo[] infos;
readonly Grid grid;
public GridPathGraph(Locomotor locomotor, Actor actor, World world, BlockedByActor check,
Func<CPos, int> 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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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
{
/// <summary>
/// Represents a pathfinding graph with nodes and edges.
/// Nodes are represented as cells, and pathfinding information
/// in the form of <see cref="CellInfo"/> is attached to each one.
/// </summary>
public interface IPathGraph : IDisposable
{
/// <summary>
/// Given a source node, returns connections to all reachable destination nodes with their cost.
/// </summary>
/// <remarks>PERF: Returns a <see cref="List{T}"/> rather than an <see cref="IEnumerable{T}"/> as enumerating
/// this efficiently is important for pathfinding performance. Callers should interact with this as an
/// <see cref="IEnumerable{T}"/> and not mutate the result.</remarks>
List<GraphConnection> GetConnections(CPos source, Func<CPos, bool> targetPredicate);
/// <summary>
/// Gets or sets the pathfinding information for a given node.
/// </summary>
CellInfo this[CPos node] { get; set; }
}
public static class PathGraph
{
public const int PathCostForInvalidPath = int.MaxValue;
public const short MovementCostForUnreachableCell = short.MaxValue;
}
/// <summary>
/// Represents a full edge in a graph, giving the cost to traverse between two nodes.
/// </summary>
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}";
}
/// <summary>
/// Represents part of an edge in a graph, giving the cost to traverse to a node.
/// </summary>
public readonly struct GraphConnection
{
public readonly struct CostComparer : IComparer<GraphConnection>
{
public int Compare(GraphConnection x, GraphConnection y)
{
return x.Cost.CompareTo(y.Cost);
}
}
public readonly CPos Destination;
public readonly int Cost;
public GraphConnection(CPos destination, int cost)
{
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");
Destination = destination;
Cost = cost;
}
public GraphEdge ToEdge(CPos source)
{
return new GraphEdge(source, Destination, Cost);
}
public override string ToString() => $"-> {Destination} = {Cost}";
}
}

View File

@@ -0,0 +1,56 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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
{
/// <summary>
/// A dense pathfinding graph that supports a search over all cells within a map.
/// It implements the ability to cost and get connections for cells, and supports <see cref="ICustomMovementLayer"/>.
/// </summary>
sealed class MapPathGraph : DensePathGraph
{
readonly CellInfoLayerPool.PooledCellInfoLayer pooledLayer;
readonly CellLayer<CellInfo>[] cellInfoForLayer;
public MapPathGraph(CellInfoLayerPool layerPool, Locomotor locomotor, Actor actor, World world, BlockedByActor check,
Func<CPos, int> customCost, Actor ignoreActor, bool laneBias, bool inReverse)
: base(locomotor, actor, world, check, customCost, ignoreActor, laneBias, inReverse)
{
// As we support a search over the whole map area,
// use the pool to grab the CellInfos we need to track the graph state.
// This allows us to avoid the cost of allocating large arrays constantly.
// PERF: Avoid LINQ
pooledLayer = layerPool.Get();
cellInfoForLayer = new CellLayer<CellInfo>[CustomMovementLayers.Length];
cellInfoForLayer[0] = pooledLayer.GetLayer();
foreach (var cml in CustomMovementLayers)
if (cml != null && cml.EnabledForLocomotor(locomotor.Info))
cellInfoForLayer[cml.Index] = pooledLayer.GetLayer();
}
public override CellInfo this[CPos pos]
{
get => cellInfoForLayer[pos.Layer][pos];
set => cellInfoForLayer[pos.Layer][pos] = value;
}
protected override void Dispose(bool disposing)
{
if (disposing)
pooledLayer.Dispose();
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,421 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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.Linq;
using System.Runtime.CompilerServices;
using OpenRA.Mods.Common.Traits;
using OpenRA.Primitives;
namespace OpenRA.Mods.Common.Pathfinder
{
public sealed class PathSearch : IDisposable
{
public interface IRecorder
{
void Add(CPos source, CPos destination, int costSoFar, int estimatedRemainingCost);
}
// 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<World, CellInfoLayerPool> LayerPoolTable = [];
static readonly ConditionalWeakTable<World, CellInfoLayerPool>.CreateValueCallback CreateLayerPool = world => new CellInfoLayerPool(world.Map);
static CellInfoLayerPool LayerPoolForWorld(World world)
{
return LayerPoolTable.GetValue(world, CreateLayerPool);
}
public static PathSearch ToTargetCellByPredicate(
World world, Locomotor locomotor, Actor self,
IEnumerable<CPos> froms, Func<CPos, bool> targetPredicate, BlockedByActor check,
Func<CPos, int> customCost = null,
Actor ignoreActor = null,
bool laneBias = true,
IRecorder recorder = null)
{
var graph = new MapPathGraph(LayerPoolForWorld(world), locomotor, self, world, check, customCost, ignoreActor, laneBias, false);
var search = new PathSearch(graph, (_, _) => 0, 0, targetPredicate, recorder);
AddInitialCells(world, locomotor, self, froms, check, customCost, ignoreActor, false, search);
return search;
}
public static PathSearch ToTargetCell(
World world, Locomotor locomotor, Actor self,
IEnumerable<CPos> froms, CPos target, BlockedByActor check, int heuristicWeightPercentage,
Func<CPos, int> customCost = null,
Actor ignoreActor = null,
bool laneBias = true,
bool inReverse = false,
Func<CPos, bool, int> heuristic = null,
Grid? grid = null,
IRecorder recorder = null)
{
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 ??= DefaultCostEstimator(locomotor, target);
var search = new PathSearch(graph, heuristic, heuristicWeightPercentage, loc => loc == target, recorder);
AddInitialCells(world, locomotor, self, froms, check, customCost, ignoreActor, inReverse, search);
return search;
}
/// <summary>
/// Determines if a cell is a valid pathfinding location.
/// <list type="bullet">
/// <item>It is in the world.</item>
/// <item>It is either on the ground layer (0) or on an *enabled* custom movement layer.</item>
/// <item>It has not been excluded by the <paramref name="customCost"/>.</item>
/// </list>
/// If required, follow this with a call to
/// <see cref="Locomotor.MovementCostToEnterCell(Actor, CPos, CPos, BlockedByActor, Actor, bool)"/> to
/// determine if the cell is accessible.
/// </summary>
public static bool CellAllowsMovement(World world, Locomotor locomotor, CPos cell, Func<CPos, int> customCost)
{
return world.Map.Contains(cell) &&
(cell.Layer == 0 || world.GetCustomMovementLayers()[cell.Layer].EnabledForLocomotor(locomotor.Info)) &&
(customCost == null || customCost(cell) != PathGraph.PathCostForInvalidPath);
}
static void AddInitialCells(World world, Locomotor locomotor, Actor self, IEnumerable<CPos> froms,
BlockedByActor check, Func<CPos, int> customCost, Actor ignoreActor, bool inReverse, PathSearch search)
{
// A source cell is allowed to have an unreachable movement cost.
// Therefore we don't need to check if the cell is accessible, only that it allows movement.
// *Unless* the search is being done in reverse, in this case the source is really a target,
// and a target is required to have a reachable cost.
// We also need to ignore self, so we don't consider the location blocked by ourselves!
foreach (var sl in froms)
if (CellAllowsMovement(world, locomotor, sl, customCost) &&
(!inReverse || locomotor.MovementCostToEnterCell(self, sl, check, ignoreActor, true)
!= PathGraph.MovementCostForUnreachableCell))
search.AddInitialCell(sl, customCost);
}
public static PathSearch ToTargetCellOverGraph(
Func<CPos, List<GraphConnection>> edges, Locomotor locomotor, CPos from, CPos target,
int estimatedSearchSize = 0, IRecorder recorder = null)
{
var graph = new SparsePathGraph(edges, estimatedSearchSize);
var search = new PathSearch(graph, DefaultCostEstimator(locomotor, target), 100, loc => loc == target, recorder);
search.AddInitialCell(from, null);
return search;
}
/// <summary>
/// Default: Diagonal distance heuristic. More information:
/// https://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html
/// Layers are ignored and incur no additional cost.
/// </summary>
/// <param name="locomotor">Locomotor used to provide terrain costs.</param>
/// <param name="destination">The cell for which costs are to be given by the estimation function.</param>
/// <returns>A delegate that calculates the cost estimation between the <paramref name="destination"/> and the given cell.</returns>
public static Func<CPos, bool, int> DefaultCostEstimator(Locomotor locomotor, CPos destination)
{
var estimator = DefaultCostEstimator(locomotor);
return (here, _) => estimator(here, destination);
}
/// <summary>
/// Default: Diagonal distance heuristic. More information:
/// https://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html
/// Layers are ignored and incur no additional cost.
/// </summary>
/// <param name="locomotor">Locomotor used to provide terrain costs.</param>
/// <returns>A delegate that calculates the cost estimation between the given cells.</returns>
public static Func<CPos, CPos, int> DefaultCostEstimator(Locomotor locomotor)
{
// Determine the minimum possible cost for moving horizontally between cells based on terrain speeds.
// The minimum possible cost diagonally is then Sqrt(2) times more costly.
var cellCost = locomotor.Info.TerrainSpeeds.Values.Min(ti => ti.Cost);
var diagonalCellCost = Exts.MultiplyBySqrtTwo(cellCost);
return (here, destination) =>
{
var diag = Math.Min(Math.Abs(here.X - destination.X), Math.Abs(here.Y - destination.Y));
var straight = Math.Abs(here.X - destination.X) + Math.Abs(here.Y - destination.Y);
// According to the information link, this is the shape of the function.
// We just extract factors to simplify.
// Possible simplification: var h = Constants.CellCost * (straight + (Constants.Sqrt2 - 2) * diag);
return cellCost * straight + (diagonalCellCost - 2 * cellCost) * diag;
};
}
public IPathGraph Graph { get; }
public Func<CPos, bool> TargetPredicate { get; set; }
readonly Func<CPos, bool, int> heuristic;
readonly int heuristicWeightPercentage;
readonly IRecorder recorder;
readonly IPriorityQueue<GraphConnection> openQueue;
/// <summary>
/// Initialize a new search.
/// </summary>
/// <param name="graph">Graph over which the search is conducted.</param>
/// <param name="heuristic">Provides an estimation of the distance between the given cell and the target.
/// The Boolean parameter indicates if the cell is known to be accessible.
/// When true, it is known accessible as it is being explored by the search.
/// When false, the cell is being considered as a starting location and might not be accessible.</param>
/// <param name="heuristicWeightPercentage">
/// The search will aim for the shortest path when given a weight of 100%.
/// We can allow the search to find paths that aren't optimal by changing the weight.
/// The weight limits the worst case length of the path,
/// e.g. a weight of 110% will find a path no more than 10% longer than the shortest possible.
/// The benefit of allowing the search to return suboptimal paths is faster computation time.
/// The search can skip some areas of the search space, meaning it has less work to do.
/// </param>
/// <param name="targetPredicate">Determines if the given cell is the target.</param>
/// <param name="recorder">If provided, will record all nodes explored by searches performed.</param>
PathSearch(IPathGraph graph, Func<CPos, bool, int> heuristic, int heuristicWeightPercentage, Func<CPos, bool> targetPredicate, IRecorder recorder)
{
Graph = graph;
this.heuristic = heuristic;
this.heuristicWeightPercentage = heuristicWeightPercentage;
TargetPredicate = targetPredicate;
this.recorder = recorder;
openQueue = new Primitives.PriorityQueue<GraphConnection, GraphConnection.CostComparer>(default);
}
void AddInitialCell(CPos location, Func<CPos, int> customCost)
{
var initialCost = 0;
if (customCost != null)
{
initialCost = customCost(location);
if (initialCost == PathGraph.PathCostForInvalidPath)
return;
}
var heuristicCost = heuristic(location, false);
if (heuristicCost == PathGraph.PathCostForInvalidPath)
return;
var estimatedCost = heuristicCost * heuristicWeightPercentage / 100;
Graph[location] = new CellInfo(CellStatus.Open, initialCost, initialCost + estimatedCost, location);
var connection = new GraphConnection(location, estimatedCost);
openQueue.Add(connection);
}
/// <summary>
/// Determines if there are more reachable cells and the search can be continued.
/// If false, <see cref="Expand"/> can no longer be called.
/// </summary>
bool CanExpand()
{
// Connections to a cell can appear more than once if a search discovers a lower cost route to the cell.
// The lower cost gets processed first and the cell will be Closed.
// When checking if we can expand, pop any Closed cells off the queue, so Expand will only see Open cells.
CellStatus status;
do
{
if (openQueue.Empty)
return false;
status = Graph[openQueue.Peek().Destination].Status;
if (status == CellStatus.Closed)
openQueue.Pop();
}
while (status == CellStatus.Closed);
return true;
}
/// <summary>
/// This function analyzes the neighbors of the most promising node in the pathfinding graph
/// using the A* algorithm (A-star) and returns that node.
/// </summary>
/// <returns>The most promising node of the iteration.</returns>
CPos Expand()
{
var currentMinNode = openQueue.Pop().Destination;
var currentInfo = Graph[currentMinNode];
Graph[currentMinNode] = new CellInfo(CellStatus.Closed, currentInfo.CostSoFar, currentInfo.EstimatedTotalCost, currentInfo.PreviousNode);
foreach (var connection in Graph.GetConnections(currentMinNode, TargetPredicate))
{
// Calculate the cost up to that point
var costSoFarToNeighbor = currentInfo.CostSoFar + connection.Cost;
var neighbor = connection.Destination;
var neighborInfo = Graph[neighbor];
// Cost is even higher; next direction:
if (neighborInfo.Status == CellStatus.Closed ||
(neighborInfo.Status == CellStatus.Open && costSoFarToNeighbor >= neighborInfo.CostSoFar))
continue;
// Now we may seriously consider this direction using heuristics.
int estimatedRemainingCostToTarget;
if (neighborInfo.Status == CellStatus.Open)
{
// If the cell has already been processed, we can reuse the result
// (just the difference between the estimated total and the cost so far)
estimatedRemainingCostToTarget = neighborInfo.EstimatedTotalCost - neighborInfo.CostSoFar;
}
else
{
// If the heuristic reports the cell is unreachable, we won't consider it.
var heuristicCost = heuristic(neighbor, true);
if (heuristicCost == PathGraph.PathCostForInvalidPath)
continue;
estimatedRemainingCostToTarget = heuristicCost * heuristicWeightPercentage / 100;
}
recorder?.Add(currentMinNode, neighbor, costSoFarToNeighbor, estimatedRemainingCostToTarget);
var estimatedTotalCostToTarget = costSoFarToNeighbor + estimatedRemainingCostToTarget;
Graph[neighbor] = new CellInfo(CellStatus.Open, costSoFarToNeighbor, estimatedTotalCostToTarget, currentMinNode);
openQueue.Add(new GraphConnection(neighbor, estimatedTotalCostToTarget));
}
return currentMinNode;
}
/// <summary>
/// Expands the path search until a path is found, and returns whether a path is found successfully.
/// </summary>
/// <remarks>
/// If the path search has previously been expanded it will only return true if a path can be found during
/// *this* expansion of the search. If the search was expanded previously and the target is already
/// <see cref="CellStatus.Closed"/> then this method will return false.
/// </remarks>
public bool ExpandToTarget()
{
while (CanExpand())
if (TargetPredicate(Expand()))
return true;
return false;
}
/// <summary>
/// Expands the path search over the whole search space.
/// Returns the cells that were visited during the search.
/// </summary>
public List<CPos> ExpandAll()
{
var consideredCells = new List<CPos>();
while (CanExpand())
consideredCells.Add(Expand());
return consideredCells;
}
/// <summary>
/// Expands the path search until a path is found, and returns that path.
/// Returned path is *reversed* and given target to source.
/// </summary>
public List<CPos> FindPath()
{
while (CanExpand())
{
var p = Expand();
if (TargetPredicate(p))
return MakePath(Graph, p);
}
return PathFinder.NoPath;
}
// Build the path from the destination.
// When we find a node that has the same previous position than itself, that node is the source node.
static List<CPos> MakePath(IPathGraph graph, CPos destination)
{
var ret = new List<CPos>();
var currentNode = destination;
while (graph[currentNode].PreviousNode != currentNode)
{
ret.Add(currentNode);
currentNode = graph[currentNode].PreviousNode;
}
ret.Add(currentNode);
return ret;
}
/// <summary>
/// Expands both path searches until they intersect, and returns the path.
/// Returned path is from the source of the first search to the source of the second search.
/// </summary>
public static List<CPos> FindBidiPath(PathSearch first, PathSearch second)
{
while (first.CanExpand() && second.CanExpand())
{
// make some progress on the first search
var p = first.Expand();
var pInfo = second.Graph[p];
if (pInfo.Status == CellStatus.Closed &&
pInfo.CostSoFar != PathGraph.PathCostForInvalidPath)
return MakeBidiPath(first, second, p);
// make some progress on the second search
var q = second.Expand();
var qInfo = first.Graph[q];
if (qInfo.Status == CellStatus.Closed &&
qInfo.CostSoFar != PathGraph.PathCostForInvalidPath)
return MakeBidiPath(first, second, q);
}
return PathFinder.NoPath;
}
// Build the path from the destination of each search.
// When we find a node that has the same previous position than itself, that is the source of that search.
static List<CPos> MakeBidiPath(PathSearch first, PathSearch second, CPos confluenceNode)
{
var ca = first.Graph;
var cb = second.Graph;
var ret = new List<CPos>();
var q = confluenceNode;
var previous = ca[q].PreviousNode;
while (previous != q)
{
ret.Add(q);
q = previous;
previous = ca[q].PreviousNode;
}
ret.Add(q);
ret.Reverse();
q = confluenceNode;
previous = cb[q].PreviousNode;
while (previous != q)
{
q = previous;
previous = cb[q].PreviousNode;
ret.Add(q);
}
return ret;
}
public void Dispose()
{
Graph.Dispose();
}
}
}

View File

@@ -0,0 +1,53 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* 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
{
/// <summary>
/// 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 <see cref="Traits.ICustomMovementLayer"/>
/// must be provided as input.
/// </summary>
sealed class SparsePathGraph : IPathGraph
{
readonly Func<CPos, List<GraphConnection>> edges;
readonly Dictionary<CPos, CellInfo> info;
public SparsePathGraph(Func<CPos, List<GraphConnection>> edges, int estimatedSearchSize = 0)
{
this.edges = edges;
info = new Dictionary<CPos, CellInfo>(estimatedSearchSize);
}
public List<GraphConnection> GetConnections(CPos position, Func<CPos, bool> targetPredicate)
{
return edges(position) ?? [];
}
public CellInfo this[CPos pos]
{
get
{
if (info.TryGetValue(pos, out var cellInfo))
return cellInfo;
return default;
}
set => info[pos] = value;
}
public void Dispose() { }
}
}