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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ script calling `Controller.advance(game, dt)`.
the graph walker. `tick(ctx, dt)` calls `ctx.getEntityPosition`/`moveEntity`
and, when declared (`@hasDecl`-gated), `ctx.onArrived`/`onPathInvalidated`.
- `components.zig` — `MovementNode` (saveable), `MovementStair` (marker),
`ClosestMovementNode` (transient), `Navigating` (saveable walk order),
`MovementLink` (saveable authored edge), `ClosestMovementNode`
(transient), `Navigating` (saveable walk order),
`MovementSpeed` (saveable, per-entity px/s).
- `graph.zig` / `floyd_warshall.zig` / `movement_path.zig` — graph storage,
axis-aligned auto-connection (horizontal cap 78 px, vertical via stairs,
cap 200 px), FW matrix, per-entity path state.
cap 200 px), plus authored `MovementLink` edges (any direction, added in
a second build pass), FW matrix, per-entity path state.
- **`src/` (algo core)** — standalone: `a_star.zig`, `floyd_warshall_optimized.zig`
(SIMD/parallel variants), `quad_tree.zig`, `heuristics.zig`, `distance_graph.zig`,
`types.zig`. Namespaced as `pathfinding.algo.*`.
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// v3.0.0 — repurposed: the ECS navigation Controller (promoted from
// flying-platform) is now the headline API; the standalone algorithm core
// moves under `.algo`; the old position-owning engine is dropped.
.version = "4.0.4",
.version = "4.1.0",
.fingerprint = 0x15f62ccc39d14685,
.minimum_zig_version = "0.16.0",
.dependencies = .{
Expand Down
17 changes: 17 additions & 0 deletions src/nav/components.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ pub const MovementStair = struct {
_marker: u8 = 0,
};

/// An authored edge from this MovementNode to the node at its world
/// position + (`dx`, `dy`), for connections the axis auto-connect can't
/// express: a diagonal flight of steps, a ramp. The pathfinder resolves the
/// target when it builds the graph (the nearest node within
/// `controller.LINK_TOLERANCE` px) and logs a warning when there is none.
/// The link is two-way, costs its straight-line length, and is walked as a
/// straight leg.
///
/// Read only when the graph builds, which happens whenever the set of
/// MovementNodes changes: author it with the node, not afterwards.
pub const MovementLink = struct {
pub const save_policy: @import("labelle-core").SavePolicy = .saveable;

dx: f32 = 0,
dy: f32 = 0,
};

/// Tracks the nearest navigation graph node for this entity.
/// Auto-assigned by the pathfinder during init to entities that have this component.
pub const ClosestMovementNode = struct {
Expand Down
34 changes: 34 additions & 0 deletions src/nav/controller.zig
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const movement_path = @import("movement_path.zig");

const MovementNode = components.MovementNode;
const MovementStair = components.MovementStair;
const MovementLink = components.MovementLink;
const ClosestMovementNode = components.ClosestMovementNode;
const Navigating = components.Navigating;
const MovementSpeed = components.MovementSpeed;
Expand Down Expand Up @@ -93,6 +94,10 @@ const MAX_VERTICAL_DISTANCE: f32 = 200.0;
/// in-progress rooms. See FP issue #360.
const MAX_HORIZONTAL_DISTANCE: f32 = 78.0;

/// How far (px) a `MovementLink` target may sit from an actual node and
/// still resolve to it — authored offsets are hand-measured from art.
pub const LINK_TOLERANCE: f32 = 4.0;

/// Maximum distance (px) between an entity's world position and a
/// movement node before `findNearestNodeInState` treats the entity
/// as off-graph (returns null). Without this, an entity placed
Expand Down Expand Up @@ -1155,6 +1160,11 @@ pub const Controller = struct {
};
}

// Second pass, once every node is in: authored links. Adding them
// after the auto-connect also keeps a later node's in-between
// pruning from ever seeing them mid-build.
linkAuthoredEdges(game, st);

st.nodes_registered = st.node_entities.count() > 0;
if (st.nodes_registered) {
log.info("graph built: {d} nodes (Floyd-Warshall)", .{st.node_entities.count()});
Expand All @@ -1169,6 +1179,30 @@ pub const Controller = struct {
emitGameEvent(game, "pathfinder__graph_rebuilt", .{ .epoch = st.graph_epoch });
}

fn linkAuthoredEdges(game: anytype, st: *State) void {
const ecs = &game.active_world.ecs_backend;
var view = ecs.view(.{ MovementNode, MovementLink }, .{});
defer view.deinit();

while (view.next()) |entity| {
const from = ecs.getComponent(entity, MovementNode).?.node_id;
// `node_id` is only this build's if the first pass registered
// this entity (addNode can fail).
const entity_id: u64 = @intCast(entity);
if (st.node_entities.get(from) != entity_id) continue;
const link = ecs.getComponent(entity, MovementLink).?;
const pos = game.getWorldPosition(entity);
const target: Position = .{ .x = pos.x + link.dx, .y = pos.y + link.dy };
const to = st.pf.graph.nearestNodeWithin(target, LINK_TOLERANCE) orelse {
log.warn("MovementLink from ({d},{d}): no node within {d} px of ({d},{d})", .{ pos.x, pos.y, LINK_TOLERANCE, target.x, target.y });
continue;
};
st.pf.addLink(from, to) catch |err| {
log.err("addLink ({d},{d}) -> ({d},{d}) failed: {}", .{ pos.x, pos.y, target.x, target.y, err });
};
}
}

fn countMovementNodes(game: anytype) u32 {
var view = game.active_world.ecs_backend.view(.{MovementNode}, .{});
defer view.deinit();
Expand Down
5 changes: 5 additions & 0 deletions src/nav/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ pub fn PathfinderWith(
return self.graph.addNode(position, is_stair);
}

/// Link two nodes directly (see `Graph.addLink`). Marks dirty.
pub fn addLink(self: *Self, a: NodeId, b: NodeId) !void {
return self.graph.addLink(a, b);
}

/// Remove a node and all its edges. Marks dirty.
pub fn removeNode(self: *Self, node_id: NodeId) void {
self.graph.removeNode(node_id);
Expand Down
34 changes: 34 additions & 0 deletions src/nav/graph.zig
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,40 @@ pub const Graph = struct {
}
}

/// Link two nodes directly, whatever their axes and distance: the
/// authored connections the axis auto-connect can't express, such as
/// a diagonal flight of steps. The cost is the straight-line distance,
/// and the walker walks it as one straight leg. No-op when the nodes
/// are already linked; error on a removed or unknown node.
///
/// A diagonal link survives later `addNode` calls: the in-between
/// pruning only drops edges between two nodes on the new node's axis.
/// An axis-aligned link can be pruned that way, like any other edge.
/// Sets dirty = true.
pub fn addLink(self: *Graph, a: NodeId, b: NodeId) !void {
if (a == b or self.isRemoved(a) or self.isRemoved(b)) return error.InvalidNode;
for (self.edges.items[a].items) |edge| {
if (edge.to == b) return;
}
try self.addBidirectionalEdge(a, b, distanceBetween(self.positions.items[a], self.positions.items[b]));
self.dirty = true;
}

/// The active node nearest `position`, if one is within `tolerance`.
pub fn nearestNodeWithin(self: *const Graph, position: Position, tolerance: f32) ?NodeId {
var best: ?NodeId = null;
var best_dist = tolerance;
for (self.positions.items, 0..) |p, i| {
if (self.removed.items[i]) continue;
const dist = distanceBetween(position, p);
if (dist <= best_dist) {
best = @intCast(i);
best_dist = dist;
}
}
return best;
}

fn addBidirectionalEdge(self: *Graph, a: NodeId, b: NodeId, cost: f32) !void {
try self.edges.items[a].append(self.allocator, .{ .to = b, .cost = cost });
// If the second append OOMs, roll back the first so the graph is never
Expand Down
3 changes: 3 additions & 0 deletions src/nav/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub const GizmoCategories = struct {
pub const components = @import("components.zig");
pub const MovementNode = components.MovementNode;
pub const MovementStair = components.MovementStair;
pub const MovementLink = components.MovementLink;
pub const ClosestMovementNode = components.ClosestMovementNode;
pub const Navigating = components.Navigating;
pub const MovementSpeed = components.MovementSpeed;
Expand Down Expand Up @@ -73,6 +74,8 @@ pub const FailReason = controller.FailReason;
pub const Components = struct {
pub const MovementNode = components.MovementNode;
pub const MovementStair = components.MovementStair;
/// Authored edges (diagonal steps, ramps) — read at graph build.
pub const MovementLink = components.MovementLink;
pub const ClosestMovementNode = components.ClosestMovementNode;
/// The persisted walk order (v4) — saveable so mid-walk saves
/// resume on load via the advance rehydration sweep.
Expand Down
3 changes: 2 additions & 1 deletion src/pathfinding.zig
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ pub const graph = nav.graph;
pub const components = nav.components;
pub const MovementNode = nav.MovementNode;
pub const MovementStair = nav.MovementStair;
pub const MovementLink = nav.MovementLink;
pub const ClosestMovementNode = nav.ClosestMovementNode;
pub const Navigating = nav.Navigating;
pub const MovementSpeed = nav.MovementSpeed;
// The `Components` aggregator the assembler scans to register the plugin's
// components (MovementNode / MovementStair / ClosestMovementNode /
// components (MovementNode / MovementStair / MovementLink / ClosestMovementNode /
// Navigating / MovementSpeed / ControllerState) into the ComponentRegistry.
pub const Components = nav.Components;

Expand Down
1 change: 1 addition & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub const graph = pathfinding.graph;
pub const components = pathfinding.components;
pub const MovementNode = pathfinding.MovementNode;
pub const MovementStair = pathfinding.MovementStair;
pub const MovementLink = pathfinding.MovementLink;
pub const ClosestMovementNode = pathfinding.ClosestMovementNode;
pub const Navigating = pathfinding.Navigating;
pub const MovementSpeed = pathfinding.MovementSpeed;
Expand Down
27 changes: 27 additions & 0 deletions tests/nav/controller_v4_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,30 @@ test "directStep: diagonal step normalizes direction" {
try std.testing.expectApproxEqAbs(@as(f32, 6.0), step.move.x, 0.001);
try std.testing.expectApproxEqAbs(@as(f32, 8.0), step.move.y, 0.001);
}

test "a walk crosses an authored diagonal link as one straight leg" {
const Pf = PathfinderWith(u64, struct {});
var pf = Pf.init(std.testing.allocator, test_config);
defer pf.deinit();

// A raised deck at y=28, steps down to the main deck at y=0.
const deck_far = try pf.addNode(.{ .x = 0, .y = 28 }, false);
const step_top = try pf.addNode(.{ .x = 88, .y = 28 }, false);
const step_bottom = try pf.addNode(.{ .x = 126, .y = 0 }, false);
const main = try pf.addNode(.{ .x = 176, .y = 0 }, false);
try pf.addLink(step_top, step_bottom);

var ctx = CallbackCtx.init(std.testing.allocator);
defer ctx.deinit();
try ctx.positions.put(3, .{ .x = 0, .y = 28 });

const path = (try pf.navigate(3, deck_far, main, 1000.0)).?;
try std.testing.expectEqualSlices(u32, &.{ deck_far, step_top, step_bottom, main }, path.node_path);

var i: usize = 0;
while (i < 100 and ctx.arrived.items.len == 0) : (i += 1) pf.tick(&ctx, 0.016);
try std.testing.expectEqual(@as(usize, 1), ctx.arrived.items.len);
const end = ctx.positions.get(3).?;
try std.testing.expectApproxEqAbs(@as(f32, 176), end.x, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, 0), end.y, 0.01);
}
82 changes: 82 additions & 0 deletions tests/nav/graph_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,85 @@ test "removed node is skipped by addNode connections" {
try std.testing.expect(e.to != a);
}
}

// --- addLink (authored edges) ---

fn hasEdge(g: *const Graph, from: u32, to: u32) bool {
for (g.getEdges(from)) |e| {
if (e.to == to) return true;
}
return false;
}

test "addLink connects diagonal nodes both ways at their straight-line cost" {
var g = Graph.init(std.testing.allocator, test_config);
defer g.deinit();

// A flight of steps: the axis auto-connect can't link these.
const top = try g.addNode(.{ .x = 88, .y = 28 }, false);
const bottom = try g.addNode(.{ .x = 126, .y = 0 }, false);
try std.testing.expect(!hasEdge(&g, top, bottom));

g.dirty = false;
try g.addLink(top, bottom);

try std.testing.expect(hasEdge(&g, top, bottom));
try std.testing.expect(hasEdge(&g, bottom, top));
try std.testing.expectApproxEqAbs(@sqrt(@as(f32, 38 * 38 + 28 * 28)), g.getEdges(top)[0].cost, 0.001);
try std.testing.expect(g.dirty);
}

test "addLink is a no-op when the nodes are already linked" {
var g = Graph.init(std.testing.allocator, test_config);
defer g.deinit();

const a = try g.addNode(.{ .x = 0, .y = 0 }, false);
const b = try g.addNode(.{ .x = 50, .y = 0 }, false); // auto-connected
try g.addLink(a, b);
try g.addLink(b, a);

try std.testing.expectEqual(@as(usize, 1), g.getEdges(a).len);
try std.testing.expectEqual(@as(usize, 1), g.getEdges(b).len);
}

test "addLink rejects a self-link and a removed node" {
var g = Graph.init(std.testing.allocator, test_config);
defer g.deinit();

const a = try g.addNode(.{ .x = 0, .y = 0 }, false);
const b = try g.addNode(.{ .x = 40, .y = 30 }, false);
try std.testing.expectError(error.InvalidNode, g.addLink(a, a));
g.removeNode(b);
try std.testing.expectError(error.InvalidNode, g.addLink(a, b));
try std.testing.expectError(error.InvalidNode, g.addLink(a, 99));
}

test "a diagonal link survives a later node landing on either end's axis" {
var g = Graph.init(std.testing.allocator, test_config);
defer g.deinit();

const top = try g.addNode(.{ .x = 88, .y = 28 }, false);
const bottom = try g.addNode(.{ .x = 126, .y = 0 }, false);
try g.addLink(top, bottom);
// Neighbours on both ends' rows: the in-between pruning only drops
// edges between two nodes on the new node's own axis.
_ = try g.addNode(.{ .x = 10, .y = 28 }, false);
_ = try g.addNode(.{ .x = 176, .y = 0 }, false);
_ = try g.addNode(.{ .x = 107, .y = 0 }, false);

try std.testing.expect(hasEdge(&g, top, bottom));
try std.testing.expect(hasEdge(&g, bottom, top));
}

test "nearestNodeWithin picks the closest live node inside the tolerance" {
var g = Graph.init(std.testing.allocator, test_config);
defer g.deinit();

const a = try g.addNode(.{ .x = 100, .y = 0 }, false);
const b = try g.addNode(.{ .x = 103, .y = 0 }, false);

try std.testing.expectEqual(@as(?u32, b), g.nearestNodeWithin(.{ .x = 102, .y = 1 }, 4));
try std.testing.expectEqual(@as(?u32, null), g.nearestNodeWithin(.{ .x = 120, .y = 0 }, 4));
g.removeNode(b);
try std.testing.expectEqual(@as(?u32, a), g.nearestNodeWithin(.{ .x = 102, .y = 1 }, 4));
}
Loading