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
4 changes: 4 additions & 0 deletions .codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ component_management:
name: lighting
paths:
- packages/exojs-lighting/src/**
- component_id: pathfinding
name: pathfinding
paths:
- packages/exojs-pathfinding/src/**
- component_id: audio-fx
name: audio-fx
paths:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: >-
pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled"
--filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-lighting" --filter "@codexo/exojs-audio-fx"
--filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-lighting" --filter "@codexo/exojs-pathfinding" --filter "@codexo/exojs-audio-fx"
--filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" build

- name: Verify production stripping against the built dist
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ jobs:
pnpm --filter @codexo/exojs-physics build
pnpm --filter @codexo/exojs-tilemap-physics build
pnpm --filter @codexo/exojs-lighting build
pnpm --filter @codexo/exojs-pathfinding build
pnpm --filter @codexo/exojs-audio-fx build
pnpm --filter @codexo/exojs-aseprite build
pnpm --filter @codexo/exojs-ldtk build
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

### Added

- **`@codexo/exojs-pathfinding`, the official pathfinding extension.** One
search core - A\* over integer node handles - serving pluggable navigation
spaces. `GridSpace` is a finite window of weighted cells with diagonal
policies, `setCost`/`revision` for runtime edits, brushfire clearance for
agents wider than one cell, and string-pulling smoothing; `WaypointGraph` is a
directed graph whose edges carry a `kind` and a typed payload, which is what a
platformer's jump and fall links need and what a grid cannot express, and
which degrades to plain Dijkstra when its nodes have no positions.
`Pathfinder.findPath`/`findPathBetween` return a `PathResult` whose `status`
distinguishes `found`, `unreachable` and `budget-exceeded` instead of throwing
or returning `null`, and `floodFrom` answers "everything reachable within this
cost". Jump-point search self-enables on a uniform-cost grid and returns the
same optimal path from a fraction of the expanded nodes. Paths are
reproducible across runs and machines, and a search allocates nothing that
scales with the nodes it visits. The package depends on `@codexo/exojs` alone:
a tilemap reaches it through the cost callback `GridSpace.from` takes, not
through a package edge.
- **`when` on `SceneInteraction.observe()` and `scope()`.** Interaction
registrations take the same `SceneAvailability` policy the input, tween and
audio facades have. The default stays `'always'`, so a pause menu drawn by
Expand Down
13 changes: 13 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,19 @@ export default defineConfig([
},
},

// The pathfinding search reads its own typed-array state by an index it just
// derived - a heap slot, a node id, a cell offset it bounds-checked one line
// earlier. `noUncheckedIndexedAccess` widens every one of those reads to
// `| undefined`, and the alternatives are a branch per read in the hottest
// loop in the package or a `?? 0` that would turn a real out-of-range bug
// into a silently wrong path.
{
files: ['packages/exojs-pathfinding/src/**/*.ts'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},

// A package's Rolldown config belongs to no package's TypeScript program:
// each package tsconfig covers `src/**` only, so the ProjectService has no
// type information to serve for these files and every type-aware rule throws
Expand Down
25 changes: 25 additions & 0 deletions examples/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,31 @@
"level": "advanced"
}
],
"pathfinding": [
{
"slug": "grid-navigation",
"path": "pathfinding/grid-navigation.js",
"language": "typescript",
"title": "Grid Navigation",
"description": "Click to move an agent across a weighted grid: paint walls and mud, toggle jump-point pruning, path smoothing and a 2x2 agent, and watch the expanded-node counter react.",
"backend": "core",
"featured": true,
"tags": ["pathfinding", "grid", "pointer"],
"capabilities": ["pointer"],
"level": "intermediate"
},
{
"slug": "tilemap-navigation",
"path": "pathfinding/tilemap-navigation.js",
"language": "typescript",
"title": "Tilemap Navigation",
"description": "Build a GridSpace from a tile layer through a cost callback, then route an agent over it — carving a door edits map and grid together.",
"backend": "core",
"tags": ["pathfinding", "tilemap", "pointer"],
"capabilities": ["pointer"],
"level": "intermediate"
}
],
"performance": [
{
"slug": "sprite-stress",
Expand Down
45 changes: 45 additions & 0 deletions examples/guides/pathfinding/custom-space.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Vector } from '@codexo/exojs';
import type { NavigationSpace } from '@codexo/exojs-pathfinding';

declare const rooms: readonly { x: number; y: number; exits: readonly number[]; travelTime: number }[];

// #region guide:custom-space
/** A room graph: one node per room, cost in seconds of travel. */
class RoomSpace implements NavigationSpace {
public readonly maxDegree = 6;
public readonly revision = 0;

public get nodeCapacity(): number {
return rooms.length;
}

// The buffers belong to the pathfinder and are reused, so a custom space is
// allocation-free on the same terms as the built-in ones.
public neighbors(node: number, _agentSize: number, outNodes: Int32Array, outCosts: Float64Array): number {
const { exits } = rooms[node]!;

for (let index = 0; index < exits.length; index++) {
outNodes[index] = exits[index]!;
outCosts[index] = rooms[exits[index]!]!.travelTime;
}

return exits.length;
}

// Must never overestimate. Returning 0 is always safe and turns the search
// into Dijkstra.
public heuristic(): number {
return 0;
}

public nodeToPoint(node: number, out: Vector): void {
out.set(rooms[node]!.x, rooms[node]!.y);
}

public pointToNode(): number {
return -1;
}
}
// #endregion guide:custom-space

void RoomSpace;
26 changes: 26 additions & 0 deletions examples/guides/pathfinding/grid-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { GridSpace } from '@codexo/exojs-pathfinding';

declare const isWall: (x: number, y: number) => boolean;
declare const isMud: (x: number, y: number) => boolean;

// #region guide:grid-setup
const grid = GridSpace.from(
64,
40,
(x, y) => {
if (isWall(x, y)) return 0;

return isMud(x, y) ? 4 : 1;
},
{ cellSize: 32 },
);
// #endregion guide:grid-setup

// #region guide:grid-edit
grid.setCost(12, 7, 0); // a door slams shut
grid.setCost(12, 7, 1); // and opens again

const revision = grid.revision; // changed, so every path taken before is suspect
// #endregion guide:grid-edit

void revision;
37 changes: 37 additions & 0 deletions examples/guides/pathfinding/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Vector } from '@codexo/exojs';
import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding';

declare const hero: { x: number; y: number; follow: (points: readonly Vector[]) => void };
declare const target: { x: number; y: number };

const grid = new GridSpace(64, 40, { cellSize: 32 });

// #region guide:query
const pathfinder = new Pathfinder();

const result = pathfinder.findPathBetween(grid, hero.x, hero.y, target.x, target.y, {
smooth: true,
agentSize: 2,
maxExpandedNodes: 4000,
});

switch (result.status) {
case 'found':
hero.follow(result.points);
break;
case 'budget-exceeded':
// A real, traversable prefix. Walk it and ask again next frame.
hero.follow(result.points);
break;
case 'unreachable':
break;
}
// #endregion guide:query

// #region guide:staleness
const plannedAt = result.revision;

const isStale = (): boolean => plannedAt !== grid.revision;
// #endregion guide:staleness

void isStale;
19 changes: 19 additions & 0 deletions examples/guides/pathfinding/reachable-area.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding';

declare const unit: { tileX: number; tileY: number; movement: number };
declare const highlight: (x: number, y: number, cost: number) => void;

const grid = new GridSpace(64, 40, { cellSize: 32 });
const pathfinder = new Pathfinder();

// #region guide:flood
const region = pathfinder.floodFrom(grid, grid.nodeAt(unit.tileX, unit.tileY), {
maxCost: unit.movement,
});

for (let index = 0; index < region.nodes.length; index++) {
const node = region.nodes[index]!;

highlight(grid.nodeX(node), grid.nodeY(node), region.costs[index]!);
}
// #endregion guide:flood
36 changes: 36 additions & 0 deletions examples/guides/pathfinding/tilemap-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { GridSpace } from '@codexo/exojs-pathfinding';
import type { ResolvedTile, TileLayer } from '@codexo/exojs-tilemap';

declare const ground: TileLayer;
declare const loaded: { x: number; y: number; width: number; height: number };

// #region guide:tilemap-bridge
// The pathfinding package has no tilemap dependency. The bridge is this
// function, which lives in the game and answers out of whatever the map stores.
const walkCost = (tile: ResolvedTile | null): number => {
if (tile === null) return 0;

const definition = tile.tileset.getTileDefinition(tile.localTileId);

// A tile with authored collision geometry is solid; everything else is
// walkable, with the terrain's own cost if the map carries one.
if (definition?.collision !== undefined) return 0;

return typeof definition?.properties?.moveCost === 'number' ? definition.properties.moveCost : 1;
};

const navigation = GridSpace.from(loaded.width, loaded.height, (x, y) => walkCost(ground.getTileAt(x, y)), {
originX: loaded.x,
originY: loaded.y,
cellSize: ground.tileWidth,
});
// #endregion guide:tilemap-bridge

// #region guide:tilemap-edit
const setTile = (x: number, y: number, tile: ResolvedTile): void => {
ground.setTileAt(x, y, tile);
navigation.setCost(x, y, walkCost(tile));
};
// #endregion guide:tilemap-edit

void setTile;
41 changes: 41 additions & 0 deletions examples/guides/pathfinding/waypoint-graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Pathfinder, WaypointGraph } from '@codexo/exojs-pathfinding';

declare const controller: { walkTo: (x: number, y: number) => void; jump: (impulse: number) => void };

// #region guide:waypoint-graph
interface Move {
readonly impulse: number;
}

const graph = new WaypointGraph<Move>();

const ledge = graph.addNode(120, 400);
const gap = graph.addNode(240, 400);
const platform = graph.addNode(420, 260);

graph.connect(ledge, gap); // cost defaults to the straight-line distance
graph.addEdge(gap, platform, { kind: 'jump', cost: 90, data: { impulse: 520 } });
graph.addEdge(platform, gap, { kind: 'fall', cost: 30 });
// #endregion guide:waypoint-graph

// #region guide:waypoint-follow
const route = new Pathfinder().findPath(graph, ledge, platform);

for (let index = 0; index < route.edges.length; index++) {
const step = route.edges[index]!;
const arrival = route.points[index + 1]!;

if (step.kind === 'jump' && step.data !== null) controller.jump(step.data.impulse);
else controller.walkTo(arrival.x, arrival.y);
}
// #endregion guide:waypoint-follow

// #region guide:dijkstra-mode
// No positions: the heuristic is zero and the same search is plain Dijkstra
// over an abstract graph.
const routing = new WaypointGraph();
const cache = routing.addNode();
const origin = routing.addNode();

routing.connect(origin, cache, { cost: 12 });
// #endregion guide:dijkstra-mode
Loading
Loading