Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/skills/jgengine-world/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,17 @@
- `slopeStepCost` (function): function slopeStepCost(field: { sampleHeight(x: number, z: number): number }, weight = DEFAULT_SLOPE_STEP_WEIGHT): (from: NavPoint, to: NavPoint) => number — `FindPathOptions.stepCost` factory that penalizes steep terrain: cost is `1 + weight * |Δheight| / horizontalDistance`, so with the default weight a 45° slope roughly doubles the step cost.
- `smoothPath` (function): function smoothPath(grid: NavGrid, points: readonly NavPoint[]): NavPoint[] — Remove waypoints the mover can skip because it has clear line-of-sight past them.

## @jgengine/core/nav/navMesh

- `NavMeshAdjacency` (interface): interface NavMeshAdjacency — Neighbor relationship for one navigation polygon.
- `NavMeshData` (interface): interface NavMeshData — Serializable polygon navigation mesh data.
- `NavMeshLink` (interface): interface NavMeshLink — Explicit traversable connection between two navigation polygons.
- `NavMeshPath` (interface): interface NavMeshPath — Route points and polygons selected through a navigation mesh.
- `buildNavAdjacency` (function): function buildNavAdjacency(mesh: NavMeshData): NavMeshAdjacency[] — Build polygon adjacency from shared edges and explicit off-mesh links.
- `closestPoint` (function): function closestPoint(mesh: NavMeshData, point: Vec3): Vec3 | null — Return the closest point on the mesh surface, or null for an empty mesh.
- `findPath` (function): function findPath(mesh: NavMeshData, from: Vec3, to: Vec3): NavMeshPath | null — A* over polygon centers, followed by deterministic visibility string-pulling.
- `raycastNav` (function): function raycastNav(mesh: NavMeshData, from: Vec3, to: Vec3): boolean — True when the segment remains over walkable polygons.

## @jgengine/core/nav/pathFollow

- `HeightSampler` (interface): interface HeightSampler — ⚠ undocumented
Expand Down Expand Up @@ -2035,6 +2046,10 @@
- `MusicTheme` (interface): interface MusicTheme — A through-composed, looping music track. `events` need not be sorted; the director schedules them ahead against a fixed anchor so loops are seamless.
- `NOCLIP_FLIGHT_TUNING` (const): const NOCLIP_FLIGHT_TUNING: FreeFlightTuning — Preset for noclip — weightless, noclips, yaw-relative with independent vertical.
- `NavGrid` (interface): interface NavGrid — ⚠ undocumented
- `NavMeshAdjacency` (interface): interface NavMeshAdjacency — Neighbor relationship for one navigation polygon.
- `NavMeshData` (interface): interface NavMeshData — Serializable polygon navigation mesh data.
- `NavMeshLink` (interface): interface NavMeshLink — Explicit traversable connection between two navigation polygons.
- `NavMeshPath` (interface): interface NavMeshPath — Route points and polygons selected through a navigation mesh.
- `NavPoint` (type): type NavPoint = readonly [number, number] — ⚠ undocumented
- `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping.
- `NoiseVoice` (interface): interface NoiseVoice — A filtered white-noise burst — impacts, whooshes, breath, crackle. Realised from a shared 1s noise buffer at a randomised playback rate and start offset, decaying exponentially to silence at `duration * decay`.
Expand Down Expand Up @@ -2329,6 +2344,7 @@
- `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112.
- `buildContextMenu` (function): function buildContextMenu(input: BuildContextMenuInput): ContextMenu | null — Assemble a menu from a target's catalog verbs; null when the target lists none.
- `buildJunctionSurface` (function): function buildJunctionSurface(junction: { x: number; z: number }, approaches: readonly JunctionApproach[], sampleHeight: (x: number, z: number) => number, options: JunctionGeometryOptions = {}): RoadRibbon — Weld one triangulated junction surface onto the corner vertices its incident ribbons END at (from {@link trimPathAtJunctions}). Corners are grouped by approach (so unequal widths cannot interleave a neighbour between a mouth pair), ordered around the node, and bridged as follows:
- `buildNavAdjacency` (function): function buildNavAdjacency(mesh: NavMeshData): NavMeshAdjacency[] — Build polygon adjacency from shared edges and explicit off-mesh links.
- `buildRoadRibbon` (function): function buildRoadRibbon(path: readonly RoadPoint[], width: number, sampleHeight: (x: number, z: number) => number, options: RoadRibbonOptions = {}): RoadRibbon — Triangulate a road centerline into a ground-draped ribbon mesh: the polyline is subdivided, each vertex is offset half a `width` along the local perpendicular, and every vertex sits at `sampleHeight(x, z) + elevation`. Pure geometry — the shell (or any renderer) turns the result into a mesh, and tests can assert on it directly.
- `buildTrimmedIntersections` (function): function buildTrimmedIntersections(streets: readonly IntersectionStreet[], junctions: readonly RoadJunctionInput[], sampleHeight: (x: number, z: number) => number, options: JunctionGeometryOptions = {}): TrimmedIntersections — Trim a set of streets against a set of junctions and weld the crossing surfaces in one call — the ergonomic entry the shell/playground consume for meshing.
- `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
Expand All @@ -2341,6 +2357,7 @@
- `circleFormation` (function): function circleFormation(options: CircleFormationOptions): FormationSlotGenerator — An evenly spaced ring around the destination — a guard cordon, a huddle, or a surround. Slot 0 sits `startAngle` from forward; slots advance evenly around the circle.
- `clampToMinimapEdge` (function): function clampToMinimapEdge(point: MinimapPoint, size: number): { x: number; y: number } — Clamp a projected point to the minimap edge, preserving direction (edge markers).
- `clearanceZonesFrom` (function): function clearanceZonesFrom(doc: SceneDocumentLike, options: ClearanceOptions = {}): AvoidZone[] — Point-pad clearance **discs** from a document's markers/volumes — the terrain-flatten set (spawns, plots, POIs get a level pad). A marker/volume contributes a disc when it carries `meta.clearance` or its kind is in `kinds`. Paths are *not* included (they render draped, never flattened — see {@link clearanceMasksFrom} for their foliage corridor). Pass `ids`/`kinds` to scope it.
- `closestPoint` (function): function closestPoint(mesh: NavMeshData, point: Vec3): Vec3 | null — Return the closest point on the mesh surface, or null for an empty mesh.
- `collectAuthoredTriggers` (function): function collectAuthoredTriggers(document: SceneDocumentLike): AuthoredTrigger[] — Collect every authored trigger on a document's markers and volumes. Pure — no runtime state. Action params use the live {@link registerTriggerAction} registry when present.
- `combineGravity` (function): function combineGravity(fields: readonly GravityField[]): GravityField — Adds several gravity sources into one field.
- `command` (function): function command(name: string, input?: unknown): PromptCommand — ⚠ undocumented
Expand Down Expand Up @@ -2519,6 +2536,7 @@
- `quarterTurnsToRotationY` (function): function quarterTurnsToRotationY(quarterTurns: number): number — Maps 0–3 quarter turns onto radians for ghost/commit rotation.
- `rain` (function): function rain(config: RainEnvironmentConfig = {}): RainEnvironmentDescriptor — Declares a rainfall weather effect for `environment()` — area, density, speed, wind, and drop width/opacity.
- `raiseAlert` (function): function raiseAlert(state: SpawnDirectorState, amount: number): SpawnDirectorState — ⚠ undocumented
- `raycastNav` (function): function raycastNav(mesh: NavMeshData, from: Vec3, to: Vec3): boolean — True when the segment remains over walkable polygons.
- `readNamedSockets` (function): function readNamedSockets(root: ModelNode, pattern: RegExp = SOCKET_PATTERN): ModelSocket[] — Depth-first collect every socket-named node's local offset, sorted by descending Y then ascending X so socket indices are stable across loads (top first, left-to-right). Empty when the model tags none — callers then fall back to computed offsets. Pass a custom `pattern` for a bespoke naming convention.
- `readScatterPalette` (function): function readScatterPalette(meta: Record<string, unknown> | undefined): ScatterPaletteEntry[] — Parses a scatter region's palette from meta: a weighted `palette` array, else a single `item`.
- `readScatterRules` (function): function readScatterRules(path: ScenePathLike): ScatterRegionRules | null — The path's scatter rules with defaults filled in; null for non-scatter paths.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ between (`--json` for structured output).
- `syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend.
- Core decision graphs provide deterministic selector, sequence, condition, action, and utility runtimes with snapshot/restore.
- Core behavior descriptors can run registered decision graphs at the interest-scheduler cadence.
- Core navigation meshes provide deterministic polygon routing, off-mesh links, closest-point queries, and nav raycasts.
- WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state.
- Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites.
- Tilemap layers can render atlas-backed textured tiles as one instanced quad mesh with parallax.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/meta/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const CHANGELOG: Record<string, ChangelogEntry> = {
"`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`, and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.",
],
added: [
"Polygon navigation meshes — `NavMeshData`, adjacency building, polygon A* paths, closest-point, and nav raycasts (`@jgengine/core/nav/navMesh`).",
"`createPerception` (`@jgengine/core/sensor/perception`) tracks sight, sound, and damage stimuli per observer, with cone/range/occlusion checks, linearly decaying memory, retuning, and snapshot/restore.",
"`syncWorldColliders(backend, ctx)` mirrors static scene-object colliders and the sampled ground heightfield into a physics backend, updating on object spawn, despawn, and pose changes.",
"Physics-backed player movement — `defineGame({ physics: { backend } })` installs the backend's `afterMovement` simulation stage and makes the shell/headless walk controller use a capsule for gravity, shapecast collision, slopes, and step-up, while retaining the existing input and impulse feel.",
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/nav/navMesh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { buildNavAdjacency, closestPoint, findPath, raycastNav, type NavMeshData } from "@jgengine/core/nav/navMesh";

const lMesh: NavMeshData = {
verts: [0, 0, 0, 2, 0, 0, 2, 0, 1, 1, 0, 1, 1, 0, 3, 0, 0, 3],
polys: [[0, 1, 2, 3], [3, 2, 4, 5]],
links: [],
};

describe("navMesh", () => {
test("builds adjacency and routes around an L", () => {
expect(buildNavAdjacency(lMesh).map((entry) => entry.neighbors)).toEqual([[1], [0]]);
const path = findPath(lMesh, [0.5, 0, 0.5], [0.5, 0, 2.5]);
expect(path?.polys).toEqual([0, 1]);
expect(path?.points[0]).toEqual([0.5, 0, 0.5]);
expect(path?.points.at(-1)).toEqual([0.5, 0, 2.5]);
expect(path?.points.length).toBeGreaterThanOrEqual(2);
});

test("supports explicit off-mesh links", () => {
const mesh: NavMeshData = { ...lMesh, links: [{ from: 0, to: 1, cost: 0.25 }] };
expect(buildNavAdjacency(mesh)[0]?.neighbors).toEqual([1]);
expect(findPath(mesh, [0.5, 0, 0.5], [0.5, 0, 2.5])?.polys).toEqual([0, 1]);
});

test("finds surface points and raycasts walkable space", () => {
expect(closestPoint(lMesh, [1, 3, 0.5])).toEqual([1, 0, 0.5]);
expect(raycastNav(lMesh, [0.5, 0, 0.5], [1.5, 0, 0.5])).toBe(true);
expect(raycastNav(lMesh, [0.5, 0, 0.5], [3, 0, 2])).toBe(false);
});
});
152 changes: 152 additions & 0 deletions packages/core/src/nav/navMesh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { Vec3 } from "../world/geometry";

/** Explicit traversable connection between two navigation polygons. */
export interface NavMeshLink {
from: number;
to: number;
cost?: number;
}

/** Serializable polygon navigation mesh data. */
export interface NavMeshData {
/** Flat xyz vertex coordinates. Polygon indices refer to triples in this array. */
verts: number[];
polys: number[][];
links: NavMeshLink[];
areas?: number[];
}

/** Neighbor relationship for one navigation polygon. */
export interface NavMeshAdjacency {
neighbors: number[];
cost: number;
}

/** Route points and polygons selected through a navigation mesh. */
export interface NavMeshPath {
points: Vec3[];
polys: number[];
}

function vertex(mesh: NavMeshData, index: number): Vec3 {
return [mesh.verts[index * 3] ?? 0, mesh.verts[index * 3 + 1] ?? 0, mesh.verts[index * 3 + 2] ?? 0];
}

function center(mesh: NavMeshData, poly: readonly number[]): Vec3 {
const result: [number, number, number] = [0, 0, 0];
for (const index of poly) {
const point = vertex(mesh, index);
result[0] += point[0]; result[1] += point[1]; result[2] += point[2];
}
const scale = poly.length > 0 ? 1 / poly.length : 0;
return [result[0] * scale, result[1] * scale, result[2] * scale];
}

function distance(a: Vec3, b: Vec3): number {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}

/** Build polygon adjacency from shared edges and explicit off-mesh links. */
export function buildNavAdjacency(mesh: NavMeshData): NavMeshAdjacency[] {
const edges = new Map<string, number[]>();
mesh.polys.forEach((poly, polyIndex) => {
for (let i = 0; i < poly.length; i += 1) {
const a = poly[i]!;
const b = poly[(i + 1) % poly.length]!;
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
const owners = edges.get(key) ?? [];
owners.push(polyIndex);
edges.set(key, owners);
}
});
const result: NavMeshAdjacency[] = mesh.polys.map(() => ({ neighbors: [], cost: 1 }));
for (const owners of edges.values()) {
for (const a of owners) for (const b of owners) {
if (a !== b && !result[a]!.neighbors.includes(b)) result[a]!.neighbors.push(b);
}
}
for (const link of mesh.links ?? []) {
if (!result[link.from] || !result[link.to]) continue;
if (!result[link.from]!.neighbors.includes(link.to)) result[link.from]!.neighbors.push(link.to);
}
return result;
}

function pointInPoly(mesh: NavMeshData, poly: readonly number[], point: Vec3): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = vertex(mesh, poly[i]!); const b = vertex(mesh, poly[j]!);
const cross = (point[0] - a[0]) * (b[2] - a[2]) - (point[2] - a[2]) * (b[0] - a[0]);
const dot = (point[0] - a[0]) * (point[0] - b[0]) + (point[2] - a[2]) * (point[2] - b[2]);
if (Math.abs(cross) < 1e-8 && dot <= 1e-8) return true;
if ((a[2] > point[2]) !== (b[2] > point[2]) && point[0] < (b[0] - a[0]) * (point[2] - a[2]) / (b[2] - a[2]) + a[0]) inside = !inside;
}
return inside;
}

function closestOnSegment(point: Vec3, a: Vec3, b: Vec3): Vec3 {
const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
const len = dx * dx + dy * dy + dz * dz;
const t = len > 0 ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy + (point[2] - a[2]) * dz) / len)) : 0;
return [a[0] + dx * t, a[1] + dy * t, a[2] + dz * t];
}

/** Return the closest point on the mesh surface, or null for an empty mesh. */
export function closestPoint(mesh: NavMeshData, point: Vec3): Vec3 | null {
let best: Vec3 | null = null; let bestDistance = Number.POSITIVE_INFINITY;
for (const poly of mesh.polys) {
if (pointInPoly(mesh, poly, point)) return [point[0], center(mesh, poly)[1], point[2]];
for (let i = 0; i < poly.length; i += 1) {
const candidate = closestOnSegment(point, vertex(mesh, poly[i]!), vertex(mesh, poly[(i + 1) % poly.length]!));
const d = distance(point, candidate);
if (d < bestDistance) { bestDistance = d; best = candidate; }
}
}
return best;
}

function polyAt(mesh: NavMeshData, point: Vec3): number | null {
for (let i = 0; i < mesh.polys.length; i += 1) if (pointInPoly(mesh, mesh.polys[i]!, point)) return i;
const nearest = closestPoint(mesh, point);
if (!nearest) return null;
let best = 0; let score = Number.POSITIVE_INFINITY;
mesh.polys.forEach((poly, i) => { const d = distance(nearest, center(mesh, poly)); if (d < score) { score = d; best = i; } });
return best;
}

/** A* over polygon centers, followed by deterministic visibility string-pulling. */
export function findPath(mesh: NavMeshData, from: Vec3, to: Vec3): NavMeshPath | null {
if (mesh.polys.length === 0) return null;
const start = polyAt(mesh, from), goal = polyAt(mesh, to);
if (start === null || goal === null) return null;
const adjacency = buildNavAdjacency(mesh);
const g = new Map<number, number>([[start, 0]]); const came = new Map<number, number>(); const open = [start];
while (open.length) {
open.sort((a, b) => (g.get(a)! + distance(center(mesh, mesh.polys[a]!), center(mesh, mesh.polys[goal]!))) - (g.get(b)! + distance(center(mesh, mesh.polys[b]!), center(mesh, mesh.polys[goal]!))));
const current = open.shift()!;
if (current === goal) break;
for (const next of adjacency[current]!.neighbors) {
const link = mesh.links.find((candidate) => candidate.from === current && candidate.to === next);
const cost = link?.cost ?? distance(center(mesh, mesh.polys[current]!), center(mesh, mesh.polys[next]!));
const nextG = g.get(current)! + Math.max(0, cost);
if (nextG < (g.get(next) ?? Number.POSITIVE_INFINITY)) { g.set(next, nextG); came.set(next, current); if (!open.includes(next)) open.push(next); }
}
}
if (start !== goal && !came.has(goal)) return null;
const polys = [goal]; while (polys[0] !== start) polys.unshift(came.get(polys[0]!)!);
const anchors = [from, ...polys.slice(1, -1).map((i) => center(mesh, mesh.polys[i]!)), to];
const points: Vec3[] = [anchors[0]!]; let anchor = 0;
for (let i = 2; i < anchors.length; i += 1) {
const steps = 24; let visible = true;
for (let step = 1; step < steps; step += 1) { const t = step / steps; const p: Vec3 = [anchors[anchor]![0] + (anchors[i]![0] - anchors[anchor]![0]) * t, anchors[anchor]![1] + (anchors[i]![1] - anchors[anchor]![1]) * t, anchors[anchor]![2] + (anchors[i]![2] - anchors[anchor]![2]) * t]; if (!mesh.polys.some((poly) => pointInPoly(mesh, poly, p))) { visible = false; break; } }
if (!visible) { points.push(anchors[i - 1]!); anchor = i - 1; }
}
points.push(anchors[anchors.length - 1]!);
return { points, polys };
}

/** True when the segment remains over walkable polygons. */
export function raycastNav(mesh: NavMeshData, from: Vec3, to: Vec3): boolean {
for (let i = 0; i <= 32; i += 1) { const t = i / 32; const point: Vec3 = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]; if (!mesh.polys.some((poly) => pointInPoly(mesh, poly, point))) return false; }
return true;
}
Loading
Loading