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
5 changes: 5 additions & 0 deletions .claude/skills/jgengine-world/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,11 @@
- `VehicleObstacleClamp` (interface): interface VehicleObstacleClamp — A planar move clamp for a kinematic car: feed {@link clampMove} to a vehicle's `clampMove` hook so an attempted XZ displacement slides along world solids instead of driving through them, and read {@link takeImpact} once per tick for the crash it produced.
- `createVehicleObstacleClamp` (function): function createVehicleObstacleClamp(options: { /** Solids near the car this tick (already filtered to the relevant, solid set). */ obstacles: () => readonly CollisionObstacle[]; /** Vehicle body radius (units). Default {@link DEFAULT_VEHICLE_RADIUS}. */ radius?: number; /** Current tick dt (seconds)… — Build a slide-along move clamp for a kinematic car (#1051). `obstacles` is sampled fresh each tick — the caller hands back the already-filtered set of solids near the car — and `dt` supplies the current tick length so a blocked move's lost displacement converts to a closing speed. `radius` inflates each obstacle footprint by the car's body radius (default {@link DEFAULT_VEHICLE_RADIUS}).

## @jgengine/core/physics/worldColliders

- `WorldColliderSync` (interface): interface WorldColliderSync — Handle for synchronizing authored static world collision with a physics backend.
- `syncWorldColliders` (function): function syncWorldColliders(backend: PhysicsBackend, ctx: GameContext): WorldColliderSync — Mirrors static scene-object physical colliders and the context's ground field into a physics backend.

## @jgengine/core/procedural

- `DecayMeterSet` (interface): interface DecayMeterSet — Set of named survival meters (hunger/thirst/…) that drain and refill over game time.
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/jgengine-world/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
- `buildTrimmedIntersections` (function) · `import { buildTrimmedIntersections } from "@jgengine/core/world"`
- `trimBandAtJunctions` (function) · `import { trimBandAtJunctions } from "@jgengine/core/world"`

## world-physics-colliders — Synchronize terrain and authored static colliders with a physics backend.

- `syncWorldColliders` (function) · `import { syncWorldColliders } from "@jgengine/core/physics/worldColliders"`

## world-place — declare the place a game happens in — flat/round/voxel/board ground, surface laws, per-place physics

- `world` (function) · `import { world } from "@jgengine/core/world"`
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ between (`--json` for structured output).
### Added

- Core perception tracks deterministic sight, hearing, occlusion, and bounded observation memory for AI.
- `syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend.
- 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.
- Core sprite atlas adapters and deterministic 2D sprite clip playback primitives.
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 @@ -19,6 +19,7 @@ export const CHANGELOG: Record<string, ChangelogEntry> = {
],
added: [
"`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.",
"`createPresenceFunctions` gains the policy hooks the pose lane was missing — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.",
"A command declares what it touches, so `runCommand` hydrates that and nothing else. `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.",
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/physics/worldColliders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import { createObjectStore } from "../scene/objectStore";
import { createPhysicsWorldBackend } from "./physicsWorldBackend";
import { syncWorldColliders } from "./worldColliders";

describe("syncWorldColliders", () => {
test("named raycast hits subscribed static object and terrain heightfield", () => {
const object = createObjectStore();
const colliders = new Map<string, any>();
const objectId = object.place("wall", 0, 1, -3);
colliders.set(objectId, {
body: { name: "wall", purpose: "physical", shape: { kind: "aabb", halfExtents: [2, 1, 0.2] } },
});
const ctx = {
scene: { object: { ...object, collidersOf: (id: string) => colliders.get(id) ?? null } },
world: { ground: { bounds: { w: 20, d: 20 }, sampleHeight: () => 0 } },
} as any;
const backend = createPhysicsWorldBackend({
capacity: 8,
bounds: { min: [-20, -20, -20], max: [20, 20, 20] },
warn: false,
});
const sync = syncWorldColliders(backend, ctx);

expect(backend.raycast({ origin: [0, 1, 0], direction: [0, 0, -1], maxDistance: 10 })?.body).toBe(2);
expect(backend.raycast({ origin: [0, 3, 0], direction: [0, -1, 0], maxDistance: 10 })?.body).toBe(1);
object.remove(objectId);
colliders.delete(objectId);
expect(backend.raycast({ origin: [0, 3, -3], direction: [0, 0, 1], maxDistance: 10 })).toBeNull();
sync.dispose();
backend.dispose();
});
});
108 changes: 108 additions & 0 deletions packages/core/src/physics/worldColliders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { GameContext } from "../runtime/gameContextTypes";
import { colliderWorldCenter, resolveColliders, type ColliderShape } from "../scene/colliders";
import type { EntityPosition } from "../scene/entityStore";
import type { BodyHandle, BodyShape, PhysicsBackend, PhysicsVec3 } from "./physicsBackend";

const TERRAIN_SEGMENTS = 32;

function bodyShape(shape: ColliderShape): BodyShape {
if (shape.kind === "sphere") return { kind: "sphere", radius: shape.radius };
if (shape.kind === "aabb") return { kind: "box", halfExtents: shape.halfExtents };
return { kind: "trimesh", vertices: shape.mesh.positions, indices: shape.mesh.indices };
}

function bodyPosition(shape: ColliderShape, position: EntityPosition, rotationY: number): PhysicsVec3 {
return colliderWorldCenter(
{ name: "body", purpose: "physical", shape, damageEligible: false, blocks: true },
position,
rotationY,
);
}

/** Handle for synchronizing authored static world collision with a physics backend. */
export interface WorldColliderSync {
/** Rebuild static terrain and object bodies from the current context. */
sync(): void;
/** Remove all bodies owned by this synchronizer and stop reacting to scene changes. */
dispose(): void;
}

/** Mirrors static scene-object physical colliders and the context's ground field into a physics backend.
* @capability world-physics-colliders Synchronize terrain and authored static colliders with a physics backend.
*/
export function syncWorldColliders(backend: PhysicsBackend, ctx: GameContext): WorldColliderSync {
const objectBodies = new Map<string, BodyHandle>();
let terrainBody: BodyHandle | null = null;
let disposed = false;

function rebuildTerrain(): void {
if (terrainBody !== null) backend.removeBody(terrainBody);
terrainBody = null;
const bounds = ctx.world.ground.bounds;
if (bounds === undefined) return;
const columns = TERRAIN_SEGMENTS + 1;
const rows = TERRAIN_SEGMENTS + 1;
const heights = new Float32Array(columns * rows);
for (let row = 0; row < rows; row += 1) {
const z = -bounds.d / 2 + (row / TERRAIN_SEGMENTS) * bounds.d;
for (let column = 0; column < columns; column += 1) {
const x = -bounds.w / 2 + (column / TERRAIN_SEGMENTS) * bounds.w;
heights[row * columns + column] = ctx.world.ground.sampleHeight(x, z);
}
}
terrainBody = backend.addBody({
shape: { kind: "heightfield", rows, columns, heights, scale: [bounds.w / TERRAIN_SEGMENTS, 1, bounds.d / TERRAIN_SEGMENTS] },
position: [0, 0, 0],
kind: "static",
userData: { kind: "terrain" },
});
}

function syncObject(objectId: string): void {
const old = objectBodies.get(objectId);
if (old !== undefined) {
backend.removeBody(old);
objectBodies.delete(objectId);
}
const object = ctx.scene.object.get(objectId);
if (object === null) return;
const body = resolveColliders(ctx.scene.object.collidersOf(objectId)).find((collider) => collider.purpose === "physical");
if (body === undefined) return;
const handle = backend.addBody({
shape: bodyShape(body.shape),
position: bodyPosition(body.shape, object.position, object.rotationY),
rotation: [0, Math.sin(object.rotationY / 2), 0, Math.cos(object.rotationY / 2)],
kind: "static",
userData: { kind: "object", instanceId: object.instanceId, catalogId: object.catalogId },
});
objectBodies.set(objectId, handle);
}

function sync(): void {
if (disposed) return;
rebuildTerrain();
const ids = new Set(ctx.scene.object.ids());
for (const [objectId, handle] of objectBodies) {
if (!ids.has(objectId)) {
backend.removeBody(handle);
objectBodies.delete(objectId);
}
}
for (const objectId of ids) syncObject(objectId);
}

const unsubscribe = ctx.scene.object.subscribe(sync);
sync();
return {
sync,
dispose() {
if (disposed) return;
disposed = true;
unsubscribe();
if (terrainBody !== null) backend.removeBody(terrainBody);
terrainBody = null;
for (const handle of objectBodies.values()) backend.removeBody(handle);
objectBodies.clear();
},
};
}
1 change: 1 addition & 0 deletions scripts/export-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@
"./physics/traversal",
"./physics/vehicleBody",
"./physics/vehicleObstacles",
"./physics/worldColliders",
"./procedural",
"./progression/statGraph",
"./puzzle/cellGrid",
Expand Down
Loading