Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/articles/game.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[!include[](../../src/SquidStd.Game/README.md)]
2 changes: 2 additions & 0 deletions docs/articles/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,7 @@
href: crypto.md
- name: SquidStd.Secrets.Aws
href: secrets-aws.md
- name: SquidStd.Game
href: game.md
- name: Felix Network
href: felix.md
198 changes: 198 additions & 0 deletions src/SquidStd.Game/Pathfinding/AStarPathfinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using System.Diagnostics;
using SquidStd.Core.Collections.PriorityQueues;

namespace SquidStd.Game.Pathfinding;

/// <summary>
/// Generic A* pathfinder over any graph: the caller supplies the successors of a node, the
/// edge cost, and the goal heuristic as delegates, so any node type works. A consistent
/// (monotone) heuristic - one that never decreases by more than the edge cost between
/// adjacent nodes, which includes all common geometric distances and the zero heuristic -
/// guarantees an optimal path; a zero heuristic turns the search into Dijkstra. Instances
/// hold no search state - every <see cref="FindPath" /> call keeps its own - so a single
/// pathfinder is thread-safe and reusable across concurrent searches, provided the supplied
/// delegates and comparer are themselves safe for concurrent invocation.
/// </summary>
/// <typeparam name="TNode">The graph node type.</typeparam>
public sealed class AStarPathfinder<TNode> where TNode : notnull
{
private const int InitialOpenSetCapacity = 64;

private readonly IEqualityComparer<TNode> _comparer;
private readonly Func<TNode, TNode, double> _cost;
private readonly Func<TNode, TNode, double> _heuristic;
private readonly Func<TNode, IEnumerable<TNode>> _neighbors;

/// <summary>
/// Initializes the pathfinder with the graph delegates.
/// </summary>
/// <param name="neighbors">
/// Returns the reachable successors of a node. Must never return null - an empty
/// sequence means no successors.
/// </param>
/// <param name="cost">
/// Edge cost from a node to a successor. Must be non-negative; negative costs are
/// undefined behavior and only checked in DEBUG builds. Model impassable edges by
/// omitting the neighbor rather than returning an infinite cost.
/// </param>
/// <param name="heuristic">
/// Estimated remaining cost from a node to the goal. Consistent (monotone) estimates
/// yield optimal paths; return 0 for Dijkstra behavior.
/// </param>
/// <param name="comparer">Node equality comparer; defaults to <see cref="EqualityComparer{T}.Default" />.</param>
public AStarPathfinder(
Func<TNode, IEnumerable<TNode>> neighbors,
Func<TNode, TNode, double> cost,
Func<TNode, TNode, double> heuristic,
IEqualityComparer<TNode>? comparer = null
)
{
ArgumentNullException.ThrowIfNull(neighbors);
ArgumentNullException.ThrowIfNull(cost);
ArgumentNullException.ThrowIfNull(heuristic);

_neighbors = neighbors;
_cost = cost;
_heuristic = heuristic;
_comparer = comparer ?? EqualityComparer<TNode>.Default;
}

/// <summary>
/// Finds the cheapest path from <paramref name="start" /> to <paramref name="goal" />.
/// The result includes both endpoints; it is empty (never null) when the goal is
/// unreachable or the expansion budget runs out, and contains only the start node when
/// start equals goal.
/// </summary>
/// <param name="start">The start node.</param>
/// <param name="goal">The goal node.</param>
/// <param name="maxExpandedNodes">
/// Optional per-call budget: the maximum number of nodes to expand before giving up.
/// Protects real-time callers from unbounded searches toward unreachable goals.
/// </param>
/// <returns>The path, or an empty list.</returns>
public IReadOnlyList<TNode> FindPath(TNode start, TNode goal, int? maxExpandedNodes = null)
{
if (maxExpandedNodes.HasValue && maxExpandedNodes.Value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maxExpandedNodes));
}

if (_comparer.Equals(start, goal))
{
return [start];
}

var open = new GenericPriorityQueue<SearchNode, double>(InitialOpenSetCapacity);
var nodes = new Dictionary<TNode, SearchNode>(_comparer);

var startNode = new SearchNode(start) { GScore = 0.0, HScore = _heuristic(start, goal) };
Debug.Assert(!double.IsNaN(startNode.HScore), "A* requires non-NaN heuristic estimates.");
nodes[start] = startNode;
open.Enqueue(startNode, startNode.HScore);

var expanded = 0;

while (open.Count > 0)
{
var current = open.Dequeue();
current.Expanded = true;
expanded++;

if (_comparer.Equals(current.Value, goal))
{
return Reconstruct(current);
}

if (expanded >= maxExpandedNodes)
{
return [];
}

foreach (var neighbor in _neighbors(current.Value))
{
var edgeCost = _cost(current.Value, neighbor);
Debug.Assert(edgeCost >= 0.0, "A* requires non-negative edge costs.");

var tentative = current.GScore + edgeCost;

if (nodes.TryGetValue(neighbor, out var known))
{
if (known.Expanded || tentative >= known.GScore)
{
continue;
}

known.GScore = tentative;
known.Parent = current;
open.UpdatePriority(known, tentative + known.HScore);
}
else
{
var created = new SearchNode(neighbor)
{
GScore = tentative, HScore = _heuristic(neighbor, goal), Parent = current
};
Debug.Assert(!double.IsNaN(created.HScore), "A* requires non-NaN heuristic estimates.");
nodes[neighbor] = created;

if (open.Count == open.MaxSize)
{
open.Resize(open.MaxSize * 2);
}

open.Enqueue(created, tentative + created.HScore);
}
}
}

return [];
}

/// <summary>
/// Try-variant of <see cref="FindPath" />: returns false (with an empty path) when the
/// goal is unreachable or the expansion budget runs out.
/// </summary>
/// <param name="start">The start node.</param>
/// <param name="goal">The goal node.</param>
/// <param name="path">The found path, or an empty list.</param>
/// <param name="maxExpandedNodes">Optional per-call expansion budget.</param>
/// <returns>True when a path was found.</returns>
public bool TryFindPath(TNode start, TNode goal, out IReadOnlyList<TNode> path, int? maxExpandedNodes = null)
{
path = FindPath(start, goal, maxExpandedNodes);

return path.Count > 0;
}

private static IReadOnlyList<TNode> Reconstruct(SearchNode goalNode)
{
var path = new List<TNode>();

for (var node = goalNode; node is not null; node = node.Parent)
{
path.Add(node.Value);
}

path.Reverse();

return path;
}

private sealed class SearchNode : GenericPriorityQueueNode<double>
{
public bool Expanded { get; set; }

public double GScore { get; set; }

public double HScore { get; set; }

public SearchNode? Parent { get; set; }

public TNode Value { get; }

public SearchNode(TNode value)
{
Value = value;
}
}
}
71 changes: 71 additions & 0 deletions src/SquidStd.Game/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<h1 align="center">SquidStd.Game</h1>

Small, dependency-light building blocks for games: dice-notation rolling and a generic A*
pathfinder. Both are pure logic - no engine, no renderer, no fixed grid type - so they drop into
any turn-based or real-time game loop built on SquidStd.

## Install

```bash
dotnet add package SquidStd.Game
```

## Usage

```csharp
using SquidStd.Game.Dice;

// Parse dice notation: "[N]dS[+/-M]" (for example "2d6+1", "d20"), the wrapped
// "dice(2d6+1)" form, or a pure constant such as "5".
var expression = DiceExpression.Parse("2d6+1");

expression.Min; // 3 - every die shows 1
expression.Max; // 13 - every die shows its max face
expression.Average; // 8.0

var total = expression.Roll(); // random total in [Min, Max]
```

## Pathfinding

```csharp
using SquidStd.Game.Pathfinding;

const int width = 20;
const int height = 20;
var blocked = new HashSet<(int X, int Y)> { (5, 5), (5, 6), (5, 7) };

var pathfinder = new AStarPathfinder<(int X, int Y)>(
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) }
.Where(c => c.X >= 0 && c.X < width && c.Y >= 0 && c.Y < height && !blocked.Contains(c)),
cost: (_, _) => 1.0,
heuristic: (p, goal) => Math.Abs(p.X - goal.X) + Math.Abs(p.Y - goal.Y) // Manhattan distance
);

var path = pathfinder.FindPath((0, 0), (10, 10), maxExpandedNodes: 5_000);

// Or the try-variant, which reports success instead of checking for an empty list:
var found = pathfinder.TryFindPath((0, 0), (10, 10), out var tryPath, maxExpandedNodes: 5_000);
```

The returned path includes both the start and the goal node. It is empty (never null) when the
goal is unreachable or the expansion budget runs out, and contains only the start node when start
equals goal. A consistent (monotone) heuristic - one that never decreases by more than the edge
cost between adjacent nodes, which includes Manhattan/Euclidean distance and the zero heuristic -
guarantees an optimal path; a zero heuristic turns the search into Dijkstra. The optional
`maxExpandedNodes` budget caps the work done per call so real-time callers (game loops) don't stall
chasing an unreachable goal. A pathfinder instance holds no search state of its own - every
`FindPath` call keeps its own - so a single instance is thread-safe and reusable across concurrent
searches, provided the supplied delegates and comparer are themselves safe for concurrent
invocation.

## Key types

| Type | Purpose |
|---------------------------|-----------------------------------------------------------------------|
| `DiceExpression` | Parsed `[N]dS[+/-M]` dice notation with `Min`/`Max`/`Average`/`Roll`. |
| `AStarPathfinder<TNode>` | Generic A* over any graph via neighbor/cost/heuristic delegates. |

## License

MIT - part of [SquidStd](https://github.com/tgiachi/squid-std).
Loading
Loading