Skip to content

Commit e22e569

Browse files
authored
Merge pull request #82 from tgiachi/develop
release: A* pathfinding
2 parents 15d8247 + 4b89991 commit e22e569

5 files changed

Lines changed: 500 additions & 0 deletions

File tree

docs/articles/game.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[!include[](../../src/SquidStd.Game/README.md)]

docs/articles/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,7 @@
130130
href: crypto.md
131131
- name: SquidStd.Secrets.Aws
132132
href: secrets-aws.md
133+
- name: SquidStd.Game
134+
href: game.md
133135
- name: Felix Network
134136
href: felix.md
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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+
}

src/SquidStd.Game/README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<h1 align="center">SquidStd.Game</h1>
2+
3+
Small, dependency-light building blocks for games: dice-notation rolling and a generic A*
4+
pathfinder. Both are pure logic - no engine, no renderer, no fixed grid type - so they drop into
5+
any turn-based or real-time game loop built on SquidStd.
6+
7+
## Install
8+
9+
```bash
10+
dotnet add package SquidStd.Game
11+
```
12+
13+
## Usage
14+
15+
```csharp
16+
using SquidStd.Game.Dice;
17+
18+
// Parse dice notation: "[N]dS[+/-M]" (for example "2d6+1", "d20"), the wrapped
19+
// "dice(2d6+1)" form, or a pure constant such as "5".
20+
var expression = DiceExpression.Parse("2d6+1");
21+
22+
expression.Min; // 3 - every die shows 1
23+
expression.Max; // 13 - every die shows its max face
24+
expression.Average; // 8.0
25+
26+
var total = expression.Roll(); // random total in [Min, Max]
27+
```
28+
29+
## Pathfinding
30+
31+
```csharp
32+
using SquidStd.Game.Pathfinding;
33+
34+
const int width = 20;
35+
const int height = 20;
36+
var blocked = new HashSet<(int X, int Y)> { (5, 5), (5, 6), (5, 7) };
37+
38+
var pathfinder = new AStarPathfinder<(int X, int Y)>(
39+
neighbors: p => new (int X, int Y)[] { (p.X + 1, p.Y), (p.X - 1, p.Y), (p.X, p.Y + 1), (p.X, p.Y - 1) }
40+
.Where(c => c.X >= 0 && c.X < width && c.Y >= 0 && c.Y < height && !blocked.Contains(c)),
41+
cost: (_, _) => 1.0,
42+
heuristic: (p, goal) => Math.Abs(p.X - goal.X) + Math.Abs(p.Y - goal.Y) // Manhattan distance
43+
);
44+
45+
var path = pathfinder.FindPath((0, 0), (10, 10), maxExpandedNodes: 5_000);
46+
47+
// Or the try-variant, which reports success instead of checking for an empty list:
48+
var found = pathfinder.TryFindPath((0, 0), (10, 10), out var tryPath, maxExpandedNodes: 5_000);
49+
```
50+
51+
The returned path includes both the start and the goal node. It is empty (never null) when the
52+
goal is unreachable or the expansion budget runs out, and contains only the start node when start
53+
equals goal. A consistent (monotone) heuristic - one that never decreases by more than the edge
54+
cost between adjacent nodes, which includes Manhattan/Euclidean distance and the zero heuristic -
55+
guarantees an optimal path; a zero heuristic turns the search into Dijkstra. The optional
56+
`maxExpandedNodes` budget caps the work done per call so real-time callers (game loops) don't stall
57+
chasing an unreachable goal. A pathfinder instance holds no search state of its own - every
58+
`FindPath` call keeps its own - so a single instance is thread-safe and reusable across concurrent
59+
searches, provided the supplied delegates and comparer are themselves safe for concurrent
60+
invocation.
61+
62+
## Key types
63+
64+
| Type | Purpose |
65+
|---------------------------|-----------------------------------------------------------------------|
66+
| `DiceExpression` | Parsed `[N]dS[+/-M]` dice notation with `Min`/`Max`/`Average`/`Roll`. |
67+
| `AStarPathfinder<TNode>` | Generic A* over any graph via neighbor/cost/heuristic delegates. |
68+
69+
## License
70+
71+
MIT - part of [SquidStd](https://github.com/tgiachi/squid-std).

0 commit comments

Comments
 (0)