From 9dc177565b00ba305e31e998a38290d704e3edbe Mon Sep 17 00:00:00 2001 From: apotema Date: Mon, 12 Oct 2026 12:39:06 -0300 Subject: [PATCH 1/2] feat(nav): MovementLink, authored edges the axis auto-connect can't make The graph only links nodes that share an X or a Y, so a diagonal flight of steps (flying-platform's stern, drawn at 45 degrees down to the main deck) can't be walked: games had to teleport entities or script straight-line moveTo hops around the graph. A MovementLink { dx, dy } on a MovementNode adds a two-way edge to the node at its world position + (dx, dy), resolved within LINK_TOLERANCE (4 px) in a second pass once every node is in, costed at its straight-line length. The walker already walks any leg as a straight line, and Floyd-Warshall needs nothing new. Graph gains addLink and nearestNodeWithin; the engine forwards addLink. A diagonal link can't be pruned by a later addNode: the in-between pruning only drops edges between two nodes on the new node's own axis. --- CLAUDE.md | 6 ++- src/nav/components.zig | 17 +++++++ src/nav/controller.zig | 34 +++++++++++++ src/nav/engine.zig | 5 ++ src/nav/graph.zig | 34 +++++++++++++ src/nav/root.zig | 3 ++ src/pathfinding.zig | 3 +- src/root.zig | 1 + tests/nav/controller_v4_test.zig | 27 +++++++++++ tests/nav/graph_test.zig | 82 ++++++++++++++++++++++++++++++++ 10 files changed, 209 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a819bf9..22ec5f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.*`. diff --git a/src/nav/components.zig b/src/nav/components.zig index f9025fe..81f2049 100644 --- a/src/nav/components.zig +++ b/src/nav/components.zig @@ -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 { diff --git a/src/nav/controller.zig b/src/nav/controller.zig index 4879023..0ffeb47 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -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; @@ -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 @@ -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()}); @@ -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(); diff --git a/src/nav/engine.zig b/src/nav/engine.zig index f169998..46112f5 100644 --- a/src/nav/engine.zig +++ b/src/nav/engine.zig @@ -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); diff --git a/src/nav/graph.zig b/src/nav/graph.zig index 8adc76c..c4f666c 100644 --- a/src/nav/graph.zig +++ b/src/nav/graph.zig @@ -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 diff --git a/src/nav/root.zig b/src/nav/root.zig index 957e709..a0e76cf 100644 --- a/src/nav/root.zig +++ b/src/nav/root.zig @@ -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; @@ -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. diff --git a/src/pathfinding.zig b/src/pathfinding.zig index e6378ad..d0cdc72 100644 --- a/src/pathfinding.zig +++ b/src/pathfinding.zig @@ -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; diff --git a/src/root.zig b/src/root.zig index 52be7a2..d11d947 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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; diff --git a/tests/nav/controller_v4_test.zig b/tests/nav/controller_v4_test.zig index 9d79cee..0537701 100644 --- a/tests/nav/controller_v4_test.zig +++ b/tests/nav/controller_v4_test.zig @@ -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); +} diff --git a/tests/nav/graph_test.zig b/tests/nav/graph_test.zig index c1292df..64016af 100644 --- a/tests/nav/graph_test.zig +++ b/tests/nav/graph_test.zig @@ -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)); +} From ef3722bf5ae2caa62eadd95605b457b9b5c9c9f6 Mon Sep 17 00:00:00 2001 From: apotema Date: Mon, 12 Oct 2026 15:17:39 -0300 Subject: [PATCH 2/2] chore(release): bump version to 4.1.0 MovementLink is an additive API, so a minor bump. Co-Authored-By: Claude Opus 5.5 --- build.zig.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig.zon b/build.zig.zon index 605daf1..52e9780 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 = .{