moonpath

MoonPath: A Grid Pathfinding Toolkit for MoonBit. Provides BFS, Dijkstra, and A* pathfinding on 2D grid maps with configurable movement, heuristics, and terrain costs.

pathfinding
algorithm
grid
bfs
dijkstra
astar
game-dev
navigation
moonbit
moon add Burnling-gx/moonpath@0.1.1
Download zip
Version
0.1.1
License
Apache-2.0
Last updated
last month
Downloads
11
README

#MoonPath

CI MoonBit License

MoonBit 原生的二维栅格寻路内核:用统一的数据模型、结果接口和可观测指标,对比 BFS、Dijkstra 与 A*。

MoonPath is a zero-dependency grid pathfinding toolkit written in MoonBit. It supports obstacles, positive weighted terrain, four/eight-way movement, configurable A* heuristics, deterministic path selection, and search statistics.

#评审速览

维度已完成内容
核心能力BFS、Dijkstra、A*,权重地形,四/八向移动,自定义启发式
正确性65 / 65 测试通过,覆盖不可达、越界、溢出、非法权重和禁止穿角等边界
覆盖率库核心源码 355 / 381 个插桩点,约 93.2%
工程质量format、全目标静态检查、零警告、build、test、coverage、Demo 全部进入 CI
可复测性Demo 与 benchmark 均为仓库内可执行入口,CI 会记录实际安装的 MoonBit 版本

#30 秒体验

git clone https://github.com/Burnling-gx/moonpath.git cd moonpath moon test moon run src/cmd/demo

#Why MoonPath

  • 统一接口:三种算法都返回路径、代价、发现节点数和展开节点数。
  • 正确处理权重:Dijkstra 与 A* 按进入目标格的地形代价计算;非法的零/负权不会进入网格。
  • 安全八向移动:斜向代价为 sqrt(2) × terrain_cost,且不会从两个障碍之间穿角。
  • 适合批量建图set_cells 只复制网格一次,加载 K 个更新的复杂度为 O(N + K)。
  • 可复现:65 个测试、基准入口、严格零警告检查、覆盖率、构建和 Demo 均进入 CI。

项目价值不在重新发明经典算法,而在于为 MoonBit 生态提供一套可复用、可比较、边界语义明确的寻路基础设施。

#Why MoonBit

MoonPath 不只是把既有算法翻译成另一种语法,而是用 MoonBit 的语言与工具链能力组织完整工程:

  • 使用 enum 与模式匹配表达网格单元和可选路径,减少无效状态。
  • 使用强类型公开 API 明确移动方式、启发式约束、搜索结果和失败语义。
  • 使用 MoonBit 原生测试、黑盒测试、benchmark 和 coverage 命令形成质量闭环。
  • 通过 moon check --target all 持续验证多目标兼容性,并以 Wasm-GC release 构建复测性能。

#Demo

示例故意把高代价地形放在步数最少的直线上:

S 9 9 9 9 9 G . # # # # # . . . . . . . .

两类最优路径直观对比(* 表示路径):

BFS(最少步数) Dijkstra / A*(最低代价) S * * * * * G S 9 9 9 9 9 G . # # # # # . * # # # # # * . . . . . . . * * * * * * *

运行结果:

AlgorithmStepsReported costActual terrain costVisited / expanded
BFS664614 / 12
Dijkstra10101013 / 11
A* + Manhattan10101012 / 10

BFS 找到步数最少但昂贵的直线路径;Dijkstra 与 A* 找到代价最低的绕行路径,A* 在本例中展开的节点更少。

#Benchmark snapshot

80 × 80 空网格、起点 (0, 40)、终点 (79, 40),本地 Wasm-GC release 构建的一次参考结果:

AlgorithmMean time
BFS385.29 µs
Dijkstra995.29 µs
A* + Manhattan44.89 µs

该场景有利于目标导向启发式,仅用于验证基准入口和展示搜索聚焦效果,不代表所有地图上的通用倍率。运行 moon bench --release 可在当前机器复测。

#Quick start

要求近期版本的 MoonBit toolchain。CI 通过官方安装器获取当前可用版本并输出实际版本号;最近一次本地完整验证使用 moon 0.1.20260703

git clone https://github.com/Burnling-gx/moonpath.git cd moonpath moon check --target all --deny-warn moon test moon build moon run src/cmd/demo # Optional: run the benchmark suite moon bench --release

预期测试摘要:

Total tests: 65, passed: 65, failed: 0.

当前测试覆盖库核心源码 355 / 381 个插桩点(约 93.2%);Demo 不计入单元测试覆盖率,但会在 CI 中单独构建和执行。

#Install and use

从 mooncakes.io 添加依赖:

moon add Burnling-gx/moonpath

在调用方的 moon.pkg 中导入:

import {
"Burnling-gx/moonpath",
}

然后通过包别名 @moonpath 使用公开 API:

let grid = @moonpath.Grid::new(10, 10).set_cells([
(@moonpath.Point::new(3, 3), @moonpath.Cell::Blocked),
(@moonpath.Point::new(3, 4), @moonpath.Cell::Blocked),
(@moonpath.Point::new(4, 2), @moonpath.Cell::Weighted(5)),
])

let start = @moonpath.Point::new(0, 0)
let goal = @moonpath.Point::new(9, 9)
let result = @moonpath.astar(
grid,
start,
goal,
@moonpath.SearchOptions::four_way(),
)

match result.path {
Some(path) => {
println("Steps: " + result.steps().to_string())
println("Cost: " + result.total_cost.to_string())
}
None => println("No path found")
}

#Algorithm guide

AlgorithmWeighted terrainMovementOptimality
bfsIgnoredFour-wayFewest steps
dijkstraYesFour-wayLowest cost for positive weights
dijkstra_with_movementYesFour/eight-wayLowest cost for positive weights
astarYesFour/eight-wayLowest cost when its heuristic contract holds

Recommended A* options:

  • SearchOptions::four_way() — Manhattan heuristic.
  • SearchOptions::eight_way() — Octile heuristic and no corner cutting.
  • SearchOptions::new(movement, heuristic) — custom configuration.

Built-in heuristics are manhattan, euclidean, chebyshev, octile, and zero. A custom heuristic must be deterministic, finite, non-negative, zero at the goal, and must not overestimate the remaining cost. Negative or non-finite values are treated as zero defensively.

#Cost and statistics semantics

  • Moving into Empty costs 1; moving into Weighted(n) costs n.
  • The start cell is not charged. A four-way path's cost is the sum of entered cells.
  • Eight-way diagonal movement multiplies the entered cell's cost by sqrt(2).
  • nodes_visited counts distinct discovered nodes, including the start.
  • nodes_expanded counts neighbor-generation events; the goal is not expanded, and a reopened node may count again.
  • On failure, path is None, total_cost is -1, and steps() returns -1.

#Safety guarantees

  • Search rejects out-of-bounds or blocked start/goal points.
  • get_cell returns Blocked outside the grid; try_get_cell returns None.
  • Lenient setters ignore invalid updates; try_set_cell and try_set_weighted report them as None.
  • from_cells rejects invalid dimensions, invalid weights, and mismatched cell counts.
  • Grid dimensions are checked before multiplication to prevent cell-count overflow.
  • Grid storage is private and copied at construction boundaries, so callers cannot break invariants through array aliasing.

#Public API at a glance

Grid new / try_new / from_cells width / height / size / in_bounds get_cell / try_get_cell / cost / is_passable set_cell / try_set_cell / set_cells / set_wall set_weighted / try_set_weighted / clear Search bfs dijkstra / dijkstra_with_movement astar + SearchOptions Result path / total_cost / nodes_visited / nodes_expanded found / steps

#Project structure

moon.mod module metadata (source root: src) src/ moon.pkg library package configuration types.mbt public data model and result semantics grid.mbt validated immutable-style grid neighbors.mbt four/eight-way movement and costs priority_queue.mbt O(log n) deterministic binary min-heap bfs.mbt dijkstra.mbt astar.mbt heuristics.mbt *_test.mbt black-box tests and benchmark cases cmd/demo/ moon.pkg main.mbt weighted ASCII comparison demo .github/workflows/ci.yml format, check, build, test, coverage, demo

#Roadmap

  • 持续发布带变更记录和可复测结果的版本。
  • 增加浏览器交互式路径可视化与更多地图密度的可复测基准。
  • 探索双向搜索、Jump Point Search 与 moon prove 形式化性质。

#License

#
Heuristic

type Heuristic = (Point, Point) -> Double

A heuristic function estimates the cost from a point to the goal.

Common choices are Manhattan, Euclidean, and Chebyshev distance.

#
Cell

pub(all) enum Cell {
Empty
Blocked
Weighted(Int)
} derive(Eq,
Debug
)

Terrain type for each grid cell.

#
Cell::cost

fn Cell::cost(self : Cell) -> Double

Returns the traversal cost for this cell. Empty = 1.0, Blocked = -1.0 (sentinel), Weighted(c) = c as Double.

#
Cell::is_passable

fn Cell::is_passable(self : Cell) -> Bool

Returns true if this cell is passable (not blocked).

#
Cell::is_valid

fn Cell::is_valid(self : Cell) -> Bool

Returns whether this value is a valid grid cell. Weighted terrain must have a strictly positive traversal cost.

#
Grid

pub struct Grid {
// private fields
}

A 2D grid map for pathfinding.

Cells are stored in row-major order: index(x, y) = y * width + x

#
Grid::clear

fn Grid::clear(self : Grid) -> Grid

Returns a copy of the grid with all cells reset to Empty.

#
Grid::cost

fn Grid::cost(self : Grid, x : Int, y : Int) -> Double

Returns the traversal cost at (x, y). Returns -1.0 for blocked cells and out-of-bounds positions.

#
Grid::cost_at

fn Grid::cost_at(self : Grid, p : Point) -> Double

Returns the traversal cost at a point. Returns -1.0 for blocked cells.

#
Grid::from_cells

fn Grid::from_cells(width : Int, height : Int, cells : Array[Cell]) -> Grid?

Creates a Grid from an existing array of cells.

Returns None for invalid dimensions, non-positive terrain weights, or a cell array length that does not match width * height.

#
Grid::get_cell

fn Grid::get_cell(self : Grid, x : Int, y : Int) -> Cell

Returns the cell at (x, y), or Blocked when out of bounds.

#
Grid::get_cell_at

fn Grid::get_cell_at(self : Grid, p : Point) -> Cell

Returns the cell at a point, or Blocked when out of bounds.

#
Grid::height

fn Grid::height(self : Grid) -> Int

Returns the number of rows in the grid.

#
Grid::in_bounds

fn Grid::in_bounds(self : Grid, p : Point) -> Bool

Checks if a point is within the grid bounds.

#
Grid::is_passable

fn Grid::is_passable(self : Grid, p : Point) -> Bool

Checks if a cell is passable (exists in bounds and is not Blocked).

#
Grid::is_passable_xy

fn Grid::is_passable_xy(self : Grid, x : Int, y : Int) -> Bool

Checks if a cell is passable by raw coordinates.
fn Grid::is_valid_search(self : Grid, start : Point, goal : Point) -> Bool

Checks if start and goal are both valid for pathfinding.

#
Grid::new

fn Grid::new(width : Int, height : Int) -> Grid

Creates an empty Grid where all cells are traversable (Cell::Empty).

Arguments

  • width - number of columns (must be > 0)
  • height - number of rows (must be > 0)

Returns a 1x1 grid when dimensions are non-positive or their cell count would overflow Int. Use try_new when invalid input must be reported.

#
Grid::num_passable

fn Grid::num_passable(self : Grid) -> Int

Counts the number of passable cells in the grid.

#
Grid::num_walls

fn Grid::num_walls(self : Grid) -> Int

Counts the number of blocked cells in the grid.

#
Grid::set_cell

fn Grid::set_cell(self : Grid, x : Int, y : Int, cell : Cell) -> Grid

Sets the Cell at (x, y). Returns a new Grid (immutable style). Invalid coordinates and non-positive weighted cells leave the grid unchanged.

#
Grid::set_cells

fn Grid::set_cells(self : Grid, updates : Array[(Point, Cell)]) -> Grid

Applies multiple cell updates with a single grid copy.

Out-of-bounds points and invalid weighted cells are ignored. Prefer this method when loading maps or placing many obstacles: it runs in O(N + K) for N grid cells and K updates, while K chained set_cell calls copy the grid K times.

#
Grid::set_wall

fn Grid::set_wall(self : Grid, x : Int, y : Int) -> Grid

Sets a wall (Blocked cell) at (x, y).

#
Grid::set_weighted

fn Grid::set_weighted(self : Grid, x : Int, y : Int, weight : Int) -> Grid

Sets a positive weighted cell at (x, y); invalid input leaves the grid unchanged.

#
Grid::size

fn Grid::size(self : Grid) -> Int

Returns the total number of cells in the grid.

#
Grid::try_get_cell

fn Grid::try_get_cell(self : Grid, x : Int, y : Int) -> Cell?

Returns the cell at (x, y), or None when the coordinates are out of bounds.

#
Grid::try_new

fn Grid::try_new(width : Int, height : Int) -> Grid?

Creates a Grid, returning None for non-positive or overflowing dimensions.

#
Grid::try_set_cell

fn Grid::try_set_cell(self : Grid, x : Int, y : Int, cell : Cell) -> Grid?

Tries to set one cell, returning None for invalid coordinates or terrain.

#
Grid::try_set_weighted

fn Grid::try_set_weighted(self : Grid, x : Int, y : Int, weight : Int) -> Grid?

Tries to set positive weighted terrain and reports invalid input as None.

#
Grid::width

fn Grid::width(self : Grid) -> Int

Returns the number of columns in the grid.

#
Movement

pub(all) enum Movement {
FourWay
EightWay
} derive(Eq,
Debug
)

Movement mode for neighbor generation.

#
Point

pub struct Point {
x : Int
y : Int
} derive(Compare, Eq,
Debug
)

A 2D coordinate point on the grid. Origin (0, 0) is top-left; x increases right, y increases down.

#
Point::new

fn Point::new(x : Int, y : Int) -> Point

Creates a new Point.

#
Point::to_string

fn Point::to_string(self : Point) -> String

Returns the string representation "(x, y)".

#
SearchOptions

pub struct SearchOptions {
movement : Movement
heuristic : (Point, Point) -> Double
}

Configuration for a pathfinding search.

#
SearchOptions::default

fn SearchOptions::default() -> SearchOptions

Creates default A* options (four-way movement with Manhattan distance).

#
SearchOptions::eight_way

fn SearchOptions::eight_way() -> SearchOptions

Creates the recommended options for eight-way movement.

#
SearchOptions::four_way

fn SearchOptions::four_way() -> SearchOptions

Creates the recommended options for four-way movement.

#
SearchOptions::new

fn SearchOptions::new(movement : Movement, heuristic : (Point, Point) -> Double) -> SearchOptions

Creates custom search options.

For optimal paths, the heuristic must be deterministic, finite, non-negative, zero at the goal, and admissible for the movement model.

#
SearchResult

pub struct SearchResult {
path : Array[Point]?
total_cost : Double
nodes_visited : Int
nodes_expanded : Int
} derive(
Debug
)

Result of a pathfinding search.

#
SearchResult::found

fn SearchResult::found(self : SearchResult) -> Bool

Returns true if a valid path was found.

#
SearchResult::steps

fn SearchResult::steps(self : SearchResult) -> Int

Returns the number of moves in the path, or -1 when no path was found.

#
astar

fn astar(grid : Grid, start : Point, goal : Point, options : SearchOptions) -> SearchResult

Performs A* search to find the shortest path from start to goal using a configurable heuristic.

A* combines Dijkstra's algorithm with a heuristic function that estimates the remaining distance to the goal. This allows A* to focus the search toward the goal, typically expanding fewer nodes than Dijkstra while still finding optimal paths (if the heuristic is admissible).

Heuristic

The heuristic function h(current, goal) must be deterministic, non-negative, zero at the goal, and admissible (never overestimate the true cost) for A* to guarantee optimality. Non-finite and negative values are defensively treated as zero. Common choices:
  • manhattan for four-way movement
  • chebyshev or octile for eight-way movement
  • euclidean for any-direction movement

Movement

The options.movement field controls whether four-way or eight-way neighbors are generated, including appropriate move costs.

Errors

Returns a failure result if:
  • start or goal is out of bounds
  • start or goal is blocked
  • No path exists

#
bfs

fn bfs(grid : Grid, start : Point, goal : Point) -> SearchResult

Performs BFS (Breadth-First Search) to find the shortest path on an unweighted grid from start to goal.

BFS ignores cell weights (all unblocked cells have cost 1) and finds the path with the fewest steps. Uses four-way movement.

Returns a SearchResult with the path, total steps, and statistics.

Errors

Returns a failure result if:
  • start or goal is out of bounds
  • start or goal is blocked
  • No path exists from start to goal

#
chebyshev

fn chebyshev(a : Point, b : Point) -> Double

Chebyshev distance: max(|dx|, |dy|)

Admissible for eight-way movement on grid maps where diagonal moves have the same cost as cardinal moves.

#
dijkstra

fn dijkstra(grid : Grid, start : Point, goal : Point) -> SearchResult

Performs Dijkstra's shortest path search on a weighted grid from start to goal.

Unlike BFS, Dijkstra respects terrain weights: moving through a Weighted(c) cell costs c instead of 1. This makes it suitable for maps with varying terrain costs.

Uses a min-heap priority queue (binary heap) to always expand the node with the lowest accumulated cost first.

Movement

Uses four-way movement by default. Neighbor generation includes cell costs.

Errors

Returns a failure result if:
  • start or goal is out of bounds
  • start or goal is blocked
  • No path exists from start to goal

#
dijkstra_with_movement

fn dijkstra_with_movement(grid : Grid, start : Point, goal : Point, movement : Movement) -> SearchResult

Performs Dijkstra search with configurable four-way or eight-way movement. Eight-way diagonal movement costs sqrt(2) times the destination terrain cost, matching the movement model used by A*.

#
eight_way_neighbors

fn eight_way_neighbors(grid : Grid, p : Point) -> Array[Point]

Returns the list of passable neighbors for a point using eight-way movement.

Includes diagonal moves without cutting through blocked corners. A diagonal destination and both adjacent cardinal cells must be passable.

#
eight_way_neighbors_with_cost

fn eight_way_neighbors_with_cost(grid : Grid, p : Point) -> Array[(Point, Double)]

Returns the list of passable neighbors as (Point, cost) pairs for eight-way movement.

Diagonal moves use cost * sqrt(2) for the Euclidean distance. Diagonal corner cutting is not allowed.

#
euclidean

fn euclidean(a : Point, b : Point) -> Double

Euclidean distance: sqrt(dx^2 + dy^2)

Admissible for both four-way and eight-way movement on grid maps. More accurate than Manhattan for diagonal movement but slower due to the square root calculation.

#
four_way_neighbors

fn four_way_neighbors(grid : Grid, p : Point) -> Array[Point]

Returns the list of passable neighbors for a point using four-way movement.

Skips out-of-bounds cells, blocked cells, and cells with negative cost.

#
four_way_neighbors_with_cost

fn four_way_neighbors_with_cost(grid : Grid, p : Point) -> Array[(Point, Double)]

Returns the list of passable neighbors as (Point, cost) pairs.

Useful for weighted pathfinding where different moves may have different traversal costs.

#
manhattan

fn manhattan(a : Point, b : Point) -> Double

Manhattan distance: |dx| + |dy|

Admissible for four-way movement on grid maps. This is the most commonly used heuristic for grid-based pathfinding.

#
octile

fn octile(a : Point, b : Point) -> Double

Octile distance: |dx| + |dy| + (sqrt(2) - 2) * min(|dx|, |dy|)

Admissible for eight-way movement where diagonal moves cost √2 and cardinal moves cost 1. This is the most accurate admissible heuristic for standard eight-way grid movement.

#
zero

fn zero(_a : Point, _b : Point) -> Double

Zero heuristic — turns A* into Dijkstra's algorithm.

Useful as a baseline for comparison. Always admissible but provides no guidance toward the goal.