|
| 1 | +using System.Diagnostics; |
| 2 | +using SquidStd.Core.Collections.PriorityQueues; |
| 3 | + |
| 4 | +namespace SquidStd.Game.Pathfinding; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// Generic A* pathfinder over any graph: the caller supplies the successors of a node, the |
| 8 | +/// edge cost, and the goal heuristic as delegates, so any node type works. A consistent |
| 9 | +/// (monotone) heuristic - one that never decreases by more than the edge cost between |
| 10 | +/// adjacent nodes, which includes all common geometric distances and the zero heuristic - |
| 11 | +/// guarantees an optimal path; a zero heuristic turns the search into Dijkstra. Instances |
| 12 | +/// hold no search state - every <see cref="FindPath" /> call keeps its own - so a single |
| 13 | +/// pathfinder is thread-safe and reusable across concurrent searches, provided the supplied |
| 14 | +/// delegates and comparer are themselves safe for concurrent invocation. |
| 15 | +/// </summary> |
| 16 | +/// <typeparam name="TNode">The graph node type.</typeparam> |
| 17 | +public sealed class AStarPathfinder<TNode> where TNode : notnull |
| 18 | +{ |
| 19 | + private const int InitialOpenSetCapacity = 64; |
| 20 | + |
| 21 | + private readonly IEqualityComparer<TNode> _comparer; |
| 22 | + private readonly Func<TNode, TNode, double> _cost; |
| 23 | + private readonly Func<TNode, TNode, double> _heuristic; |
| 24 | + private readonly Func<TNode, IEnumerable<TNode>> _neighbors; |
| 25 | + |
| 26 | + /// <summary> |
| 27 | + /// Initializes the pathfinder with the graph delegates. |
| 28 | + /// </summary> |
| 29 | + /// <param name="neighbors"> |
| 30 | + /// Returns the reachable successors of a node. Must never return null - an empty |
| 31 | + /// sequence means no successors. |
| 32 | + /// </param> |
| 33 | + /// <param name="cost"> |
| 34 | + /// Edge cost from a node to a successor. Must be non-negative; negative costs are |
| 35 | + /// undefined behavior and only checked in DEBUG builds. Model impassable edges by |
| 36 | + /// omitting the neighbor rather than returning an infinite cost. |
| 37 | + /// </param> |
| 38 | + /// <param name="heuristic"> |
| 39 | + /// Estimated remaining cost from a node to the goal. Consistent (monotone) estimates |
| 40 | + /// yield optimal paths; return 0 for Dijkstra behavior. |
| 41 | + /// </param> |
| 42 | + /// <param name="comparer">Node equality comparer; defaults to <see cref="EqualityComparer{T}.Default" />.</param> |
| 43 | + public AStarPathfinder( |
| 44 | + Func<TNode, IEnumerable<TNode>> neighbors, |
| 45 | + Func<TNode, TNode, double> cost, |
| 46 | + Func<TNode, TNode, double> heuristic, |
| 47 | + IEqualityComparer<TNode>? comparer = null |
| 48 | + ) |
| 49 | + { |
| 50 | + ArgumentNullException.ThrowIfNull(neighbors); |
| 51 | + ArgumentNullException.ThrowIfNull(cost); |
| 52 | + ArgumentNullException.ThrowIfNull(heuristic); |
| 53 | + |
| 54 | + _neighbors = neighbors; |
| 55 | + _cost = cost; |
| 56 | + _heuristic = heuristic; |
| 57 | + _comparer = comparer ?? EqualityComparer<TNode>.Default; |
| 58 | + } |
| 59 | + |
| 60 | + /// <summary> |
| 61 | + /// Finds the cheapest path from <paramref name="start" /> to <paramref name="goal" />. |
| 62 | + /// The result includes both endpoints; it is empty (never null) when the goal is |
| 63 | + /// unreachable or the expansion budget runs out, and contains only the start node when |
| 64 | + /// start equals goal. |
| 65 | + /// </summary> |
| 66 | + /// <param name="start">The start node.</param> |
| 67 | + /// <param name="goal">The goal node.</param> |
| 68 | + /// <param name="maxExpandedNodes"> |
| 69 | + /// Optional per-call budget: the maximum number of nodes to expand before giving up. |
| 70 | + /// Protects real-time callers from unbounded searches toward unreachable goals. |
| 71 | + /// </param> |
| 72 | + /// <returns>The path, or an empty list.</returns> |
| 73 | + public IReadOnlyList<TNode> FindPath(TNode start, TNode goal, int? maxExpandedNodes = null) |
| 74 | + { |
| 75 | + if (maxExpandedNodes.HasValue && maxExpandedNodes.Value <= 0) |
| 76 | + { |
| 77 | + throw new ArgumentOutOfRangeException(nameof(maxExpandedNodes)); |
| 78 | + } |
| 79 | + |
| 80 | + if (_comparer.Equals(start, goal)) |
| 81 | + { |
| 82 | + return [start]; |
| 83 | + } |
| 84 | + |
| 85 | + var open = new GenericPriorityQueue<SearchNode, double>(InitialOpenSetCapacity); |
| 86 | + var nodes = new Dictionary<TNode, SearchNode>(_comparer); |
| 87 | + |
| 88 | + var startNode = new SearchNode(start) { GScore = 0.0, HScore = _heuristic(start, goal) }; |
| 89 | + Debug.Assert(!double.IsNaN(startNode.HScore), "A* requires non-NaN heuristic estimates."); |
| 90 | + nodes[start] = startNode; |
| 91 | + open.Enqueue(startNode, startNode.HScore); |
| 92 | + |
| 93 | + var expanded = 0; |
| 94 | + |
| 95 | + while (open.Count > 0) |
| 96 | + { |
| 97 | + var current = open.Dequeue(); |
| 98 | + current.Expanded = true; |
| 99 | + expanded++; |
| 100 | + |
| 101 | + if (_comparer.Equals(current.Value, goal)) |
| 102 | + { |
| 103 | + return Reconstruct(current); |
| 104 | + } |
| 105 | + |
| 106 | + if (expanded >= maxExpandedNodes) |
| 107 | + { |
| 108 | + return []; |
| 109 | + } |
| 110 | + |
| 111 | + foreach (var neighbor in _neighbors(current.Value)) |
| 112 | + { |
| 113 | + var edgeCost = _cost(current.Value, neighbor); |
| 114 | + Debug.Assert(edgeCost >= 0.0, "A* requires non-negative edge costs."); |
| 115 | + |
| 116 | + var tentative = current.GScore + edgeCost; |
| 117 | + |
| 118 | + if (nodes.TryGetValue(neighbor, out var known)) |
| 119 | + { |
| 120 | + if (known.Expanded || tentative >= known.GScore) |
| 121 | + { |
| 122 | + continue; |
| 123 | + } |
| 124 | + |
| 125 | + known.GScore = tentative; |
| 126 | + known.Parent = current; |
| 127 | + open.UpdatePriority(known, tentative + known.HScore); |
| 128 | + } |
| 129 | + else |
| 130 | + { |
| 131 | + var created = new SearchNode(neighbor) |
| 132 | + { |
| 133 | + GScore = tentative, HScore = _heuristic(neighbor, goal), Parent = current |
| 134 | + }; |
| 135 | + Debug.Assert(!double.IsNaN(created.HScore), "A* requires non-NaN heuristic estimates."); |
| 136 | + nodes[neighbor] = created; |
| 137 | + |
| 138 | + if (open.Count == open.MaxSize) |
| 139 | + { |
| 140 | + open.Resize(open.MaxSize * 2); |
| 141 | + } |
| 142 | + |
| 143 | + open.Enqueue(created, tentative + created.HScore); |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + return []; |
| 149 | + } |
| 150 | + |
| 151 | + /// <summary> |
| 152 | + /// Try-variant of <see cref="FindPath" />: returns false (with an empty path) when the |
| 153 | + /// goal is unreachable or the expansion budget runs out. |
| 154 | + /// </summary> |
| 155 | + /// <param name="start">The start node.</param> |
| 156 | + /// <param name="goal">The goal node.</param> |
| 157 | + /// <param name="path">The found path, or an empty list.</param> |
| 158 | + /// <param name="maxExpandedNodes">Optional per-call expansion budget.</param> |
| 159 | + /// <returns>True when a path was found.</returns> |
| 160 | + public bool TryFindPath(TNode start, TNode goal, out IReadOnlyList<TNode> path, int? maxExpandedNodes = null) |
| 161 | + { |
| 162 | + path = FindPath(start, goal, maxExpandedNodes); |
| 163 | + |
| 164 | + return path.Count > 0; |
| 165 | + } |
| 166 | + |
| 167 | + private static IReadOnlyList<TNode> Reconstruct(SearchNode goalNode) |
| 168 | + { |
| 169 | + var path = new List<TNode>(); |
| 170 | + |
| 171 | + for (var node = goalNode; node is not null; node = node.Parent) |
| 172 | + { |
| 173 | + path.Add(node.Value); |
| 174 | + } |
| 175 | + |
| 176 | + path.Reverse(); |
| 177 | + |
| 178 | + return path; |
| 179 | + } |
| 180 | + |
| 181 | + private sealed class SearchNode : GenericPriorityQueueNode<double> |
| 182 | + { |
| 183 | + public bool Expanded { get; set; } |
| 184 | + |
| 185 | + public double GScore { get; set; } |
| 186 | + |
| 187 | + public double HScore { get; set; } |
| 188 | + |
| 189 | + public SearchNode? Parent { get; set; } |
| 190 | + |
| 191 | + public TNode Value { get; } |
| 192 | + |
| 193 | + public SearchNode(TNode value) |
| 194 | + { |
| 195 | + Value = value; |
| 196 | + } |
| 197 | + } |
| 198 | +} |
0 commit comments