From 01a62f7864f10806616685030675876bb05553d2 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 4 Sep 2026 23:21:14 +0200 Subject: [PATCH 1/4] feat(pathfinding): add the @codexo/exojs-pathfinding extension package One search core - A* over integer node handles, with an optional pruned expansion - serving two navigation spaces. GridSpace is a finite window of weighted cells with diagonal policies, brushfire clearance for wide agents and string-pulling smoothing; WaypointGraph is a directed graph whose edges carry a kind and a payload, which is what a platformer needs and what a grid cannot express. Both implement NavigationSpace, and so can application code. The window is finite by construction rather than chunk-aware, which is the answer for streamed maps: size it to the loaded region and push edits through setCost. That keeps every search terminating without the package learning what a tilemap is - the integration is a cost callback, not a package edge. Jump-point search is a pruned successor generator the grid hands to the same A*, not a second solver, and it self-enables only where its assumptions hold: uniform costs, agentSize 1, corner cutting disallowed. Its pruning rules are derived for that stricter movement rule and differ from the textbook ones - a diagonal step has no forced neighbours at all, and a straight step gains them from the cell diagonally behind it. A randomized suite pins jump-point cost against plain A* cost on the same grids. Determinism is a contract: equal-f nodes leave the open list by ascending node id, so a query is reproducible across runs and machines. The search state lives in typed arrays reset by a generation counter, so interleaved queries over different spaces cost one counter increment and a search allocates nothing that scales with the nodes it visits - gated by an allocation suite that budgets a query against the result it returns while expanding an order of magnitude more nodes than the path is long. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- .codecov.yml | 4 + .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 1 + eslint.config.ts | 13 + package.json | 8 +- packages/exojs-pathfinding/LICENSE | 21 + packages/exojs-pathfinding/README.md | 172 +++++++ packages/exojs-pathfinding/package.json | 46 ++ packages/exojs-pathfinding/rolldown.config.ts | 10 + packages/exojs-pathfinding/src/Pathfinder.ts | 200 +++++++++ .../exojs-pathfinding/src/core/BinaryHeap.ts | 111 +++++ .../exojs-pathfinding/src/core/SearchState.ts | 72 +++ packages/exojs-pathfinding/src/core/search.ts | 157 +++++++ packages/exojs-pathfinding/src/index.ts | 3 + packages/exojs-pathfinding/src/public.ts | 10 + .../src/spaces/GridJumpExpansion.ts | 184 ++++++++ .../exojs-pathfinding/src/spaces/GridSpace.ts | 425 ++++++++++++++++++ .../src/spaces/WaypointGraph.ts | 238 ++++++++++ .../src/spaces/gridGeometry.ts | 22 + packages/exojs-pathfinding/src/types.ts | 189 ++++++++ .../exojs-pathfinding/test/GridSpace.test.ts | 151 +++++++ .../exojs-pathfinding/test/Pathfinder.test.ts | 188 ++++++++ .../test/WaypointGraph.test.ts | 142 ++++++ .../exojs-pathfinding/test/allocation.test.ts | 164 +++++++ packages/exojs-pathfinding/test/helpers.ts | 130 ++++++ .../test/jumpPointSearch.test.ts | 111 +++++ .../exojs-pathfinding/test/smoothing.test.ts | 106 +++++ .../exojs-pathfinding/tsconfig.build.json | 12 + packages/exojs-pathfinding/tsconfig.json | 11 + packages/exojs-pathfinding/tsconfig.test.json | 7 + pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + scripts/ci/lanes.ts | 2 +- scripts/ci/select-lanes.ts | 1 + scripts/exo-full.entry.ts | 3 + scripts/release/RELEASING.md | 8 +- scripts/release/external-consumers.ts | 8 + scripts/release/lockstep-packages.ts | 1 + site/public/preview.html | 4 + site/scripts/build-api.ts | 7 + site/scripts/sync-exo-vendor.ts | 1 + site/src/components/EditorCode.tsx | 1 + site/src/lib/api-reference.ts | 5 + test/tsconfig.json | 1 + tsconfig.examples.json | 1 + tsconfig.guides.json | 3 +- vitest.config.ts | 7 + 47 files changed, 2962 insertions(+), 11 deletions(-) create mode 100644 packages/exojs-pathfinding/LICENSE create mode 100644 packages/exojs-pathfinding/README.md create mode 100644 packages/exojs-pathfinding/package.json create mode 100644 packages/exojs-pathfinding/rolldown.config.ts create mode 100644 packages/exojs-pathfinding/src/Pathfinder.ts create mode 100644 packages/exojs-pathfinding/src/core/BinaryHeap.ts create mode 100644 packages/exojs-pathfinding/src/core/SearchState.ts create mode 100644 packages/exojs-pathfinding/src/core/search.ts create mode 100644 packages/exojs-pathfinding/src/index.ts create mode 100644 packages/exojs-pathfinding/src/public.ts create mode 100644 packages/exojs-pathfinding/src/spaces/GridJumpExpansion.ts create mode 100644 packages/exojs-pathfinding/src/spaces/GridSpace.ts create mode 100644 packages/exojs-pathfinding/src/spaces/WaypointGraph.ts create mode 100644 packages/exojs-pathfinding/src/spaces/gridGeometry.ts create mode 100644 packages/exojs-pathfinding/src/types.ts create mode 100644 packages/exojs-pathfinding/test/GridSpace.test.ts create mode 100644 packages/exojs-pathfinding/test/Pathfinder.test.ts create mode 100644 packages/exojs-pathfinding/test/WaypointGraph.test.ts create mode 100644 packages/exojs-pathfinding/test/allocation.test.ts create mode 100644 packages/exojs-pathfinding/test/helpers.ts create mode 100644 packages/exojs-pathfinding/test/jumpPointSearch.test.ts create mode 100644 packages/exojs-pathfinding/test/smoothing.test.ts create mode 100644 packages/exojs-pathfinding/tsconfig.build.json create mode 100644 packages/exojs-pathfinding/tsconfig.json create mode 100644 packages/exojs-pathfinding/tsconfig.test.json diff --git a/.codecov.yml b/.codecov.yml index 2dda7dd0f..d5a2db76e 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -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: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0e38510a..19e429a62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 189244deb..5284a4f9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/eslint.config.ts b/eslint.config.ts index df10670bd..794285158 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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 diff --git a/package.json b/package.json index b90d5bb05..9eb8fb6b3 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "verify:exports": "tsx ./scripts/verify-exports.ts", "verify:declaration-imports": "tsx ./scripts/verify-declaration-imports.ts", "verify:package-policy": "tsx ./scripts/verify-package-policy.ts", - "verify:publint": "pnpm dlx publint@0.3.21 --strict . && pnpm --filter \"@codexo/exojs-build\" --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\" exec pnpm dlx publint@0.3.21 --strict .", + "verify:publint": "pnpm dlx publint@0.3.21 --strict . && pnpm --filter \"@codexo/exojs-build\" --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-pathfinding\" --filter \"@codexo/exojs-audio-fx\" exec pnpm dlx publint@0.3.21 --strict .", "verify:package": "pnpm build && pnpm verify:exports && pnpm verify:declaration-imports && pnpm pack", "verify:lockstep": "tsx ./scripts/verify-lockstep-versions.ts", "verify:release-matrix": "tsx ./scripts/verify-release-matrix.ts", @@ -115,7 +115,7 @@ "typecheck:guides:update-baseline": "tsx scripts/extract-guide-snippets.ts --update-baseline", "typecheck:guides:no-check": "tsx scripts/check-guide-no-check-reasons.ts", "typecheck:guides:no-check:update-baseline": "tsx scripts/check-guide-no-check-reasons.ts --update-baseline", - "typecheck:packages": "pnpm --filter \"@codexo/exojs-build\" --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-aseprite\" --filter \"@codexo/exojs-ldtk\" --filter \"@codexo/exojs-react\" typecheck", + "typecheck:packages": "pnpm --filter \"@codexo/exojs-build\" --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-pathfinding\" --filter \"@codexo/exojs-audio-fx\" --filter \"@codexo/exojs-aseprite\" --filter \"@codexo/exojs-ldtk\" --filter \"@codexo/exojs-react\" typecheck", "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "typecheck:site": "pnpm --filter @codexo/exojs-examples check-ts", "typecheck:site-scripts": "pnpm --filter @codexo/exojs-examples check-ts:scripts", @@ -140,9 +140,9 @@ "lint:shaders": "tsx scripts/check-shader-sources.ts", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf --project=rendering-alloc", + "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf --project=rendering-alloc", "test:core": "vitest run --project=exojs", - "test:coverage": "vitest run --coverage --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf", + "test:coverage": "vitest run --coverage --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf", "test:alloc": "vitest run --project=rendering-alloc", "test:watch": "vitest --project=exojs", "test:production-stripping": "vitest run --project=exojs test/build-defines/production-stripping.test.ts", diff --git a/packages/exojs-pathfinding/LICENSE b/packages/exojs-pathfinding/LICENSE new file mode 100644 index 000000000..dfb7cd04a --- /dev/null +++ b/packages/exojs-pathfinding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Codexo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/exojs-pathfinding/README.md b/packages/exojs-pathfinding/README.md new file mode 100644 index 000000000..9b0d96157 --- /dev/null +++ b/packages/exojs-pathfinding/README.md @@ -0,0 +1,172 @@ +# @codexo/exojs-pathfinding + +Official ExoJS extension for 2D pathfinding. One search core - A\* with jump-point pruning - +over pluggable navigation spaces: weighted grids for top-down worlds, waypoint graphs for +platformers and for graphs that have no geometry at all. + +It is plain logic. No scene node, no renderer, no asset type, no registration step: you +construct a space and a `Pathfinder`, and you own both. + +## Installation + +```sh +npm install @codexo/exojs @codexo/exojs-pathfinding +``` + +`@codexo/exojs` is a peer dependency and the only one. In particular this package does **not** +depend on `@codexo/exojs-tilemap`: feeding a tilemap into a grid is three lines of your code, +shown below. + +## What this package provides + +- `Pathfinder` - runs the queries and owns the reusable search buffers. +- `GridSpace` - a finite window of weighted cells, with diagonal policies, per-cell costs, + clearance for wide agents, string-pulling path smoothing, and a jump-point fast path. +- `WaypointGraph` - a directed graph of hand-placed nodes whose edges carry a `kind` and a + payload, so a path can tell a jump from a walk. +- `NavigationSpace` - the interface both implement, and the one your own space implements when + neither fits. + +## Capability matrix + +| Capability | `GridSpace` | `WaypointGraph` | +| ----------------------------------- | ------------------------------------------- | -------------------------------- | +| Per-step costs | per cell, `0` blocks | per edge | +| Heuristic | octile / Manhattan, scaled by cheapest cell | straight-line distance | +| Directed traversal | no - movement is symmetric | yes | +| Traversal kinds (`walk`/`jump`/...) | no | yes, with an arbitrary payload | +| Positionless (pure Dijkstra) | no | yes, when a node has no position | +| Clearance / wide agents | yes, `agentSize` | no | +| Path smoothing | yes, string pulling | no | +| Jump-point pruning | yes, on a uniform-cost grid | no | +| Mutable at runtime | `setCost` | `addNode`/`addEdge`/`remove*` | + +## Usage + +```ts +import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding'; + +const grid = GridSpace.from(64, 64, (x, y) => (isWall(x, y) ? 0 : terrainCost(x, y)), { + cellSize: 32, +}); +const pathfinder = new Pathfinder(); + +const result = pathfinder.findPathBetween(grid, hero.x, hero.y, target.x, target.y, { + smooth: true, +}); + +if (result.status === 'found') { + hero.follow(result.points); +} +``` + +`findPath` takes node ids, `findPathBetween` takes world coordinates. Both return the same +`PathResult`, and `status` is a value rather than an exception, because "no path" is an ordinary +game state: + +| `status` | Meaning | +| ----------------- | -------------------------------------------------------------------- | +| `found` | Complete, cost-optimal path. | +| `unreachable` | The search exhausted the space. Empty path, unless `snapToNearest`. | +| `budget-exceeded` | `maxExpandedNodes` ran out. The best partial path is still returned. | + +`result.revision` records `space.revision` at search time, so a follower can notice that the +world changed under its path and ask for a new one. + +## Grids + +Coordinates are absolute cell coordinates - the same numbers your map uses - not offsets into +the window. The window itself is finite by construction, and everything outside it is blocked; +that is the answer for infinite or streamed maps: size the window to the region the actors are +in, and push chunk changes into it with `setCost`. + +```ts +// Streamed tilemap, no package dependency in either direction. +const window = GridSpace.from(96, 96, (x, y) => walkCost(map.getTileAt(layerId, x, y)), { + originX: chunkX * 32, + originY: chunkY * 32, + cellSize: map.tileWidth, +}); + +map.onTileChanged(({ x, y, tile }) => window.setCost(x, y, walkCost(tile))); +``` + +Cost `0` blocks a cell, `1` is ordinary ground, larger values are terrain the search routes +around when the detour is cheaper. Diagonal steps cost their length, and the default diagonal +policy (`'no-corner-cutting'`) forbids the diagonal that would clip through the corner where two +walls meet. + +`agentSize` restricts a query to cells where an agent that many cells wide fits. Clearance is +anchored at a cell's **top-left** corner, so the last row and column of a window can never hold +an agent wider than one cell. + +## Jump-point search + +`GridSpace` substitutes jump-point search for plain neighbour expansion whenever the grid is +uniform-cost, `agentSize` is `1`, and the diagonal policy is the default. It returns the same +cost-optimal path while expanding a fraction of the nodes; `result.expandedNodes` shows the +difference. Nothing has to be switched on, and `{ pruning: false }` switches it off. + +Because pruning settles only jump points, a `budget-exceeded` partial path under pruning ends on +a jump point rather than on the nearest cell. `snapToNearest` is unaffected: when the goal turns +out to be unreachable, the query re-runs unpruned so the snapped node really is the closest one. + +## Waypoint graphs + +```ts +import { Pathfinder, WaypointGraph } from '@codexo/exojs-pathfinding'; + +interface Move { + readonly impulse: number; +} + +const graph = new WaypointGraph(); +const ledge = graph.addNode(120, 400); +const platform = graph.addNode(320, 260); + +graph.connect(ledge, platform, { kind: 'jump', data: { impulse: 520 }, cost: 40 }); + +for (const step of new Pathfinder().findPath(graph, ledge, platform).edges) { + controller.execute(step.kind, step.data); +} +``` + +Node positions are optional. Leave them out and the heuristic drops to zero, which turns the +same search into plain Dijkstra over an abstract graph - the shape a web application's routing +problem usually has. + +## Reachable-area queries + +`floodFrom` returns every node within a cost budget, cheapest first: the "tiles I can still +reach with the movement points I have left" query, and the input a flow field would be built +from. + +```ts +const region = pathfinder.floodFrom(grid, grid.nodeAt(unit.tileX, unit.tileY), { maxCost: 6 }); + +for (const node of region.nodes) { + highlight(grid.nodeX(node), grid.nodeY(node)); +} +``` + +## Determinism and allocation + +Equal-cost paths are resolved by a pinned tie-break (lower node id first), so the same query on +an unmutated space returns the identical path on every machine and every run - which is what +makes paths safe to record in a replay or assert in a test. + +A `Pathfinder` owns its search state and reuses it across queries, including queries against +different spaces of different sizes. Once the buffers have grown to fit, a search allocates +nothing that scales with the number of nodes it visits. The result object is allocated fresh +every time, deliberately: callers keep paths, and pooling something a caller keeps buys a little +garbage back at the price of use-after-reuse bugs. + +## Custom spaces + +Implement `NavigationSpace` and every query in this package works against it. `neighbors` +receives the pathfinder's own buffers instead of returning an array, so a third-party space is +allocation-free on the same terms as the built-in ones. + +## License + +MIT diff --git a/packages/exojs-pathfinding/package.json b/packages/exojs-pathfinding/package.json new file mode 100644 index 000000000..528ee0fbf --- /dev/null +++ b/packages/exojs-pathfinding/package.json @@ -0,0 +1,46 @@ +{ + "name": "@codexo/exojs-pathfinding", + "version": "0.16.2", + "description": "Deterministic, allocation-free 2D pathfinding for ExoJS: A*, jump-point search, weighted grids and waypoint graphs.", + "repository": { + "type": "git", + "url": "git+https://github.com/Exoridus/ExoJS.git", + "directory": "packages/exojs-pathfinding" + }, + "type": "module", + "sideEffects": false, + "main": "./dist/esm/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/esm/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsx ../../scripts/build-extension.ts", + "build:dev": "tsx ../../scripts/build-extension.ts --dev", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "test": "vitest run --root ../.. --project=exojs-pathfinding" + }, + "peerDependencies": { + "@codexo/exojs": "0.16.x" + }, + "devDependencies": { + "@codexo/exojs": "workspace:*", + "@codexo/exojs-config": "workspace:*" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/exojs-pathfinding/rolldown.config.ts b/packages/exojs-pathfinding/rolldown.config.ts new file mode 100644 index 000000000..682a481fd --- /dev/null +++ b/packages/exojs-pathfinding/rolldown.config.ts @@ -0,0 +1,10 @@ +import { createExtensionBuildOptions } from '@codexo/exojs-config/rolldown'; + +// @codexo/exojs-pathfinding is a library package: a single side-effect-free +// entry. No package-internal `#` imports (all relative `./`), so no source +// condition / node-resolve is needed; Core's `#` resolves to its dist. +export default createExtensionBuildOptions({ + root: import.meta.dirname, + sourceCondition: null, + inputs: ['src/index.ts'], +}); diff --git a/packages/exojs-pathfinding/src/Pathfinder.ts b/packages/exojs-pathfinding/src/Pathfinder.ts new file mode 100644 index 000000000..0b48b8be7 --- /dev/null +++ b/packages/exojs-pathfinding/src/Pathfinder.ts @@ -0,0 +1,200 @@ +import { Vector } from '@codexo/exojs'; + +import { runFlood, runSearch } from './core/search'; +import { SearchState } from './core/SearchState'; +import type { FindPathOptions, FloodOptions, FloodRegion, NavigationSpace, PathEdge, PathResult, PrunedExpansion } from './types'; + +const EMPTY_NODES: readonly number[] = Object.freeze([]); +const EMPTY_POINTS: readonly Vector[] = Object.freeze([]); +const EMPTY_EDGES: ReadonlyArray> = Object.freeze([]); + +/** + * Runs path queries against any {@link NavigationSpace}. + * + * One pathfinder owns the search buffers and reuses them across every query, + * including queries against different spaces of different sizes, so a search + * itself allocates nothing once the buffers have reached their size. The result + * objects are freshly allocated by design: callers hold on to a path, and + * pooling something a caller retains trades a little garbage for use-after-reuse + * bugs. + * + * A pathfinder holds no world state and no lifecycle - construct one per system + * that needs paths, or share one, as long as queries do not interleave with a + * mutation of the space being searched. + */ +export class Pathfinder { + private readonly state = new SearchState(); + private readonly reversed: number[] = []; + + /** + * Finds a cost-optimal path between two node ids. + * + * `unreachable` yields an empty path unless {@link FindPathOptions.snapToNearest} + * is set; `budget-exceeded` always carries the best partial path found, which + * is a real, traversable prefix and not a guess at the rest. + */ + public findPath(space: NavigationSpace, start: number, goal: number, options: FindPathOptions = {}): PathResult { + const agentSize = options.agentSize ?? 1; + const budget = options.maxExpandedNodes ?? 0; + const snapToNearest = options.snapToNearest === true; + + this.state.reserve(space.nodeCapacity, space.maxDegree); + + let expansion = options.pruning === false || space.pruning === undefined ? null : space.pruning(agentSize); + let outcome = runSearch(this.state, space, start, goal, agentSize, budget, expansion); + + // A pruned expansion only ever settles jump points, so the closest node it + // can name is a jump point and not the nearest reachable one. Re-running + // unpruned costs a second search, but only on the path where the first one + // already failed to reach the goal. + if (outcome.status === 'unreachable' && snapToNearest && expansion !== null) { + expansion = null; + outcome = runSearch(this.state, space, start, goal, agentSize, budget, null); + } + + const snapped = outcome.status === 'unreachable' && snapToNearest; + + if (outcome.status === 'unreachable' && !snapped) { + return { + status: 'unreachable', + nodes: EMPTY_NODES, + points: EMPTY_POINTS, + edges: EMPTY_EDGES, + cost: 0, + revision: space.revision, + expandedNodes: outcome.expandedNodes, + }; + } + + const nodes: number[] = []; + + this.reconstruct(outcome.endNode, expansion, nodes); + + let path = nodes; + let smoothed = false; + + if (options.smooth === true && space.smoothPath !== undefined) { + path = space.smoothPath(nodes, agentSize); + smoothed = true; + } + + return { + status: snapped ? 'found' : outcome.status, + nodes: path, + points: this.toPoints(space, path), + // A smoothed path no longer steps along space edges, so there is nothing + // truthful to report for it. + edges: smoothed ? EMPTY_EDGES : this.toEdges(space, path), + cost: outcome.cost, + revision: space.revision, + expandedNodes: outcome.expandedNodes, + }; + } + + /** + * {@link findPath} between two points instead of node ids. A point outside the + * space makes the query `unreachable` unless + * {@link FindPathOptions.snapToNearest} is set and the space can resolve a + * nearest node. + */ + public findPathBetween( + space: NavigationSpace, + startX: number, + startY: number, + goalX: number, + goalY: number, + options: FindPathOptions = {}, + ): PathResult { + const snap = options.snapToNearest === true; + const start = this.resolve(space, startX, startY, snap); + const goal = this.resolve(space, goalX, goalY, snap); + + if (start < 0 || goal < 0) { + return { + status: 'unreachable', + nodes: EMPTY_NODES, + points: EMPTY_POINTS, + edges: EMPTY_EDGES, + cost: 0, + revision: space.revision, + expandedNodes: 0, + }; + } + + return this.findPath(space, start, goal, options); + } + + /** + * Every node reachable from `origin` within {@link FloodOptions.maxCost}, with + * its cost - the "tiles I can still move to this turn" query, and the input a + * flow field for many agents heading to one goal is built from. + */ + public floodFrom(space: NavigationSpace, origin: number, options: FloodOptions = {}): FloodRegion { + const nodes: number[] = []; + const costs: number[] = []; + + this.state.reserve(space.nodeCapacity, space.maxDegree); + + const expandedNodes = runFlood(this.state, space, origin, options.agentSize ?? 1, options.maxCost ?? Infinity, options.maxExpandedNodes ?? 0, nodes, costs); + + return { nodes, costs, revision: space.revision, expandedNodes }; + } + + private resolve(space: NavigationSpace, x: number, y: number, snap: boolean): number { + const node = space.pointToNode(x, y); + + if (node >= 0 || !snap || space.nearestNode === undefined) return node; + + return space.nearestNode(x, y); + } + + /** + * Walks the parent chain back from `end` and writes it forwards into `out`. + * With a pruned expansion the chain holds jump points only, so each hop is + * handed back to the expansion to fill in the run it skipped. + */ + private reconstruct(end: number, expansion: PrunedExpansion | null, out: number[]): void { + const { reversed } = this; + const { parent } = this.state; + + reversed.length = 0; + + for (let node = end; node >= 0; node = parent[node]!) { + reversed.push(node); + } + + out.push(reversed[reversed.length - 1]!); + + for (let index = reversed.length - 2; index >= 0; index--) { + if (expansion === null) out.push(reversed[index]!); + else expansion.expand(reversed[index + 1]!, reversed[index]!, out); + } + } + + private toPoints(space: NavigationSpace, nodes: readonly number[]): Vector[] { + const points: Vector[] = []; + + for (let index = 0; index < nodes.length; index++) { + const point = new Vector(); + + space.nodeToPoint(nodes[index]!, point); + points.push(point); + } + + return points; + } + + private toEdges(space: NavigationSpace, nodes: readonly number[]): ReadonlyArray> { + if (space.describeEdge === undefined) return EMPTY_EDGES; + + const edges: Array> = []; + + for (let index = 1; index < nodes.length; index++) { + const edge = space.describeEdge(nodes[index - 1]!, nodes[index]!); + + if (edge !== null) edges.push(edge); + } + + return edges; + } +} diff --git a/packages/exojs-pathfinding/src/core/BinaryHeap.ts b/packages/exojs-pathfinding/src/core/BinaryHeap.ts new file mode 100644 index 000000000..7b379f8a3 --- /dev/null +++ b/packages/exojs-pathfinding/src/core/BinaryHeap.ts @@ -0,0 +1,111 @@ +/** + * Min-heap of `(key, node)` pairs over two parallel typed arrays. + * + * Ties on `key` are broken by the lower node id, which is what makes a search + * reproducible: equal-f nodes come off the open list in the same order on every + * platform and every run, so two identical queries return the identical path + * rather than an arbitrary member of the optimal set. + * + * The heap grows by doubling and never shrinks, so a pathfinder that has run + * once at a given problem size does not allocate here again. + * + * @internal + */ +export class BinaryHeap { + private nodes: Int32Array; + private keys: Float64Array; + private count = 0; + + public constructor(capacity = 64) { + this.nodes = new Int32Array(capacity); + this.keys = new Float64Array(capacity); + } + + public get size(): number { + return this.count; + } + + public clear(): void { + this.count = 0; + } + + public push(node: number, key: number): void { + if (this.count === this.nodes.length) this.grow(); + + let index = this.count++; + + this.nodes[index] = node; + this.keys[index] = key; + + while (index > 0) { + const parent = (index - 1) >> 1; + + if (!this.less(index, parent)) break; + + this.swap(index, parent); + index = parent; + } + } + + /** Removes and returns the minimum node. The heap must not be empty. */ + public pop(): number { + const top = this.nodes[0]!; + const last = --this.count; + + if (last > 0) { + this.nodes[0] = this.nodes[last]!; + this.keys[0] = this.keys[last]!; + this.sink(); + } + + return top; + } + + private less(a: number, b: number): boolean { + const keyA = this.keys[a]!; + const keyB = this.keys[b]!; + + if (keyA !== keyB) return keyA < keyB; + + return this.nodes[a]! < this.nodes[b]!; + } + + private swap(a: number, b: number): void { + const node = this.nodes[a]!; + const key = this.keys[a]!; + + this.nodes[a] = this.nodes[b]!; + this.keys[a] = this.keys[b]!; + this.nodes[b] = node; + this.keys[b] = key; + } + + private sink(): void { + let index = 0; + + for (;;) { + const left = index * 2 + 1; + + if (left >= this.count) break; + + const right = left + 1; + const child = right < this.count && this.less(right, left) ? right : left; + + if (!this.less(child, index)) break; + + this.swap(index, child); + index = child; + } + } + + private grow(): void { + const nodes = new Int32Array(this.nodes.length * 2); + const keys = new Float64Array(this.keys.length * 2); + + nodes.set(this.nodes); + keys.set(this.keys); + + this.nodes = nodes; + this.keys = keys; + } +} diff --git a/packages/exojs-pathfinding/src/core/SearchState.ts b/packages/exojs-pathfinding/src/core/SearchState.ts new file mode 100644 index 000000000..f13cac671 --- /dev/null +++ b/packages/exojs-pathfinding/src/core/SearchState.ts @@ -0,0 +1,72 @@ +import { BinaryHeap } from './BinaryHeap'; + +/** Largest generation stamp before the counter has to wrap. */ +const MAX_GENERATION = 0xffffffff; + +/** + * Reusable per-search bookkeeping: g-scores, parent links, the closed set, the + * open list and the neighbour scratch buffers. + * + * Nothing is cleared between searches. A node's g-score, parent and closed flag + * count only while its generation stamp matches the current one, so starting a + * search is a single counter increment instead of an O(nodeCapacity) wipe. That + * is what lets one pathfinder serve interleaved queries over several spaces of + * different sizes without touching memory it does not visit. + * + * @internal + */ +export class SearchState { + public gScore = new Float64Array(0); + public parent = new Int32Array(0); + public closed = new Uint8Array(0); + public neighborNodes = new Int32Array(0); + public neighborCosts = new Float64Array(0); + public readonly heap = new BinaryHeap(); + + private stamp = new Uint32Array(0); + private generation = 0; + + /** Grows the buffers to fit a space, keeping the generation stamps valid. */ + public reserve(nodeCapacity: number, degree: number): void { + if (nodeCapacity > this.gScore.length) { + const stamp = new Uint32Array(nodeCapacity); + + stamp.set(this.stamp); + + this.gScore = new Float64Array(nodeCapacity); + this.parent = new Int32Array(nodeCapacity); + this.closed = new Uint8Array(nodeCapacity); + this.stamp = stamp; + } + + if (degree > this.neighborNodes.length) { + this.neighborNodes = new Int32Array(degree); + this.neighborCosts = new Float64Array(degree); + } + } + + public begin(): void { + if (this.generation === MAX_GENERATION) { + this.stamp.fill(0); + this.generation = 0; + } + + this.generation++; + this.heap.clear(); + } + + /** + * Brings a node into the current search, resetting it on first touch. + * Returns `true` when the node was already part of this search. + */ + public touch(node: number): boolean { + if (this.stamp[node] === this.generation) return true; + + this.stamp[node] = this.generation; + this.gScore[node] = Infinity; + this.parent[node] = -1; + this.closed[node] = 0; + + return false; + } +} diff --git a/packages/exojs-pathfinding/src/core/search.ts b/packages/exojs-pathfinding/src/core/search.ts new file mode 100644 index 000000000..f1d8580a2 --- /dev/null +++ b/packages/exojs-pathfinding/src/core/search.ts @@ -0,0 +1,157 @@ +import type { NavigationSpace, PathStatus, PrunedExpansion } from '../types'; +import type { SearchState } from './SearchState'; + +/** What a completed search leaves behind for path reconstruction. @internal */ +export interface SearchOutcome { + status: PathStatus; + /** Goal node when the goal was reached, otherwise the best node seen. */ + endNode: number; + cost: number; + expandedNodes: number; +} + +/** + * A* over a {@link NavigationSpace}, with an optional pruned expansion. + * + * Successors come either from `space.neighbors` or, when `expansion` is given, + * from a parent-dependent pruned generator. Both feed the same relaxation, so + * jump-point search is not a second solver: it is the same A* over a smaller + * successor set. + * + * `endNode` is the goal on success and otherwise the reachable node with the + * lowest heuristic, which is what makes both a budget-exceeded result and a + * snapped result a real path rather than an empty one. + * + * @internal + */ +export const runSearch = ( + state: SearchState, + space: NavigationSpace, + start: number, + goal: number, + agentSize: number, + maxExpandedNodes: number, + expansion: PrunedExpansion | null, +): SearchOutcome => { + const { gScore, parent, closed, neighborNodes, neighborCosts, heap } = state; + + state.begin(); + state.touch(start); + gScore[start] = 0; + + let bestNode = start; + let bestHeuristic = space.heuristic(start, goal); + let expandedNodes = 0; + + heap.push(start, bestHeuristic); + + while (heap.size > 0) { + const node = heap.pop(); + + if (closed[node] === 1) continue; + + closed[node] = 1; + expandedNodes++; + + if (node === goal) { + return { status: 'found', endNode: goal, cost: gScore[goal]!, expandedNodes }; + } + + const heuristic = space.heuristic(node, goal); + + if (heuristic < bestHeuristic) { + bestHeuristic = heuristic; + bestNode = node; + } + + if (maxExpandedNodes > 0 && expandedNodes >= maxExpandedNodes) { + return { status: 'budget-exceeded', endNode: bestNode, cost: gScore[bestNode]!, expandedNodes }; + } + + const count = + expansion === null + ? space.neighbors(node, agentSize, neighborNodes, neighborCosts) + : expansion.successors(node, parent[node]!, goal, neighborNodes, neighborCosts); + const nodeCost = gScore[node]!; + + for (let i = 0; i < count; i++) { + const next = neighborNodes[i]!; + + state.touch(next); + + if (closed[next] === 1) continue; + + const tentative = nodeCost + neighborCosts[i]!; + + if (tentative >= gScore[next]!) continue; + + gScore[next] = tentative; + parent[next] = node; + heap.push(next, tentative + space.heuristic(next, goal)); + } + } + + return { status: 'unreachable', endNode: bestNode, cost: gScore[bestNode]!, expandedNodes }; +}; + +/** + * Dijkstra flood from `origin`, appending every settled node and its cost to + * the output arrays in settle order. + * + * @internal + */ +export const runFlood = ( + state: SearchState, + space: NavigationSpace, + origin: number, + agentSize: number, + maxCost: number, + maxExpandedNodes: number, + outNodes: number[], + outCosts: number[], +): number => { + const { gScore, closed, neighborNodes, neighborCosts, heap } = state; + + state.begin(); + state.touch(origin); + gScore[origin] = 0; + heap.push(origin, 0); + + let expandedNodes = 0; + + while (heap.size > 0) { + const node = heap.pop(); + + if (closed[node] === 1) continue; + + const cost = gScore[node]!; + + if (cost > maxCost) break; + + closed[node] = 1; + expandedNodes++; + outNodes.push(node); + outCosts.push(cost); + + if (maxExpandedNodes > 0 && expandedNodes >= maxExpandedNodes) break; + + const count = space.neighbors(node, agentSize, neighborNodes, neighborCosts); + + for (let i = 0; i < count; i++) { + const next = neighborNodes[i]!; + + state.touch(next); + + if (closed[next] === 1) continue; + + const tentative = cost + neighborCosts[i]!; + + if (tentative >= gScore[next]! || tentative > maxCost) continue; + + gScore[next] = tentative; + heap.push(next, tentative); + } + } + + return expandedNodes; +}; diff --git a/packages/exojs-pathfinding/src/index.ts b/packages/exojs-pathfinding/src/index.ts new file mode 100644 index 000000000..a45c57f37 --- /dev/null +++ b/packages/exojs-pathfinding/src/index.ts @@ -0,0 +1,3 @@ +// @codexo/exojs-pathfinding - side-effect-free root entry. + +export * from './public'; diff --git a/packages/exojs-pathfinding/src/public.ts b/packages/exojs-pathfinding/src/public.ts new file mode 100644 index 000000000..b1243af5c --- /dev/null +++ b/packages/exojs-pathfinding/src/public.ts @@ -0,0 +1,10 @@ +// Side-effect-free public API for @codexo/exojs-pathfinding. +// Importing this entry performs no registration: a Pathfinder and a space are +// constructed directly, and nothing is added to the application or the scene. + +export { Pathfinder } from './Pathfinder'; +export type { DiagonalPolicy, GridSpaceOptions } from './spaces/GridSpace'; +export { GridSpace } from './spaces/GridSpace'; +export type { WaypointEdgeOptions } from './spaces/WaypointGraph'; +export { WaypointGraph } from './spaces/WaypointGraph'; +export type { FindPathOptions, FloodOptions, FloodRegion, NavigationSpace, PathEdge, PathResult, PathStatus, PrunedExpansion } from './types'; diff --git a/packages/exojs-pathfinding/src/spaces/GridJumpExpansion.ts b/packages/exojs-pathfinding/src/spaces/GridJumpExpansion.ts new file mode 100644 index 000000000..451aebb15 --- /dev/null +++ b/packages/exojs-pathfinding/src/spaces/GridJumpExpansion.ts @@ -0,0 +1,184 @@ +import type { PrunedExpansion } from '../types'; +import { DIRECTION_X, DIRECTION_Y, runLength } from './gridGeometry'; + +/** + * Jump-point search over a uniform-cost grid with corner cutting disallowed. + * + * Instead of every walkable neighbour, a node reports only the *jump points* + * reachable from it: the first cell along each surviving direction where the + * obstacle layout makes a turn worth considering. Everything between two jump + * points is a forced straight or diagonal run, so the search skips it entirely + * and expands orders of magnitude fewer nodes for the same optimal path. + * + * The pruning rules below are the corner-cutting-free ones and differ from the + * textbook formulation, which assumes a diagonal may pass between two blocked + * cells. Under the stricter rule the step into a node guarantees that both + * cells beside its predecessor are walkable, so a diagonal step has no forced + * neighbours at all, and a straight step gains them from the cell diagonally + * *behind* it being blocked rather than the one beside it. + * + * @internal + */ +export class GridJumpExpansion implements PrunedExpansion { + private readonly costs: Float32Array; + private readonly width: number; + private readonly height: number; + + public constructor(costs: Float32Array, width: number, height: number) { + this.costs = costs; + this.width = width; + this.height = height; + } + + public successors(node: number, parent: number, goal: number, outNodes: Int32Array, outCosts: Float64Array): number { + const { width } = this; + const x = node % width; + const y = (node / width) | 0; + const goalX = goal % width; + const goalY = (goal / width) | 0; + + let count = 0; + + if (parent < 0) { + for (let direction = 0; direction < 8; direction++) { + count = this.emit(x, y, DIRECTION_X[direction]!, DIRECTION_Y[direction]!, goalX, goalY, outNodes, outCosts, count); + } + + return count; + } + + const stepX = Math.sign(x - (parent % width)); + const stepY = Math.sign(y - ((parent / width) | 0)); + + if (stepX !== 0 && stepY !== 0) { + count = this.emit(x, y, stepX, 0, goalX, goalY, outNodes, outCosts, count); + count = this.emit(x, y, 0, stepY, goalX, goalY, outNodes, outCosts, count); + + return this.emit(x, y, stepX, stepY, goalX, goalY, outNodes, outCosts, count); + } + + // The side loops below are unrolled rather than iterated: this runs once per + // expanded node, and a `for...of` over a two-element array would allocate a + // fresh iterator every time. + if (stepY === 0) { + count = this.emit(x, y, stepX, 0, goalX, goalY, outNodes, outCosts, count); + + if (!this.walkable(x - stepX, y + 1)) { + count = this.emit(x, y, 0, 1, goalX, goalY, outNodes, outCosts, count); + count = this.emit(x, y, stepX, 1, goalX, goalY, outNodes, outCosts, count); + } + + if (!this.walkable(x - stepX, y - 1)) { + count = this.emit(x, y, 0, -1, goalX, goalY, outNodes, outCosts, count); + count = this.emit(x, y, stepX, -1, goalX, goalY, outNodes, outCosts, count); + } + + return count; + } + + count = this.emit(x, y, 0, stepY, goalX, goalY, outNodes, outCosts, count); + + if (!this.walkable(x + 1, y - stepY)) { + count = this.emit(x, y, 1, 0, goalX, goalY, outNodes, outCosts, count); + count = this.emit(x, y, 1, stepY, goalX, goalY, outNodes, outCosts, count); + } + + if (!this.walkable(x - 1, y - stepY)) { + count = this.emit(x, y, -1, 0, goalX, goalY, outNodes, outCosts, count); + count = this.emit(x, y, -1, stepY, goalX, goalY, outNodes, outCosts, count); + } + + return count; + } + + public expand(from: number, to: number, out: number[]): void { + const { width } = this; + const targetX = to % width; + const targetY = (to / width) | 0; + + let x = from % width; + let y = (from / width) | 0; + + const stepX = Math.sign(targetX - x); + const stepY = Math.sign(targetY - y); + + while (x !== targetX || y !== targetY) { + x += stepX; + y += stepY; + out.push(y * width + x); + } + } + + private walkable(x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return false; + + return this.costs[y * this.width + x]! > 0; + } + + private emit( + x: number, + y: number, + stepX: number, + stepY: number, + goalX: number, + goalY: number, + outNodes: Int32Array, + outCosts: Float64Array, + count: number, + ): number { + const jump = this.jump(x, y, stepX, stepY, goalX, goalY); + + if (jump < 0) return count; + + outNodes[count] = jump; + outCosts[count] = runLength(Math.abs((jump % this.width) - x), Math.abs(((jump / this.width) | 0) - y)); + + return count + 1; + } + + /** + * Runs from `(x, y)` in one direction until it hits the goal, a cell with a + * forced neighbour, or a wall. Returns the node it stopped on, or `-1` when + * the run died against an obstacle without passing anything worth expanding. + */ + private jump(x: number, y: number, stepX: number, stepY: number, goalX: number, goalY: number): number { + const { width } = this; + + let currentX = x; + let currentY = y; + + for (;;) { + const nextX = currentX + stepX; + const nextY = currentY + stepY; + + if (!this.walkable(nextX, nextY)) return -1; + if (stepX !== 0 && stepY !== 0 && (!this.walkable(nextX, currentY) || !this.walkable(currentX, nextY))) return -1; + if (nextX === goalX && nextY === goalY) return nextY * width + nextX; + + if (stepY === 0) { + if (this.forcedBeside(currentX, nextX, nextY, 1) || this.forcedBeside(currentX, nextX, nextY, -1)) return nextY * width + nextX; + } else if (stepX === 0) { + if (this.forcedAbove(currentY, nextY, nextX, 1) || this.forcedAbove(currentY, nextY, nextX, -1)) return nextY * width + nextX; + } else if (this.jump(nextX, nextY, stepX, 0, goalX, goalY) >= 0 || this.jump(nextX, nextY, 0, stepY, goalX, goalY) >= 0) { + return nextY * width + nextX; + } + + currentX = nextX; + currentY = nextY; + } + } + + /** + * Horizontal run: the cell beside the node is only worth turning into when + * the cell diagonally behind it is blocked, because otherwise the predecessor + * reaches it at least as cheaply without passing through this node. + */ + private forcedBeside(previousX: number, x: number, y: number, side: number): boolean { + return !this.walkable(previousX, y + side) && this.walkable(x, y + side); + } + + /** Vertical run; the mirror of {@link forcedBeside}. */ + private forcedAbove(previousY: number, y: number, x: number, side: number): boolean { + return !this.walkable(x + side, previousY) && this.walkable(x + side, y); + } +} diff --git a/packages/exojs-pathfinding/src/spaces/GridSpace.ts b/packages/exojs-pathfinding/src/spaces/GridSpace.ts new file mode 100644 index 000000000..742564ea9 --- /dev/null +++ b/packages/exojs-pathfinding/src/spaces/GridSpace.ts @@ -0,0 +1,425 @@ +import type { Vector } from '@codexo/exojs'; + +import type { NavigationSpace, PrunedExpansion } from '../types'; +import { DIRECTION_X, DIRECTION_Y, SQRT2 } from './gridGeometry'; +import { GridJumpExpansion } from './GridJumpExpansion'; + +/** + * How diagonal steps are allowed on a {@link GridSpace}. + * + * - `never` - four-connected movement only. + * - `no-corner-cutting` - a diagonal step needs both cells it passes between to + * be walkable, which is what stops an agent from clipping through the corner + * where two walls meet. + * - `always` - eight-connected movement with no such restriction. + */ +export type DiagonalPolicy = 'never' | 'no-corner-cutting' | 'always'; + +/** Construction options for {@link GridSpace}. */ +export interface GridSpaceOptions { + /** Cell x of the window's left column. Defaults to `0`. */ + readonly originX?: number | undefined; + /** Cell y of the window's top row. Defaults to `0`. */ + readonly originY?: number | undefined; + /** Defaults to `'no-corner-cutting'`. */ + readonly diagonals?: DiagonalPolicy | undefined; + /** + * World size of one cell. The distance metric assumes square cells, so a + * tilemap with non-square tiles has to pick one axis. Defaults to `1`. + */ + readonly cellSize?: number | undefined; + /** World x of cell `0`'s left edge, before the window origin. Defaults to `0`. */ + readonly cellOriginX?: number | undefined; + /** World y of cell `0`'s top edge, before the window origin. Defaults to `0`. */ + readonly cellOriginY?: number | undefined; +} + +/** + * A rectangular window of weighted, optionally blocked cells. + * + * The window is finite by construction: everything outside it is blocked, so a + * search always terminates and an infinite or streamed world is served by + * sizing the window to the region the actors are in, then feeding chunk changes + * back through {@link setCost}. Coordinates in the public API are absolute cell + * coordinates - the same numbers a tilemap uses - not offsets into the window. + * + * Cost `0` blocks a cell, `1` is ordinary ground and larger values are terrain + * an agent will route around when it is cheaper to do so. Diagonal steps cost + * their length, so the metric stays consistent with the octile heuristic. + * + * The space carries no scene node and no rendering: it is data plus a neighbour + * relation, and it is built and mutated entirely by the application. + */ +export class GridSpace implements NavigationSpace { + public readonly width: number; + public readonly height: number; + public readonly originX: number; + public readonly originY: number; + public readonly cellSize: number; + public readonly cellOriginX: number; + public readonly cellOriginY: number; + public readonly diagonals: DiagonalPolicy; + public readonly maxDegree: number; + + private readonly costs: Float32Array; + private clearance: Uint16Array | null = null; + private jumpExpansion: GridJumpExpansion | null = null; + private weightedCells = 0; + private minCost = 1; + private currentRevision = 0; + + public constructor(width: number, height: number, options: GridSpaceOptions = {}) { + if (__DEV__ && (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1)) { + throw new RangeError(`GridSpace needs positive integer dimensions, received ${width}x${height}.`); + } + + this.width = width; + this.height = height; + this.originX = options.originX ?? 0; + this.originY = options.originY ?? 0; + this.cellSize = options.cellSize ?? 1; + this.cellOriginX = options.cellOriginX ?? 0; + this.cellOriginY = options.cellOriginY ?? 0; + this.diagonals = options.diagonals ?? 'no-corner-cutting'; + this.maxDegree = this.diagonals === 'never' ? 4 : 8; + this.costs = new Float32Array(width * height).fill(1); + } + + /** + * Builds a window and fills it from a cost callback, which receives absolute + * cell coordinates. This is the tilemap bridge: return `0` for a solid tile + * and the terrain's cost for a walkable one, and the grid never learns what a + * tilemap is. + * + * Values that are not finite and positive are stored as blocked. + */ + public static from(width: number, height: number, cost: (x: number, y: number) => number, options: GridSpaceOptions = {}): GridSpace { + const grid = new GridSpace(width, height, options); + const { costs, originX, originY } = grid; + + let minCost = Infinity; + let weighted = 0; + + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const raw = cost(originX + column, originY + row); + const value = Number.isFinite(raw) && raw > 0 ? raw : 0; + + costs[row * width + column] = value; + + if (value === 0) continue; + if (value !== 1) weighted++; + if (value < minCost) minCost = value; + } + } + + grid.weightedCells = weighted; + grid.minCost = minCost === Infinity ? 1 : minCost; + + return grid; + } + + public get nodeCapacity(): number { + return this.costs.length; + } + + public get revision(): number { + return this.currentRevision; + } + + /** + * `true` while every walkable cell costs exactly `1`. Jump-point search is + * only available on such a grid. + */ + public get uniformCost(): boolean { + return this.weightedCells === 0; + } + + /** The node at absolute cell coordinates, or `-1` outside the window. */ + public nodeAt(x: number, y: number): number { + const column = x - this.originX; + const row = y - this.originY; + + if (column < 0 || row < 0 || column >= this.width || row >= this.height) return -1; + + return row * this.width + column; + } + + /** Absolute cell x of a node. */ + public nodeX(node: number): number { + return this.originX + (node % this.width); + } + + /** Absolute cell y of a node. */ + public nodeY(node: number): number { + return this.originY + Math.floor(node / this.width); + } + + /** Traversal cost of a cell; `0` for blocked cells and everything outside the window. */ + public costAt(x: number, y: number): number { + const node = this.nodeAt(x, y); + + return node < 0 ? 0 : this.costs[node]!; + } + + public isWalkable(x: number, y: number): boolean { + return this.costAt(x, y) > 0; + } + + /** + * Sets a cell's cost and bumps {@link revision}. Values that are not finite + * and positive block the cell. Coordinates outside the window are ignored. + */ + public setCost(x: number, y: number, cost: number): void { + const node = this.nodeAt(x, y); + + if (node < 0) return; + + const value = Number.isFinite(cost) && cost > 0 ? cost : 0; + const previous = this.costs[node]!; + + if (value === previous) return; + + if (previous > 0 && previous !== 1) this.weightedCells--; + if (value > 0 && value !== 1) this.weightedCells++; + // Only lowered costs tighten the bound. A raised cost leaves it looser than + // it could be, which keeps the heuristic admissible - the direction that + // matters - without rescanning the whole window on every edit. + if (value > 0 && value < this.minCost) this.minCost = value; + + this.costs[node] = value; + this.clearance = null; + this.currentRevision++; + } + + /** + * Largest agent width that fits with its top-left corner on this cell, or `0` + * for a blocked cell. Recomputed lazily after the first edit that follows a + * query. + */ + public clearanceAt(x: number, y: number): number { + const node = this.nodeAt(x, y); + + if (node < 0) return 0; + + return this.ensureClearance()[node]!; + } + + public neighbors(node: number, agentSize: number, outNodes: Int32Array, outCosts: Float64Array): number { + const { width, height, costs, diagonals } = this; + const column = node % width; + const row = (node / width) | 0; + const clearance = agentSize > 1 ? this.ensureClearance() : null; + const directions = diagonals === 'never' ? 4 : 8; + const guardCorners = diagonals === 'no-corner-cutting'; + + let count = 0; + + for (let direction = 0; direction < directions; direction++) { + const stepX = DIRECTION_X[direction]!; + const stepY = DIRECTION_Y[direction]!; + const nextColumn = column + stepX; + const nextRow = row + stepY; + + if (nextColumn < 0 || nextRow < 0 || nextColumn >= width || nextRow >= height) continue; + + const next = nextRow * width + nextColumn; + const cost = costs[next]!; + + if (cost <= 0) continue; + if (clearance !== null && clearance[next]! < agentSize) continue; + + if (direction < 4) { + outCosts[count] = cost; + } else { + if (guardCorners && (!this.fits(column + stepX, row, agentSize, clearance) || !this.fits(column, row + stepY, agentSize, clearance))) continue; + + outCosts[count] = cost * SQRT2; + } + + outNodes[count] = next; + count++; + } + + return count; + } + + public heuristic(node: number, goal: number): number { + const { width } = this; + const deltaX = Math.abs((node % width) - (goal % width)); + const deltaY = Math.abs(((node / width) | 0) - ((goal / width) | 0)); + + if (this.diagonals === 'never') return (deltaX + deltaY) * this.minCost; + + // Octile distance, scaled by the cheapest walkable cell so that a weighted + // grid cannot make the estimate exceed the true remaining cost. + return (deltaX + deltaY + (SQRT2 - 2) * Math.min(deltaX, deltaY)) * this.minCost; + } + + public nodeToPoint(node: number, out: Vector): void { + const { width, cellSize } = this; + + out.set((this.originX + (node % width) + 0.5) * cellSize + this.cellOriginX, (this.originY + ((node / width) | 0) + 0.5) * cellSize + this.cellOriginY); + } + + public pointToNode(x: number, y: number): number { + const { cellSize } = this; + + return this.nodeAt(Math.floor((x - this.cellOriginX) / cellSize), Math.floor((y - this.cellOriginY) / cellSize)); + } + + public nearestNode(x: number, y: number): number { + const { width, height, cellSize } = this; + const column = Math.floor((x - this.cellOriginX) / cellSize) - this.originX; + const row = Math.floor((y - this.cellOriginY) / cellSize) - this.originY; + + return Math.min(Math.max(row, 0), height - 1) * width + Math.min(Math.max(column, 0), width - 1); + } + + public pruning(agentSize: number): PrunedExpansion | null { + // Jump-point search derives its pruning rules from a uniform-cost grid with + // a single-cell agent: weights make the symmetric alternatives it discards + // no longer equivalent, and clearance changes which of them are legal. + if (this.weightedCells > 0 || agentSize > 1 || this.diagonals !== 'no-corner-cutting') return null; + + this.jumpExpansion ??= new GridJumpExpansion(this.costs, this.width, this.height); + + return this.jumpExpansion; + } + + /** + * String-pulls the path: keeps a node only when the straight line past it is + * blocked, so the result is the same route with its staircase removed. + * + * The returned nodes are no longer adjacent - the guarantee is that the + * straight segment between two consecutive ones stays inside walkable cells + * an agent of `agentSize` fits through, and never crosses terrain more + * expensive than the section it replaces. + */ + public smoothPath(nodes: readonly number[], agentSize: number): number[] { + const last = nodes.length - 1; + + if (last < 2) return [...nodes]; + + const { costs } = this; + const out: number[] = [nodes[0]!]; + + let anchor = 0; + + while (anchor < last) { + let best = anchor + 1; + let budget = Math.max(costs[nodes[anchor]!]!, costs[nodes[best]!]!); + + for (let index = anchor + 2; index <= last; index++) { + budget = Math.max(budget, costs[nodes[index]!]!); + + if (!this.lineOfSight(nodes[anchor]!, nodes[index]!, agentSize, budget)) break; + + best = index; + } + + out.push(nodes[best]!); + anchor = best; + } + + return out; + } + + private fits(column: number, row: number, agentSize: number, clearance: Uint16Array | null): boolean { + const { width, height } = this; + + if (column < 0 || row < 0 || column >= width || row >= height) return false; + + const index = row * width + column; + + if (this.costs[index]! <= 0) return false; + + return clearance === null || clearance[index]! >= agentSize; + } + + /** + * Walks the cells a centre-to-centre segment passes through and reports + * whether all of them are traversable within `budget`. A segment that leaves + * a cell exactly through its corner counts as a diagonal step and is subject + * to the same corner rule as movement. + */ + private lineOfSight(from: number, to: number, agentSize: number, budget: number): boolean { + const { width, costs, diagonals } = this; + const clearance = agentSize > 1 ? this.ensureClearance() : null; + const targetColumn = to % width; + const targetRow = (to / width) | 0; + + let column = from % width; + let row = (from / width) | 0; + + const spanX = Math.abs(targetColumn - column); + const spanY = Math.abs(targetRow - row); + const stepX = Math.sign(targetColumn - column); + const stepY = Math.sign(targetRow - row); + const deltaX = spanX === 0 ? Infinity : 1 / spanX; + const deltaY = spanY === 0 ? Infinity : 1 / spanY; + const guardCorners = diagonals !== 'always'; + + let nextX = spanX === 0 ? Infinity : deltaX / 2; + let nextY = spanY === 0 ? Infinity : deltaY / 2; + + while (column !== targetColumn || row !== targetRow) { + if (nextX < nextY) { + column += stepX; + nextX += deltaX; + } else if (nextY < nextX) { + row += stepY; + nextY += deltaY; + } else { + if (guardCorners && (!this.fits(column + stepX, row, agentSize, clearance) || !this.fits(column, row + stepY, agentSize, clearance))) return false; + + column += stepX; + row += stepY; + nextX += deltaX; + nextY += deltaY; + } + + if (!this.fits(column, row, agentSize, clearance)) return false; + if (costs[row * width + column]! > budget) return false; + } + + return true; + } + + /** + * Brushfire clearance: how far the walkable block anchored at a cell extends + * down and to the right. Filling it bottom-right to top-left makes it one + * pass, because a cell only ever depends on the three cells after it. + */ + private ensureClearance(): Uint16Array { + const cached = this.clearance; + + if (cached !== null) return cached; + + const { width, height, costs } = this; + const clearance = new Uint16Array(costs.length); + + for (let row = height - 1; row >= 0; row--) { + for (let column = width - 1; column >= 0; column--) { + const index = row * width + column; + + if (costs[index]! <= 0) continue; + + if (column === width - 1 || row === height - 1) { + clearance[index] = 1; + continue; + } + + const right = clearance[index + 1]!; + const below = clearance[index + width]!; + const diagonal = clearance[index + width + 1]!; + + clearance[index] = Math.min(right, below, diagonal, 0xfffe) + 1; + } + } + + this.clearance = clearance; + + return clearance; + } +} diff --git a/packages/exojs-pathfinding/src/spaces/WaypointGraph.ts b/packages/exojs-pathfinding/src/spaces/WaypointGraph.ts new file mode 100644 index 000000000..667d2a493 --- /dev/null +++ b/packages/exojs-pathfinding/src/spaces/WaypointGraph.ts @@ -0,0 +1,238 @@ +import type { Vector } from '@codexo/exojs'; + +import type { NavigationSpace, PathEdge } from '../types'; + +/** Options for {@link WaypointGraph.addEdge} and {@link WaypointGraph.connect}. */ +export interface WaypointEdgeOptions { + /** + * Traversal cost. Defaults to the straight-line distance between the two + * nodes, or `1` when either of them has no position. + */ + readonly cost?: number | undefined; + /** + * Free-form traversal tag surfaced through {@link PathResult.edges} - the + * hook a movement controller reads to tell a jump from a walk. Defaults to + * `'walk'`. + */ + readonly kind?: string | undefined; + /** Payload for the movement controller: jump impulse, ladder id, anything. */ + readonly data?: Payload | undefined; +} + +interface Edge { + to: number; + cost: number; + kind: string; + data: Payload | null; +} + +/** + * A directed graph of hand-placed waypoints. + * + * This is the representation for worlds a grid cannot describe: a platformer + * where traversal is a topology of walk, jump and fall links rather than cell + * walkability, or a purely abstract graph with no geometry at all. Nodes carry + * an optional position, edges carry a cost, a {@link WaypointEdgeOptions.kind} + * and an arbitrary payload, and the resulting path reports the edges it took so + * the game can execute each step in its own way. + * + * With positions the search is A* over straight-line distance; without them the + * heuristic is zero and the same search degrades cleanly to Dijkstra. + */ +export class WaypointGraph implements NavigationSpace { + private readonly positionsX: number[] = []; + private readonly positionsY: number[] = []; + private readonly adjacency: Array>> = []; + private readonly alive: boolean[] = []; + private readonly positioned: boolean[] = []; + private readonly freeSlots: number[] = []; + private positionlessCount = 0; + private largestDegree = 0; + private currentRevision = 0; + // Smallest ratio of an edge's cost to its straight-line length. A caller who + // prices an edge below its geometric length (a zipline, a teleporter) would + // otherwise make the distance heuristic overestimate and cost the search its + // optimality; scaling by this ratio keeps it admissible instead of forbidding + // the edge. Never raised when an edge is removed - a looser bound is still a + // correct one, and rescanning every edge on every removal is not worth it. + private distanceScale = 1; + + public get nodeCapacity(): number { + return this.alive.length; + } + + public get maxDegree(): number { + return this.largestDegree; + } + + public get revision(): number { + return this.currentRevision; + } + + /** Number of live nodes. */ + public get nodeCount(): number { + return this.alive.length - this.freeSlots.length; + } + + /** + * Adds a node. Omitting the position puts the graph in Dijkstra mode: the + * heuristic drops to zero for every query, since a positionless node makes no + * geometric estimate meaningful. + */ + public addNode(x?: number, y?: number): number { + const hasPosition = x !== undefined && y !== undefined; + const slot = this.freeSlots.pop(); + const node = slot ?? this.alive.length; + + this.positionsX[node] = x ?? 0; + this.positionsY[node] = y ?? 0; + this.positioned[node] = hasPosition; + this.alive[node] = true; + this.adjacency[node] = []; + + if (!hasPosition) this.positionlessCount++; + + this.currentRevision++; + + return node; + } + + /** + * Removes a node together with every edge touching it. + * + * Ids are recycled: a later {@link addNode} may hand out the id this call + * freed, so a node id held across a removal can silently refer to a different + * node. + */ + public removeNode(node: number): void { + if (this.alive[node] !== true) return; + + if (this.positioned[node] === false) this.positionlessCount--; + + this.alive[node] = false; + this.adjacency[node] = []; + this.freeSlots.push(node); + + for (let index = 0; index < this.adjacency.length; index++) { + const edges = this.adjacency[index]!; + + for (let edge = edges.length - 1; edge >= 0; edge--) { + if (edges[edge]!.to === node) edges.splice(edge, 1); + } + } + + this.currentRevision++; + } + + /** Adds a directed edge. A second edge between the same pair replaces the first. */ + public addEdge(from: number, to: number, options: WaypointEdgeOptions = {}): void { + const edges = this.adjacency[from]; + + if (edges === undefined || this.alive[to] !== true) return; + + const length = this.length(from, to); + const cost = options.cost ?? (length > 0 ? length : 1); + const edge: Edge = { to, cost, kind: options.kind ?? 'walk', data: options.data ?? null }; + const existing = edges.findIndex(candidate => candidate.to === to); + + if (existing !== -1) edges[existing] = edge; + else edges.push(edge); + + if (edges.length > this.largestDegree) this.largestDegree = edges.length; + if (length > 0 && cost / length < this.distanceScale) this.distanceScale = cost / length; + + this.currentRevision++; + } + + /** Adds the edge in both directions with the same options. */ + public connect(a: number, b: number, options: WaypointEdgeOptions = {}): void { + this.addEdge(a, b, options); + this.addEdge(b, a, options); + } + + public removeEdge(from: number, to: number): void { + const edges = this.adjacency[from]; + + if (edges === undefined) return; + + const index = edges.findIndex(candidate => candidate.to === to); + + if (index === -1) return; + + edges.splice(index, 1); + this.currentRevision++; + } + + public neighbors(node: number, _agentSize: number, outNodes: Int32Array, outCosts: Float64Array): number { + const edges = this.adjacency[node]; + + if (edges === undefined) return 0; + + for (let index = 0; index < edges.length; index++) { + const edge = edges[index]!; + + outNodes[index] = edge.to; + outCosts[index] = edge.cost; + } + + return edges.length; + } + + public heuristic(node: number, goal: number): number { + if (this.positionlessCount > 0) return 0; + + return this.length(node, goal) * this.distanceScale; + } + + public nodeToPoint(node: number, out: Vector): void { + out.set(this.positionsX[node] ?? 0, this.positionsY[node] ?? 0); + } + + /** + * The positioned node closest to the point, or `-1` when the graph has none. + * A graph has no cells, so there is no "outside" for a point to fall into. + */ + public pointToNode(x: number, y: number): number { + let best = -1; + let bestDistance = Infinity; + + for (let node = 0; node < this.alive.length; node++) { + if (this.alive[node] !== true || this.positioned[node] !== true) continue; + + const deltaX = this.positionsX[node]! - x; + const deltaY = this.positionsY[node]! - y; + const distance = deltaX * deltaX + deltaY * deltaY; + + if (distance >= bestDistance) continue; + + bestDistance = distance; + best = node; + } + + return best; + } + + public nearestNode(x: number, y: number): number { + return this.pointToNode(x, y); + } + + public describeEdge(from: number, to: number): PathEdge | null { + const edges = this.adjacency[from]; + + if (edges === undefined) return null; + + for (let index = 0; index < edges.length; index++) { + const edge = edges[index]!; + + if (edge.to === to) return { from, to, kind: edge.kind, data: edge.data }; + } + + return null; + } + + private length(from: number, to: number): number { + if (this.positioned[from] !== true || this.positioned[to] !== true) return 0; + + return Math.hypot(this.positionsX[to]! - this.positionsX[from]!, this.positionsY[to]! - this.positionsY[from]!); + } +} diff --git a/packages/exojs-pathfinding/src/spaces/gridGeometry.ts b/packages/exojs-pathfinding/src/spaces/gridGeometry.ts new file mode 100644 index 000000000..268c1fcb1 --- /dev/null +++ b/packages/exojs-pathfinding/src/spaces/gridGeometry.ts @@ -0,0 +1,22 @@ +/** Grid step geometry shared by the neighbour expansion and the jump search. @internal */ + +export const SQRT2 = Math.SQRT2; + +/** + * Orthogonal directions first, then diagonals: a four-connected grid is the + * same table truncated to four entries, and the fixed order is half of what + * makes equal-cost searches reproducible (the heap tie-break is the other half). + * + * @internal + */ +export const DIRECTION_X: readonly number[] = [1, -1, 0, 0, 1, 1, -1, -1]; + +/** @internal */ +export const DIRECTION_Y: readonly number[] = [0, 0, 1, -1, 1, -1, 1, -1]; + +/** Octile length of a straight or diagonal run between two cells. @internal */ +export const runLength = (spanX: number, spanY: number): number => { + const diagonal = Math.min(spanX, spanY); + + return spanX + spanY - 2 * diagonal + diagonal * SQRT2; +}; diff --git a/packages/exojs-pathfinding/src/types.ts b/packages/exojs-pathfinding/src/types.ts new file mode 100644 index 000000000..1c15e75f3 --- /dev/null +++ b/packages/exojs-pathfinding/src/types.ts @@ -0,0 +1,189 @@ +import type { Vector } from '@codexo/exojs'; + +/** + * Outcome of a path query. + * + * - `found` - a complete path from start to goal (or, with `snapToNearest`, to + * the reachable node closest to the goal). + * - `unreachable` - the search exhausted the space without reaching the goal. + * - `budget-exceeded` - `maxExpandedNodes` ran out first. The result still + * carries the best partial path found so far. + */ +export type PathStatus = 'found' | 'unreachable' | 'budget-exceeded'; + +/** + * One traversal step of a path, as described by the space it came from. + * + * Spaces that model traversal kinds - {@link WaypointGraph} is the one in this + * package - report them here, so a movement controller can react to a `'jump'` + * step differently than to a `'walk'` step. Grid spaces describe no edges. + */ +export interface PathEdge { + readonly from: number; + readonly to: number; + /** Free-form traversal tag defined by whoever authored the space. */ + readonly kind: string; + /** Payload the author attached to the edge, or `null`. */ + readonly data: Payload | null; +} + +/** Result of {@link Pathfinder.findPath} and {@link Pathfinder.findPathBetween}. */ +export interface PathResult { + readonly status: PathStatus; + /** + * Node ids from start to goal inclusive. Consecutive entries are adjacent in + * the space unless the path was smoothed, which removes intermediate nodes by + * design. + */ + readonly nodes: readonly number[]; + /** {@link nodes} mapped through the space's node-to-point conversion. */ + readonly points: readonly Vector[]; + /** + * Traversal steps for spaces that describe them, empty otherwise. Not filled + * for smoothed paths, whose steps are no longer space edges. + */ + readonly edges: ReadonlyArray>; + /** Total traversal cost of {@link nodes}. Smoothing does not change it. */ + readonly cost: number; + /** + * `space.revision` at search time. Compare it against the space's current + * revision to detect a path that the world has invalidated since. + */ + readonly revision: number; + /** Nodes taken off the open list. Useful for sizing `maxExpandedNodes`. */ + readonly expandedNodes: number; +} + +/** Result of {@link Pathfinder.floodFrom}: every node reached, with its cost. */ +export interface FloodRegion { + /** Reached nodes in the order the flood settled them, origin first. */ + readonly nodes: readonly number[]; + /** Traversal cost from the origin to the node at the same index. */ + readonly costs: readonly number[]; + readonly revision: number; + readonly expandedNodes: number; +} + +/** Options for {@link Pathfinder.findPath} and {@link Pathfinder.findPathBetween}. */ +export interface FindPathOptions { + /** + * Run the space's path smoother over the result. No-op for spaces that + * implement none. Defaults to `false`. + */ + readonly smooth?: boolean | undefined; + /** + * Agent width in nodes. Spaces that model clearance restrict expansion to + * nodes where an agent this wide fits; spaces that do not ignore it. + * Defaults to `1`. + */ + readonly agentSize?: number | undefined; + /** + * Stop after this many expanded nodes and return the best partial path with + * status `budget-exceeded`. Defaults to `0`, meaning no budget - a search + * over a finite space terminates regardless. + */ + readonly maxExpandedNodes?: number | undefined; + /** + * When the goal cannot be reached, return the path to the reachable node + * closest to it instead of `unreachable`. For coordinate queries this also + * resolves a goal point outside the space to the space's nearest node. + * Defaults to `false`. + */ + readonly snapToNearest?: boolean | undefined; + /** + * Allow the space to substitute a pruned expansion (jump-point search on + * uniform grids) for plain neighbour expansion. Both produce a cost-optimal + * path; pruning expands far fewer nodes. Defaults to `true`. + */ + readonly pruning?: boolean | undefined; +} + +/** Options for {@link Pathfinder.floodFrom}. */ +export interface FloodOptions { + /** Highest traversal cost to include. Defaults to `Infinity`. */ + readonly maxCost?: number | undefined; + /** Node budget, as in {@link FindPathOptions.maxExpandedNodes}. */ + readonly maxExpandedNodes?: number | undefined; + /** Agent width in nodes, as in {@link FindPathOptions.agentSize}. */ + readonly agentSize?: number | undefined; +} + +/** + * A parent-dependent successor generator that prunes symmetric alternatives + * without losing optimality - the shape jump-point search takes. + * + * Obtained from {@link NavigationSpace.pruning} for the duration of one search. + */ +export interface PrunedExpansion { + /** + * Writes the pruned successors of `node`, reached from `parent` (`-1` for the + * start node), into the buffers and returns how many were written. The + * successors may lie several nodes away; `expand` fills in what lies between. + */ + successors(node: number, parent: number, goal: number, outNodes: Int32Array, outCosts: Float64Array): number; + /** + * Appends the nodes strictly between `from` and `to`, then `to` itself, to + * `out` - turning a path of pruned successors back into a contiguous one. + */ + expand(from: number, to: number, out: number[]): void; +} + +/** + * The search core's view of a world: integer node ids, a neighbour relation and + * a heuristic. {@link GridSpace} and {@link WaypointGraph} implement it, and so + * can application code - a space needs no scene node, no renderer and no asset. + * + * Implementations must be deterministic: the same query on an unmutated space + * has to produce the same neighbours in the same order, or paths stop being + * reproducible across runs and machines. + */ +export interface NavigationSpace { + /** One past the largest node id. Sizes the pathfinder's search buffers. */ + readonly nodeCapacity: number; + /** Upper bound on how many neighbours one node can have. */ + readonly maxDegree: number; + /** + * Increments on every mutation that can invalidate a path. Carried into + * {@link PathResult.revision} so callers can detect stale paths. + */ + readonly revision: number; + /** + * Writes the neighbours of `node` and the cost of stepping to each into the + * buffers, and returns how many were written. The buffers belong to the + * pathfinder and are reused across nodes and searches, so an implementation + * must not retain them. + * + * Costs must be positive and finite. `agentSize` is the requested clearance; + * spaces that do not model clearance ignore it. + */ + neighbors(node: number, agentSize: number, outNodes: Int32Array, outCosts: Float64Array): number; + /** + * Estimated remaining cost from `node` to `goal`. Must never overestimate, or + * the result stops being cost-optimal; returning `0` degrades the search to + * Dijkstra, which is the correct answer for a space without positions. + */ + heuristic(node: number, goal: number): number; + /** Writes the node's position into `out`. */ + nodeToPoint(node: number, out: Vector): void; + /** The node at a point, or `-1` when the point lies outside the space. */ + pointToNode(x: number, y: number): number; + /** + * The node closest to a point, whether or not it is traversable and whether + * or not the point lies inside the space. Backs `snapToNearest` for + * coordinate queries; without it such a query reports `unreachable`. + */ + nearestNode?(x: number, y: number): number; + /** Describes the traversal from `from` to `to`, if the space models one. */ + describeEdge?(from: number, to: number): PathEdge | null; + /** + * Returns a shortened node sequence with the same start and goal that is + * still traversable for an agent of `agentSize`. Backs `smooth`. + */ + smoothPath?(nodes: readonly number[], agentSize: number): number[]; + /** + * Returns a pruned expansion valid for a search at this `agentSize`, or + * `null` when the space cannot prune under those conditions. Called once per + * search, so an implementation may build state here - but not per node. + */ + pruning?(agentSize: number): PrunedExpansion | null; +} diff --git a/packages/exojs-pathfinding/test/GridSpace.test.ts b/packages/exojs-pathfinding/test/GridSpace.test.ts new file mode 100644 index 000000000..2aba73e12 --- /dev/null +++ b/packages/exojs-pathfinding/test/GridSpace.test.ts @@ -0,0 +1,151 @@ +import { Vector } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { GridSpace } from '../src/spaces/GridSpace'; +import { gridFrom } from './helpers'; + +const neighborsOf = (grid: GridSpace, x: number, y: number, agentSize = 1): [number, number][] => { + const nodes = new Int32Array(8); + const costs = new Float64Array(8); + const count = grid.neighbors(grid.nodeAt(x, y), agentSize, nodes, costs); + const out: [number, number][] = []; + + for (let index = 0; index < count; index++) { + out.push([grid.nodeX(nodes[index]!), grid.nodeY(nodes[index]!)]); + } + + return out.sort((a, b) => a[0] - b[0] || a[1] - b[1]); +}; + +describe('GridSpace', () => { + it('treats everything outside the window as blocked', () => { + const grid = new GridSpace(3, 3, { originX: 100, originY: 200 }); + + expect(grid.nodeAt(99, 200)).toBe(-1); + expect(grid.isWalkable(99, 200)).toBe(false); + expect(grid.costAt(1000, 1000)).toBe(0); + expect(neighborsOf(grid, 100, 200)).toEqual([ + [100, 201], + [101, 200], + [101, 201], + ]); + }); + + it('passes absolute cell coordinates to the cost callback', () => { + const seen: [number, number][] = []; + + GridSpace.from( + 2, + 1, + (x, y) => { + seen.push([x, y]); + + return 1; + }, + { originX: 7, originY: 9 }, + ); + + expect(seen).toEqual([ + [7, 9], + [8, 9], + ]); + }); + + it('blocks cells whose cost is not finite and positive', () => { + const grid = GridSpace.from(3, 1, x => [1, Number.NaN, -4][x]!); + + expect(grid.isWalkable(0, 0)).toBe(true); + expect(grid.isWalkable(1, 0)).toBe(false); + expect(grid.isWalkable(2, 0)).toBe(false); + }); + + it('forbids a diagonal past a blocked orthogonal neighbour by default', () => { + const grid = gridFrom(['.#', '#.']); + + expect(neighborsOf(grid, 0, 0)).toEqual([]); + expect(gridFrom(['.#', '#.'], 'always').neighbors(0, 1, new Int32Array(8), new Float64Array(8))).toBe(1); + }); + + it('prices a diagonal step by its length', () => { + const grid = gridFrom(['..', '.4']); + const nodes = new Int32Array(8); + const costs = new Float64Array(8); + const count = grid.neighbors(grid.nodeAt(0, 0), 1, nodes, costs); + const diagonal = costs[[...nodes.slice(0, count)].indexOf(grid.nodeAt(1, 1))]!; + + expect(diagonal).toBeCloseTo(4 * Math.SQRT2, 9); + }); + + it('drops diagonals entirely under the four-connected policy', () => { + expect(neighborsOf(new GridSpace(3, 3, { diagonals: 'never' }), 1, 1)).toEqual([ + [0, 1], + [1, 0], + [1, 2], + [2, 1], + ]); + }); + + it('bumps the revision only when a cost actually changes', () => { + const grid = new GridSpace(4, 4); + const before = grid.revision; + + grid.setCost(1, 1, 1); + + expect(grid.revision).toBe(before); + + grid.setCost(1, 1, 0); + + expect(grid.revision).toBe(before + 1); + }); + + it('measures clearance as the walkable block anchored at a cell', () => { + const grid = gridFrom(['....', '....', '..#.', '....']); + + expect(grid.clearanceAt(0, 0)).toBe(2); + expect(grid.clearanceAt(2, 2)).toBe(0); + expect(grid.clearanceAt(3, 3)).toBe(1); + expect(grid.clearanceAt(0, 2)).toBe(2); + }); + + it('recomputes clearance after an edit', () => { + const grid = new GridSpace(4, 4); + + expect(grid.clearanceAt(0, 0)).toBe(4); + + grid.setCost(2, 2, 0); + + expect(grid.clearanceAt(0, 0)).toBe(2); + }); + + it('keeps an agent wider than one cell out of a gap it does not fit through', () => { + const grid = gridFrom(['....', '....', '.##.', '....']); + + expect(neighborsOf(grid, 0, 0, 2)).toEqual([[1, 0]]); + expect(neighborsOf(grid, 0, 0, 1)).toContainEqual([1, 1]); + }); + + it('maps nodes to cell centres in world space', () => { + const grid = new GridSpace(4, 4, { originX: 2, originY: 3, cellSize: 16, cellOriginX: 100, cellOriginY: 50 }); + const point = new Vector(); + + grid.nodeToPoint(grid.nodeAt(2, 3), point); + + expect(point.x).toBeCloseTo(2 * 16 + 8 + 100, 9); + expect(point.y).toBeCloseTo(3 * 16 + 8 + 50, 9); + expect(grid.pointToNode(point.x, point.y)).toBe(grid.nodeAt(2, 3)); + }); + + it('clamps a point outside the window to the closest node', () => { + const grid = new GridSpace(4, 4, { cellSize: 10 }); + + expect(grid.nearestNode(-500, 500)).toBe(grid.nodeAt(0, 3)); + }); + + it('keeps the heuristic admissible on a weighted grid', () => { + const grid = GridSpace.from(8, 8, (x, y) => (x === 0 && y === 0 ? 0.25 : 4)); + + // The estimate is scaled by the cheapest walkable cell, so it can never + // exceed the true remaining cost of a route made of expensive cells. + expect(grid.heuristic(grid.nodeAt(0, 7), grid.nodeAt(0, 0))).toBeCloseTo(7 * 0.25, 9); + }); +}); diff --git a/packages/exojs-pathfinding/test/Pathfinder.test.ts b/packages/exojs-pathfinding/test/Pathfinder.test.ts new file mode 100644 index 000000000..0300d98cd --- /dev/null +++ b/packages/exojs-pathfinding/test/Pathfinder.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; + +import { Pathfinder } from '../src/Pathfinder'; +import { GridSpace } from '../src/spaces/GridSpace'; +import { createRandom, gridFrom, parseCosts, referenceCost, walkCost } from './helpers'; + +const MAZE = ['..........', '.####.###.', '.#....#...', '.#.####.##', '.#......#.', '.#####.##.', '.....#....', '####.#.###', '.....#....', '.#########']; + +describe('Pathfinder.findPath', () => { + it('returns a cost-optimal path, checked against an independent Dijkstra', () => { + const rows = MAZE; + const grid = gridFrom(rows); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(9, 8), { pruning: false }); + + expect(result.status).toBe('found'); + expect(result.cost).toBeCloseTo(referenceCost(parseCosts(rows), 0, 0, 9, 8, 'no-corner-cutting'), 9); + expect(walkCost(grid, result.nodes)).toBeCloseTo(result.cost, 9); + expect(result.nodes[0]).toBe(grid.nodeAt(0, 0)); + expect(result.nodes.at(-1)).toBe(grid.nodeAt(9, 8)); + }); + + it('stays optimal on randomized weighted grids', () => { + const random = createRandom(0x5eed); + const pathfinder = new Pathfinder(); + + for (let trial = 0; trial < 40; trial++) { + const size = 16; + const costs: number[][] = []; + + for (let y = 0; y < size; y++) { + const row: number[] = []; + + for (let x = 0; x < size; x++) { + const roll = random(); + + row.push(roll < 0.22 ? 0 : 1 + Math.floor(roll * 6)); + } + + costs.push(row); + } + + costs[0]![0] = 1; + costs[size - 1]![size - 1] = 1; + + const grid = GridSpace.from(size, size, (x, y) => costs[y]![x]!); + const result = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(size - 1, size - 1)); + const expected = referenceCost(costs, 0, 0, size - 1, size - 1, 'no-corner-cutting'); + + if (expected === Infinity) { + expect(result.status).toBe('unreachable'); + continue; + } + + expect(result.status).toBe('found'); + expect(result.cost).toBeCloseTo(expected, 9); + expect(walkCost(grid, result.nodes)).toBeCloseTo(expected, 9); + } + }); + + it('reports an unreachable goal without a partial path', () => { + const grid = gridFrom(['..#..', '..#..', '..#..']); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(4, 2)); + + expect(result.status).toBe('unreachable'); + expect(result.nodes).toEqual([]); + expect(result.points).toEqual([]); + expect(result.cost).toBe(0); + }); + + it('snaps to the reachable node closest to an unreachable goal', () => { + const grid = gridFrom(['..#..', '..#..', '..#..']); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(4, 1), { snapToNearest: true }); + + expect(result.status).toBe('found'); + expect(grid.nodeX(result.nodes.at(-1)!)).toBe(1); + expect(walkCost(grid, result.nodes)).toBeCloseTo(result.cost, 9); + }); + + it('returns a traversable prefix when the node budget runs out', () => { + const grid = gridFrom(MAZE); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(9, 8), { maxExpandedNodes: 5 }); + + expect(result.status).toBe('budget-exceeded'); + expect(result.expandedNodes).toBe(5); + expect(result.nodes[0]).toBe(grid.nodeAt(0, 0)); + expect(walkCost(grid, result.nodes)).toBeCloseTo(result.cost, 9); + }); + + it('is deterministic across repeated and equal-cost queries', () => { + // A fully open grid has a large set of equally optimal diagonal-first and + // straight-first routes; only the pinned tie-break makes one of them the + // answer every time. + const grid = new GridSpace(9, 9); + const pathfinder = new Pathfinder(); + const first = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(8, 8)); + const second = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(8, 8)); + const third = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(8, 8)); + + expect(second.nodes).toEqual(first.nodes); + expect(third.nodes).toEqual(first.nodes); + expect(first.cost).toBeCloseTo(8 * Math.SQRT2, 9); + }); + + it('keeps interleaved queries over different spaces independent', () => { + const small = gridFrom(['...', '.#.', '...']); + const large = gridFrom(MAZE); + const pathfinder = new Pathfinder(); + + const smallAlone = pathfinder.findPath(small, small.nodeAt(0, 0), small.nodeAt(2, 2)); + const largeAlone = pathfinder.findPath(large, large.nodeAt(0, 0), large.nodeAt(9, 8)); + + // Round trip through the larger space and back: the reused buffers keep the + // previous space's g-scores and parents until a generation stamp retires + // them, so a leak between spaces shows up here or nowhere. + const smallAgain = pathfinder.findPath(small, small.nodeAt(0, 0), small.nodeAt(2, 2)); + const largeAgain = pathfinder.findPath(large, large.nodeAt(0, 0), large.nodeAt(9, 8)); + + expect(smallAgain.nodes).toEqual(smallAlone.nodes); + expect(smallAgain.cost).toBeCloseTo(smallAlone.cost, 9); + expect(largeAgain.nodes).toEqual(largeAlone.nodes); + expect(largeAgain.cost).toBeCloseTo(largeAlone.cost, 9); + }); + + it('carries the space revision so a caller can spot a stale path', () => { + const grid = gridFrom(['...', '...', '...']); + const pathfinder = new Pathfinder(); + const result = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(2, 2)); + + expect(result.revision).toBe(grid.revision); + + grid.setCost(1, 1, 0); + + expect(grid.revision).not.toBe(result.revision); + }); + + it('routes around expensive terrain rather than through it', () => { + const grid = gridFrom(['.9.', '.9.', '...'], 'never'); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(2, 0)); + + expect(result.nodes.map(node => [grid.nodeX(node), grid.nodeY(node)])).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 2], + [2, 2], + [2, 1], + [2, 0], + ]); + }); +}); + +describe('Pathfinder.findPathBetween', () => { + it('maps world coordinates through the cell size and window origin', () => { + const grid = new GridSpace(4, 4, { originX: 10, originY: 20, cellSize: 32 }); + const result = new Pathfinder().findPathBetween(grid, 10 * 32 + 5, 20 * 32 + 5, 12 * 32 + 5, 20 * 32 + 5); + + expect(result.status).toBe('found'); + expect(result.points[0]!.x).toBeCloseTo(10 * 32 + 16, 9); + expect(result.points.at(-1)!.x).toBeCloseTo(12 * 32 + 16, 9); + }); + + it('reports unreachable for a point outside the window unless it may snap', () => { + const grid = new GridSpace(4, 4, { cellSize: 32 }); + const pathfinder = new Pathfinder(); + + expect(pathfinder.findPathBetween(grid, 16, 16, 1000, 1000).status).toBe('unreachable'); + expect(pathfinder.findPathBetween(grid, 16, 16, 1000, 1000, { snapToNearest: true }).status).toBe('found'); + }); +}); + +describe('Pathfinder.floodFrom', () => { + it('settles every node within the cost budget, cheapest first', () => { + const grid = gridFrom(['...', '.#.', '...'], 'never'); + const region = new Pathfinder().floodFrom(grid, grid.nodeAt(0, 0), { maxCost: 2 }); + + expect(region.nodes).toHaveLength(5); + expect(region.costs).toEqual([...region.costs].sort((a, b) => a - b)); + expect(region.nodes).not.toContain(grid.nodeAt(1, 1)); + expect(region.nodes).not.toContain(grid.nodeAt(2, 2)); + }); + + it('prices terrain the same way a path query does', () => { + const grid = gridFrom(['.5.'], 'never'); + const region = new Pathfinder().floodFrom(grid, grid.nodeAt(0, 0)); + + expect(region.costs).toEqual([0, 5, 6]); + }); +}); diff --git a/packages/exojs-pathfinding/test/WaypointGraph.test.ts b/packages/exojs-pathfinding/test/WaypointGraph.test.ts new file mode 100644 index 000000000..16ce5b0aa --- /dev/null +++ b/packages/exojs-pathfinding/test/WaypointGraph.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import { Pathfinder } from '../src/Pathfinder'; +import { WaypointGraph } from '../src/spaces/WaypointGraph'; + +interface JumpData { + readonly impulse: number; +} + +describe('WaypointGraph', () => { + it('reports the traversal kind and payload of every step', () => { + const graph = new WaypointGraph(); + const ledge = graph.addNode(0, 100); + const gap = graph.addNode(80, 100); + const platform = graph.addNode(160, 40); + + graph.addEdge(ledge, gap, { kind: 'walk' }); + graph.addEdge(gap, platform, { kind: 'jump', data: { impulse: 520 } }); + + const result = new Pathfinder().findPath(graph, ledge, platform); + + expect(result.status).toBe('found'); + expect(result.edges.map(edge => edge.kind)).toEqual(['walk', 'jump']); + expect(result.edges[1]!.data?.impulse).toBe(520); + }); + + it('prices an edge by straight-line distance unless told otherwise', () => { + const graph = new WaypointGraph(); + const a = graph.addNode(0, 0); + const b = graph.addNode(30, 40); + const c = graph.addNode(60, 80); + + graph.addEdge(a, b); + graph.addEdge(b, c, { cost: 5 }); + + const result = new Pathfinder().findPath(graph, a, c); + + expect(result.cost).toBeCloseTo(55, 9); + }); + + it('stays optimal when an edge is priced below its geometric length', () => { + // A zipline shorter than the straight line would make a raw distance + // heuristic overestimate and hand back a suboptimal route. + const graph = new WaypointGraph(); + const start = graph.addNode(0, 0); + const via = graph.addNode(0, 1000); + const goal = graph.addNode(1000, 0); + + graph.addEdge(start, via, { cost: 1 }); + graph.addEdge(via, goal, { cost: 1 }); + graph.addEdge(start, goal); + + const result = new Pathfinder().findPath(graph, start, goal); + + expect(result.cost).toBeCloseTo(2, 9); + expect(result.nodes).toEqual([start, via, goal]); + }); + + it('degrades to Dijkstra when a node has no position', () => { + const graph = new WaypointGraph(); + const a = graph.addNode(); + const b = graph.addNode(); + const c = graph.addNode(); + + graph.connect(a, b, { cost: 2 }); + graph.connect(b, c, { cost: 3 }); + graph.connect(a, c, { cost: 9 }); + + expect(graph.heuristic(a, c)).toBe(0); + + const result = new Pathfinder().findPath(graph, a, c); + + expect(result.cost).toBe(5); + expect(result.nodes).toEqual([a, b, c]); + }); + + it('keeps directed edges directed and connect() bidirectional', () => { + const graph = new WaypointGraph(); + const high = graph.addNode(0, 0); + const low = graph.addNode(0, 200); + const side = graph.addNode(100, 200); + + graph.addEdge(high, low, { kind: 'fall' }); + graph.connect(low, side); + + const pathfinder = new Pathfinder(); + + expect(pathfinder.findPath(graph, high, side).status).toBe('found'); + expect(pathfinder.findPath(graph, side, high).status).toBe('unreachable'); + }); + + it('recycles node ids after a removal, edges included', () => { + const graph = new WaypointGraph(); + const a = graph.addNode(0, 0); + const b = graph.addNode(10, 0); + const c = graph.addNode(20, 0); + + graph.connect(a, b); + graph.connect(b, c); + graph.removeNode(b); + + expect(new Pathfinder().findPath(graph, a, c).status).toBe('unreachable'); + + const reused = graph.addNode(10, 0); + + expect(reused).toBe(b); + // The recycled id starts with no edges: the removal cleared both the + // outgoing list and every incoming reference to it. + expect(new Pathfinder().findPath(graph, a, c).status).toBe('unreachable'); + + graph.connect(a, reused); + graph.connect(reused, c); + + expect(new Pathfinder().findPath(graph, a, c).status).toBe('found'); + }); + + it('replaces an edge rather than duplicating it', () => { + const graph = new WaypointGraph(); + const a = graph.addNode(0, 0); + const b = graph.addNode(10, 0); + + graph.addEdge(a, b, { cost: 10, kind: 'walk' }); + graph.addEdge(a, b, { cost: 3, kind: 'jump' }); + + const result = new Pathfinder().findPath(graph, a, b); + + expect(result.cost).toBe(3); + expect(graph.describeEdge(a, b)?.kind).toBe('jump'); + }); + + it('finds the nearest node for a coordinate query', () => { + const graph = new WaypointGraph(); + const a = graph.addNode(0, 0); + const b = graph.addNode(100, 0); + + graph.connect(a, b); + + const result = new Pathfinder().findPathBetween(graph, 5, 5, 90, 5); + + expect(result.nodes).toEqual([a, b]); + }); +}); diff --git a/packages/exojs-pathfinding/test/allocation.test.ts b/packages/exojs-pathfinding/test/allocation.test.ts new file mode 100644 index 000000000..485cf2c70 --- /dev/null +++ b/packages/exojs-pathfinding/test/allocation.test.ts @@ -0,0 +1,164 @@ +import { Session } from 'node:inspector'; + +import { describe, expect, it } from 'vitest'; + +import { Pathfinder } from '../src/Pathfinder'; +import { GridSpace } from '../src/spaces/GridSpace'; +import type { FindPathOptions } from '../src/types'; +import { createRandom } from './helpers'; + +/** + * Allocation gate for the search core. + * + * A query allocates its result - the node list and one point per node, which + * the caller keeps - and that is deliberate. What it must not allocate is + * anything that scales with the *search*: no per-expansion record, no per-node + * closure, no buffer regrown after the pathfinder has already run at this + * problem size. + * + * The gate is an absolute budget per path node on a grid whose searches expand + * an order of magnitude more nodes than the path is long. One 32-byte object + * per expanded node would therefore land several times over the budget, while + * the result itself stays comfortably inside it. + * + * Measurement uses V8's allocation sampling profiler rather than a `heapUsed` + * delta: these objects die immediately and are reclaimed inside the sampling + * window, so a heap-size delta never sees them. + */ + +const SIZE = 128; +const QUERIES = 200; +const WARMUP = 50; + +/** + * Bytes per path node the result itself is allowed to cost: one `Vector`, two + * array slots, and the slack of the doubling both arrays grow by. Measured at + * roughly half this on the reference machine; the headroom absorbs a different + * V8 object layout, not a regression. + */ +const BUDGET_PER_NODE = 300; + +// Istanbul rewrites every statement, which defeats escape analysis and inflates +// these numbers by orders of magnitude. The suite refuses to assert rather than +// report figures that describe instrumented code. +const INSTRUMENTED = /\bcov_[0-9a-z]+\b/u.test(String(Pathfinder.prototype.findPath)); + +const sumSelfSize = (node: import('node:inspector').HeapProfiler.SamplingHeapProfileNode): number => + node.selfSize + node.children.reduce((total, child) => total + sumSelfSize(child), 0); + +const sampleBytes = async (body: () => void): Promise => { + const session = new Session(); + + session.connect(); + + const post = (method: string, params?: Record): Promise => + new Promise((resolve, reject) => { + session.post(method, params, (error: Error | null, result?: unknown) => { + if (error) reject(error); + else resolve(result as T); + }); + }); + + await post('HeapProfiler.enable'); + // Without these flags the profiler reports only what is still live when + // sampling stops, which is none of the garbage this gate is about. + await post('HeapProfiler.startSampling', { + samplingInterval: 512, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + + body(); + + const { profile } = await post<{ profile: import('node:inspector').HeapProfiler.SamplingHeapProfile }>('HeapProfiler.stopSampling'); + + await post('HeapProfiler.disable'); + session.disconnect(); + + return sumSelfSize(profile.head); +}; + +const buildGrid = (): GridSpace => { + const random = createRandom(0x9e3779b9); + const blocked: boolean[] = []; + + for (let index = 0; index < SIZE * SIZE; index++) { + blocked.push(random() < 0.25); + } + + blocked[0] = false; + blocked[SIZE * SIZE - 1] = false; + + return GridSpace.from(SIZE, SIZE, (x, y) => (blocked[y * SIZE + x] === true ? 0 : 1)); +}; + +interface Measurement { + bytesPerQuery: number; + expandedNodes: number; + pathLength: number; +} + +const measure = async (options: FindPathOptions): Promise => { + const grid = buildGrid(); + const pathfinder = new Pathfinder(); + const start = grid.nodeAt(0, 0); + const goal = grid.nodeAt(SIZE - 1, SIZE - 1); + + let last = pathfinder.findPath(grid, start, goal, options); + + for (let index = 0; index < WARMUP; index++) { + last = pathfinder.findPath(grid, start, goal, options); + } + + expect(last.status).toBe('found'); + + const totalBytes = await sampleBytes(() => { + for (let index = 0; index < QUERIES; index++) { + pathfinder.findPath(grid, start, goal, options); + } + }); + + return { bytesPerQuery: totalBytes / QUERIES, expandedNodes: last.expandedNodes, pathLength: last.nodes.length }; +}; + +describe('search allocation', () => { + it.skipIf(INSTRUMENTED)('stays within the result budget while expanding far more nodes than it returns', async () => { + const plain = await measure({ pruning: false }); + + // Without this the budget below would prove nothing: it only bites because + // the search visits many times more nodes than the path contains. + expect(plain.expandedNodes).toBeGreaterThan(plain.pathLength * 10); + expect(plain.bytesPerQuery).toBeLessThan(plain.pathLength * BUDGET_PER_NODE); + }); + + it.skipIf(INSTRUMENTED)('costs no more with jump-point pruning, which returns the same path', async () => { + const pruned = await measure({}); + + expect(pruned.expandedNodes).toBeGreaterThan(pruned.pathLength * 10); + expect(pruned.bytesPerQuery).toBeLessThan(pruned.pathLength * BUDGET_PER_NODE); + }); + + it.skipIf(INSTRUMENTED)('does not keep growing its buffers across queries', async () => { + const grid = buildGrid(); + const pathfinder = new Pathfinder(); + const start = grid.nodeAt(0, 0); + const goal = grid.nodeAt(SIZE - 1, SIZE - 1); + + for (let index = 0; index < WARMUP; index++) { + pathfinder.findPath(grid, start, goal); + } + + const run = async (): Promise => + sampleBytes(() => { + for (let index = 0; index < QUERIES; index++) { + pathfinder.findPath(grid, start, goal); + } + }); + + const first = await run(); + const second = await run(); + + // A buffer that regrew per query would make the later window the larger one. + expect(second).toBeLessThan(first * 1.25); + }); +}); diff --git a/packages/exojs-pathfinding/test/helpers.ts b/packages/exojs-pathfinding/test/helpers.ts new file mode 100644 index 000000000..d60c84c53 --- /dev/null +++ b/packages/exojs-pathfinding/test/helpers.ts @@ -0,0 +1,130 @@ +import type { DiagonalPolicy } from '../src/spaces/GridSpace'; +import { GridSpace } from '../src/spaces/GridSpace'; + +/** Deterministic PRNG, so a failing randomized case is reproducible from its seed. */ +export const createRandom = (seed: number): (() => number) => { + let state = seed >>> 0; + + return () => { + state = (state + 0x6d2b79f5) >>> 0; + + let value = Math.imul(state ^ (state >>> 15), 1 | state); + + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +}; + +/** + * Cost grid from an ASCII map: `#` blocks, `.` costs 1, a digit costs its value. + */ +export const parseCosts = (rows: readonly string[]): number[][] => + rows.map(row => + [...row].map(cell => { + if (cell === '#') return 0; + if (cell === '.') return 1; + + return Number.parseInt(cell, 10); + }), + ); + +export const gridFrom = (rows: readonly string[], diagonals: DiagonalPolicy = 'no-corner-cutting'): GridSpace => { + const costs = parseCosts(rows); + + return GridSpace.from(costs[0]!.length, costs.length, (x, y) => costs[y]![x]!, { diagonals }); +}; + +const SQRT2 = Math.SQRT2; + +/** + * Plain Dijkstra over the same movement rules, written independently of the + * package so that "A* is optimal" is checked against something other than A*. + */ +export const referenceCost = (costs: readonly number[][], startX: number, startY: number, goalX: number, goalY: number, diagonals: DiagonalPolicy): number => { + const height = costs.length; + const width = costs[0]!.length; + const best = costs.map(row => row.map(() => Infinity)); + const settled = costs.map(row => row.map(() => false)); + + best[startY]![startX] = 0; + + for (;;) { + let bestX = -1; + let bestY = -1; + let bestCost = Infinity; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (settled[y]![x] === true || best[y]![x]! >= bestCost) continue; + + bestCost = best[y]![x]!; + bestX = x; + bestY = y; + } + } + + if (bestX < 0) break; + + settled[bestY]![bestX] = true; + + for (let stepY = -1; stepY <= 1; stepY++) { + for (let stepX = -1; stepX <= 1; stepX++) { + if (stepX === 0 && stepY === 0) continue; + + const diagonal = stepX !== 0 && stepY !== 0; + + if (diagonal && diagonals === 'never') continue; + + const x = bestX + stepX; + const y = bestY + stepY; + + if (x < 0 || y < 0 || x >= width || y >= height) continue; + + const cellCost = costs[y]![x]!; + + if (cellCost <= 0) continue; + + if (diagonal && diagonals === 'no-corner-cutting') { + if ((costs[bestY]![x] ?? 0) <= 0 || (costs[y]![bestX] ?? 0) <= 0) continue; + } + + const candidate = bestCost + cellCost * (diagonal ? SQRT2 : 1); + + if (candidate < best[y]![x]!) best[y]![x] = candidate; + } + } + } + + return best[goalY]![goalX]!; +}; + +/** + * Recomputes a node path's cost while asserting it is a legal walk: contiguous + * steps, walkable cells, and no diagonal that cuts a corner. + */ +export const walkCost = (grid: GridSpace, nodes: readonly number[], diagonals: DiagonalPolicy = 'no-corner-cutting'): number => { + let total = 0; + + for (let index = 1; index < nodes.length; index++) { + const fromX = grid.nodeX(nodes[index - 1]!); + const fromY = grid.nodeY(nodes[index - 1]!); + const toX = grid.nodeX(nodes[index]!); + const toY = grid.nodeY(nodes[index]!); + const stepX = toX - fromX; + const stepY = toY - fromY; + + if (Math.abs(stepX) > 1 || Math.abs(stepY) > 1) throw new Error(`Non-contiguous step ${fromX},${fromY} -> ${toX},${toY}.`); + if (!grid.isWalkable(toX, toY)) throw new Error(`Step into blocked cell ${toX},${toY}.`); + + const diagonal = stepX !== 0 && stepY !== 0; + + if (diagonal && diagonals === 'no-corner-cutting' && (!grid.isWalkable(toX, fromY) || !grid.isWalkable(fromX, toY))) { + throw new Error(`Corner cut at ${fromX},${fromY} -> ${toX},${toY}.`); + } + + total += grid.costAt(toX, toY) * (diagonal ? SQRT2 : 1); + } + + return total; +}; diff --git a/packages/exojs-pathfinding/test/jumpPointSearch.test.ts b/packages/exojs-pathfinding/test/jumpPointSearch.test.ts new file mode 100644 index 000000000..5fa9b5c7e --- /dev/null +++ b/packages/exojs-pathfinding/test/jumpPointSearch.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { Pathfinder } from '../src/Pathfinder'; +import { GridSpace } from '../src/spaces/GridSpace'; +import { createRandom, walkCost } from './helpers'; + +const buildGrid = (size: number, blockedRatio: number, seed: number): GridSpace => { + const random = createRandom(seed); + const blocked: boolean[] = []; + + for (let index = 0; index < size * size; index++) { + blocked.push(random() < blockedRatio); + } + + blocked[0] = false; + blocked[size * size - 1] = false; + + return GridSpace.from(size, size, (x, y) => (blocked[y * size + x] === true ? 0 : 1)); +}; + +describe('jump-point search', () => { + it('produces the same optimal cost as plain A* on randomized grids', () => { + const pathfinder = new Pathfinder(); + + for (let trial = 0; trial < 30; trial++) { + const size = 24; + const grid = buildGrid(size, 0.28, 0x1000 + trial); + const start = grid.nodeAt(0, 0); + const goal = grid.nodeAt(size - 1, size - 1); + const jumped = pathfinder.findPath(grid, start, goal); + const plain = pathfinder.findPath(grid, start, goal, { pruning: false }); + + expect(jumped.status).toBe(plain.status); + + if (plain.status !== 'found') continue; + + expect(jumped.cost).toBeCloseTo(plain.cost, 9); + // Both are contiguous cell paths, so the pruned one must be walkable step + // by step - a jump run that skipped a wall would surface right here. + expect(walkCost(grid, jumped.nodes)).toBeCloseTo(jumped.cost, 9); + expect(jumped.nodes[0]).toBe(start); + expect(jumped.nodes.at(-1)).toBe(goal); + expect(jumped.nodes).toHaveLength(plain.nodes.length); + } + }); + + it('agrees with A* on open grids, where the pruning is at its most aggressive', () => { + const grid = new GridSpace(64, 64); + const pathfinder = new Pathfinder(); + const jumped = pathfinder.findPath(grid, grid.nodeAt(2, 60), grid.nodeAt(60, 3)); + const plain = pathfinder.findPath(grid, grid.nodeAt(2, 60), grid.nodeAt(60, 3), { pruning: false }); + + expect(jumped.cost).toBeCloseTo(plain.cost, 9); + expect(jumped.expandedNodes).toBeLessThan(plain.expandedNodes); + }); + + it('never cuts the corner of a diagonal wall', () => { + // Only the two diagonal cells are open; under 'no-corner-cutting' the two + // halves of this grid are disconnected, and a textbook jump-point rule set + // (which assumes corner cutting is legal) would happily walk through. + const open = new Set(['0,0', '1,1', '2,2']); + const grid = GridSpace.from(3, 3, (x, y) => (open.has(`${x},${y}`) ? 1 : 0)); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(2, 2)); + + expect(result.status).toBe('unreachable'); + }); + + it('refuses to prune where its assumptions do not hold', () => { + const weighted = GridSpace.from(4, 4, (x, y) => (x === 1 && y === 1 ? 3 : 1)); + const cornerCutting = new GridSpace(4, 4, { diagonals: 'always' }); + const orthogonal = new GridSpace(4, 4, { diagonals: 'never' }); + const uniform = new GridSpace(4, 4); + + expect(weighted.pruning(1)).toBeNull(); + expect(cornerCutting.pruning(1)).toBeNull(); + expect(orthogonal.pruning(1)).toBeNull(); + expect(uniform.pruning(2)).toBeNull(); + expect(uniform.pruning(1)).not.toBeNull(); + }); + + it('stops pruning as soon as a cost edit makes the grid non-uniform', () => { + const grid = new GridSpace(8, 8); + + expect(grid.uniformCost).toBe(true); + + grid.setCost(3, 3, 4); + + expect(grid.uniformCost).toBe(false); + expect(grid.pruning(1)).toBeNull(); + + grid.setCost(3, 3, 1); + + expect(grid.uniformCost).toBe(true); + expect(grid.pruning(1)).not.toBeNull(); + }); + + it('finds the same path after the grid is edited under it', () => { + const grid = new GridSpace(16, 16); + const pathfinder = new Pathfinder(); + + for (let y = 0; y < 15; y++) { + grid.setCost(8, y, 0); + } + + const jumped = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(15, 0)); + const plain = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(15, 0), { pruning: false }); + + expect(jumped.cost).toBeCloseTo(plain.cost, 9); + expect(walkCost(grid, jumped.nodes)).toBeCloseTo(jumped.cost, 9); + }); +}); diff --git a/packages/exojs-pathfinding/test/smoothing.test.ts b/packages/exojs-pathfinding/test/smoothing.test.ts new file mode 100644 index 000000000..ce6507a85 --- /dev/null +++ b/packages/exojs-pathfinding/test/smoothing.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { Pathfinder } from '../src/Pathfinder'; +import { GridSpace } from '../src/spaces/GridSpace'; +import { createRandom, gridFrom } from './helpers'; + +/** + * Re-walks a smoothed path the way an agent would - straight from waypoint to + * waypoint - and reports whether every cell it crosses is walkable. + */ +const segmentsAreClear = (grid: GridSpace, nodes: readonly number[]): boolean => { + for (let index = 1; index < nodes.length; index++) { + const fromX = grid.nodeX(nodes[index - 1]!) + 0.5; + const fromY = grid.nodeY(nodes[index - 1]!) + 0.5; + const toX = grid.nodeX(nodes[index]!) + 0.5; + const toY = grid.nodeY(nodes[index]!) + 0.5; + const steps = Math.ceil(Math.hypot(toX - fromX, toY - fromY) * 64); + + for (let step = 0; step <= steps; step++) { + const t = step / steps; + const x = Math.floor(fromX + (toX - fromX) * t); + const y = Math.floor(fromY + (toY - fromY) * t); + + if (!grid.isWalkable(x, y)) return false; + } + } + + return true; +}; + +describe('GridSpace.smoothPath', () => { + it('removes the staircase from an open diagonal run', () => { + const grid = new GridSpace(8, 8, { diagonals: 'never' }); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(7, 7), { smooth: true }); + + expect(result.nodes).toEqual([grid.nodeAt(0, 0), grid.nodeAt(7, 7)]); + expect(result.points).toHaveLength(2); + }); + + it('keeps the corners it has to keep', () => { + const grid = gridFrom(['..........', '..........', '#####.####', '..........', '..........']); + const pathfinder = new Pathfinder(); + const raw = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(0, 4)); + const smoothed = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(0, 4), { smooth: true }); + + expect(smoothed.nodes.length).toBeLessThan(raw.nodes.length); + expect(smoothed.nodes.length).toBeGreaterThan(2); + expect(segmentsAreClear(grid, smoothed.nodes)).toBe(true); + }); + + it('leaves the reported cost as the cost of the walked path', () => { + const grid = new GridSpace(8, 8, { diagonals: 'never' }); + const smoothed = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(7, 7), { smooth: true }); + + expect(smoothed.cost).toBe(14); + }); + + it('never shortcuts across terrain more expensive than the section it replaces', () => { + const grid = gridFrom(['....', '.99.', '....'], 'never'); + const pathfinder = new Pathfinder(); + const smoothed = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(3, 2), { smooth: true }); + + for (const node of smoothed.nodes) { + expect(grid.costAt(grid.nodeX(node), grid.nodeY(node))).toBe(1); + } + + expect(segmentsAreClear(grid, smoothed.nodes)).toBe(true); + // The straight line from the top-left to the bottom-right corner crosses the + // expensive row, so smoothing must not collapse the detour into it. + expect(smoothed.nodes.length).toBeGreaterThan(2); + }); + + it('keeps every smoothed segment walkable on randomized maps', () => { + const random = createRandom(0xc0ffee); + const pathfinder = new Pathfinder(); + + for (let trial = 0; trial < 25; trial++) { + const size = 20; + const grid = GridSpace.from(size, size, (x, y) => { + if ((x === 0 && y === 0) || (x === size - 1 && y === size - 1)) return 1; + + return random() < 0.2 ? 0 : 1; + }); + const result = pathfinder.findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(size - 1, size - 1), { smooth: true }); + + if (result.status !== 'found') continue; + + expect(segmentsAreClear(grid, result.nodes)).toBe(true); + expect(result.nodes[0]).toBe(grid.nodeAt(0, 0)); + expect(result.nodes.at(-1)).toBe(grid.nodeAt(size - 1, size - 1)); + } + }); + + it('respects clearance so a wide agent keeps its smoothed line', () => { + const grid = gridFrom(['.......', '.......', '...#...', '.......', '.......']); + const result = new Pathfinder().findPath(grid, grid.nodeAt(0, 0), grid.nodeAt(4, 3), { smooth: true, agentSize: 2 }); + + expect(result.status).toBe('found'); + + for (let index = 1; index < result.nodes.length; index++) { + const node = result.nodes[index]!; + + expect(grid.clearanceAt(grid.nodeX(node), grid.nodeY(node))).toBeGreaterThanOrEqual(2); + } + }); +}); diff --git a/packages/exojs-pathfinding/tsconfig.build.json b/packages/exojs-pathfinding/tsconfig.build.json new file mode 100644 index 000000000..037677bbc --- /dev/null +++ b/packages/exojs-pathfinding/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "_comment": "Declaration emit for the Rolldown build (`rolldown.config.ts`), which has no declaration emitter. `paths` resolves the public `@codexo/exojs*` specifiers this package's source imports against Core's built declarations (not source, unlike tsconfig.json's own paths, which back the regular source-mode typecheck).", + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "customConditions": [], + "paths": { + "@codexo/exojs": ["../../dist/esm/index.d.ts"] + } + } +} diff --git a/packages/exojs-pathfinding/tsconfig.json b/packages/exojs-pathfinding/tsconfig.json new file mode 100644 index 000000000..d9042e12c --- /dev/null +++ b/packages/exojs-pathfinding/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@codexo/exojs-config/typescript/extension.json", + "compilerOptions": { + "customConditions": ["@codexo/exojs-source"], + "paths": { + "@codexo/exojs": ["../../src/index.ts"] + } + }, + "include": ["src/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/exojs-pathfinding/tsconfig.test.json b/packages/exojs-pathfinding/tsconfig.test.json new file mode 100644 index 000000000..f24ad586e --- /dev/null +++ b/packages/exojs-pathfinding/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "_comment": "Type-checks this package's own test/** - the package's main program only includes src/**. Run by the package's 'typecheck' script. The loosened flags live in the shared package-test profile.", + "extends": ["./tsconfig.json", "@codexo/exojs-config/typescript/package-test.json"], + "include": ["test/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19c89c9b0..e2585e2de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,15 @@ importers: specifier: workspace:* version: link:../exojs-config + packages/exojs-pathfinding: + devDependencies: + '@codexo/exojs': + specifier: workspace:* + version: link:../.. + '@codexo/exojs-config': + specifier: workspace:* + version: link:../exojs-config + packages/exojs-physics: devDependencies: '@codexo/exojs': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 75174eb42..88a43afac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ packages: - packages/exojs-audio-fx - packages/exojs-tilemap-physics - packages/exojs-lighting + - packages/exojs-pathfinding - packages/create-exo-app # Packages permitted to run install/build scripts. pnpm v10+ blocks build diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts index f1bd847c2..430803ac8 100644 --- a/scripts/ci/lanes.ts +++ b/scripts/ci/lanes.ts @@ -146,7 +146,7 @@ export const LANES: readonly Lane[] = [ run: 'pnpm size && pnpm size:summary && pnpm verify:exports && pnpm verify:declaration-imports && pnpm verify:lockstep && pnpm verify:release-matrix ' + '&& pnpm pack --dry-run && pnpm --filter "@codexo/exojs-build" --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-tiled" --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" pack --dry-run && pnpm verify:publint', dist: true, }, diff --git a/scripts/ci/select-lanes.ts b/scripts/ci/select-lanes.ts index 142424c41..7256fbf9d 100644 --- a/scripts/ci/select-lanes.ts +++ b/scripts/ci/select-lanes.ts @@ -85,6 +85,7 @@ const RUNTIME_PACKAGES = [ 'exojs-react', 'exojs-tilemap-physics', 'exojs-lighting', + 'exojs-pathfinding', ]; /** diff --git a/scripts/exo-full.entry.ts b/scripts/exo-full.entry.ts index 0df3cc1cf..7b6adcdbf 100644 --- a/scripts/exo-full.entry.ts +++ b/scripts/exo-full.entry.ts @@ -117,5 +117,8 @@ export { // ── Lighting ─────────────────────────────────────────────────────────────── export * from '@codexo/exojs-lighting'; +// ── Pathfinding ──────────────────────────────────────────────────────────── +export * from '@codexo/exojs-pathfinding'; + // ── Tilemap physics bridge ──────────────────────────────────────────────────── export { buildObjectLayerColliders, TileColliderStreamer } from '@codexo/exojs-tilemap-physics'; diff --git a/scripts/release/RELEASING.md b/scripts/release/RELEASING.md index 21dc937b3..10040dac9 100644 --- a/scripts/release/RELEASING.md +++ b/scripts/release/RELEASING.md @@ -156,10 +156,10 @@ followed by its Trusted Publisher config on npmjs.com. ### Open at the time of writing (checked against the registry 2026-08-29) -- `@codexo/exojs-tilemap-physics` and `@codexo/exojs-lighting` are in - `LOCKSTEP_PACKAGES` and therefore in `PUBLISH_ORDER`, but neither has ever - been published (npm answers E404). The next coordinated release would reach - them and abort the chain there. +- `@codexo/exojs-tilemap-physics`, `@codexo/exojs-lighting` and + `@codexo/exojs-pathfinding` are in `LOCKSTEP_PACKAGES` and therefore in + `PUBLISH_ORDER`, but none of them has ever been published (npm answers E404). + The next coordinated release would reach them and abort the chain there. **Bootstrap each as part of that release, not before it:** run `release:cut` first so the package carries the release version, then diff --git a/scripts/release/external-consumers.ts b/scripts/release/external-consumers.ts index b95dd071f..623bb4eb9 100644 --- a/scripts/release/external-consumers.ts +++ b/scripts/release/external-consumers.ts @@ -66,6 +66,7 @@ import { AsepriteSheet, asepriteExtension } from '@codexo/exojs-aseprite'; import { LdtkMap, ldtkExtension } from '@codexo/exojs-ldtk'; import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics'; import { LightingSystem, LitSpriteMaterial, PointLight } from '@codexo/exojs-lighting'; +import { GridSpace, Pathfinder, WaypointGraph } from '@codexo/exojs-pathfinding'; export class DemoScene extends Scene {} @@ -103,6 +104,9 @@ export function bootstrap(): { app: Application; system: typeof ParticleSystem; void LightingSystem; void LitSpriteMaterial; void PointLight; + void GridSpace; + void Pathfinder; + void WaypointGraph; return { app, system: ParticleSystem, tiles: TileMap, map: TiledMap }; } `; @@ -168,6 +172,7 @@ import * as aseprite from '@codexo/exojs-aseprite'; import * as ldtk from '@codexo/exojs-ldtk'; import * as tilemapPhysics from '@codexo/exojs-tilemap-physics'; import * as lighting from '@codexo/exojs-lighting'; +import * as pathfinding from '@codexo/exojs-pathfinding'; const checks = [ ['@codexo/exojs Application', typeof exo.Application === 'function'], @@ -194,6 +199,9 @@ const checks = [ ['@codexo/exojs-lighting LightingSystem', typeof lighting.LightingSystem === 'function'], ['@codexo/exojs-lighting LitSpriteMaterial', typeof lighting.LitSpriteMaterial === 'function'], ['@codexo/exojs-lighting PointLight', typeof lighting.PointLight === 'function'], + ['@codexo/exojs-pathfinding Pathfinder', typeof pathfinding.Pathfinder === 'function'], + ['@codexo/exojs-pathfinding GridSpace', typeof pathfinding.GridSpace === 'function'], + ['@codexo/exojs-pathfinding WaypointGraph', typeof pathfinding.WaypointGraph === 'function'], ]; const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); if (failed.length > 0) { diff --git a/scripts/release/lockstep-packages.ts b/scripts/release/lockstep-packages.ts index 36c28da90..087f14633 100644 --- a/scripts/release/lockstep-packages.ts +++ b/scripts/release/lockstep-packages.ts @@ -46,6 +46,7 @@ export const LOCKSTEP_PACKAGES = [ { name: '@codexo/exojs-react', dir: 'packages/exojs-react', isExtension: true, inOfflineSmoke: false }, { name: '@codexo/exojs-tilemap-physics', dir: 'packages/exojs-tilemap-physics', isExtension: true, inOfflineSmoke: true }, { name: '@codexo/exojs-lighting', dir: 'packages/exojs-lighting', isExtension: true, inOfflineSmoke: true }, + { name: '@codexo/exojs-pathfinding', dir: 'packages/exojs-pathfinding', isExtension: true, inOfflineSmoke: true }, ] as const satisfies readonly LockstepPackage[]; /** diff --git a/site/public/preview.html b/site/public/preview.html index e6d7a8ab1..30e273bfc 100644 --- a/site/public/preview.html +++ b/site/public/preview.html @@ -74,6 +74,7 @@ var physicsDebugEntry; var tilemapPhysicsEntry; var lightingEntry; + var pathfindingEntry; if (!safeVersion || safeVersion === 'current') { exoEntry = './vendor/exojs/esm/index.js?no-cache=' + noCache; exoDebugEntry = './vendor/exojs/esm/debug/index.js?no-cache=' + noCache; @@ -89,6 +90,7 @@ physicsDebugEntry = './vendor/exojs-physics/esm/debug/index.js?no-cache=' + noCache; tilemapPhysicsEntry = './vendor/exojs-tilemap-physics/esm/index.js?no-cache=' + noCache; lightingEntry = './vendor/exojs-lighting/esm/index.js?no-cache=' + noCache; + pathfindingEntry = './vendor/exojs-pathfinding/esm/index.js?no-cache=' + noCache; } else { // jsDelivr serves immutable npm tarballs — the version pin // alone is enough, no cache-buster needed. @@ -107,6 +109,7 @@ physicsDebugEntry = cdnBase + 'exojs-physics@' + safeVersion + '/dist/esm/debug/index.js'; tilemapPhysicsEntry = cdnBase + 'exojs-tilemap-physics@' + safeVersion + '/dist/esm/index.js'; lightingEntry = cdnBase + 'exojs-lighting@' + safeVersion + '/dist/esm/index.js'; + pathfindingEntry = cdnBase + 'exojs-pathfinding@' + safeVersion + '/dist/esm/index.js'; } var importMap = { @@ -125,6 +128,7 @@ '@codexo/exojs-physics/debug': physicsDebugEntry, '@codexo/exojs-tilemap-physics': tilemapPhysicsEntry, '@codexo/exojs-lighting': lightingEntry, + '@codexo/exojs-pathfinding': pathfindingEntry, '@examples/runtime': './examples/shared/runtime.js?no-cache=' + noCache, '@examples/terrain-noise': './examples/shared/terrain-noise.js?no-cache=' + noCache, }, diff --git a/site/scripts/build-api.ts b/site/scripts/build-api.ts index 6271611a5..3bc22fb34 100644 --- a/site/scripts/build-api.ts +++ b/site/scripts/build-api.ts @@ -118,6 +118,13 @@ const EXTENSION_PACKAGES: readonly ExtensionPackage[] = [ tsconfig: 'packages/exojs-lighting/tsconfig.json', sourceMarker: 'packages/exojs-lighting/src/', }, + { + importPath: '@codexo/exojs-pathfinding', + subsystem: 'pathfinding', + entryPoint: 'packages/exojs-pathfinding/src/index.ts', + tsconfig: 'packages/exojs-pathfinding/tsconfig.json', + sourceMarker: 'packages/exojs-pathfinding/src/', + }, { importPath: '@codexo/exojs-ldtk', subsystem: 'ldtk', diff --git a/site/scripts/sync-exo-vendor.ts b/site/scripts/sync-exo-vendor.ts index d7fce71cf..16ec7823f 100644 --- a/site/scripts/sync-exo-vendor.ts +++ b/site/scripts/sync-exo-vendor.ts @@ -435,6 +435,7 @@ const syncVendor = (): void => { 'exojs-physics', 'exojs-tilemap-physics', 'exojs-lighting', + 'exojs-pathfinding', ] as const; for (const pkgName of extensionPackages) { let pkgRoot: string; diff --git a/site/src/components/EditorCode.tsx b/site/src/components/EditorCode.tsx index 1bfe8e519..cb47ae397 100644 --- a/site/src/components/EditorCode.tsx +++ b/site/src/components/EditorCode.tsx @@ -656,6 +656,7 @@ const EXTENSION_PACKAGES: ReadonlyArray<{ baseUrl: string; packageName: string } { baseUrl: 'vendor/exojs-physics/', packageName: '@codexo/exojs-physics' }, { baseUrl: 'vendor/exojs-tilemap-physics/', packageName: '@codexo/exojs-tilemap-physics' }, { baseUrl: 'vendor/exojs-lighting/', packageName: '@codexo/exojs-lighting' }, + { baseUrl: 'vendor/exojs-pathfinding/', packageName: '@codexo/exojs-pathfinding' }, ]; const loadTypingsForVersion = async (versionId: string): Promise> => { diff --git a/site/src/lib/api-reference.ts b/site/src/lib/api-reference.ts index 54423cdff..2a4fe8959 100644 --- a/site/src/lib/api-reference.ts +++ b/site/src/lib/api-reference.ts @@ -19,6 +19,7 @@ export const API_SUBSYSTEM_ORDER = [ 'physics', 'tilemap-physics', 'lighting', + 'pathfinding', 'aseprite', 'ldtk', ] as const; @@ -98,6 +99,10 @@ export const API_SUBSYSTEM_META: Record Date: Fri, 4 Sep 2026 23:31:24 +0200 Subject: [PATCH 2/4] docs(pathfinding): add the pathfinding examples, guide part and API reference Two playground examples under a new `pathfinding` category. grid-navigation puts the decisions that change the answer on a panel - jump-point pruning, path smoothing, a 2x2 agent, the diagonal policy - next to the expanded-node counter that shows what each of them costs; painting a single mud cell makes the grid non-uniform and switches pruning off by itself, which is the clearest way to see what the fast path is worth. tilemap-navigation builds the grid from a tile layer through the cost callback, which is the only place the two packages meet: the example imports the tilemap package, the pathfinding package does not. A two-chapter guide part follows the same split - the grid chapter covers costs, diagonals, clearance, smoothing, statuses, staleness and the tilemap recipe, the waypoint chapter covers typed traversal edges, positionless Dijkstra mode, and implementing NavigationSpace for a world of one's own. Every snippet is a real compiled file under examples/guides. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- CHANGELOG.md | 17 + examples/examples.json | 25 + examples/guides/pathfinding/custom-space.ts | 45 + examples/guides/pathfinding/grid-setup.ts | 26 + examples/guides/pathfinding/queries.ts | 37 + examples/guides/pathfinding/reachable-area.ts | 19 + examples/guides/pathfinding/tilemap-bridge.ts | 36 + examples/guides/pathfinding/waypoint-graph.ts | 41 + examples/pathfinding/grid-navigation.js | 175 ++ examples/pathfinding/grid-navigation.ts | 215 +++ examples/pathfinding/tilemap-navigation.js | 194 +++ examples/pathfinding/tilemap-navigation.ts | 249 +++ site/src/content/api/diagonal-policy.json | 80 + site/src/content/api/find-path-options.json | 175 ++ site/src/content/api/flood-options.json | 125 ++ site/src/content/api/flood-region.json | 158 ++ site/src/content/api/grid-space-options.json | 200 +++ site/src/content/api/grid-space.json | 1534 +++++++++++++++++ site/src/content/api/navigation-space.json | 748 ++++++++ site/src/content/api/path-edge.json | 143 ++ site/src/content/api/path-result.json | 245 +++ site/src/content/api/path-status.json | 80 + site/src/content/api/pathfinder.json | 489 ++++++ site/src/content/api/pruned-expansion.json | 275 +++ .../content/api/waypoint-edge-options.json | 125 ++ site/src/content/api/waypoint-graph.json | 1058 ++++++++++++ .../guide/pathfinding/grid-pathfinding.mdx | 181 ++ .../guide/pathfinding/waypoint-graphs.mdx | 105 ++ site/src/lib/chapters.ts | 1 + site/src/lib/guide-structure.ts | 31 + 30 files changed, 6832 insertions(+) create mode 100644 examples/guides/pathfinding/custom-space.ts create mode 100644 examples/guides/pathfinding/grid-setup.ts create mode 100644 examples/guides/pathfinding/queries.ts create mode 100644 examples/guides/pathfinding/reachable-area.ts create mode 100644 examples/guides/pathfinding/tilemap-bridge.ts create mode 100644 examples/guides/pathfinding/waypoint-graph.ts create mode 100644 examples/pathfinding/grid-navigation.js create mode 100644 examples/pathfinding/grid-navigation.ts create mode 100644 examples/pathfinding/tilemap-navigation.js create mode 100644 examples/pathfinding/tilemap-navigation.ts create mode 100644 site/src/content/api/diagonal-policy.json create mode 100644 site/src/content/api/find-path-options.json create mode 100644 site/src/content/api/flood-options.json create mode 100644 site/src/content/api/flood-region.json create mode 100644 site/src/content/api/grid-space-options.json create mode 100644 site/src/content/api/grid-space.json create mode 100644 site/src/content/api/navigation-space.json create mode 100644 site/src/content/api/path-edge.json create mode 100644 site/src/content/api/path-result.json create mode 100644 site/src/content/api/path-status.json create mode 100644 site/src/content/api/pathfinder.json create mode 100644 site/src/content/api/pruned-expansion.json create mode 100644 site/src/content/api/waypoint-edge-options.json create mode 100644 site/src/content/api/waypoint-graph.json create mode 100644 site/src/content/guide/pathfinding/grid-pathfinding.mdx create mode 100644 site/src/content/guide/pathfinding/waypoint-graphs.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a031dad..a95ba3ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/examples/examples.json b/examples/examples.json index cf3eff435..7f2383490 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -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", diff --git a/examples/guides/pathfinding/custom-space.ts b/examples/guides/pathfinding/custom-space.ts new file mode 100644 index 000000000..0d7dd9013 --- /dev/null +++ b/examples/guides/pathfinding/custom-space.ts @@ -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; diff --git a/examples/guides/pathfinding/grid-setup.ts b/examples/guides/pathfinding/grid-setup.ts new file mode 100644 index 000000000..0548419f5 --- /dev/null +++ b/examples/guides/pathfinding/grid-setup.ts @@ -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; diff --git a/examples/guides/pathfinding/queries.ts b/examples/guides/pathfinding/queries.ts new file mode 100644 index 000000000..98cbb9efc --- /dev/null +++ b/examples/guides/pathfinding/queries.ts @@ -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; diff --git a/examples/guides/pathfinding/reachable-area.ts b/examples/guides/pathfinding/reachable-area.ts new file mode 100644 index 000000000..1e8a5b960 --- /dev/null +++ b/examples/guides/pathfinding/reachable-area.ts @@ -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 diff --git a/examples/guides/pathfinding/tilemap-bridge.ts b/examples/guides/pathfinding/tilemap-bridge.ts new file mode 100644 index 000000000..a0f1ba0b5 --- /dev/null +++ b/examples/guides/pathfinding/tilemap-bridge.ts @@ -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; diff --git a/examples/guides/pathfinding/waypoint-graph.ts b/examples/guides/pathfinding/waypoint-graph.ts new file mode 100644 index 000000000..e661953d9 --- /dev/null +++ b/examples/guides/pathfinding/waypoint-graph.ts @@ -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(); + +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 diff --git a/examples/pathfinding/grid-navigation.js b/examples/pathfinding/grid-navigation.js new file mode 100644 index 000000000..d47baf7d6 --- /dev/null +++ b/examples/pathfinding/grid-navigation.js @@ -0,0 +1,175 @@ +// Auto-generated from grid-navigation.ts - edit the .ts source, not this file. +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; +import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +// Click-to-move over a weighted grid. Everything here is plain data: a GridSpace +// of costs and a Pathfinder, neither of which is a scene node, neither of which +// is registered with the Application. The Graphics below only *draws* what the +// query returned. +// +// The panel exposes the three decisions that actually change the answer: +// - jump-point pruning, which returns the same optimal path from far fewer +// expanded nodes (watch the counter) and switches itself off the moment the +// grid stops being uniform-cost, which is what painting mud does; +// - smoothing, which string-pulls the staircase out of the result; +// - agent size, which restricts the route to cells a 2x2 agent fits through. +const CELL = 32; +const COLUMNS = 40; +const ROWS = 22; +const MUD_COST = 6; +const AGENT_SPEED = 260; +const WALL_COLOR = new Color(38, 44, 58); +const FLOOR_COLOR = new Color(24, 28, 38); +const MUD_COLOR = new Color(78, 62, 34); +const PATH_COLOR = new Color(90, 200, 255); +const GOAL_COLOR = new Color(255, 170, 80); +const AGENT_COLOR = new Color(240, 245, 255); +/** Deterministic room-and-pillar layout, so the example looks the same every run. */ +const initialCost = (x, y) => { + if (x === 0 || y === 0 || x === COLUMNS - 1 || y === ROWS - 1) return 0; + if (x % 8 === 4 && y % 3 !== 1) return 0; + if (x % 4 === 2 && y % 6 === 3) return 0; + return 1; +}; +class GridNavigationScene extends Scene { + grid = GridSpace.from(COLUMNS, ROWS, initialCost, { cellSize: CELL }); + pathfinder = new Pathfinder(); + terrain = new Graphics(); + overlay = new Graphics(); + result = null; + goal = { x: COLUMNS - 3, y: ROWS - 3 }; + agent = { x: 2.5 * CELL, y: 2.5 * CELL }; + waypoint = 0; + paint = 'goal'; + smooth = false; + pruning = true; + agentSize = 1; + diagonals = 'no-corner-cutting'; + hud; + init() { + this.app.input.onPointerTap.add(pointer => this.applyPaint(pointer.x, pointer.y)); + this.hud = mountControls({ + title: 'Grid Navigation', + controls: [ + { keys: 'Click', action: 'set the goal (or paint, see panel)' }, + { keys: 'panel', action: 'pruning / smoothing / agent size' }, + ], + status: '', + hint: 'Painting mud makes the grid non-uniform, which turns jump-point pruning off by itself — the expanded-node counter jumps.', + }); + const panel = mountControlPanel({ title: 'Pathfinding' }); + panel.addCycle({ + label: 'Click paints', + options: ['goal', 'wall', 'mud'], + index: 0, + onChange: (_index, value) => (this.paint = value), + }); + panel.addToggle({ label: 'Jump-point pruning', value: true, onChange: value => this.replan(() => (this.pruning = value)) }); + panel.addToggle({ label: 'Smooth path', value: false, onChange: value => this.replan(() => (this.smooth = value)) }); + panel.addToggle({ label: '2x2 agent', value: false, onChange: value => this.replan(() => (this.agentSize = value ? 2 : 1)) }); + panel.addCycle({ + label: 'Diagonals', + options: ['no-corner-cutting', 'never', 'always'], + index: 0, + // The diagonal policy is fixed at construction, so changing it rebuilds + // the window from the costs the current one holds. + onChange: (_index, value) => { + const previous = this.grid; + this.grid = GridSpace.from(COLUMNS, ROWS, (x, y) => previous.costAt(x, y), { cellSize: CELL, diagonals: value }); + this.diagonals = value; + this.replan(); + }, + }); + this.drawTerrain(); + this.replan(); + } + update(delta) { + const points = this.result?.points ?? []; + if (this.waypoint >= points.length) return; + let travel = AGENT_SPEED * delta; + while (travel > 0 && this.waypoint < points.length) { + const target = points[this.waypoint]; + const distance = Math.hypot(target.x - this.agent.x, target.y - this.agent.y); + if (distance <= travel) { + this.agent.x = target.x; + this.agent.y = target.y; + travel -= distance; + this.waypoint++; + continue; + } + this.agent.x += ((target.x - this.agent.x) / distance) * travel; + this.agent.y += ((target.y - this.agent.y) / distance) * travel; + travel = 0; + } + } + draw(context) { + this.overlay.clear(); + const points = this.result?.points ?? []; + if (points.length > 1) { + this.overlay.lineWidth = 4; + this.overlay.lineColor = PATH_COLOR; + for (let index = 1; index < points.length; index++) { + this.overlay.drawLine(points[index - 1].x, points[index - 1].y, points[index].x, points[index].y); + } + } + this.overlay.fillColor = GOAL_COLOR; + this.overlay.drawCircle((this.goal.x + 0.5) * CELL, (this.goal.y + 0.5) * CELL, 8); + this.overlay.fillColor = AGENT_COLOR; + this.overlay.drawCircle(this.agent.x, this.agent.y, 4 + this.agentSize * 4); + context.render(this.terrain); + context.render(this.overlay); + } + applyPaint(screenX, screenY) { + const x = Math.floor(screenX / CELL); + const y = Math.floor(screenY / CELL); + if (this.grid.nodeAt(x, y) < 0) return; + if (this.paint === 'goal') { + this.goal = { x, y }; + } else { + const painted = this.paint === 'wall' ? 0 : MUD_COST; + this.grid.setCost(x, y, this.grid.costAt(x, y) === painted ? 1 : painted); + this.drawTerrain(); + } + this.replan(); + } + replan(mutate) { + mutate?.(); + // The agent is somewhere between two cells, so the query starts from the + // cell it currently stands in rather than from the previous path's start. + this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * CELL, (this.goal.y + 0.5) * CELL, { + smooth: this.smooth, + pruning: this.pruning, + agentSize: this.agentSize, + snapToNearest: true, + }); + this.waypoint = 0; + const { status, cost, nodes, expandedNodes } = this.result; + const pruned = this.pruning && this.grid.pruning(this.agentSize) !== null; + this.hud.setStatus( + `${status} · cost ${cost.toFixed(1)} · ${nodes.length} waypoints · ${expandedNodes} nodes expanded · ${pruned ? 'jump-point' : 'plain A*'} · ${this.diagonals}`, + ); + } + drawTerrain() { + this.terrain.clear(); + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLUMNS; x++) { + const cost = this.grid.costAt(x, y); + if (cost === 0) this.terrain.fillColor = WALL_COLOR; + else if (cost > 1) this.terrain.fillColor = MUD_COLOR; + else this.terrain.fillColor = FLOOR_COLOR; + this.terrain.drawRectangle(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2); + } + } + } +} +const app = new Application({ + scenes: { GridNavigationScene }, + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizing: new FixedResolutionCanvasSizing(), + }, + clearColor: new Color(12, 14, 20), +}); +await app.start(GridNavigationScene); diff --git a/examples/pathfinding/grid-navigation.ts b/examples/pathfinding/grid-navigation.ts new file mode 100644 index 000000000..583bbdf93 --- /dev/null +++ b/examples/pathfinding/grid-navigation.ts @@ -0,0 +1,215 @@ +import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds } from '@codexo/exojs'; +import { type DiagonalPolicy, GridSpace, Pathfinder, type PathResult } from '@codexo/exojs-pathfinding'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +// Click-to-move over a weighted grid. Everything here is plain data: a GridSpace +// of costs and a Pathfinder, neither of which is a scene node, neither of which +// is registered with the Application. The Graphics below only *draws* what the +// query returned. +// +// The panel exposes the three decisions that actually change the answer: +// - jump-point pruning, which returns the same optimal path from far fewer +// expanded nodes (watch the counter) and switches itself off the moment the +// grid stops being uniform-cost, which is what painting mud does; +// - smoothing, which string-pulls the staircase out of the result; +// - agent size, which restricts the route to cells a 2x2 agent fits through. + +const CELL = 32; +const COLUMNS = 40; +const ROWS = 22; +const MUD_COST = 6; +const AGENT_SPEED = 260; + +const WALL_COLOR = new Color(38, 44, 58); +const FLOOR_COLOR = new Color(24, 28, 38); +const MUD_COLOR = new Color(78, 62, 34); +const PATH_COLOR = new Color(90, 200, 255); +const GOAL_COLOR = new Color(255, 170, 80); +const AGENT_COLOR = new Color(240, 245, 255); + +type PaintMode = 'goal' | 'wall' | 'mud'; + +/** Deterministic room-and-pillar layout, so the example looks the same every run. */ +const initialCost = (x: number, y: number): number => { + if (x === 0 || y === 0 || x === COLUMNS - 1 || y === ROWS - 1) return 0; + if (x % 8 === 4 && y % 3 !== 1) return 0; + if (x % 4 === 2 && y % 6 === 3) return 0; + + return 1; +}; + +class GridNavigationScene extends Scene { + private grid = GridSpace.from(COLUMNS, ROWS, initialCost, { cellSize: CELL }); + private readonly pathfinder = new Pathfinder(); + private terrain = new Graphics(); + private overlay = new Graphics(); + private result: PathResult | null = null; + private goal = { x: COLUMNS - 3, y: ROWS - 3 }; + private agent = { x: 2.5 * CELL, y: 2.5 * CELL }; + private waypoint = 0; + private paint: PaintMode = 'goal'; + private smooth = false; + private pruning = true; + private agentSize = 1; + private diagonals: DiagonalPolicy = 'no-corner-cutting'; + private hud!: ReturnType; + + override init(): void { + this.app.input.onPointerTap.add(pointer => this.applyPaint(pointer.x, pointer.y)); + + this.hud = mountControls({ + title: 'Grid Navigation', + controls: [ + { keys: 'Click', action: 'set the goal (or paint, see panel)' }, + { keys: 'panel', action: 'pruning / smoothing / agent size' }, + ], + status: '', + hint: 'Painting mud makes the grid non-uniform, which turns jump-point pruning off by itself — the expanded-node counter jumps.', + }); + + const panel = mountControlPanel({ title: 'Pathfinding' }); + + panel.addCycle({ + label: 'Click paints', + options: ['goal', 'wall', 'mud'], + index: 0, + onChange: (_index, value) => (this.paint = value as PaintMode), + }); + panel.addToggle({ label: 'Jump-point pruning', value: true, onChange: value => this.replan(() => (this.pruning = value)) }); + panel.addToggle({ label: 'Smooth path', value: false, onChange: value => this.replan(() => (this.smooth = value)) }); + panel.addToggle({ label: '2x2 agent', value: false, onChange: value => this.replan(() => (this.agentSize = value ? 2 : 1)) }); + panel.addCycle({ + label: 'Diagonals', + options: ['no-corner-cutting', 'never', 'always'], + index: 0, + // The diagonal policy is fixed at construction, so changing it rebuilds + // the window from the costs the current one holds. + onChange: (_index, value) => { + const previous = this.grid; + + this.grid = GridSpace.from(COLUMNS, ROWS, (x, y) => previous.costAt(x, y), { cellSize: CELL, diagonals: value as DiagonalPolicy }); + this.diagonals = value as DiagonalPolicy; + this.replan(); + }, + }); + + this.drawTerrain(); + this.replan(); + } + + override update(delta: Seconds): void { + const points = this.result?.points ?? []; + + if (this.waypoint >= points.length) return; + + let travel = AGENT_SPEED * delta; + + while (travel > 0 && this.waypoint < points.length) { + const target = points[this.waypoint]!; + const distance = Math.hypot(target.x - this.agent.x, target.y - this.agent.y); + + if (distance <= travel) { + this.agent.x = target.x; + this.agent.y = target.y; + travel -= distance; + this.waypoint++; + continue; + } + + this.agent.x += ((target.x - this.agent.x) / distance) * travel; + this.agent.y += ((target.y - this.agent.y) / distance) * travel; + travel = 0; + } + } + + override draw(context: RenderingContext): void { + this.overlay.clear(); + + const points = this.result?.points ?? []; + + if (points.length > 1) { + this.overlay.lineWidth = 4; + this.overlay.lineColor = PATH_COLOR; + + for (let index = 1; index < points.length; index++) { + this.overlay.drawLine(points[index - 1]!.x, points[index - 1]!.y, points[index]!.x, points[index]!.y); + } + } + + this.overlay.fillColor = GOAL_COLOR; + this.overlay.drawCircle((this.goal.x + 0.5) * CELL, (this.goal.y + 0.5) * CELL, 8); + this.overlay.fillColor = AGENT_COLOR; + this.overlay.drawCircle(this.agent.x, this.agent.y, 4 + this.agentSize * 4); + + context.render(this.terrain); + context.render(this.overlay); + } + + private applyPaint(screenX: number, screenY: number): void { + const x = Math.floor(screenX / CELL); + const y = Math.floor(screenY / CELL); + + if (this.grid.nodeAt(x, y) < 0) return; + + if (this.paint === 'goal') { + this.goal = { x, y }; + } else { + const painted = this.paint === 'wall' ? 0 : MUD_COST; + + this.grid.setCost(x, y, this.grid.costAt(x, y) === painted ? 1 : painted); + this.drawTerrain(); + } + + this.replan(); + } + + private replan(mutate?: () => void): void { + mutate?.(); + + // The agent is somewhere between two cells, so the query starts from the + // cell it currently stands in rather than from the previous path's start. + this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * CELL, (this.goal.y + 0.5) * CELL, { + smooth: this.smooth, + pruning: this.pruning, + agentSize: this.agentSize, + snapToNearest: true, + }); + this.waypoint = 0; + + const { status, cost, nodes, expandedNodes } = this.result; + const pruned = this.pruning && this.grid.pruning(this.agentSize) !== null; + + this.hud.setStatus( + `${status} · cost ${cost.toFixed(1)} · ${nodes.length} waypoints · ${expandedNodes} nodes expanded · ${pruned ? 'jump-point' : 'plain A*'} · ${this.diagonals}`, + ); + } + + private drawTerrain(): void { + this.terrain.clear(); + + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLUMNS; x++) { + const cost = this.grid.costAt(x, y); + + if (cost === 0) this.terrain.fillColor = WALL_COLOR; + else if (cost > 1) this.terrain.fillColor = MUD_COLOR; + else this.terrain.fillColor = FLOOR_COLOR; + + this.terrain.drawRectangle(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2); + } + } + } +} + +const app = new Application({ + scenes: { GridNavigationScene }, + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizing: new FixedResolutionCanvasSizing(), + }, + clearColor: new Color(12, 14, 20), +}); + +await app.start(GridNavigationScene); diff --git a/examples/pathfinding/tilemap-navigation.js b/examples/pathfinding/tilemap-navigation.js new file mode 100644 index 000000000..158efc002 --- /dev/null +++ b/examples/pathfinding/tilemap-navigation.js @@ -0,0 +1,194 @@ +// Auto-generated from tilemap-navigation.ts - edit the .ts source, not this file. +import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, Graphics, Scene, TextureRegion } from '@codexo/exojs'; +import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding'; +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +// Pathfinding over a tilemap without a package dependency in either direction. +// +// @codexo/exojs-pathfinding knows nothing about tilemaps: the entire bridge is +// the cost callback `GridSpace.from` takes, which the game answers out of +// whatever its map layer actually stores. Here that is the placed tile id; a +// Tiled- or LDtk-authored map would instead read the tile's collision data: +// +// const definition = tile.tileset.getTileDefinition(tile.localTileId); +// return definition?.collision === undefined ? 1 : 0; +// +// Editing the map keeps the two in step through `setCost`, which bumps the +// grid's revision so anything following an older path can notice. +const TILE = 32; +const COLUMNS = 40; +const ROWS = 22; +const FLOOR_TILE = 0; +const WALL_TILE = 9; +const ROUGH_TILE = 1; +const ROUGH_COST = 5; +const AGENT_SPEED = 220; +const PATH_COLOR = new Color(120, 240, 190); +const GOAL_COLOR = new Color(255, 150, 90); +const AGENT_COLOR = new Color(255, 255, 255); +const isBorder = (x, y) => x === 0 || y === 0 || x === COLUMNS - 1 || y === ROWS - 1; +/** Deterministic layout: a walled arena with pillars and a band of rough ground. */ +const tileAt = (x, y) => { + if (isBorder(x, y) || (x % 6 === 3 && y % 4 !== 2)) return WALL_TILE; + if (y >= 9 && y <= 11 && x > 1 && x < COLUMNS - 2) return ROUGH_TILE; + return FLOOR_TILE; +}; +/** The one place the two packages meet: a tile turns into a traversal cost. */ +const walkCost = tile => { + if (tile === null || tile.localTileId === WALL_TILE) return 0; + return tile.localTileId === ROUGH_TILE ? ROUGH_COST : 1; +}; +class TilemapNavigationScene extends Scene { + pathfinder = new Pathfinder(); + layer; + grid; + mapView; + worldRoot; + overlay = new Graphics(); + result = null; + goal = { x: COLUMNS - 4, y: ROWS - 4 }; + agent = { x: 1.5 * TILE, y: 1.5 * TILE }; + waypoint = 0; + avoidRough = true; + hud; + async load() { + const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tileset = new TileSet({ + name: 'map', + texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + this.layer = new TileLayer({ id: 1, name: 'ground', width: COLUMNS, height: ROWS, tileWidth: TILE, tileHeight: TILE, tilesets: [tileset] }); + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLUMNS; x++) { + this.layer.setTileAt(x, y, { tileset, localTileId: tileAt(x, y), transform: TILE_TRANSFORM_IDENTITY }); + } + } + const map = new TileMap({ name: 'arena', width: COLUMNS, height: ROWS, tileWidth: TILE, tileHeight: TILE, tilesets: [tileset], layers: [this.layer] }); + this.mapView = map.createView({ bands: { ground: ['ground'] } }); + this.worldRoot = new Container(); + this.worldRoot.addChild(this.mapView.band('ground')); + this.buildGrid(); + this.app.input.onPointerTap.add(pointer => { + const x = Math.floor(pointer.x / TILE); + const y = Math.floor(pointer.y / TILE); + if (this.grid.nodeAt(x, y) < 0) return; + this.goal = { x, y }; + this.replan(); + }); + this.hud = mountControls({ + title: 'Tilemap Navigation', + controls: [ + { keys: 'Click', action: 'send the agent to a tile' }, + { keys: 'panel', action: 'terrain cost / carve a door' }, + ], + status: '', + hint: 'The grid is built from a cost callback over the tile layer — the pathfinding package never sees the tilemap.', + }); + const panel = mountControlPanel({ title: 'Navigation' }); + panel.addToggle({ + label: 'Rough ground costs more', + value: true, + onChange: value => { + this.avoidRough = value; + this.buildGrid(); + this.replan(); + }, + }); + panel.addButton({ + label: 'Carve a door in the next wall', + onClick: () => this.carveDoor(), + }); + this.replan(); + } + update(delta) { + const points = this.result?.points ?? []; + if (this.waypoint >= points.length) return; + let travel = AGENT_SPEED * delta; + while (travel > 0 && this.waypoint < points.length) { + const target = points[this.waypoint]; + const distance = Math.hypot(target.x - this.agent.x, target.y - this.agent.y); + if (distance <= travel) { + this.agent.x = target.x; + this.agent.y = target.y; + travel -= distance; + this.waypoint++; + continue; + } + this.agent.x += ((target.x - this.agent.x) / distance) * travel; + this.agent.y += ((target.y - this.agent.y) / distance) * travel; + travel = 0; + } + } + draw(context) { + this.overlay.clear(); + const points = this.result?.points ?? []; + if (points.length > 1) { + this.overlay.lineWidth = 3; + this.overlay.lineColor = PATH_COLOR; + for (let index = 1; index < points.length; index++) { + this.overlay.drawLine(points[index - 1].x, points[index - 1].y, points[index].x, points[index].y); + } + } + this.overlay.fillColor = GOAL_COLOR; + this.overlay.drawCircle((this.goal.x + 0.5) * TILE, (this.goal.y + 0.5) * TILE, 7); + this.overlay.fillColor = AGENT_COLOR; + this.overlay.drawCircle(this.agent.x, this.agent.y, 7); + context.render(this.worldRoot); + context.render(this.overlay); + } + /** + * Rebuilds the whole window from the layer. A streamed world would size the + * window to the loaded region instead and keep it in step with `setCost`. + */ + buildGrid() { + this.grid = GridSpace.from( + COLUMNS, + ROWS, + (x, y) => { + const cost = walkCost(this.layer.getTileAt(x, y)); + return this.avoidRough ? cost : Math.min(cost, 1); + }, + { cellSize: TILE }, + ); + } + /** Edits map and grid together, which is what `setCost` and `revision` exist for. */ + carveDoor() { + for (let x = 1; x < COLUMNS - 1; x++) { + for (let y = 1; y < ROWS - 1; y++) { + if (this.layer.getTileAt(x, y)?.localTileId !== WALL_TILE) continue; + const tileset = this.layer.tilesets[0]; + this.layer.setTileAt(x, y, { tileset, localTileId: FLOOR_TILE, transform: TILE_TRANSFORM_IDENTITY }); + this.grid.setCost(x, y, 1); + this.replan(); + return; + } + } + } + replan() { + this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * TILE, (this.goal.y + 0.5) * TILE, { + smooth: true, + snapToNearest: true, + }); + this.waypoint = 0; + const { status, cost, expandedNodes } = this.result; + this.hud.setStatus( + `${status} · cost ${cost.toFixed(1)} · ${expandedNodes} nodes expanded · grid revision ${this.grid.revision} · ${this.grid.uniformCost ? 'jump-point' : 'weighted A*'}`, + ); + } +} +const app = new Application({ + scenes: { TilemapNavigationScene }, + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizing: new FixedResolutionCanvasSizing(), + }, + clearColor: new Color(16, 20, 26), + extensions: [tilemapExtension], +}); +await app.start(TilemapNavigationScene); diff --git a/examples/pathfinding/tilemap-navigation.ts b/examples/pathfinding/tilemap-navigation.ts new file mode 100644 index 000000000..bc923c47e --- /dev/null +++ b/examples/pathfinding/tilemap-navigation.ts @@ -0,0 +1,249 @@ +import { + Application, + Asset, + Color, + Container, + FixedResolutionCanvasSizing, + Graphics, + type RenderingContext, + Scene, + type Seconds, + TextureRegion, +} from '@codexo/exojs'; +import { GridSpace, Pathfinder, type PathResult } from '@codexo/exojs-pathfinding'; +import { type ResolvedTile, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, type TileMapView, TileSet } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +// Pathfinding over a tilemap without a package dependency in either direction. +// +// @codexo/exojs-pathfinding knows nothing about tilemaps: the entire bridge is +// the cost callback `GridSpace.from` takes, which the game answers out of +// whatever its map layer actually stores. Here that is the placed tile id; a +// Tiled- or LDtk-authored map would instead read the tile's collision data: +// +// const definition = tile.tileset.getTileDefinition(tile.localTileId); +// return definition?.collision === undefined ? 1 : 0; +// +// Editing the map keeps the two in step through `setCost`, which bumps the +// grid's revision so anything following an older path can notice. + +const TILE = 32; +const COLUMNS = 40; +const ROWS = 22; +const FLOOR_TILE = 0; +const WALL_TILE = 9; +const ROUGH_TILE = 1; +const ROUGH_COST = 5; +const AGENT_SPEED = 220; + +const PATH_COLOR = new Color(120, 240, 190); +const GOAL_COLOR = new Color(255, 150, 90); +const AGENT_COLOR = new Color(255, 255, 255); + +const isBorder = (x: number, y: number): boolean => x === 0 || y === 0 || x === COLUMNS - 1 || y === ROWS - 1; + +/** Deterministic layout: a walled arena with pillars and a band of rough ground. */ +const tileAt = (x: number, y: number): number => { + if (isBorder(x, y) || (x % 6 === 3 && y % 4 !== 2)) return WALL_TILE; + if (y >= 9 && y <= 11 && x > 1 && x < COLUMNS - 2) return ROUGH_TILE; + + return FLOOR_TILE; +}; + +/** The one place the two packages meet: a tile turns into a traversal cost. */ +const walkCost = (tile: ResolvedTile | null): number => { + if (tile === null || tile.localTileId === WALL_TILE) return 0; + + return tile.localTileId === ROUGH_TILE ? ROUGH_COST : 1; +}; + +class TilemapNavigationScene extends Scene { + private readonly pathfinder = new Pathfinder(); + private layer!: TileLayer; + private grid!: GridSpace; + private mapView!: TileMapView; + private worldRoot!: Container; + private overlay = new Graphics(); + private result: PathResult | null = null; + private goal = { x: COLUMNS - 4, y: ROWS - 4 }; + private agent = { x: 1.5 * TILE, y: 1.5 * TILE }; + private waypoint = 0; + private avoidRough = true; + private hud!: ReturnType; + + override async load(): Promise { + const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tileset = new TileSet({ + name: 'map', + texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + + this.layer = new TileLayer({ id: 1, name: 'ground', width: COLUMNS, height: ROWS, tileWidth: TILE, tileHeight: TILE, tilesets: [tileset] }); + + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLUMNS; x++) { + this.layer.setTileAt(x, y, { tileset, localTileId: tileAt(x, y), transform: TILE_TRANSFORM_IDENTITY }); + } + } + + const map = new TileMap({ name: 'arena', width: COLUMNS, height: ROWS, tileWidth: TILE, tileHeight: TILE, tilesets: [tileset], layers: [this.layer] }); + + this.mapView = map.createView({ bands: { ground: ['ground'] } }); + this.worldRoot = new Container(); + this.worldRoot.addChild(this.mapView.band('ground')); + + this.buildGrid(); + + this.app.input.onPointerTap.add(pointer => { + const x = Math.floor(pointer.x / TILE); + const y = Math.floor(pointer.y / TILE); + + if (this.grid.nodeAt(x, y) < 0) return; + + this.goal = { x, y }; + this.replan(); + }); + + this.hud = mountControls({ + title: 'Tilemap Navigation', + controls: [ + { keys: 'Click', action: 'send the agent to a tile' }, + { keys: 'panel', action: 'terrain cost / carve a door' }, + ], + status: '', + hint: 'The grid is built from a cost callback over the tile layer — the pathfinding package never sees the tilemap.', + }); + + const panel = mountControlPanel({ title: 'Navigation' }); + + panel.addToggle({ + label: 'Rough ground costs more', + value: true, + onChange: value => { + this.avoidRough = value; + this.buildGrid(); + this.replan(); + }, + }); + panel.addButton({ + label: 'Carve a door in the next wall', + onClick: () => this.carveDoor(), + }); + + this.replan(); + } + + override update(delta: Seconds): void { + const points = this.result?.points ?? []; + + if (this.waypoint >= points.length) return; + + let travel = AGENT_SPEED * delta; + + while (travel > 0 && this.waypoint < points.length) { + const target = points[this.waypoint]!; + const distance = Math.hypot(target.x - this.agent.x, target.y - this.agent.y); + + if (distance <= travel) { + this.agent.x = target.x; + this.agent.y = target.y; + travel -= distance; + this.waypoint++; + continue; + } + + this.agent.x += ((target.x - this.agent.x) / distance) * travel; + this.agent.y += ((target.y - this.agent.y) / distance) * travel; + travel = 0; + } + } + + override draw(context: RenderingContext): void { + this.overlay.clear(); + + const points = this.result?.points ?? []; + + if (points.length > 1) { + this.overlay.lineWidth = 3; + this.overlay.lineColor = PATH_COLOR; + + for (let index = 1; index < points.length; index++) { + this.overlay.drawLine(points[index - 1]!.x, points[index - 1]!.y, points[index]!.x, points[index]!.y); + } + } + + this.overlay.fillColor = GOAL_COLOR; + this.overlay.drawCircle((this.goal.x + 0.5) * TILE, (this.goal.y + 0.5) * TILE, 7); + this.overlay.fillColor = AGENT_COLOR; + this.overlay.drawCircle(this.agent.x, this.agent.y, 7); + + context.render(this.worldRoot); + context.render(this.overlay); + } + + /** + * Rebuilds the whole window from the layer. A streamed world would size the + * window to the loaded region instead and keep it in step with `setCost`. + */ + private buildGrid(): void { + this.grid = GridSpace.from( + COLUMNS, + ROWS, + (x, y) => { + const cost = walkCost(this.layer.getTileAt(x, y)); + + return this.avoidRough ? cost : Math.min(cost, 1); + }, + { cellSize: TILE }, + ); + } + + /** Edits map and grid together, which is what `setCost` and `revision` exist for. */ + private carveDoor(): void { + for (let x = 1; x < COLUMNS - 1; x++) { + for (let y = 1; y < ROWS - 1; y++) { + if (this.layer.getTileAt(x, y)?.localTileId !== WALL_TILE) continue; + + const tileset = this.layer.tilesets[0]!; + + this.layer.setTileAt(x, y, { tileset, localTileId: FLOOR_TILE, transform: TILE_TRANSFORM_IDENTITY }); + this.grid.setCost(x, y, 1); + this.replan(); + + return; + } + } + } + + private replan(): void { + this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * TILE, (this.goal.y + 0.5) * TILE, { + smooth: true, + snapToNearest: true, + }); + this.waypoint = 0; + + const { status, cost, expandedNodes } = this.result; + + this.hud.setStatus( + `${status} · cost ${cost.toFixed(1)} · ${expandedNodes} nodes expanded · grid revision ${this.grid.revision} · ${this.grid.uniformCost ? 'jump-point' : 'weighted A*'}`, + ); + } +} + +const app = new Application({ + scenes: { TilemapNavigationScene }, + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizing: new FixedResolutionCanvasSizing(), + }, + clearColor: new Color(16, 20, 26), + extensions: [tilemapExtension], +}); + +await app.start(TilemapNavigationScene); diff --git a/site/src/content/api/diagonal-policy.json b/site/src/content/api/diagonal-policy.json new file mode 100644 index 000000000..17b1c78a4 --- /dev/null +++ b/site/src/content/api/diagonal-policy.json @@ -0,0 +1,80 @@ +{ + "title": "DiagonalPolicy", + "description": "How diagonal steps are allowed on a GridSpace. - `never` - four-connected movement only. - `no-corner-cutting` - a diagonal step needs both cells it passes between to be walkable, which is what stops an agent from clipping through the corner where two walls meet. - `always` - eight-connected movement with no such restriction.", + "symbol": "DiagonalPolicy", + "kind": "type", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 0, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "How diagonal steps are allowed on a GridSpace.", + "- `never` - four-connected movement only. - `no-corner-cutting` - a diagonal step needs both cells it passes between to be walkable, which is what stops an agent from clipping through the corner where two walls meet. - `always` - eight-connected movement with no such restriction." + ], + "importLine": "import { DiagonalPolicy } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "definition", + "title": "Definition", + "members": [ + { + "name": "DiagonalPolicy", + "signature": "\"always\" | \"never\" | \"no-corner-cutting\"", + "signatureTokens": [ + { + "text": "\"always\"", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "\"never\"", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "\"no-corner-cutting\"", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/spaces/GridSpace.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/GridSpace.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/spaces/GridSpace.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/GridSpace.ts" +} diff --git a/site/src/content/api/find-path-options.json b/site/src/content/api/find-path-options.json new file mode 100644 index 000000000..30c9c776a --- /dev/null +++ b/site/src/content/api/find-path-options.json @@ -0,0 +1,175 @@ +{ + "title": "FindPathOptions", + "description": "Options for Pathfinder.findPath and Pathfinder.findPathBetween.", + "symbol": "FindPathOptions", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 5, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 5, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Options for Pathfinder.findPath and Pathfinder.findPathBetween." + ], + "importLine": "import { FindPathOptions } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "agentSize", + "signature": "agentSize?: number", + "signatureTokens": [ + { + "text": "agentSize", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Agent width in nodes. Spaces that model clearance restrict expansion to nodes where an agent this wide fits; spaces that do not ignore it. Defaults to 1." + }, + { + "name": "maxExpandedNodes", + "signature": "maxExpandedNodes?: number", + "signatureTokens": [ + { + "text": "maxExpandedNodes", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Stop after this many expanded nodes and return the best partial path with status budget-exceeded. Defaults to 0, meaning no budget - a search over a finite space terminates regardless." + }, + { + "name": "pruning", + "signature": "pruning?: boolean", + "signatureTokens": [ + { + "text": "pruning", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Allow the space to substitute a pruned expansion (jump-point search on uniform grids) for plain neighbour expansion. Both produce a cost-optimal path; pruning expands far fewer nodes. Defaults to true." + }, + { + "name": "smooth", + "signature": "smooth?: boolean", + "signatureTokens": [ + { + "text": "smooth", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Run the space's path smoother over the result. No-op for spaces that implement none. Defaults to false." + }, + { + "name": "snapToNearest", + "signature": "snapToNearest?: boolean", + "signatureTokens": [ + { + "text": "snapToNearest", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "When the goal cannot be reached, return the path to the reachable node closest to it instead of unreachable. For coordinate queries this also resolves a goal point outside the space to the space's nearest node. Defaults to false." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/flood-options.json b/site/src/content/api/flood-options.json new file mode 100644 index 000000000..013684b77 --- /dev/null +++ b/site/src/content/api/flood-options.json @@ -0,0 +1,125 @@ +{ + "title": "FloodOptions", + "description": "Options for Pathfinder.floodFrom.", + "symbol": "FloodOptions", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Options for Pathfinder.floodFrom." + ], + "importLine": "import { FloodOptions } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "agentSize", + "signature": "agentSize?: number", + "signatureTokens": [ + { + "text": "agentSize", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Agent width in nodes, as in FindPathOptions.agentSize." + }, + { + "name": "maxCost", + "signature": "maxCost?: number", + "signatureTokens": [ + { + "text": "maxCost", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Highest traversal cost to include. Defaults to Infinity." + }, + { + "name": "maxExpandedNodes", + "signature": "maxExpandedNodes?: number", + "signatureTokens": [ + { + "text": "maxExpandedNodes", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Node budget, as in FindPathOptions.maxExpandedNodes." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/flood-region.json b/site/src/content/api/flood-region.json new file mode 100644 index 000000000..d1ff015b7 --- /dev/null +++ b/site/src/content/api/flood-region.json @@ -0,0 +1,158 @@ +{ + "title": "FloodRegion", + "description": "Result of Pathfinder.floodFrom: every node reached, with its cost.", + "symbol": "FloodRegion", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 4, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 4, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Result of Pathfinder.floodFrom: every node reached, with its cost." + ], + "importLine": "import { FloodRegion } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "costs", + "signature": "costs: readonly number[]", + "signatureTokens": [ + { + "text": "costs", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Traversal cost from the origin to the node at the same index." + }, + { + "name": "expandedNodes", + "signature": "expandedNodes: number", + "signatureTokens": [ + { + "text": "expandedNodes", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "nodes", + "signature": "nodes: readonly number[]", + "signatureTokens": [ + { + "text": "nodes", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Reached nodes in the order the flood settled them, origin first." + }, + { + "name": "revision", + "signature": "revision: number", + "signatureTokens": [ + { + "text": "revision", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/grid-space-options.json b/site/src/content/api/grid-space-options.json new file mode 100644 index 000000000..60e29edae --- /dev/null +++ b/site/src/content/api/grid-space-options.json @@ -0,0 +1,200 @@ +{ + "title": "GridSpaceOptions", + "description": "Construction options for GridSpace.", + "symbol": "GridSpaceOptions", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 6, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 6, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Construction options for GridSpace." + ], + "importLine": "import { GridSpaceOptions } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "cellOriginX", + "signature": "cellOriginX?: number", + "signatureTokens": [ + { + "text": "cellOriginX", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "World x of cell 0's left edge, before the window origin. Defaults to 0." + }, + { + "name": "cellOriginY", + "signature": "cellOriginY?: number", + "signatureTokens": [ + { + "text": "cellOriginY", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "World y of cell 0's top edge, before the window origin. Defaults to 0." + }, + { + "name": "cellSize", + "signature": "cellSize?: number", + "signatureTokens": [ + { + "text": "cellSize", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "World size of one cell. The distance metric assumes square cells, so a tilemap with non-square tiles has to pick one axis. Defaults to 1." + }, + { + "name": "diagonals", + "signature": "diagonals?: DiagonalPolicy", + "signatureTokens": [ + { + "text": "diagonals", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "DiagonalPolicy", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Defaults to 'no-corner-cutting'." + }, + { + "name": "originX", + "signature": "originX?: number", + "signatureTokens": [ + { + "text": "originX", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Cell x of the window's left column. Defaults to 0." + }, + { + "name": "originY", + "signature": "originY?: number", + "signatureTokens": [ + { + "text": "originY", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Cell y of the window's top row. Defaults to 0." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/spaces/GridSpace.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/GridSpace.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/spaces/GridSpace.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/GridSpace.ts" +} diff --git a/site/src/content/api/grid-space.json b/site/src/content/api/grid-space.json new file mode 100644 index 000000000..1134d2a7e --- /dev/null +++ b/site/src/content/api/grid-space.json @@ -0,0 +1,1534 @@ +{ + "title": "GridSpace", + "description": "A rectangular window of weighted, optionally blocked cells. The window is finite by construction: everything outside it is blocked, so a search always terminates and an infinite or streamed world is served by sizing the window to the region the actors are in, then feeding chunk changes back through setCost. Coordinates in the public API are absolute cell coordinates - the same numbers a tilemap uses - not offsets into the window. Cost `0` blocks a cell, `1` is ordinary ground and larger values are terrain an agent will route around when it is cheaper to do so. Diagonal steps cost their length, so the metric stays consistent with the octile heuristic. The space carries no scene node and no rendering: it is data plus a neighbour relation, and it is built and mutated entirely by the application.", + "symbol": "GridSpace", + "kind": "class", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 28, + "counts": { + "constructors": 1, + "methods": 15, + "properties": 12, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A rectangular window of weighted, optionally blocked cells.", + "The window is finite by construction: everything outside it is blocked, so a search always terminates and an infinite or streamed world is served by sizing the window to the region the actors are in, then feeding chunk changes back through setCost. Coordinates in the public API are absolute cell coordinates - the same numbers a tilemap uses - not offsets into the window.", + "Cost `0` blocks a cell, `1` is ordinary ground and larger values are terrain an agent will route around when it is cheaper to do so. Diagonal steps cost their length, so the metric stays consistent with the octile heuristic.", + "The space carries no scene node and no rendering: it is data plus a neighbour relation, and it is built and mutated entirely by the application." + ], + "importLine": "import { GridSpace } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(width: number, height: number, options: GridSpaceOptions): GridSpace", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "width", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "height", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "GridSpaceOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "GridSpace", + "kind": "type" + } + ], + "params": [ + { + "name": "width", + "type": "number", + "optional": false + }, + { + "name": "height", + "type": "number", + "optional": false + }, + { + "name": "options", + "type": "GridSpaceOptions", + "optional": false + } + ], + "returnType": "GridSpace", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "clearanceAt", + "signature": "clearanceAt(x: number, y: number): number", + "signatureTokens": [ + { + "text": "clearanceAt", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Largest agent width that fits with its top-left corner on this cell, or 0 for a blocked cell. Recomputed lazily after the first edit that follows a query." + }, + { + "name": "costAt", + "signature": "costAt(x: number, y: number): number", + "signatureTokens": [ + { + "text": "costAt", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Traversal cost of a cell; 0 for blocked cells and everything outside the window." + }, + { + "name": "heuristic", + "signature": "heuristic(node: number, goal: number): number", + "signatureTokens": [ + { + "text": "heuristic", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "goal", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "goal", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Estimated remaining cost from node to goal. Must never overestimate, or the result stops being cost-optimal; returning 0 degrades the search to Dijkstra, which is the correct answer for a space without positions." + }, + { + "name": "isWalkable", + "signature": "isWalkable(x: number, y: number): boolean", + "signatureTokens": [ + { + "text": "isWalkable", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "boolean", + "description": "" + }, + { + "name": "nearestNode", + "signature": "nearestNode(x: number, y: number): number", + "signatureTokens": [ + { + "text": "nearestNode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "The node closest to a point, whether or not it is traversable and whether or not the point lies inside the space. Backs snapToNearest for coordinate queries; without it such a query reports unreachable." + }, + { + "name": "neighbors", + "signature": "neighbors(node: number, agentSize: number, outNodes: Int32Array, outCosts: Float64Array): number", + "signatureTokens": [ + { + "text": "neighbors", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "agentSize", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outNodes", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Int32Array", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outCosts", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Float64Array", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "agentSize", + "type": "number", + "optional": false + }, + { + "name": "outNodes", + "type": "Int32Array", + "optional": false + }, + { + "name": "outCosts", + "type": "Float64Array", + "optional": false + } + ], + "returnType": "number", + "description": "Writes the neighbours of node and the cost of stepping to each into the buffers, and returns how many were written. The buffers belong to the pathfinder and are reused across nodes and searches, so an implementation must not retain them. Costs must be positive and finite. agentSize is the requested clearance; spaces that do not model clearance ignore it." + }, + { + "name": "nodeAt", + "signature": "nodeAt(x: number, y: number): number", + "signatureTokens": [ + { + "text": "nodeAt", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "The node at absolute cell coordinates, or -1 outside the window." + }, + { + "name": "nodeToPoint", + "signature": "nodeToPoint(node: number, out: Vector): void", + "signatureTokens": [ + { + "text": "nodeToPoint", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "out", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "out", + "type": "Vector", + "optional": false + } + ], + "returnType": "void", + "description": "Writes the node's position into out." + }, + { + "name": "nodeX", + "signature": "nodeX(node: number): number", + "signatureTokens": [ + { + "text": "nodeX", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Absolute cell x of a node." + }, + { + "name": "nodeY", + "signature": "nodeY(node: number): number", + "signatureTokens": [ + { + "text": "nodeY", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Absolute cell y of a node." + }, + { + "name": "pointToNode", + "signature": "pointToNode(x: number, y: number): number", + "signatureTokens": [ + { + "text": "pointToNode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "The node at a point, or -1 when the point lies outside the space." + }, + { + "name": "pruning", + "signature": "pruning(agentSize: number): PrunedExpansion | null", + "signatureTokens": [ + { + "text": "pruning", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "agentSize", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PrunedExpansion", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [ + { + "name": "agentSize", + "type": "number", + "optional": false + } + ], + "returnType": "PrunedExpansion | null", + "description": "Returns a pruned expansion valid for a search at this agentSize, or null when the space cannot prune under those conditions. Called once per search, so an implementation may build state here - but not per node." + }, + { + "name": "setCost", + "signature": "setCost(x: number, y: number, cost: number): void", + "signatureTokens": [ + { + "text": "setCost", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "cost", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + }, + { + "name": "cost", + "type": "number", + "optional": false + } + ], + "returnType": "void", + "description": "Sets a cell's cost and bumps revision. Values that are not finite and positive block the cell. Coordinates outside the window are ignored." + }, + { + "name": "smoothPath", + "signature": "smoothPath(nodes: readonly number[], agentSize: number): number[]", + "signatureTokens": [ + { + "text": "smoothPath", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "nodes", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "agentSize", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "nodes", + "type": "readonly number[]", + "optional": false + }, + { + "name": "agentSize", + "type": "number", + "optional": false + } + ], + "returnType": "number[]", + "description": "String-pulls the path: keeps a node only when the straight line past it is blocked, so the result is the same route with its staircase removed. The returned nodes are no longer adjacent - the guarantee is that the straight segment between two consecutive ones stays inside walkable cells an agent of agentSize fits through, and never crosses terrain more expensive than the section it replaces." + }, + { + "name": "from", + "signature": "from(width: number, height: number, cost: (x: number, y: number) => number, options: GridSpaceOptions): GridSpace", + "signatureTokens": [ + { + "text": "from", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "width", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "height", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "cost", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "GridSpaceOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "GridSpace", + "kind": "type" + } + ], + "params": [ + { + "name": "width", + "type": "number", + "optional": false + }, + { + "name": "height", + "type": "number", + "optional": false + }, + { + "name": "cost", + "type": "(x: number, y: number) => number", + "optional": false + }, + { + "name": "options", + "type": "GridSpaceOptions", + "optional": false + } + ], + "returnType": "GridSpace", + "description": "Builds a window and fills it from a cost callback, which receives absolute cell coordinates. This is the tilemap bridge: return 0 for a solid tile and the terrain's cost for a walkable one, and the grid never learns what a tilemap is. Values that are not finite and positive are stored as blocked." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "cellOriginX", + "signature": "cellOriginX: number", + "signatureTokens": [ + { + "text": "cellOriginX", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "cellOriginY", + "signature": "cellOriginY: number", + "signatureTokens": [ + { + "text": "cellOriginY", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "cellSize", + "signature": "cellSize: number", + "signatureTokens": [ + { + "text": "cellSize", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "diagonals", + "signature": "diagonals: DiagonalPolicy", + "signatureTokens": [ + { + "text": "diagonals", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "DiagonalPolicy", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "height", + "signature": "height: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "maxDegree", + "signature": "maxDegree: number", + "signatureTokens": [ + { + "text": "maxDegree", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Upper bound on how many neighbours one node can have." + }, + { + "name": "originX", + "signature": "originX: number", + "signatureTokens": [ + { + "text": "originX", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "originY", + "signature": "originY: number", + "signatureTokens": [ + { + "text": "originY", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "width", + "signature": "width: number", + "signatureTokens": [ + { + "text": "width", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "nodeCapacity", + "signature": "nodeCapacity: number", + "signatureTokens": [ + { + "text": "nodeCapacity", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "One past the largest node id. Sizes the pathfinder's search buffers." + }, + { + "name": "revision", + "signature": "revision: number", + "signatureTokens": [ + { + "text": "revision", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Increments on every mutation that can invalidate a path. Carried into PathResult.revision so callers can detect stale paths." + }, + { + "name": "uniformCost", + "signature": "uniformCost: boolean", + "signatureTokens": [ + { + "text": "uniformCost", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true while every walkable cell costs exactly 1. Jump-point search is only available on such a grid." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/spaces/GridSpace.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/GridSpace.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/spaces/GridSpace.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/GridSpace.ts" +} diff --git a/site/src/content/api/navigation-space.json b/site/src/content/api/navigation-space.json new file mode 100644 index 000000000..482a734e6 --- /dev/null +++ b/site/src/content/api/navigation-space.json @@ -0,0 +1,748 @@ +{ + "title": "NavigationSpace", + "description": "The search core's view of a world: integer node ids, a neighbour relation and a heuristic. GridSpace and WaypointGraph implement it, and so can application code - a space needs no scene node, no renderer and no asset. Implementations must be deterministic: the same query on an unmutated space has to produce the same neighbours in the same order, or paths stop being reproducible across runs and machines.", + "symbol": "NavigationSpace", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 11, + "counts": { + "constructors": 0, + "methods": 8, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "The search core's view of a world: integer node ids, a neighbour relation and a heuristic. GridSpace and WaypointGraph implement it, and so can application code - a space needs no scene node, no renderer and no asset.", + "Implementations must be deterministic: the same query on an unmutated space has to produce the same neighbours in the same order, or paths stop being reproducible across runs and machines." + ], + "importLine": "import { NavigationSpace } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "describeEdge", + "signature": "describeEdge?(from: number, to: number): PathEdge | null", + "signatureTokens": [ + { + "text": "describeEdge", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "from", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "to", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PathEdge", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [ + { + "name": "from", + "type": "number", + "optional": false + }, + { + "name": "to", + "type": "number", + "optional": false + } + ], + "returnType": "PathEdge | null", + "description": "Describes the traversal from from to to, if the space models one." + }, + { + "name": "heuristic", + "signature": "heuristic(node: number, goal: number): number", + "signatureTokens": [ + { + "text": "heuristic", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "goal", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "goal", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Estimated remaining cost from node to goal. Must never overestimate, or the result stops being cost-optimal; returning 0 degrades the search to Dijkstra, which is the correct answer for a space without positions." + }, + { + "name": "nearestNode", + "signature": "nearestNode?(x: number, y: number): number", + "signatureTokens": [ + { + "text": "nearestNode", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "The node closest to a point, whether or not it is traversable and whether or not the point lies inside the space. Backs snapToNearest for coordinate queries; without it such a query reports unreachable." + }, + { + "name": "neighbors", + "signature": "neighbors(node: number, agentSize: number, outNodes: Int32Array, outCosts: Float64Array): number", + "signatureTokens": [ + { + "text": "neighbors", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "agentSize", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outNodes", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Int32Array", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outCosts", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Float64Array", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "agentSize", + "type": "number", + "optional": false + }, + { + "name": "outNodes", + "type": "Int32Array", + "optional": false + }, + { + "name": "outCosts", + "type": "Float64Array", + "optional": false + } + ], + "returnType": "number", + "description": "Writes the neighbours of node and the cost of stepping to each into the buffers, and returns how many were written. The buffers belong to the pathfinder and are reused across nodes and searches, so an implementation must not retain them. Costs must be positive and finite. agentSize is the requested clearance; spaces that do not model clearance ignore it." + }, + { + "name": "nodeToPoint", + "signature": "nodeToPoint(node: number, out: Vector): void", + "signatureTokens": [ + { + "text": "nodeToPoint", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "out", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "out", + "type": "Vector", + "optional": false + } + ], + "returnType": "void", + "description": "Writes the node's position into out." + }, + { + "name": "pointToNode", + "signature": "pointToNode(x: number, y: number): number", + "signatureTokens": [ + { + "text": "pointToNode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "The node at a point, or -1 when the point lies outside the space." + }, + { + "name": "pruning", + "signature": "pruning?(agentSize: number): PrunedExpansion | null", + "signatureTokens": [ + { + "text": "pruning", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "agentSize", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PrunedExpansion", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [ + { + "name": "agentSize", + "type": "number", + "optional": false + } + ], + "returnType": "PrunedExpansion | null", + "description": "Returns a pruned expansion valid for a search at this agentSize, or null when the space cannot prune under those conditions. Called once per search, so an implementation may build state here - but not per node." + }, + { + "name": "smoothPath", + "signature": "smoothPath?(nodes: readonly number[], agentSize: number): number[]", + "signatureTokens": [ + { + "text": "smoothPath", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "nodes", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "agentSize", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "nodes", + "type": "readonly number[]", + "optional": false + }, + { + "name": "agentSize", + "type": "number", + "optional": false + } + ], + "returnType": "number[]", + "description": "Returns a shortened node sequence with the same start and goal that is still traversable for an agent of agentSize. Backs smooth." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "maxDegree", + "signature": "maxDegree: number", + "signatureTokens": [ + { + "text": "maxDegree", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Upper bound on how many neighbours one node can have." + }, + { + "name": "nodeCapacity", + "signature": "nodeCapacity: number", + "signatureTokens": [ + { + "text": "nodeCapacity", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "One past the largest node id. Sizes the pathfinder's search buffers." + }, + { + "name": "revision", + "signature": "revision: number", + "signatureTokens": [ + { + "text": "revision", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Increments on every mutation that can invalidate a path. Carried into PathResult.revision so callers can detect stale paths." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/path-edge.json b/site/src/content/api/path-edge.json new file mode 100644 index 000000000..5e1cb2eee --- /dev/null +++ b/site/src/content/api/path-edge.json @@ -0,0 +1,143 @@ +{ + "title": "PathEdge", + "description": "One traversal step of a path, as described by the space it came from. Spaces that model traversal kinds - WaypointGraph is the one in this package - report them here, so a movement controller can react to a `'jump'` step differently than to a `'walk'` step. Grid spaces describe no edges.", + "symbol": "PathEdge", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 4, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 4, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "One traversal step of a path, as described by the space it came from.", + "Spaces that model traversal kinds - WaypointGraph is the one in this package - report them here, so a movement controller can react to a `'jump'` step differently than to a `'walk'` step. Grid spaces describe no edges." + ], + "importLine": "import { PathEdge } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "data", + "signature": "data: Payload | null", + "signatureTokens": [ + { + "text": "data", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Payload the author attached to the edge, or null." + }, + { + "name": "from", + "signature": "from: number", + "signatureTokens": [ + { + "text": "from", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "kind", + "signature": "kind: string", + "signatureTokens": [ + { + "text": "kind", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Free-form traversal tag defined by whoever authored the space." + }, + { + "name": "to", + "signature": "to: number", + "signatureTokens": [ + { + "text": "to", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/path-result.json b/site/src/content/api/path-result.json new file mode 100644 index 000000000..2879f903e --- /dev/null +++ b/site/src/content/api/path-result.json @@ -0,0 +1,245 @@ +{ + "title": "PathResult", + "description": "Result of Pathfinder.findPath and Pathfinder.findPathBetween.", + "symbol": "PathResult", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 7, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 7, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Result of Pathfinder.findPath and Pathfinder.findPathBetween." + ], + "importLine": "import { PathResult } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "cost", + "signature": "cost: number", + "signatureTokens": [ + { + "text": "cost", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Total traversal cost of nodes. Smoothing does not change it." + }, + { + "name": "edges", + "signature": "edges: readonly PathEdge[]", + "signatureTokens": [ + { + "text": "edges", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "PathEdge", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Traversal steps for spaces that describe them, empty otherwise. Not filled for smoothed paths, whose steps are no longer space edges." + }, + { + "name": "expandedNodes", + "signature": "expandedNodes: number", + "signatureTokens": [ + { + "text": "expandedNodes", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Nodes taken off the open list. Useful for sizing maxExpandedNodes." + }, + { + "name": "nodes", + "signature": "nodes: readonly number[]", + "signatureTokens": [ + { + "text": "nodes", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Node ids from start to goal inclusive. Consecutive entries are adjacent in the space unless the path was smoothed, which removes intermediate nodes by design." + }, + { + "name": "points", + "signature": "points: readonly Vector[]", + "signatureTokens": [ + { + "text": "points", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "nodes mapped through the space's node-to-point conversion." + }, + { + "name": "revision", + "signature": "revision: number", + "signatureTokens": [ + { + "text": "revision", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "space.revision at search time. Compare it against the space's current revision to detect a path that the world has invalidated since." + }, + { + "name": "status", + "signature": "status: PathStatus", + "signatureTokens": [ + { + "text": "status", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PathStatus", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/path-status.json b/site/src/content/api/path-status.json new file mode 100644 index 000000000..6f49c9b66 --- /dev/null +++ b/site/src/content/api/path-status.json @@ -0,0 +1,80 @@ +{ + "title": "PathStatus", + "description": "Outcome of a path query. - `found` - a complete path from start to goal (or, with `snapToNearest`, to the reachable node closest to the goal). - `unreachable` - the search exhausted the space without reaching the goal. - `budget-exceeded` - `maxExpandedNodes` ran out first. The result still carries the best partial path found so far.", + "symbol": "PathStatus", + "kind": "type", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 0, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Outcome of a path query.", + "- `found` - a complete path from start to goal (or, with `snapToNearest`, to the reachable node closest to the goal). - `unreachable` - the search exhausted the space without reaching the goal. - `budget-exceeded` - `maxExpandedNodes` ran out first. The result still carries the best partial path found so far." + ], + "importLine": "import { PathStatus } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "definition", + "title": "Definition", + "members": [ + { + "name": "PathStatus", + "signature": "\"budget-exceeded\" | \"found\" | \"unreachable\"", + "signatureTokens": [ + { + "text": "\"budget-exceeded\"", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "\"found\"", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "\"unreachable\"", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/pathfinder.json b/site/src/content/api/pathfinder.json new file mode 100644 index 000000000..0069c3bb9 --- /dev/null +++ b/site/src/content/api/pathfinder.json @@ -0,0 +1,489 @@ +{ + "title": "Pathfinder", + "description": "Runs path queries against any NavigationSpace. One pathfinder owns the search buffers and reuses them across every query, including queries against different spaces of different sizes, so a search itself allocates nothing once the buffers have reached their size. The result objects are freshly allocated by design: callers hold on to a path, and pooling something a caller retains trades a little garbage for use-after-reuse bugs. A pathfinder holds no world state and no lifecycle - construct one per system that needs paths, or share one, as long as queries do not interleave with a mutation of the space being searched.", + "symbol": "Pathfinder", + "kind": "class", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 4, + "counts": { + "constructors": 1, + "methods": 3, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Runs path queries against any NavigationSpace.", + "One pathfinder owns the search buffers and reuses them across every query, including queries against different spaces of different sizes, so a search itself allocates nothing once the buffers have reached their size. The result objects are freshly allocated by design: callers hold on to a path, and pooling something a caller retains trades a little garbage for use-after-reuse bugs.", + "A pathfinder holds no world state and no lifecycle - construct one per system that needs paths, or share one, as long as queries do not interleave with a mutation of the space being searched." + ], + "importLine": "import { Pathfinder } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(): Pathfinder", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Pathfinder", + "kind": "type" + } + ], + "params": [], + "returnType": "Pathfinder", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "findPath", + "signature": "findPath(space: NavigationSpace, start: number, goal: number, options: FindPathOptions): PathResult", + "signatureTokens": [ + { + "text": "findPath", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "space", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "NavigationSpace", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "start", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "goal", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "FindPathOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PathResult", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "space", + "type": "NavigationSpace", + "optional": false + }, + { + "name": "start", + "type": "number", + "optional": false + }, + { + "name": "goal", + "type": "number", + "optional": false + }, + { + "name": "options", + "type": "FindPathOptions", + "optional": false + } + ], + "returnType": "PathResult", + "description": "Finds a cost-optimal path between two node ids. unreachable yields an empty path unless FindPathOptions.snapToNearest is set; budget-exceeded always carries the best partial path found, which is a real, traversable prefix and not a guess at the rest." + }, + { + "name": "findPathBetween", + "signature": "findPathBetween(space: NavigationSpace, startX: number, startY: number, goalX: number, goalY: number, options: FindPathOptions): PathResult", + "signatureTokens": [ + { + "text": "findPathBetween", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "space", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "NavigationSpace", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "startX", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "startY", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "goalX", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "goalY", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "FindPathOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PathResult", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "space", + "type": "NavigationSpace", + "optional": false + }, + { + "name": "startX", + "type": "number", + "optional": false + }, + { + "name": "startY", + "type": "number", + "optional": false + }, + { + "name": "goalX", + "type": "number", + "optional": false + }, + { + "name": "goalY", + "type": "number", + "optional": false + }, + { + "name": "options", + "type": "FindPathOptions", + "optional": false + } + ], + "returnType": "PathResult", + "description": "findPath between two points instead of node ids. A point outside the space makes the query unreachable unless FindPathOptions.snapToNearest is set and the space can resolve a nearest node." + }, + { + "name": "floodFrom", + "signature": "floodFrom(space: NavigationSpace, origin: number, options: FloodOptions): FloodRegion", + "signatureTokens": [ + { + "text": "floodFrom", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "space", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "NavigationSpace", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "origin", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "FloodOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "FloodRegion", + "kind": "type" + } + ], + "params": [ + { + "name": "space", + "type": "NavigationSpace", + "optional": false + }, + { + "name": "origin", + "type": "number", + "optional": false + }, + { + "name": "options", + "type": "FloodOptions", + "optional": false + } + ], + "returnType": "FloodRegion", + "description": "Every node reachable from origin within FloodOptions.maxCost, with its cost - the \"tiles I can still move to this turn\" query, and the input a flow field for many agents heading to one goal is built from." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/Pathfinder.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/Pathfinder.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/Pathfinder.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/Pathfinder.ts" +} diff --git a/site/src/content/api/pruned-expansion.json b/site/src/content/api/pruned-expansion.json new file mode 100644 index 000000000..4f4d2c980 --- /dev/null +++ b/site/src/content/api/pruned-expansion.json @@ -0,0 +1,275 @@ +{ + "title": "PrunedExpansion", + "description": "A parent-dependent successor generator that prunes symmetric alternatives without losing optimality - the shape jump-point search takes. Obtained from NavigationSpace.pruning for the duration of one search.", + "symbol": "PrunedExpansion", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 2, + "counts": { + "constructors": 0, + "methods": 2, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A parent-dependent successor generator that prunes symmetric alternatives without losing optimality - the shape jump-point search takes.", + "Obtained from NavigationSpace.pruning for the duration of one search." + ], + "importLine": "import { PrunedExpansion } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "expand", + "signature": "expand(from: number, to: number, out: number[]): void", + "signatureTokens": [ + { + "text": "expand", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "from", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "to", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "out", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "from", + "type": "number", + "optional": false + }, + { + "name": "to", + "type": "number", + "optional": false + }, + { + "name": "out", + "type": "number[]", + "optional": false + } + ], + "returnType": "void", + "description": "Appends the nodes strictly between from and to, then to itself, to out - turning a path of pruned successors back into a contiguous one." + }, + { + "name": "successors", + "signature": "successors(node: number, parent: number, goal: number, outNodes: Int32Array, outCosts: Float64Array): number", + "signatureTokens": [ + { + "text": "successors", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "parent", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "goal", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outNodes", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Int32Array", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outCosts", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Float64Array", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "parent", + "type": "number", + "optional": false + }, + { + "name": "goal", + "type": "number", + "optional": false + }, + { + "name": "outNodes", + "type": "Int32Array", + "optional": false + }, + { + "name": "outCosts", + "type": "Float64Array", + "optional": false + } + ], + "returnType": "number", + "description": "Writes the pruned successors of node, reached from parent (-1 for the start node), into the buffers and returns how many were written. The successors may lie several nodes away; expand fills in what lies between." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/types.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/types.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/types.ts" +} diff --git a/site/src/content/api/waypoint-edge-options.json b/site/src/content/api/waypoint-edge-options.json new file mode 100644 index 000000000..b0338ba22 --- /dev/null +++ b/site/src/content/api/waypoint-edge-options.json @@ -0,0 +1,125 @@ +{ + "title": "WaypointEdgeOptions", + "description": "Options for WaypointGraph.addEdge and WaypointGraph.connect.", + "symbol": "WaypointEdgeOptions", + "kind": "interface", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Options for WaypointGraph.addEdge and WaypointGraph.connect." + ], + "importLine": "import { WaypointEdgeOptions } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "cost", + "signature": "cost?: number", + "signatureTokens": [ + { + "text": "cost", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Traversal cost. Defaults to the straight-line distance between the two nodes, or 1 when either of them has no position." + }, + { + "name": "data", + "signature": "data?: Payload", + "signatureTokens": [ + { + "text": "data", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Payload for the movement controller: jump impulse, ladder id, anything." + }, + { + "name": "kind", + "signature": "kind?: string", + "signatureTokens": [ + { + "text": "kind", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Free-form traversal tag surfaced through PathResult.edges - the hook a movement controller reads to tell a jump from a walk. Defaults to 'walk'." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/spaces/WaypointGraph.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/WaypointGraph.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/spaces/WaypointGraph.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/WaypointGraph.ts" +} diff --git a/site/src/content/api/waypoint-graph.json b/site/src/content/api/waypoint-graph.json new file mode 100644 index 000000000..e1618c8dc --- /dev/null +++ b/site/src/content/api/waypoint-graph.json @@ -0,0 +1,1058 @@ +{ + "title": "WaypointGraph", + "description": "A directed graph of hand-placed waypoints. This is the representation for worlds a grid cannot describe: a platformer where traversal is a topology of walk, jump and fall links rather than cell walkability, or a purely abstract graph with no geometry at all. Nodes carry an optional position, edges carry a cost, a WaypointEdgeOptions.kind and an arbitrary payload, and the resulting path reports the edges it took so the game can execute each step in its own way. With positions the search is A* over straight-line distance; without them the heuristic is zero and the same search degrades cleanly to Dijkstra.", + "symbol": "WaypointGraph", + "kind": "class", + "subsystem": "pathfinding", + "importPath": "@codexo/exojs-pathfinding", + "tier": "stable", + "memberCount": 16, + "counts": { + "constructors": 1, + "methods": 11, + "properties": 4, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A directed graph of hand-placed waypoints.", + "This is the representation for worlds a grid cannot describe: a platformer where traversal is a topology of walk, jump and fall links rather than cell walkability, or a purely abstract graph with no geometry at all. Nodes carry an optional position, edges carry a cost, a WaypointEdgeOptions.kind and an arbitrary payload, and the resulting path reports the edges it took so the game can execute each step in its own way.", + "With positions the search is A* over straight-line distance; without them the heuristic is zero and the same search degrades cleanly to Dijkstra." + ], + "importLine": "import { WaypointGraph } from '@codexo/exojs-pathfinding'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(): WaypointGraph", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "WaypointGraph", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": "WaypointGraph", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "addEdge", + "signature": "addEdge(from: number, to: number, options: WaypointEdgeOptions): void", + "signatureTokens": [ + { + "text": "addEdge", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "from", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "to", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "WaypointEdgeOptions", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "from", + "type": "number", + "optional": false + }, + { + "name": "to", + "type": "number", + "optional": false + }, + { + "name": "options", + "type": "WaypointEdgeOptions", + "optional": false + } + ], + "returnType": "void", + "description": "Adds a directed edge. A second edge between the same pair replaces the first." + }, + { + "name": "addNode", + "signature": "addNode(x?: number, y?: number): number", + "signatureTokens": [ + { + "text": "addNode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": true + }, + { + "name": "y", + "type": "number", + "optional": true + } + ], + "returnType": "number", + "description": "Adds a node. Omitting the position puts the graph in Dijkstra mode: the heuristic drops to zero for every query, since a positionless node makes no geometric estimate meaningful." + }, + { + "name": "connect", + "signature": "connect(a: number, b: number, options: WaypointEdgeOptions): void", + "signatureTokens": [ + { + "text": "connect", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "a", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "b", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "WaypointEdgeOptions", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "a", + "type": "number", + "optional": false + }, + { + "name": "b", + "type": "number", + "optional": false + }, + { + "name": "options", + "type": "WaypointEdgeOptions", + "optional": false + } + ], + "returnType": "void", + "description": "Adds the edge in both directions with the same options." + }, + { + "name": "describeEdge", + "signature": "describeEdge(from: number, to: number): PathEdge | null", + "signatureTokens": [ + { + "text": "describeEdge", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "from", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "to", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PathEdge", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Payload", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [ + { + "name": "from", + "type": "number", + "optional": false + }, + { + "name": "to", + "type": "number", + "optional": false + } + ], + "returnType": "PathEdge | null", + "description": "Describes the traversal from from to to, if the space models one." + }, + { + "name": "heuristic", + "signature": "heuristic(node: number, goal: number): number", + "signatureTokens": [ + { + "text": "heuristic", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "goal", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "goal", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Estimated remaining cost from node to goal. Must never overestimate, or the result stops being cost-optimal; returning 0 degrades the search to Dijkstra, which is the correct answer for a space without positions." + }, + { + "name": "nearestNode", + "signature": "nearestNode(x: number, y: number): number", + "signatureTokens": [ + { + "text": "nearestNode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "The node closest to a point, whether or not it is traversable and whether or not the point lies inside the space. Backs snapToNearest for coordinate queries; without it such a query reports unreachable." + }, + { + "name": "neighbors", + "signature": "neighbors(node: number, _agentSize: number, outNodes: Int32Array, outCosts: Float64Array): number", + "signatureTokens": [ + { + "text": "neighbors", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "_agentSize", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outNodes", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Int32Array", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "outCosts", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Float64Array", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "_agentSize", + "type": "number", + "optional": false + }, + { + "name": "outNodes", + "type": "Int32Array", + "optional": false + }, + { + "name": "outCosts", + "type": "Float64Array", + "optional": false + } + ], + "returnType": "number", + "description": "Writes the neighbours of node and the cost of stepping to each into the buffers, and returns how many were written. The buffers belong to the pathfinder and are reused across nodes and searches, so an implementation must not retain them. Costs must be positive and finite. agentSize is the requested clearance; spaces that do not model clearance ignore it." + }, + { + "name": "nodeToPoint", + "signature": "nodeToPoint(node: number, out: Vector): void", + "signatureTokens": [ + { + "text": "nodeToPoint", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "out", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + }, + { + "name": "out", + "type": "Vector", + "optional": false + } + ], + "returnType": "void", + "description": "Writes the node's position into out." + }, + { + "name": "pointToNode", + "signature": "pointToNode(x: number, y: number): number", + "signatureTokens": [ + { + "text": "pointToNode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "The positioned node closest to the point, or -1 when the graph has none. A graph has no cells, so there is no \"outside\" for a point to fall into." + }, + { + "name": "removeEdge", + "signature": "removeEdge(from: number, to: number): void", + "signatureTokens": [ + { + "text": "removeEdge", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "from", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "to", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "from", + "type": "number", + "optional": false + }, + { + "name": "to", + "type": "number", + "optional": false + } + ], + "returnType": "void", + "description": "" + }, + { + "name": "removeNode", + "signature": "removeNode(node: number): void", + "signatureTokens": [ + { + "text": "removeNode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "node", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "node", + "type": "number", + "optional": false + } + ], + "returnType": "void", + "description": "Removes a node together with every edge touching it. Ids are recycled: a later addNode may hand out the id this call freed, so a node id held across a removal can silently refer to a different node." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "maxDegree", + "signature": "maxDegree: number", + "signatureTokens": [ + { + "text": "maxDegree", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Upper bound on how many neighbours one node can have." + }, + { + "name": "nodeCapacity", + "signature": "nodeCapacity: number", + "signatureTokens": [ + { + "text": "nodeCapacity", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "One past the largest node id. Sizes the pathfinder's search buffers." + }, + { + "name": "nodeCount", + "signature": "nodeCount: number", + "signatureTokens": [ + { + "text": "nodeCount", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Number of live nodes." + }, + { + "name": "revision", + "signature": "revision: number", + "signatureTokens": [ + { + "text": "revision", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Increments on every mutation that can invalidate a path. Carried into PathResult.revision so callers can detect stale paths." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-pathfinding/src/spaces/WaypointGraph.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/WaypointGraph.ts" + } + } + ], + "sourcePath": "packages/exojs-pathfinding/src/spaces/WaypointGraph.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/src/spaces/WaypointGraph.ts" +} diff --git a/site/src/content/guide/pathfinding/grid-pathfinding.mdx b/site/src/content/guide/pathfinding/grid-pathfinding.mdx new file mode 100644 index 000000000..99b4b48e0 --- /dev/null +++ b/site/src/content/guide/pathfinding/grid-pathfinding.mdx @@ -0,0 +1,181 @@ +--- +title: 'Grid pathfinding' +description: 'Route agents over a weighted grid with @codexo/exojs-pathfinding: build a GridSpace from your own map data, read a PathResult, and shape the route with costs, diagonals, clearance and smoothing.' +--- + +import Callout from '../../../components/Callout.astro'; +import SourceSnippet from '../../../components/SourceSnippet.astro'; + +# Grid pathfinding + +`@codexo/exojs-pathfinding` answers one question — *how does this actor get from here to +there* — and it answers it over a world you describe, not one it owns. There is no scene node, +no renderer, no asset type and no extension to activate: you build a **navigation space**, you +construct a **`Pathfinder`**, and you own both. + +> **Note:** pathfinding ships as a separate package. Install it alongside `@codexo/exojs`: +> +> ```sh +> npm install @codexo/exojs @codexo/exojs-pathfinding +> ``` + + +There is no `extensions: [...]` entry. Nothing in this package renders, loads or registers +anything, and importing it has no side effects. + + +## A grid is a window of costs + +[`GridSpace`](/ExoJS/en/api/grid-space/) is a rectangular window of cells. Each cell carries a +**cost**: `0` blocks it, `1` is ordinary ground, and anything larger is terrain an actor will +walk around when the detour is cheaper than crossing it. + + + +The callback receives **absolute cell coordinates** — the same numbers your map uses — so the +grid never needs to know where its window sits. `cellSize` is how the grid converts between +cells and world pixels, which is what makes coordinate queries and `PathResult.points` work. + +Everything outside the window counts as blocked. That is deliberate, and it is the answer for +infinite or streamed maps: size the window to the region your actors are in, and keep it in +step as chunks stream by. + + + +Every edit bumps `grid.revision`, which is how anything holding an older path finds out that the +world moved under it. + +## Querying + + + +`findPath` takes node ids; `findPathBetween` takes world coordinates and resolves them for you. +Both return a [`PathResult`](/ExoJS/en/api/path-result/) whose `status` is a **value, not an +exception** — "there is no way through" is an ordinary game state, not an error: + +| `status` | What you get | +| ----------------- | --------------------------------------------------------------------- | +| `found` | A complete, cost-optimal path. | +| `unreachable` | An empty path. The search exhausted the space. | +| `budget-exceeded` | The best partial path, when `maxExpandedNodes` ran out first. | + +A `budget-exceeded` result is not a guess: it is a real, traversable prefix towards the goal, so +an actor can start walking it and ask again next frame. That is the shape a frame budget wants. + +Set `snapToNearest` when "get as close as you can" is the right behaviour — a click on a wall, +or a target that has since been walled in. + + + +## Shaping the route + +**Diagonals.** By default a diagonal step needs both cells it passes between to be walkable, so +an actor never clips through the corner where two walls meet. `diagonals: 'never'` gives +four-connected movement; `'always'` allows the clip. The policy is fixed when the grid is +constructed, because the heuristic and the neighbour rules are derived from it. + +**Costs.** A diagonal costs its length, so `√2` cells of ordinary ground. Weighted cells +multiply that. The heuristic is scaled by the cheapest walkable cell in the window, which is +what keeps it from overestimating on a weighted map — and an overestimating heuristic is exactly +how a search stops being optimal. + +**Clearance.** `agentSize: 2` restricts the route to cells a two-by-two actor fits through. +Clearance is anchored at a cell's **top-left** corner, so the last row and column of a window +can never hold an actor wider than one cell. + +**Smoothing.** `smooth: true` string-pulls the staircase out of the result: it keeps a waypoint +only where the straight line past it is blocked. The nodes it returns are no longer adjacent — +the guarantee is that the straight segment between two consecutive ones stays walkable, and +never crosses terrain more expensive than the section it replaces. + + +`result.cost` still reports the cost of the grid path that was smoothed, and `result.edges` is +empty, because the smoothed steps are no longer space edges. + + +## Jump-point search comes for free + +On a uniform-cost grid with a one-cell actor and the default diagonal policy, the grid hands the +search a **pruned expansion**: jump-point search. It skips whole runs of forced steps and +returns the same cost-optimal path from a fraction of the expanded nodes. `result.expandedNodes` +shows the difference, and the +[Grid Navigation](/ExoJS/en/playground/?example=pathfinding/grid-navigation) example puts that +counter on screen. + +Nothing has to be switched on. Painting a single weighted cell switches it back off by itself, +because its pruning rules only hold while every walkable cell costs the same. + +## Feeding it a tilemap + +The package has **no dependency on `@codexo/exojs-tilemap`**, in either direction. The bridge is +the cost callback, and it lives in your game: + + + +Keep the two in step wherever you edit the map: + + + +The [Tilemap Navigation](/ExoJS/en/playground/?example=pathfinding/tilemap-navigation) example +does exactly this against a live [`TileLayer`](/ExoJS/en/api/tile-layer/). + +## Reachable areas + +`floodFrom` walks outwards from a node and reports everything within a cost budget, cheapest +first — the "which tiles can this unit still reach" query a turn-based game needs, and the input +a flow field for many actors heading to one goal would be built from. + + + +## Determinism and cost + +Equal-cost paths are resolved by a pinned tie-break, so the same query on an unmutated grid +returns the identical path on every run and every machine. That is what makes a path safe to +record in a replay or assert in a test. + +One `Pathfinder` reuses its search buffers across every query, including queries against +different spaces of different sizes, so a search allocates nothing that scales with the nodes it +visits. The result object is fresh every time — you keep paths, and pooling something you keep +is how use-after-reuse bugs happen. + +Share a pathfinder freely; just do not mutate a space while a query against it is running. + + +A grid cannot express "jump across this gap". [Waypoint +graphs](/ExoJS/en/guide/pathfinding/waypoint-graphs/) can. + diff --git a/site/src/content/guide/pathfinding/waypoint-graphs.mdx b/site/src/content/guide/pathfinding/waypoint-graphs.mdx new file mode 100644 index 000000000..073ef3315 --- /dev/null +++ b/site/src/content/guide/pathfinding/waypoint-graphs.mdx @@ -0,0 +1,105 @@ +--- +title: 'Waypoint graphs' +description: 'Model traversal a grid cannot express — jumps, falls, ladders, teleports — with WaypointGraph, and implement NavigationSpace for a world of your own.' +--- + +import Callout from '../../../components/Callout.astro'; +import SourceSnippet from '../../../components/SourceSnippet.astro'; + +# Waypoint graphs + +A grid answers "which cells may I stand on". That is the whole question in a top-down world and +almost none of it in a sidescroller, where getting from one platform to the next is a *jump* +with an impulse, a *fall* off a ledge, or a ladder — none of which is a property of a cell. + +[`WaypointGraph`](/ExoJS/en/api/waypoint-graph/) models that directly: nodes you place, edges +you type, and a path that tells you which kind of move each step was. + +## Nodes and typed edges + + + +Edges are **directed**: falling off a platform is not the same move as jumping onto it, and +`connect` is the shorthand for the cases where it is. An edge's cost defaults to the straight-line +distance between its nodes, which is the right default for a walk and the wrong one for a jump — +price those yourself. + +`kind` is a free-form string and `data` is whatever your movement controller needs. Both come +back on the path: + + + +`WaypointGraph` carries the payload type through to `result.edges[i].data`, so the +controller reads a typed value rather than casting one. + + +Nothing says the graph has to be written in code. A Tiled object layer of points and polylines +with a `kind` property maps onto `addNode`/`addEdge` in a few lines, which keeps the level +designer in the level editor. + + +## Graphs without geometry + +Positions are optional. Leave them out and the heuristic drops to zero, which turns the same A\* +into plain Dijkstra over an abstract graph — a dialogue tree, a quest dependency, a routing +problem in an application that never draws a tile: + + + +A single positionless node is enough to switch the whole graph into that mode, because a +geometric estimate stops being meaningful as soon as one node has no place in the world. + + +`removeNode` frees its id, and a later `addNode` may hand the same id out again. An id held +across a removal can silently name a different node — store node ids the way you would store +array indices into a pool. + + +## Pricing an edge below its length + +An edge cheaper than the straight line between its ends — a zipline, a teleporter — would make a +distance heuristic overestimate, and an overestimating heuristic costs the search its +optimality. The graph handles this by scaling its heuristic down to the cheapest ratio any edge +has, rather than by forbidding the edge. You pay a slightly weaker heuristic on that graph, and +you keep optimal paths. + +## Your own space + +Both built-in spaces implement one interface, +[`NavigationSpace`](/ExoJS/en/api/navigation-space/), and so can your own — a room graph, a +hex grid, a navmesh. Everything in the package works against it unchanged. + + + +Two contracts matter: + +- **`neighbors` writes into the buffers it is handed** rather than returning an array. They + belong to the pathfinder and are reused across nodes and searches, so do not retain them — and + in exchange your space is allocation-free on the same terms as the built-in ones. +- **`heuristic` must never overestimate** the remaining cost. Returning `0` is always safe and + turns the search into Dijkstra; anything else has to be a genuine lower bound, or paths stop + being optimal in ways that are very hard to notice. + +`nodeToPoint`, `pointToNode`, `nearestNode`, `describeEdge`, `smoothPath` and `pruning` are the +optional extras: implement the ones your world can answer, and the queries that need the rest +degrade rather than break. diff --git a/site/src/lib/chapters.ts b/site/src/lib/chapters.ts index 8c00f843f..5560f8c1f 100644 --- a/site/src/lib/chapters.ts +++ b/site/src/lib/chapters.ts @@ -29,6 +29,7 @@ export const CHAPTERS: ReadonlyArray = [ { order: 21, slug: 'showcase', title: 'Showcase', complexity: 'Mixed' }, { order: 22, slug: 'ui', title: 'UI', complexity: 'Medium' }, { order: 23, slug: 'lighting', title: 'Lighting', complexity: 'High' }, + { order: 24, slug: 'pathfinding', title: 'Pathfinding', complexity: 'Medium' }, ]; export const CHAPTER_BY_SLUG = new Map(CHAPTERS.map(chapter => [chapter.slug, chapter])); diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index 2e0f90b40..7ea90b1fa 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -617,6 +617,37 @@ const RAW_PARTS: ReadonlyArray = [ }, ], }, + { + slug: 'pathfinding', + title: 'Pathfinding', + description: + 'Route agents through a world with @codexo/exojs-pathfinding: weighted grids, jump-point search, path smoothing, reachable-area queries, and waypoint graphs for traversal a grid cannot express.', + chapters: [ + { + slug: 'grid-pathfinding', + level: 'intermediate', + learningGoals: [ + 'build a GridSpace from your own map data and query it', + 'read a PathResult status instead of catching an exception', + 'use costs, diagonals, clearance and smoothing to shape a route', + ], + prerequisites: ['runtime/scenes-and-lifecycle'], + examples: ['pathfinding/grid-navigation', 'pathfinding/tilemap-navigation'], + apiLinks: ['pathfinder', 'grid-space', 'grid-space-options', 'path-result', 'find-path-options'], + }, + { + slug: 'waypoint-graphs', + level: 'advanced', + learningGoals: [ + 'model jump and fall links a grid cannot express', + 'read traversal kinds and payloads off a path', + 'implement NavigationSpace for a world of your own', + ], + prerequisites: ['pathfinding/grid-pathfinding'], + apiLinks: ['waypoint-graph', 'waypoint-edge-options', 'path-edge', 'navigation-space'], + }, + ], + }, { slug: 'recipes', title: 'Recipes', From 6f80f001a8299e056824f00f21e00a526ea11c29 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 4 Sep 2026 23:42:46 +0200 Subject: [PATCH 3/4] docs(pathfinding): reword the grid example's replan comment The source-hygiene gate reads a comment opening with "The agent" as development provenance. The sentence says the same thing about the walker it actually describes. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- examples/pathfinding/grid-navigation.js | 4 ++-- examples/pathfinding/grid-navigation.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/pathfinding/grid-navigation.js b/examples/pathfinding/grid-navigation.js index d47baf7d6..0e0062cd6 100644 --- a/examples/pathfinding/grid-navigation.js +++ b/examples/pathfinding/grid-navigation.js @@ -134,8 +134,8 @@ class GridNavigationScene extends Scene { } replan(mutate) { mutate?.(); - // The agent is somewhere between two cells, so the query starts from the - // cell it currently stands in rather than from the previous path's start. + // Replanning starts from the cell the walker currently stands in, not from + // the previous path's start: it is usually somewhere between two cells. this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * CELL, (this.goal.y + 0.5) * CELL, { smooth: this.smooth, pruning: this.pruning, diff --git a/examples/pathfinding/grid-navigation.ts b/examples/pathfinding/grid-navigation.ts index 583bbdf93..3276612c4 100644 --- a/examples/pathfinding/grid-navigation.ts +++ b/examples/pathfinding/grid-navigation.ts @@ -166,8 +166,8 @@ class GridNavigationScene extends Scene { private replan(mutate?: () => void): void { mutate?.(); - // The agent is somewhere between two cells, so the query starts from the - // cell it currently stands in rather than from the previous path's start. + // Replanning starts from the cell the walker currently stands in, not from + // the previous path's start: it is usually somewhere between two cells. this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * CELL, (this.goal.y + 0.5) * CELL, { smooth: this.smooth, pruning: this.pruning, From c20a07c57e1d95d1f37bb9950459c2d8d1bc37ef Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 4 Sep 2026 23:46:07 +0200 Subject: [PATCH 4/4] fix(pathfinding): put the package on the lockstep release version Every lockstep package carries the repository's current version, 0.16.1, not the next one; release:cut is what moves them together. Claude-Session: https://claude.ai/code/session_01NSrXpH1WyqP7udpWWhHz1i --- packages/exojs-pathfinding/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/exojs-pathfinding/package.json b/packages/exojs-pathfinding/package.json index 528ee0fbf..5b1535463 100644 --- a/packages/exojs-pathfinding/package.json +++ b/packages/exojs-pathfinding/package.json @@ -1,6 +1,6 @@ { "name": "@codexo/exojs-pathfinding", - "version": "0.16.2", + "version": "0.16.1", "description": "Deterministic, allocation-free 2D pathfinding for ExoJS: A*, jump-point search, weighted grids and waypoint graphs.", "repository": { "type": "git",