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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,25 @@ Releases are driven by interactive bash scripts in `ci/` (macOS-oriented; uses B

## Architecture

### Core algorithm — `Source/Pathfinder.cs` (+ `Pathfinder_PathSmoothing.cs`)
### Core algorithm — `Source/Pathfinder.cs` (+ `Pathfinder_PathSmoothing.cs`, `Pathfinder_Reachability.cs`)

`Pathfinder` is a `sealed unsafe partial class`. The hot path operates on a flat `Cell[]` via a raw `Cell*` pointer (`fixed`/`stackalloc`), indexed as `x * Height + y` (see `GetCell`). Key performance design points to preserve when editing:

- **Cells are reused, not reallocated.** `GetPath` pins the cell array, calls `ResetCells`, clears the open set, and runs A*. The same buffer serves every query.
- **`ArrayPool<Cell>.Shared`** backs every constructor except the raw `Cell[]` one. `Dispose()` returns the rented array — so `Pathfinder` is `IDisposable` and meant to be a **reused, long-lived instance**, while `PathResult` (which rents a `Coordinate[]` from a pool) is **per-query and must be disposed** (`using`). Disposing results, not recreating the pathfinder, is the intended usage.
- **Four constructors → `InitializationMode`** (`CellsArray`, `CellHoldersArray`, `CellsMatrix`, `CellHoldersMatrix`). `InitializeCellsArray()` dispatches to the matching `TryInitializing...` method to flatten the caller's representation into `_cells` before each search. The `Cell[]`-array mode sorts in place (`Utils.CellsComparison`) and is the only mode that does **not** pool/copy.
- **Four constructors → `InitializationMode`** (`CellsArray`, `CellHoldersArray`, `CellsMatrix`, `CellHoldersMatrix`). `InitializeCellsArray()` dispatches to the matching `TryInitializing...` method to flatten the caller's representation into `_cells` before each search. The `Cell[]`-array mode sorts in place (`Utils.CellsComparison`) and is the only mode that does **not** pool/copy. `CellsComparison` sorts **X-major then Y-minor** so the sorted layout matches `GetCell`'s `x * Height + y` indexing (the matrix/holder modes write cells at `Coordinate.X * Height + Coordinate.Y`); keep all four modes consistent or neighbor adjacency silently transposes.
- **`stackalloc Cell*[8]` neighbors**, populated cardinal-first then diagonal. Diagonal inclusion respects `IsDiagonalMovementEnabled` and `IsMovementBetweenCornersEnabled` (corner-cutting). Agent `Size > 1` triggers a clearance scan in `GetWalkableLocation`.
- Heuristic is **Manhattan distance** (`GetH`). G-score folds in per-cell `Weight` (when `IsCellWeightEnabled`) and straight-vs-diagonal travel multipliers.

### Open set — `Source/Internal/UnsafePriorityQueue.cs`

A custom binary-heap min-priority-queue over `Cell*`. Each `Cell` stores its own `QueueIndex` (internal field) so membership tests (`Contains`) and decrease-key updates are O(1)/O(log n) without a separate map.
A custom binary-heap min-priority-queue over `Cell*`. Each `Cell` stores its own `QueueIndex` (internal field) so membership tests (`Contains`) and decrease-key updates are O(1)/O(log n) without a separate map. `UpdatePriority` performs a real decrease-key (re-heapifies via cascade up/down) and is used by the reachability search; the A* hot path mutates scores in place instead.

### Data types — `Source/Data/`

- `Cell` (struct): public fields (`Coordinate`, `IsWalkable`, `IsOccupied`, `Weight`) + `internal` algorithm scratch fields (`ScoreF/G/H`, `Depth`, `ParentCoordinate`, `QueueIndex`, `IsClosed`). `Reset()` clears only the scratch state.
- `Coordinate` (struct), `PathResult` (`IDisposable`; `IsSuccess`, `Length`, `Get(int)` and a `Path` enumerable over the pooled array), `PathfinderSettings` (public, mutable, implements `IPathfinderSettings`), `PathSmoothingMethod` (enum: `None`, etc.).
- `RangeResult` (`IDisposable`; result of `Pathfinder.GetReachable`, rents a `ReachableCell[]` from the pool — must be disposed) and `ReachableCell` (readonly struct: `Coordinate` + `Cost`). The reachability search is a budget-bounded uniform-cost (Dijkstra) flood fill in `Pathfinder_Reachability.cs`; per-step cost = straight/diagonal multiplier **+** cell `Weight` (added when weighting is enabled, matching the main A*'s additive `GetCellWeightMultiplier` term), independent of the A* heuristic. Weight must be added, not multiplied — `Cell.Weight` defaults to 0, so a multiplier would zero out every step cost and flood the whole board.

### Settings flow

Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A high-performance A* implementation for 2D grid navigation, designed primarily
- Fast A* pathfinding with near-zero garbage collection overhead
- Allocates memory only when necessary to maximize performance
- Designed for 2D grid-based navigation in games
- Movement-range queries (`GetReachable`) for "where can this unit move?" mechanics
- First-class support for Unity with dedicated integration components
- Fully usable in any standalone .NET application
- Extensively tested with comprehensive unit tests
Expand Down Expand Up @@ -145,6 +146,20 @@ if (result.IsSuccess)
}
```

### Movement range

Need to know every tile a unit can reach within a movement budget (e.g. for a tactics game)? Use `GetReachable`:

```csharp
// Every cell whose cheapest path cost is <= 5
using var range = pathfinder.GetReachable(agent, new Coordinate(4, 4), 5f);

foreach (var cell in range.Cells)
{
Debug.Log($"{cell.Coordinate} reachable for {cell.Cost}");
}
```

### Documentation

MPath comes with comprehensive documentation:
Expand All @@ -157,7 +172,7 @@ The API reference provides detailed information about all public classes, interf

### Important Notes

- Always dispose `PathResult` objects after use (use `using` statements)
- Always dispose `PathResult` and `RangeResult` objects after use (use `using` statements)
- Reuse the pathfinder instance for best performance

## License
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ MPath provides a robust implementation of the A* pathfinding algorithm with seve
- [Grid Setup Guide](guides/grid-setup.md) - Methods for creating and managing grids
- [Agent Configuration](guides/agent-configuration.md) - Working with pathfinding agents
- [Pathfinder Settings](guides/pathfinder-settings.md) - Customizing pathfinding behavior
- [Movement Range](guides/movement-range.md) - Finding every cell an agent can reach within a budget
- [Unity Integration](guides/unity-example.md) - Detailed guide for Unity projects
- [Performance Considerations](guides/performance.md) - Optimizing for different scenarios
- [API Reference](api/README.md) - Detailed API documentation
Expand Down
24 changes: 23 additions & 1 deletion docs/api/Pathfinder.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A high-performance A* pathfinding implementation for grid-based environments.
| Method | Description |
|--------|-------------|
| `PathResult GetPath(IAgent agent, Coordinate from, Coordinate to)` | Calculates a path from starting position to destination. |
| `RangeResult GetReachable(IAgent agent, Coordinate from, float budget)` | Finds every cell whose cheapest path cost from the origin is within the budget. |
| `Pathfinder EnablePathCaching(IPathCaching pathCachingHandler = null)` | Enables path caching with optional custom implementation. |
| `Pathfinder DisablePathCaching()` | Disables path caching. |
| `Pathfinder InvalidateCache()` | Clears the current path cache without disabling caching. |
Expand Down Expand Up @@ -51,4 +52,25 @@ pathfinder.InvalidateCache();

// Disable caching when no longer needed
pathfinder.DisablePathCaching();
```
```

### Movement range (reachability)

`GetReachable` returns every cell an agent can reach from an origin without exceeding a movement budget — useful for tactics/strategy games that highlight where a unit can move. It is a uniform-cost (Dijkstra) flood fill that honours the same movement rules as `GetPath` (diagonal movement, corner-cutting, occupied cells and agent clearance).

Per-step cost is `StraightMovementMultiplier` for cardinal moves and `DiagonalMovementMultiplier` for diagonal moves; when `IsCellWeightEnabled` is set, the destination cell's `Weight` is added to the step cost. The origin is always included with cost `0`.

```csharp
using var pathfinder = new Pathfinder(cells, 10, 10);
var agent = new SimpleAgent { Size = 1 };

// Every tile reachable within 4 movement points
using var range = pathfinder.GetReachable(agent, new Coordinate(4, 4), 4f);

foreach (var cell in range.Cells)
{
Highlight(cell.Coordinate, cell.Cost);
}
```

See [RangeResult](RangeResult.md) and [ReachableCell](ReachableCell.md) for the returned types. Like `PathResult`, a `RangeResult` must be disposed (use `using`).
2 changes: 2 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ MPath uses a single codebase for both Unity and .NET:
|------|-------------|
| [Pathfinder](Pathfinder.md) | Main class for performing pathfinding operations |
| [PathResult](PathResult.md) | Contains the results of pathfinding operations |
| [RangeResult](RangeResult.md) | Contains the results of a movement-range (reachability) query |
| [ReachableCell](ReachableCell.md) | A single reachable cell paired with its cheapest path cost |
| [Cell](Cell.md) | Represents a single cell in the pathfinding grid |
| [Coordinate](Coordinate.md) | Represents a position in the grid |
| [IAgent](IAgent.md) | Interface for entities that need pathfinding |
Expand Down
52 changes: 52 additions & 0 deletions docs/api/RangeResult.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# RangeResult Class

**Namespace:** `Migs.MPath.Core.Data`

Represents the result of a movement-range (reachability) query produced by `Pathfinder.GetReachable`. Contains every cell whose cheapest path cost from the origin is within the supplied budget. Implements `IDisposable`.

## Properties

| Property | Type | Description |
|----------|------|-------------|
| `IsSuccess` | `bool` | Whether the query produced at least one reachable cell. |
| `Length` | `int` | The number of reachable cells. |
| `Cells` | `IEnumerable<ReachableCell>` | The reachable cells, each paired with its cheapest path cost. |

## Methods

| Method | Description |
|--------|-------------|
| `static RangeResult Create(ReachableCell[] cells, int length)` | Creates a successful range result backed by the specified array. |
| `static RangeResult Empty()` | Returns a shared empty result (no reachable cells). |
| `ReachableCell Get(int index)` | Gets the reachable cell at the specified index. |
| `bool Contains(Coordinate coordinate)` | Determines whether the coordinate is reachable (linear scan). |
| `bool TryGetCost(Coordinate coordinate, out float cost)` | Gets the cheapest path cost to the coordinate, if reachable (linear scan). |
| `void Dispose()` | Releases resources used by the RangeResult. |

## Remarks

- **IMPORTANT:** Always dispose of `RangeResult` objects after use to return the pooled backing array.
- Use the `using` statement for automatic disposal.
- The origin cell is always included with a cost of `0` when the budget is non-negative.
- `Contains` and `TryGetCost` perform a linear scan over the result. For repeated lookups, build your own `HashSet`/`Dictionary` from `Cells` once.
- If you need to store reachable cells for later use, copy them before disposing.

## Example

```csharp
// Find every cell an agent can reach within 5 movement points
using (var range = pathfinder.GetReachable(agent, new Coordinate(4, 4), 5f))
{
Console.WriteLine($"{range.Length} cells reachable");

foreach (var cell in range.Cells)
{
Console.WriteLine($"{cell.Coordinate} costs {cell.Cost}");
}

if (range.TryGetCost(new Coordinate(6, 4), out var cost))
{
Console.WriteLine($"That tile is reachable for {cost}");
}
}
```
23 changes: 23 additions & 0 deletions docs/api/ReachableCell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# ReachableCell Struct

**Namespace:** `Migs.MPath.Core.Data`

A readonly value type representing a single cell reachable within a movement budget, together with the cost of the cheapest path to it from the query origin. Returned as part of a [RangeResult](RangeResult.md).

## Properties

| Property | Type | Description |
|----------|------|-------------|
| `Coordinate` | `Coordinate` | The coordinate of the reachable cell. |
| `Cost` | `float` | The cost of the cheapest path from the origin to this cell. Always `<=` the query budget. |

## Constructors

| Constructor | Description |
|-------------|-------------|
| `ReachableCell(Coordinate coordinate, float cost)` | Initializes a new reachable cell with the given coordinate and cost. |

## Remarks

- The cost is accumulated from per-step movement costs: `StraightMovementMultiplier` for cardinal moves and `DiagonalMovementMultiplier` for diagonal moves, plus the destination cell's `Weight` (added) when `IsCellWeightEnabled` is set.
- This is a `readonly struct`; enumerate it via [RangeResult.Cells](RangeResult.md) or index it with `RangeResult.Get(int)`.
67 changes: 67 additions & 0 deletions docs/guides/movement-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Movement Range (Reachability)

## Overview

In addition to finding a path between two points, MPath can answer "where can this agent move?" — every cell reachable from an origin without exceeding a movement budget. This is the core mechanic behind tactics and strategy games that highlight a unit's reachable tiles.

`Pathfinder.GetReachable` performs a uniform-cost (Dijkstra) flood fill bounded by the budget. It honours exactly the same movement rules as `GetPath`:

- Diagonal movement (`IsDiagonalMovementEnabled`) and corner-cutting (`IsMovementBetweenCornersEnabled`)
- Occupied cells (`IsCalculatingOccupiedCells`)
- Agent clearance for agents with `Size > 1`

## Cost model

Each step's cost is:

- `StraightMovementMultiplier` for a cardinal (horizontal/vertical) move
- `DiagonalMovementMultiplier` for a diagonal move

When `IsCellWeightEnabled` is set, the destination cell's `Weight` is **added** to the step cost (so a cell with `Weight = 2` costs an extra 2 to enter, on top of the travel cost). Weight is added rather than multiplied — consistent with the main pathfinder, and so that the default `Weight` of 0 leaves the step cost equal to the travel cost instead of zeroing it out. The origin cell is always included with a cost of `0`.

A cell is reachable when the cheapest accumulated cost to it is **less than or equal to** the budget.

## Basic usage

```csharp
using var pathfinder = new Pathfinder(cells, width, height);
var agent = new SimpleAgent { Size = 1 };

// Find every cell reachable from (4, 4) for a budget of 5 movement points.
using var range = pathfinder.GetReachable(agent, new Coordinate(4, 4), 5f);

Console.WriteLine($"{range.Length} cells reachable");

foreach (var cell in range.Cells)
{
Highlight(cell.Coordinate, cell.Cost);
}
```

## Querying the result

[`RangeResult`](../api/RangeResult.md) exposes the reachable cells as [`ReachableCell`](../api/ReachableCell.md) values (a `Coordinate` plus its `Cost`):

```csharp
// Index access
var first = range.Get(0);

// Convenience lookups (linear scans)
if (range.Contains(new Coordinate(6, 4)))
{
range.TryGetCost(new Coordinate(6, 4), out var cost);
Console.WriteLine($"Reachable for {cost}");
}
```

For repeated lookups, build a `Dictionary<Coordinate, float>` from `range.Cells` once rather than calling `TryGetCost` in a loop.

## Disposal

Like `PathResult`, `RangeResult` rents its backing array from a pool and **must be disposed** — always wrap it in a `using` statement (or call `Dispose()`), and copy out any cells you need to keep before disposing.

## Edge cases

- A negative budget returns an empty result (`IsSuccess == false`, `Length == 0`).
- A budget of `0` returns just the origin.
- An origin outside the grid throws `ArgumentException`; a `null` agent throws `ArgumentNullException`.
12 changes: 6 additions & 6 deletions src/mpath-source/Migs.MPath.Benchmarks/MazeBenchmarkRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@
private readonly (int x, int y) _destination = (502, 374);

private static readonly string AtomicRunner = nameof(MPathMazeBenchmarkRunner);
private static readonly string RoyTRunner = nameof(RoyTAStarMazeBenchmarkRunner);

Check warning on line 13 in src/mpath-source/Migs.MPath.Benchmarks/MazeBenchmarkRunner.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

The field 'MazeBenchmarkRunner.RoyTRunner' is assigned but its value is never used
private static readonly string AStarLiteRunner = nameof(AStarLiteBenchmarkRunner);

Check warning on line 14 in src/mpath-source/Migs.MPath.Benchmarks/MazeBenchmarkRunner.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

The field 'MazeBenchmarkRunner.AStarLiteRunner' is assigned but its value is never used
private static readonly string LinqToAStarRunner = nameof(LinqToAStarMazeBenchmarkRunner);

Check warning on line 15 in src/mpath-source/Migs.MPath.Benchmarks/MazeBenchmarkRunner.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

The field 'MazeBenchmarkRunner.LinqToAStarRunner' is assigned but its value is never used

private readonly Dictionary<string, IMazeBenchmarkRunner> _benchmarkRunners =
new()
{
[AtomicRunner] = new MPathMazeBenchmarkRunner(),
[RoyTRunner] = new RoyTAStarMazeBenchmarkRunner(),
[AStarLiteRunner] = new AStarLiteBenchmarkRunner(),
[LinqToAStarRunner] = new LinqToAStarMazeBenchmarkRunner(),
//[RoyTRunner] = new RoyTAStarMazeBenchmarkRunner(),
//[AStarLiteRunner] = new AStarLiteBenchmarkRunner(),
//[LinqToAStarRunner] = new LinqToAStarMazeBenchmarkRunner(),
};

public MazeBenchmarkRunner()
Expand All @@ -40,9 +40,9 @@
}

[Benchmark] public void MPath() => _benchmarkRunners[AtomicRunner].FindPath(_start, _destination);
[Benchmark] public void RoyTAStar() => _benchmarkRunners[RoyTRunner].FindPath(_start, _destination);
[Benchmark] public void AStarLite() => _benchmarkRunners[AStarLiteRunner].FindPath(_start, _destination);
//[Benchmark] public void RoyTAStar() => _benchmarkRunners[RoyTRunner].FindPath(_start, _destination);
//[Benchmark] public void AStarLite() => _benchmarkRunners[AStarLiteRunner].FindPath(_start, _destination);

// This one is so slow that it's not included in the benchmark
[Benchmark] public void LinqToAStar() => _benchmarkRunners[LinqToAStarRunner].FindPath(_start, _destination);
//[Benchmark] public void LinqToAStar() => _benchmarkRunners[LinqToAStarRunner].FindPath(_start, _destination);
}
Loading
Loading