@jgengine/core 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,33 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.35.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.35.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.32.0`; `@jgengine/github` is `0.22.0`.
34
+
35
+ ### Added
36
+
37
+ - `ModelLodConfig.crowdCapacity` declares how many characters of one model a batch draws (default 128). A character past the cap keeps its own rig at its distance tier instead of disappearing.
38
+ - `ModelLodConfig.crowdDistance` accepts `0`, which batches a character at every distance instead of only past 25 m. A game with a near crowd or a close-quarters army sets it to batch members in front of the camera too.
39
+ - `characterRigTier` (`@jgengine/shell/render/characterLod`) resolves the distance tier a character poses its own rig on, which is the fallback for an over-cap crowd.
40
+ - `audio.zones` (`@jgengine/core/game/playableGame`): a scene declares world-space ambience zones, one looping bed per zone. The shell crossfades bed levels from the listener position on each sound's own bus, so entering, leaving and walking between zones hand over instead of cutting.
41
+ - `audio.occlusion` (`@jgengine/core/audio/occlusion`): budgeted geometry occlusion for retained positional loops. A blocked line attenuates the loop and darkens it through a low-pass; the per-tick test budget and interval bound the cost, and a loop that cannot be tested stays on distance-only.
42
+ - `SoundDef.duck`: a cue can lower named buses for its own duration. `AudioEngine.duck` returns a release, and the player's own gain is restored exactly even when they move the slider mid-duck.
43
+ - `AudioEngine.ambience()`, `AudioEngine.occlusionStats()` and `window.__jgengineAudio` report the zone mix, the newest occlusion test and the measured per-test cost for capture and diagnostics.
44
+
45
+ ### Changed
46
+
47
+ - The character batch path now covers every drawable character model, not only skinned locomotion rigs: a node-transform character (rotating limbs, no skin) rides the node a clip moves, and a model with no clips at all batches as a static geometry. Both still bake to one geometry per material, so a crowd draws once per model and material.
48
+ - Batched characters are pickable: the batch carries the entity ids of the instances it drew, so `worldHit().entity` and a click on a batched character resolve to the character instead of falling through to the ground.
49
+
50
+ ### Fixed
51
+
52
+ - Load-time shader warmup compiles what the first frame can reach, reveals as soon as those programs are linked, and finishes the rest in background rounds, so a scene no longer holds the loading cover for its whole model stream and no longer drops a compile onto a frame that is already showing. The loading cover names the shader count, the seconds waited and the work left for the background.
53
+ - A crowd batch silently dropped members past its fixed 128 instances while their markers still hid them, so a full scene lost characters. Members past the declared capacity now keep their own rigs.
54
+ - A crowd batch's clock now follows the game clock when one is bound, so a paused game no longer keeps its crowd walking.
55
+
29
56
  ## 0.34.0
30
57
 
31
58
  ### Migrate
@@ -34,6 +61,7 @@ At publish, rename this heading to the new version and mirror the entries into
34
61
 
35
62
  ### Added
36
63
 
64
+ - `PaceBand` on `DriverGoal` (`paceBand` + caller-measured `paceGap`) gives catch-up pacing an owner in `@jgengine/core/ai/driver`: a dead zone around `margin` (`tolerance`), `ease` shedding pace for a car ahead over `easeAt`, `push` adding it for a car behind over `pushAt`, and `scale` as the per-car share. `paceBandFactor(band, gap)` exposes the rule on its own. Deterministic scalar math, no allocation per step; the push never outdrives `DriverTuning.maxSpeed`. First adopter: Drift Foundry's rival field, which deletes its own `paceBand`; second: Harbor Heat's interceptors.
37
65
  - `createRunLoop` (`@jgengine/core/run/runLoop`) holds a run's at-risk haul, banks it scaled by the line or loses it on a failure, and draws one authored change for each next run from a seeded table, so the same seed and run history replay the same sequence. State round-trips through `snapshot`/`restore`; the money stays in `progression/careerBank`.
38
66
 
39
67
  ## 0.33.0
@@ -0,0 +1,47 @@
1
+ /** A world-space volume whose sound bed plays while the listener is inside it. */
2
+ export type AmbienceZoneShape = {
3
+ kind: "sphere";
4
+ center: readonly [number, number, number];
5
+ radius: number;
6
+ } | {
7
+ kind: "box";
8
+ min: readonly [number, number, number];
9
+ max: readonly [number, number, number];
10
+ };
11
+ /** One ambience zone: a sound bed keyed to a volume of the scene.
12
+ * @capability ambience-zones a scene declares looping sound beds keyed to world volumes; walking between them crossfades bed levels on the existing buses instead of cutting.
13
+ */
14
+ export interface AmbienceZoneDef {
15
+ /** Stable id; the shell retains one looping bed per zone for the engine's lifetime. */
16
+ id: string;
17
+ /** Sound id in the playable's `audio.sounds` catalog. The shell loops it regardless of the sound's `loop` flag. */
18
+ sound: string;
19
+ shape: AmbienceZoneShape;
20
+ /**
21
+ * World units over which the bed fades across the boundary, measured on both sides. Default 4.
22
+ * Two zones that share a boundary hand over their full level exactly across this band.
23
+ */
24
+ crossfade?: number;
25
+ /** Bed level multiplier, 0..1. Default 1. */
26
+ gain?: number;
27
+ }
28
+ /** One zone's share of the ambience mix at a listener position, 0..1. */
29
+ export interface AmbienceWeight {
30
+ id: string;
31
+ weight: number;
32
+ }
33
+ /**
34
+ * Distance from a point to a zone boundary, positive inside and negative outside.
35
+ * Spheres measure from the surface; boxes use the nearest face.
36
+ */
37
+ export declare function zoneSignedDistance(shape: AmbienceZoneShape, x: number, y: number, z: number): number;
38
+ /**
39
+ * Every zone's mix weight at a listener position. A zone is at full level `crossfade` units
40
+ * inside its boundary, at half level on the boundary and silent `crossfade` units outside, so a
41
+ * listener walking between two zones that share a boundary hears a constant-sum handover.
42
+ * Weights that would sum above 1 (overlapping zones) are normalized so total bed level never
43
+ * exceeds unity. Writes into `out` when given, so a per-frame caller allocates nothing.
44
+ */
45
+ export declare function ambienceWeights(zones: readonly AmbienceZoneDef[], x: number, y: number, z: number, out?: AmbienceWeight[]): AmbienceWeight[];
46
+ /** The loudest zone in a weight list, or null when every zone is silent. */
47
+ export declare function activeAmbienceZone(weights: readonly AmbienceWeight[]): string | null;
@@ -0,0 +1,75 @@
1
+ const DEFAULT_CROSSFADE = 4;
2
+ function clamp01(value) {
3
+ return value < 0 ? 0 : value > 1 ? 1 : value;
4
+ }
5
+ /**
6
+ * Distance from a point to a zone boundary, positive inside and negative outside.
7
+ * Spheres measure from the surface; boxes use the nearest face.
8
+ */
9
+ export function zoneSignedDistance(shape, x, y, z) {
10
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
11
+ return -Infinity;
12
+ if (shape.kind === "sphere") {
13
+ if (!(shape.radius > 0))
14
+ return -Infinity;
15
+ const dx = x - shape.center[0];
16
+ const dy = y - shape.center[1];
17
+ const dz = z - shape.center[2];
18
+ return shape.radius - Math.hypot(dx, dy, dz);
19
+ }
20
+ const min = shape.min;
21
+ const max = shape.max;
22
+ const values = [x, y, z];
23
+ let outsideSquared = 0;
24
+ let nearestFace = Infinity;
25
+ for (let axis = 0; axis < 3; axis += 1) {
26
+ const lo = min[axis];
27
+ const hi = max[axis];
28
+ const value = values[axis];
29
+ if (value < lo)
30
+ outsideSquared += (lo - value) * (lo - value);
31
+ else if (value > hi)
32
+ outsideSquared += (value - hi) * (value - hi);
33
+ else
34
+ nearestFace = Math.min(nearestFace, value - lo, hi - value);
35
+ }
36
+ if (outsideSquared > 0)
37
+ return -Math.sqrt(outsideSquared);
38
+ return Number.isFinite(nearestFace) ? nearestFace : 0;
39
+ }
40
+ /**
41
+ * Every zone's mix weight at a listener position. A zone is at full level `crossfade` units
42
+ * inside its boundary, at half level on the boundary and silent `crossfade` units outside, so a
43
+ * listener walking between two zones that share a boundary hears a constant-sum handover.
44
+ * Weights that would sum above 1 (overlapping zones) are normalized so total bed level never
45
+ * exceeds unity. Writes into `out` when given, so a per-frame caller allocates nothing.
46
+ */
47
+ export function ambienceWeights(zones, x, y, z, out = []) {
48
+ out.length = 0;
49
+ let total = 0;
50
+ for (const zone of zones) {
51
+ const crossfade = zone.crossfade ?? DEFAULT_CROSSFADE;
52
+ const signed = zoneSignedDistance(zone.shape, x, y, z);
53
+ if (!Number.isFinite(signed))
54
+ continue;
55
+ const weight = crossfade > 0 ? clamp01(0.5 + signed / (2 * crossfade)) : signed >= 0 ? 1 : 0;
56
+ if (weight <= 0)
57
+ continue;
58
+ out.push({ id: zone.id, weight });
59
+ total += weight;
60
+ }
61
+ if (total > 1) {
62
+ for (const entry of out)
63
+ entry.weight /= total;
64
+ }
65
+ return out;
66
+ }
67
+ /** The loudest zone in a weight list, or null when every zone is silent. */
68
+ export function activeAmbienceZone(weights) {
69
+ let best = null;
70
+ for (const entry of weights) {
71
+ if (best === null || entry.weight > best.weight)
72
+ best = entry;
73
+ }
74
+ return best !== null && best.weight > 0.01 ? best.id : null;
75
+ }
@@ -28,6 +28,18 @@ export interface SoundDef {
28
28
  maxVoices?: number;
29
29
  /** Playback priority; higher priorities survive voice-budget pressure. Default 0. */
30
30
  priority?: number;
31
+ /**
32
+ * Ducking policy for this cue: while it plays, the named buses drop so a hit, explosion or voice
33
+ * line cuts through the music and ambience, then return to exactly the player's own level.
34
+ */
35
+ duck?: {
36
+ /** Buses to lower. Default `["music"]`. */
37
+ buses?: readonly string[];
38
+ /** How far to lower them, 0..1 (0.6 leaves 40% of the player's level). Default 0.6. */
39
+ amount?: number;
40
+ /** Seconds to hold the duck. Default the cue's own length: synth patch duration or decoded sample length. */
41
+ seconds?: number;
42
+ };
31
43
  /** Positional emitters attenuate by distance from the listener; non-positional sounds (UI, music) play at flat gain. Default true. */
32
44
  positional?: boolean;
33
45
  falloff?: AudioFalloffConfig;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Geometry occlusion for retained positional loops: a cheap line test between the listener and an
3
+ * emitter behind blocking geometry, bounded so a busy scene can never turn audio into a raycast
4
+ * pass. The shell runs the test against the scene's blockable colliders; the engine applies the
5
+ * resulting attenuation and low-pass to the loop it already owns.
6
+ * @capability audio-occlusion an emitter behind blocking world geometry is attenuated and darkened by a budgeted line test, and falls back to distance-only once the tick's budget is spent.
7
+ */
8
+ export interface AudioOcclusionConfig {
9
+ /** Geometry tests shared by every retained loop in one listener tick. Default 8; 0 keeps distance-only. */
10
+ maxTestsPerTick?: number;
11
+ /** Minimum milliseconds before the same loop is tested again. Default 150. */
12
+ intervalMs?: number;
13
+ /** Gain multiplier at full blockage; 1 disables attenuation. Default 0.55. */
14
+ attenuation?: number;
15
+ /** Low-pass cutoff at full blockage in Hz. Default 1200; a loop already darker keeps its own cutoff. */
16
+ lowpassHz?: number;
17
+ /** Loops farther than this from the listener are never tested. Default 60. */
18
+ maxDistance?: number;
19
+ }
20
+ /** Defaults for {@link AudioOcclusionConfig}. */
21
+ export declare const AUDIO_OCCLUSION_DEFAULTS: {
22
+ readonly maxTestsPerTick: 8;
23
+ readonly intervalMs: 150;
24
+ readonly attenuation: 0.55;
25
+ readonly lowpassHz: 1200;
26
+ readonly maxDistance: 60;
27
+ };
28
+ /** A fully specified occlusion policy, with every unset field replaced by its default. */
29
+ export interface ResolvedAudioOcclusionConfig {
30
+ maxTestsPerTick: number;
31
+ intervalMs: number;
32
+ attenuation: number;
33
+ lowpassHz: number;
34
+ maxDistance: number;
35
+ }
36
+ /**
37
+ * Fill in the occlusion defaults and clamp each field to a workable range: a finite test budget,
38
+ * a non-negative retest interval and distance cap, and an attenuation in `0..1`.
39
+ */
40
+ export declare function resolveOcclusionConfig(config?: AudioOcclusionConfig | false): ResolvedAudioOcclusionConfig;
@@ -0,0 +1,27 @@
1
+ /** Defaults for {@link AudioOcclusionConfig}. */
2
+ export const AUDIO_OCCLUSION_DEFAULTS = {
3
+ maxTestsPerTick: 8,
4
+ intervalMs: 150,
5
+ attenuation: 0.55,
6
+ lowpassHz: 1200,
7
+ maxDistance: 60,
8
+ };
9
+ function finite(value, fallback) {
10
+ return value !== undefined && Number.isFinite(value) ? value : fallback;
11
+ }
12
+ /**
13
+ * Fill in the occlusion defaults and clamp each field to a workable range: a finite test budget,
14
+ * a non-negative retest interval and distance cap, and an attenuation in `0..1`.
15
+ */
16
+ export function resolveOcclusionConfig(config = {}) {
17
+ if (config === false) {
18
+ return { ...AUDIO_OCCLUSION_DEFAULTS, maxTestsPerTick: 0 };
19
+ }
20
+ return {
21
+ maxTestsPerTick: Math.max(0, Math.floor(finite(config.maxTestsPerTick, AUDIO_OCCLUSION_DEFAULTS.maxTestsPerTick))),
22
+ intervalMs: Math.max(0, finite(config.intervalMs, AUDIO_OCCLUSION_DEFAULTS.intervalMs)),
23
+ attenuation: Math.min(1, Math.max(0, finite(config.attenuation, AUDIO_OCCLUSION_DEFAULTS.attenuation))),
24
+ lowpassHz: Math.max(10, finite(config.lowpassHz, AUDIO_OCCLUSION_DEFAULTS.lowpassHz)),
25
+ maxDistance: Math.max(0, finite(config.maxDistance, AUDIO_OCCLUSION_DEFAULTS.maxDistance)),
26
+ };
27
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AnimGraph } from "../anim/animGraph.js";
2
+ import type { AmbienceZoneDef } from "../audio/ambience.js";
2
3
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
4
+ import type { AudioOcclusionConfig } from "../audio/occlusion.js";
3
5
  import type { MusicTheme } from "../audio/music.js";
4
6
  import type { UiCueOverrides } from "../audio/uiCues.js";
5
7
  import type { FeedbackCueOverrides } from "../audio/feedbackCues.js";
@@ -315,10 +317,19 @@ export interface ModelLodConfig {
315
317
  fullRateDistance?: number;
316
318
  /** Within this distance the rig poses every other frame, carrying the accumulated time. Default 40. */
317
319
  halfRateDistance?: number;
318
- /** Beyond this distance a crowd-eligible rig renders through the GPU crowd path and updates no bones. Default 25. */
320
+ /**
321
+ * At or beyond this distance a crowd-eligible character renders through the GPU batch path and
322
+ * updates no bones. Default 25. `0` batches it at every distance, which is what a crowd or an army
323
+ * wants: near members then draw from the same instance buffer instead of one rig each.
324
+ */
319
325
  crowdDistance?: number;
320
326
  /** Metres a character must come back inside `crowdDistance` before it returns to its own rig, so the swap cannot flicker at the boundary. Default 2. */
321
327
  crowdHysteresis?: number;
328
+ /**
329
+ * Characters of this model one batch can draw. A character past the cap keeps its own rig, so a
330
+ * crowded scene loses draws rather than characters. Default 128.
331
+ */
332
+ crowdCapacity?: number;
322
333
  }
323
334
  /** Wind sway for a foliage model; see {@link ModelConfig.wind}. */
324
335
  export interface ModelWindConfig {
@@ -789,6 +800,15 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
789
800
  uiCues?: UiCueOverrides;
790
801
  /** Default gameplay reactions (`feel.hit`, `feel.kill`, `feel.pickup`, ...) on an "sfx" bus, played by the shell from the events the engine announces. A sound named `feel.<cue>` in `sounds` replaces one; a map entry replaces or (`false`) silences one; `false` silences all. */
791
802
  feedbackCues?: FeedbackCueOverrides;
803
+ /**
804
+ * World-space ambience zones. While the listener is inside a zone the shell loops its bed on
805
+ * the sound's own bus; walking between zones hands over across each floor's `crossfade`
806
+ * distance, and leaving every zone fades to silence. Beds are retained, so a zone you return to
807
+ * resumes without a seam.
808
+ */
809
+ zones?: readonly AmbienceZoneDef[];
810
+ /** Geometry occlusion for retained positional loops. Unset uses the engine defaults; `false` keeps distance-only attenuation everywhere. */
811
+ occlusion?: AudioOcclusionConfig | false;
792
812
  };
793
813
  /** Continuous positional emitter keyed by entity kind name: while a matching entity exists, the shell plays and repositions the mapped `audio.sounds` id (looping engine hum, campfire crackle, footstep loop) with listener-distance falloff. */
794
814
  entitySounds?: Record<string, string>;
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.34.0";
2
+ export declare const VERSION = "0.35.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];
@@ -1,12 +1,35 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.34.0";
2
+ export const VERSION = "0.35.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.35.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.35.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.32.0`; `@jgengine/github` is `0.22.0`.",
8
+ ],
9
+ added: [
10
+ "`ModelLodConfig.crowdCapacity` declares how many characters of one model a batch draws (default 128). A character past the cap keeps its own rig at its distance tier instead of disappearing.",
11
+ "`ModelLodConfig.crowdDistance` accepts `0`, which batches a character at every distance instead of only past 25 m. A game with a near crowd or a close-quarters army sets it to batch members in front of the camera too.",
12
+ "`characterRigTier` (`@jgengine/shell/render/characterLod`) resolves the distance tier a character poses its own rig on, which is the fallback for an over-cap crowd.",
13
+ "`audio.zones` (`@jgengine/core/game/playableGame`): a scene declares world-space ambience zones, one looping bed per zone. The shell crossfades bed levels from the listener position on each sound's own bus, so entering, leaving and walking between zones hand over instead of cutting.",
14
+ "`audio.occlusion` (`@jgengine/core/audio/occlusion`): budgeted geometry occlusion for retained positional loops. A blocked line attenuates the loop and darkens it through a low-pass; the per-tick test budget and interval bound the cost, and a loop that cannot be tested stays on distance-only.",
15
+ "`SoundDef.duck`: a cue can lower named buses for its own duration. `AudioEngine.duck` returns a release, and the player's own gain is restored exactly even when they move the slider mid-duck.",
16
+ "`AudioEngine.ambience()`, `AudioEngine.occlusionStats()` and `window.__jgengineAudio` report the zone mix, the newest occlusion test and the measured per-test cost for capture and diagnostics.",
17
+ ],
18
+ changed: [
19
+ "The character batch path now covers every drawable character model, not only skinned locomotion rigs: a node-transform character (rotating limbs, no skin) rides the node a clip moves, and a model with no clips at all batches as a static geometry. Both still bake to one geometry per material, so a crowd draws once per model and material.",
20
+ "Batched characters are pickable: the batch carries the entity ids of the instances it drew, so `worldHit().entity` and a click on a batched character resolve to the character instead of falling through to the ground.",
21
+ "Load-time shader warmup compiles what the first frame can reach, reveals as soon as those programs are linked, and finishes the rest in background rounds, so a scene no longer holds the loading cover for its whole model stream and no longer drops a compile onto a frame that is already showing. The loading cover names the shader count, the seconds waited and the work left for the background.",
22
+ "A crowd batch silently dropped members past its fixed 128 instances while their markers still hid them, so a full scene lost characters. Members past the declared capacity now keep their own rigs.",
23
+ "A crowd batch's clock now follows the game clock when one is bound, so a paused game no longer keeps its crowd walking.",
24
+ ],
25
+ removed: [],
26
+ },
5
27
  "0.34.0": {
6
28
  migrate: [
7
29
  "Bump lockstep SDK packages to `^0.34.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.31.0`; `@jgengine/github` is `0.21.0`.",
8
30
  ],
9
31
  added: [
32
+ "`PaceBand` on `DriverGoal` (`paceBand` + caller-measured `paceGap`) gives catch-up pacing an owner in `@jgengine/core/ai/driver`: a dead zone around `margin` (`tolerance`), `ease` shedding pace for a car ahead over `easeAt`, `push` adding it for a car behind over `pushAt`, and `scale` as the per-car share. `paceBandFactor(band, gap)` exposes the rule on its own. Deterministic scalar math, no allocation per step; the push never outdrives `DriverTuning.maxSpeed`. First adopter: Drift Foundry's rival field, which deletes its own `paceBand`; second: Harbor Heat's interceptors.",
10
33
  "`createRunLoop` (`@jgengine/core/run/runLoop`) holds a run's at-risk haul, banks it scaled by the line or loses it on a failure, and draws one authored change for each next run from a seeded table, so the same seed and run history replay the same sequence. State round-trips through `snapshot`/`restore`; the money stays in `progression/careerBank`.",
11
34
  ],
12
35
  changed: [],
@@ -26,6 +26,8 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
26
26
  };
27
27
  /** Wall-clock source for the sim clock — inject a deterministic counter for reproducible runs. */
28
28
  now?: () => number;
29
+ /** Seed for `ctx.rng` — pass one so a recorded run and its replay draw the same numbers. Defaults to the definition's name. */
30
+ seed?: string | number;
29
31
  /** Per-step real-dt clamp in seconds, matching the shell's frame cap so one long stall can't fast-forward the sim. Default `0.05`. */
30
32
  maxStepSeconds?: number;
31
33
  /**
@@ -36,7 +38,9 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
36
38
  playerMovement?: boolean;
37
39
  /** Movement tuning — when `playerMovement` is on, the shell resolves from `definition.world`/`physics`; pass a direct config to test flight or other movement overrides headlessly. */
38
40
  movement?: import("../game/playableGame.js").PlayerMovementConfig;
39
- /** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. */
41
+ /** Voxel collision config (`defineGame({ collision })`) — the shell's walk controller reads it, so a headless caller passes the same object to walk the same geometry. */
42
+ collision?: import("../game/playableGame.js").VoxelCollisionConfig;
43
+ /** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. Change it mid-run with {@link HeadlessRunner.aim}. */
40
44
  heading?: number;
41
45
  /** Pitch (radians) fed alongside yaw for 6DOF spectator flight when `alignWithLook` is set. */
42
46
  pitch?: number;
@@ -78,6 +82,11 @@ export interface HeadlessRunner {
78
82
  publishInput(input: HeadlessInput): void;
79
83
  /** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
80
84
  readonly ui: HeadlessCommandInterface;
85
+ /** Point the movement controller at a yaw/pitch mid-run — what the shell does when the camera turns, so a scripted run can walk one way and then another. */
86
+ aim(look: {
87
+ heading?: number;
88
+ pitch?: number;
89
+ }): void;
81
90
  /** Capture the whole simulation (world, clock, loop, rng cursor) for {@link restore}; with a fixed `simulation.hz` and recorded input, replaying from it reproduces the run exactly. */
82
91
  snapshot(): SimSnapshot;
83
92
  restore(snapshot: SimSnapshot): void;
@@ -12,13 +12,16 @@ export function createHeadlessRunner(options) {
12
12
  content,
13
13
  player,
14
14
  ...(options.now === undefined ? {} : { now: options.now }),
15
+ ...(options.seed === undefined ? {} : { seed: options.seed }),
15
16
  ...(options.models === undefined ? {} : { models: options.models }),
16
17
  });
17
18
  loop?.onInit?.(ctx);
18
19
  loop?.onNewPlayer?.(ctx);
19
20
  const simSnapshot = createContextSimSnapshot(ctx);
21
+ const look = { heading: options.heading ?? 0, pitch: options.pitch ?? 0 };
20
22
  const tuning = options.playerMovement === true
21
23
  ? { ...resolvePlayerMovementTuning({
24
+ collision: options.collision,
22
25
  physics: definition.physics,
23
26
  world: definition.world,
24
27
  movement: options.movement,
@@ -48,6 +51,12 @@ export function createHeadlessRunner(options) {
48
51
  names: () => ctx.game.commands.names(),
49
52
  has: (name) => ctx.game.commands.has(name),
50
53
  },
54
+ aim(next) {
55
+ if (next.heading !== undefined && Number.isFinite(next.heading))
56
+ look.heading = next.heading;
57
+ if (next.pitch !== undefined && Number.isFinite(next.pitch))
58
+ look.pitch = next.pitch;
59
+ },
51
60
  step(dtSeconds, input) {
52
61
  if (input !== undefined)
53
62
  publishInput(input);
@@ -58,7 +67,7 @@ export function createHeadlessRunner(options) {
58
67
  total += gameDt;
59
68
  ctx.sim.runStages("beforeMovement", stepDt);
60
69
  if (tuning !== null && gameDt > 0) {
61
- stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning, options.heading, options.pitch);
70
+ stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning, look.heading, look.pitch);
62
71
  }
63
72
  ctx.sim.runStages("afterMovement", stepDt);
64
73
  loop?.onTick?.(ctx, gameDt);
@@ -1,3 +1,4 @@
1
+ import { buildPolylineIndex } from "./polylineIndex.js";
1
2
  function clamp01(value) {
2
3
  return value < 0 ? 0 : value > 1 ? 1 : value;
3
4
  }
@@ -15,34 +16,28 @@ function buildCorridor(points) {
15
16
  const b = points[i];
16
17
  cumulative.push(cumulative[i - 1] + Math.hypot(b[0] - a[0], b[1] - a[1]));
17
18
  }
18
- return { points, cumulative, total: cumulative[cumulative.length - 1] ?? 0 };
19
+ return { points, cumulative, total: cumulative[cumulative.length - 1] ?? 0, index: buildPolylineIndex(points) };
19
20
  }
20
21
  /**
21
- * Closest point on the corridor to `(x, z)`: unsigned lateral distance and arc-length position. Self-
22
- * contained (each segment projects independently and the minimum lateral wins) so a vertex always reports
23
- * a near-zero lateral, regardless of which side of the line the neighbours fall.
22
+ * Closest point on the corridor to `(x, z)` within `limit`: unsigned lateral distance and arc-length
23
+ * position. Self-contained (each segment projects independently and the minimum lateral wins) so a
24
+ * vertex always reports a near-zero lateral, regardless of which side of the line the neighbours fall.
25
+ * Beyond `limit` the corridor cannot change the sample, so the answer is `null`.
24
26
  */
25
- function nearestOnCorridor(corridor, x, z) {
26
- let bestLateral = Number.POSITIVE_INFINITY;
27
- let bestAlong = 0;
28
- let bestPoint = corridor.points[0] ?? [x, z];
29
- for (let i = 1; i < corridor.points.length; i += 1) {
30
- const a = corridor.points[i - 1];
31
- const b = corridor.points[i];
32
- const abx = b[0] - a[0];
33
- const abz = b[1] - a[1];
34
- const segLenSq = abx * abx + abz * abz;
35
- const t = segLenSq <= 0 ? 0 : Math.max(0, Math.min(1, ((x - a[0]) * abx + (z - a[1]) * abz) / segLenSq));
36
- const px = a[0] + abx * t;
37
- const pz = a[1] + abz * t;
38
- const lateral = Math.hypot(x - px, z - pz);
39
- if (lateral < bestLateral) {
40
- bestLateral = lateral;
41
- bestAlong = corridor.cumulative[i - 1] + t * Math.sqrt(segLenSq);
42
- bestPoint = [px, pz];
43
- }
44
- }
45
- return { lateral: bestLateral, along: bestAlong, point: bestPoint };
27
+ function nearestOnCorridor(corridor, x, z, limit) {
28
+ const hit = corridor.index.nearest(x, z, limit);
29
+ if (hit === null)
30
+ return null;
31
+ const a = corridor.points[hit.segment];
32
+ const b = corridor.points[hit.segment + 1];
33
+ const abx = b[0] - a[0];
34
+ const abz = b[1] - a[1];
35
+ const segLenSq = abx * abx + abz * abz;
36
+ return {
37
+ lateral: hit.distance,
38
+ along: corridor.cumulative[hit.segment] + hit.t * Math.sqrt(segLenSq),
39
+ point: hit.point,
40
+ };
46
41
  }
47
42
  function resolveProfile(profile) {
48
43
  if (profile.points.length < 2 || profile.width <= 0)
@@ -94,10 +89,10 @@ function centerlineTarget(profile, along, point, base) {
94
89
  return base(point[0], point[1]);
95
90
  }
96
91
  function applyProfileAt(profile, x, z, surrounding, base) {
97
- const hit = nearestOnCorridor(profile.corridor, x, z);
98
- const lateral = hit.lateral;
99
- if (lateral > profile.reach)
92
+ const hit = nearestOnCorridor(profile.corridor, x, z, profile.reach);
93
+ if (hit === null)
100
94
  return surrounding;
95
+ const lateral = hit.lateral;
101
96
  const target = clampCutFill(centerlineTarget(profile, hit.along, hit.point, base), surrounding, profile.maxCut, profile.maxFill);
102
97
  // Inside the core: carve a channel below the target, deepest at the centerline, level at the edge.
103
98
  if (lateral <= profile.half) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Nearest-point queries against a polyline within a distance bound. The corridor sampler, road
3
+ * draping and the palette's polyline regions ask "is this vertex near that line?" for every vertex of
4
+ * a terrain mesh, so a linear scan of every segment per vertex turned one authored road into seconds
5
+ * of boot time. This keeps the exact same arithmetic as a full scan while visiting only the segments
6
+ * that can possibly win: a query returns the same segment, the same `t` and the same distance a scan
7
+ * would, and `null` only when the whole polyline is farther away than the bound.
8
+ * @internal
9
+ */
10
+ import type { Vec2 } from "./geometry.js";
11
+ /** Where the polyline comes closest to the query, within the query's bound. @internal */
12
+ export interface PolylineHit {
13
+ /** Index of the winning segment, i.e. the one from `points[segment]` to `points[segment + 1]`. */
14
+ segment: number;
15
+ /** Position along that segment, 0..1. */
16
+ t: number;
17
+ /** Distance from the query to the projected point. */
18
+ distance: number;
19
+ /** Projected point on the polyline. */
20
+ point: Vec2;
21
+ }
22
+ /** @internal */
23
+ export interface PolylineIndex {
24
+ /** Exact nearest segment sample within `limit`, or null when the polyline stays farther away. */
25
+ nearest(x: number, z: number, limit: number): PolylineHit | null;
26
+ /** Segments tested by the most recent query — the bounded-work invariant tests assert on. */
27
+ examined(): number;
28
+ }
29
+ /** @internal */
30
+ export declare function buildPolylineIndex(points: readonly Vec2[]): PolylineIndex;
@@ -0,0 +1,80 @@
1
+ import { buildSpatialBuckets } from "./spatialBuckets.js";
2
+ /** @internal */
3
+ export function buildPolylineIndex(points) {
4
+ const count = Math.max(0, points.length - 1);
5
+ const ax = new Float64Array(count);
6
+ const az = new Float64Array(count);
7
+ const abx = new Float64Array(count);
8
+ const abz = new Float64Array(count);
9
+ const lengthSq = new Float64Array(count);
10
+ let minX = Number.POSITIVE_INFINITY;
11
+ let minZ = Number.POSITIVE_INFINITY;
12
+ let maxX = Number.NEGATIVE_INFINITY;
13
+ let maxZ = Number.NEGATIVE_INFINITY;
14
+ for (let i = 0; i < count; i += 1) {
15
+ const a = points[i];
16
+ const b = points[i + 1];
17
+ ax[i] = a[0];
18
+ az[i] = a[1];
19
+ abx[i] = b[0] - a[0];
20
+ abz[i] = b[1] - a[1];
21
+ lengthSq[i] = abx[i] * abx[i] + abz[i] * abz[i];
22
+ minX = Math.min(minX, a[0], b[0]);
23
+ maxX = Math.max(maxX, a[0], b[0]);
24
+ minZ = Math.min(minZ, a[1], b[1]);
25
+ maxZ = Math.max(maxZ, a[1], b[1]);
26
+ }
27
+ const span = count === 0 ? 1 : Math.max(maxX - minX, maxZ - minZ, 1);
28
+ // Twice the average segment length keeps a typical reach inside one or two cells, so a query tests a
29
+ // handful of segments; the 256-cell cap bounds memory on a very long, very dense polyline.
30
+ const buckets = buildSpatialBuckets(Math.max(1, span / 256, (span / Math.max(1, count)) * 2));
31
+ for (let i = 0; i < count; i += 1) {
32
+ buckets.insert(i, Math.min(ax[i], ax[i] + abx[i]), Math.min(az[i], az[i] + abz[i]), Math.max(ax[i], ax[i] + abx[i]), Math.max(az[i], az[i] + abz[i]));
33
+ }
34
+ const candidates = [];
35
+ let examined = 0;
36
+ return {
37
+ nearest(x, z, limit) {
38
+ if (count === 0 || !(limit >= 0)) {
39
+ examined = 0;
40
+ return null;
41
+ }
42
+ if (x < minX - limit || x > maxX + limit || z < minZ - limit || z > maxZ + limit) {
43
+ examined = 0;
44
+ return null;
45
+ }
46
+ // A segment closer than `limit` passes through this box, so it is registered in one of its cells.
47
+ // Clamping to the polyline's own box keeps an infinite bound from walking an infinite grid.
48
+ const reach = limit * (1 + 1e-9) + 1e-9;
49
+ buckets.query(Math.max(minX, x - reach), Math.max(minZ, z - reach), Math.min(maxX, x + reach), Math.min(maxZ, z + reach), candidates);
50
+ examined = candidates.length;
51
+ if (candidates.length === 0)
52
+ return null;
53
+ let bestSegment = -1;
54
+ let bestT = 0;
55
+ let bestDistance = Number.POSITIVE_INFINITY;
56
+ let bestX = 0;
57
+ let bestZ = 0;
58
+ for (const segment of candidates) {
59
+ const offsetX = x - ax[segment];
60
+ const offsetZ = z - az[segment];
61
+ const segLengthSq = lengthSq[segment];
62
+ const t = segLengthSq <= 0 ? 0 : Math.max(0, Math.min(1, (offsetX * abx[segment] + offsetZ * abz[segment]) / segLengthSq));
63
+ const px = ax[segment] + abx[segment] * t;
64
+ const pz = az[segment] + abz[segment] * t;
65
+ const distance = Math.hypot(x - px, z - pz);
66
+ if (distance < bestDistance) {
67
+ bestDistance = distance;
68
+ bestSegment = segment;
69
+ bestT = t;
70
+ bestX = px;
71
+ bestZ = pz;
72
+ }
73
+ }
74
+ if (bestSegment < 0 || bestDistance > limit)
75
+ return null;
76
+ return { segment: bestSegment, t: bestT, distance: bestDistance, point: [bestX, bestZ] };
77
+ },
78
+ examined: () => examined,
79
+ };
80
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Uniform XZ grid over items with a rectangular footprint: insert once, then ask which items cover a
3
+ * box. One mechanism behind the two sampling hot paths — a corridor's segments and a palette's regions
4
+ * — so a per-vertex query tests the handful of items nearby instead of every item in the world.
5
+ * @internal
6
+ */
7
+ /** @internal */
8
+ export interface SpatialBuckets {
9
+ /** Register `id` under every cell its rectangle overlaps. Ids are returned in insertion order. */
10
+ insert(id: number, minX: number, minZ: number, maxX: number, maxZ: number): void;
11
+ /** Ids whose rectangle overlaps the query box, ascending and deduped, written into `out`. */
12
+ query(minX: number, minZ: number, maxX: number, maxZ: number, out: number[]): number[];
13
+ }
14
+ /** @internal */
15
+ export declare function buildSpatialBuckets(cellSize: number): SpatialBuckets;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Uniform XZ grid over items with a rectangular footprint: insert once, then ask which items cover a
3
+ * box. One mechanism behind the two sampling hot paths — a corridor's segments and a palette's regions
4
+ * — so a per-vertex query tests the handful of items nearby instead of every item in the world.
5
+ * @internal
6
+ */
7
+ /** @internal */
8
+ export function buildSpatialBuckets(cellSize) {
9
+ const cell = Math.max(1e-6, cellSize);
10
+ const columns = new Map();
11
+ let marks = new Int32Array(16);
12
+ let generation = 0;
13
+ let highest = -1;
14
+ const mark = (id) => {
15
+ if (id <= highest)
16
+ return;
17
+ highest = id;
18
+ if (id < marks.length)
19
+ return;
20
+ let size = marks.length;
21
+ while (size <= id)
22
+ size *= 2;
23
+ const grown = new Int32Array(size);
24
+ grown.set(marks);
25
+ marks = grown;
26
+ };
27
+ return {
28
+ insert(id, minX, minZ, maxX, maxZ) {
29
+ mark(id);
30
+ const cx0 = Math.floor(minX / cell);
31
+ const cx1 = Math.floor(maxX / cell);
32
+ const cz0 = Math.floor(minZ / cell);
33
+ const cz1 = Math.floor(maxZ / cell);
34
+ for (let cx = cx0; cx <= cx1; cx += 1) {
35
+ let column = columns.get(cx);
36
+ if (column === undefined) {
37
+ column = new Map();
38
+ columns.set(cx, column);
39
+ }
40
+ for (let cz = cz0; cz <= cz1; cz += 1) {
41
+ const bucket = column.get(cz);
42
+ if (bucket === undefined)
43
+ column.set(cz, [id]);
44
+ else
45
+ bucket.push(id);
46
+ }
47
+ }
48
+ },
49
+ query(minX, minZ, maxX, maxZ, out) {
50
+ out.length = 0;
51
+ generation += 1;
52
+ if (generation >= 2147483647) {
53
+ marks.fill(0);
54
+ generation = 1;
55
+ }
56
+ const cx0 = Math.floor(minX / cell);
57
+ const cx1 = Math.floor(maxX / cell);
58
+ const cz0 = Math.floor(minZ / cell);
59
+ const cz1 = Math.floor(maxZ / cell);
60
+ for (let cx = cx0; cx <= cx1; cx += 1) {
61
+ const column = columns.get(cx);
62
+ if (column === undefined)
63
+ continue;
64
+ for (let cz = cz0; cz <= cz1; cz += 1) {
65
+ const bucket = column.get(cz);
66
+ if (bucket === undefined)
67
+ continue;
68
+ for (const id of bucket) {
69
+ if (marks[id] === generation)
70
+ continue;
71
+ marks[id] = generation;
72
+ out.push(id);
73
+ }
74
+ }
75
+ }
76
+ out.sort((a, b) => a - b);
77
+ return out;
78
+ },
79
+ };
80
+ }
@@ -1,5 +1,6 @@
1
1
  import { withPathProfiles } from "./pathTerrain.js";
2
- import { isOnRoad, nearestOnPath } from "./roads.js";
2
+ import { buildPolylineIndex } from "./polylineIndex.js";
3
+ import { buildSpatialBuckets } from "./spatialBuckets.js";
3
4
  function clamp01(value) {
4
5
  return value < 0 ? 0 : value > 1 ? 1 : value;
5
6
  }
@@ -321,10 +322,15 @@ export function flattenFieldAround(base, zones) {
321
322
  export function roadDrapedField(base, roads) {
322
323
  if (roads.length === 0)
323
324
  return base;
325
+ const draped = roads.map((road) => ({
326
+ elevation: road.elevation,
327
+ half: road.width / 2,
328
+ index: buildPolylineIndex(road.path),
329
+ }));
324
330
  const sampleHeight = (x, z) => {
325
331
  let lift = 0;
326
- for (const road of roads) {
327
- if (road.elevation > lift && isOnRoad(road.path, road.width, x, z))
332
+ for (const road of draped) {
333
+ if (road.elevation > lift && road.index.nearest(x, z, road.half) !== null)
328
334
  lift = road.elevation;
329
335
  }
330
336
  return base.sampleHeight(x, z) + lift;
@@ -680,9 +686,13 @@ export function createTerrainPaletteSampler(descriptor) {
680
686
  }));
681
687
  if (regions.length === 0 && bandSampler === null)
682
688
  return () => base;
689
+ const buckets = buildSpatialBuckets(PALETTE_REGION_CELL);
690
+ regions.forEach((region, index) => buckets.insert(index, region.minX, region.minZ, region.maxX, region.maxZ));
691
+ const nearby = [];
683
692
  return (x, z) => {
684
693
  let palette = bandSampler === null ? base : bandSampler(z);
685
- for (const region of regions) {
694
+ for (const index of buckets.query(x, z, x, z, nearby)) {
695
+ const region = regions[index];
686
696
  const distance = region.coreDistance(x, z);
687
697
  if (distance <= 0) {
688
698
  palette = region.palette;
@@ -695,20 +705,44 @@ export function createTerrainPaletteSampler(descriptor) {
695
705
  return palette;
696
706
  };
697
707
  }
708
+ /**
709
+ * Cell size for the palette's region index, in world units: coarse enough that inserting a small
710
+ * region touches one cell, fine enough that a vertex only tests the handful of regions over it.
711
+ */
712
+ const PALETTE_REGION_CELL = 32;
698
713
  /**
699
714
  * Resolves a `TerrainMaterialRegion` of any shape to a `coreDistance(x, z)` — 0 inside the painted
700
- * core, growing positive outward — plus its blend-ring `falloff`, so the palette sampler blends every
701
- * shape through one shape-agnostic loop.
715
+ * core, growing positive outward — plus its blend-ring `falloff` and the box a region can paint
716
+ * inside (core plus blend ring), so the palette sampler blends every shape through one shape-agnostic
717
+ * loop that only visits regions covering the sample.
702
718
  */
703
719
  function resolveRegionShape(region) {
704
720
  if (region.shape === "polyline") {
705
721
  const half = region.width * 0.5;
722
+ const falloff = region.falloff ?? half;
723
+ const index = buildPolylineIndex(region.points);
724
+ const limit = half + falloff;
725
+ let minX = Number.POSITIVE_INFINITY;
726
+ let minZ = Number.POSITIVE_INFINITY;
727
+ let maxX = Number.NEGATIVE_INFINITY;
728
+ let maxZ = Number.NEGATIVE_INFINITY;
729
+ for (const point of region.points) {
730
+ minX = Math.min(minX, point[0]);
731
+ maxX = Math.max(maxX, point[0]);
732
+ minZ = Math.min(minZ, point[1]);
733
+ maxZ = Math.max(maxZ, point[1]);
734
+ }
735
+ const empty = region.points.length < 2;
706
736
  return {
707
- falloff: region.falloff ?? half,
737
+ falloff,
708
738
  coreDistance: (x, z) => {
709
- const sample = nearestOnPath(region.points, x, z);
710
- return sample === null ? Number.POSITIVE_INFINITY : Math.max(0, sample.distance - half);
739
+ const hit = index.nearest(x, z, limit);
740
+ return hit === null ? Number.POSITIVE_INFINITY : Math.max(0, hit.distance - half);
711
741
  },
742
+ minX: empty ? 0 : minX - limit,
743
+ minZ: empty ? 0 : minZ - limit,
744
+ maxX: empty ? -1 : maxX + limit,
745
+ maxZ: empty ? -1 : maxZ + limit,
712
746
  };
713
747
  }
714
748
  if (region.shape === "rect") {
@@ -716,8 +750,11 @@ function resolveRegionShape(region) {
716
750
  const angle = region.rotationY ?? 0;
717
751
  const cos = Math.cos(angle);
718
752
  const sin = Math.sin(angle);
753
+ const falloff = region.falloff ?? Math.min(hx, hz) * 0.5;
754
+ const extentX = Math.abs(hx * cos) + Math.abs(hz * sin) + falloff;
755
+ const extentZ = Math.abs(hx * sin) + Math.abs(hz * cos) + falloff;
719
756
  return {
720
- falloff: region.falloff ?? Math.min(hx, hz) * 0.5,
757
+ falloff,
721
758
  coreDistance: (x, z) => {
722
759
  const dx = x - region.center[0];
723
760
  const dz = z - region.center[1];
@@ -725,11 +762,21 @@ function resolveRegionShape(region) {
725
762
  const lz = -dx * sin + dz * cos;
726
763
  return Math.hypot(Math.max(0, Math.abs(lx) - hx), Math.max(0, Math.abs(lz) - hz));
727
764
  },
765
+ minX: region.center[0] - extentX,
766
+ minZ: region.center[1] - extentZ,
767
+ maxX: region.center[0] + extentX,
768
+ maxZ: region.center[1] + extentZ,
728
769
  };
729
770
  }
771
+ const falloff = region.falloff ?? region.radius * 0.5;
772
+ const extent = region.radius + falloff;
730
773
  return {
731
- falloff: region.falloff ?? region.radius * 0.5,
774
+ falloff,
732
775
  coreDistance: (x, z) => Math.max(0, Math.hypot(x - region.center[0], z - region.center[1]) - region.radius),
776
+ minX: region.center[0] - extent,
777
+ minZ: region.center[1] - extent,
778
+ maxX: region.center[0] + extent,
779
+ maxZ: region.center[1] + extent,
733
780
  };
734
781
  }
735
782
  function withFlattenMasks(sampleHeight, masks) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.34.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.35.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.34.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.35.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -436,7 +436,7 @@
436
436
  - `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
437
437
  - `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
438
438
  - `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
439
- - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> { definition: GameDefinition<TAssetRef, TMultiplayer>; content?: GameContextContent; loop?: GameLoop<GameContext>; player?: {userId: string; isNew: boolean}; now?: () => number; maxStepSeconds?: number; playerMovement?: boolean; movement?: … — ⚠ undocumented
439
+ - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> { definition: GameDefinition<TAssetRef, TMultiplayer>; content?: GameContextContent; loop?: GameLoop<GameContext>; player?: {userId: string; isNew: boolean}; now?: () => number; seed?: string | number; maxStepSeconds?: number; playerMovemen… — ⚠ undocumented
440
440
  - `createHeadlessRunner` (function): function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner — ⚠ undocumented
441
441
 
442
442
  ## @jgengine/core/runtime/hostPersistence
@@ -455,13 +455,22 @@
455
455
  - `sumMagnitude` (function): function sumMagnitude<P>(memberships: readonly AreaMembership<P>[], magnitudeOf: MagnitudeOf<P>): number — Sum a numeric magnitude across memberships — additive aggregation for a total field strength (total damage per tick, total slow). A terminal reducer, not a filter, so it returns the number.
456
456
  - `uniqueByStackKey` (function): function uniqueByStackKey<P>(magnitudeOf?: MagnitudeOf<P>): AreaStackPolicy<P> — Keep at most one membership per `stackKey`. With `magnitudeOf` the strongest per key wins (ties broken by `sourceId` for determinism); without it the first-seen per key wins. Use for unique-by-key buffs where reapplying the same aura should not stack.
457
457
 
458
+ ## @jgengine/core/audio/ambience
459
+
460
+ - `AmbienceWeight` (interface): interface AmbienceWeight — One zone's share of the ambience mix at a listener position, 0..1.
461
+ - `AmbienceZoneDef` (interface): interface AmbienceZoneDef — One ambience zone: a sound bed keyed to a volume of the scene.
462
+ - `AmbienceZoneShape` (type): type AmbienceZoneShape = | { kind: "sphere"; center: readonly [number, number, number]; radius: number } | { kind: "box"; min: readonly [number, number, number]; max: readonly [number, number, number] } — A world-space volume whose sound bed plays while the listener is inside it.
463
+ - `activeAmbienceZone` (function): function activeAmbienceZone(weights: readonly AmbienceWeight[]): string | null — The loudest zone in a weight list, or null when every zone is silent.
464
+ - `ambienceWeights` (function): function ambienceWeights(zones: readonly AmbienceZoneDef[], x: number, y: number, z: number, out: AmbienceWeight[] = []): AmbienceWeight[] — Every zone's mix weight at a listener position. A zone is at full level `crossfade` units inside its boundary, at half level on the boundary and silent `crossfade` units outside, so a listener walking between two zones that share a boundary hears a constant-sum handover. Weights that would sum above 1 (overlapping zones) are normalized so total bed level never exceeds unity. Writes into `out` when given, so a per-frame caller allocates nothing.
465
+ - `zoneSignedDistance` (function): function zoneSignedDistance(shape: AmbienceZoneShape, x: number, y: number, z: number): number — Distance from a point to a zone boundary, positive inside and negative outside. Spheres measure from the surface; boxes use the nearest face.
466
+
458
467
  ## @jgengine/core/audio/audioFalloff
459
468
 
460
469
  - `AudioBusDef` (interface): interface AudioBusDef { id: AudioBusId; gain?: number } — ⚠ undocumented · used by `withFeedbackCueSounds` (@jgengine/core/audio/feedbackCues): Merge the default feedback reactions into a game's sound catalog.
461
470
  - `AudioBusId` (type): type AudioBusId = string — ⚠ undocumented
462
471
  - `AudioFalloffConfig` (interface): interface AudioFalloffConfig { minDistance?: number; maxDistance?: number; curve?: FalloffCurve } — ⚠ undocumented
463
472
  - `FalloffCurve` (type): type FalloffCurve = "linear" | "inverse" | "none" — ⚠ undocumented
464
- - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; positional?: boolean; falloff?: AudioFalloffConfig; doppler?: number; spatial?: {panning: "hrtf" | "equal… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
473
+ - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; duck?: {buses?: readonly string[]; amount?: number; seconds?: number;}; positional?: boolean; falloff?: A… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
465
474
  - `computeFalloffGain` (function): function computeFalloffGain(distance: number, config: AudioFalloffConfig = {}): number — ⚠ undocumented
466
475
  - `distance3` (function): function distance3(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number — ⚠ undocumented
467
476
  - `resolveEmitterGain` (function): function resolveEmitterGain(distance: number, sound: Pick<SoundDef, "gain" | "positional" | "falloff">, busGain: number): number — ⚠ undocumented
@@ -513,6 +522,13 @@
513
522
  - `createMusicState` (function): function createMusicState(): MusicStateSnapshot — Fresh state with no tier entered, so the first resolve always settles and reports `changed`.
514
523
  - `resolveMusicState` (function): function resolveMusicState(state: MusicStateSnapshot, input: MusicStateInput, config: MusicStateConfig): MusicStateResult — Pick the theme for the current intensity. Escalation is immediate; de-escalation waits for the tier's `holdMs` and needs the intensity to fall a `release` margin below the tier it is leaving.
515
524
 
525
+ ## @jgengine/core/audio/occlusion
526
+
527
+ - `AUDIO_OCCLUSION_DEFAULTS` (const): const AUDIO_OCCLUSION_DEFAULTS: { readonly maxTestsPerTick: 8; readonly intervalMs: 150; readonly attenuation: 0.55; readonly lowpassHz: 1200; readonly maxDistance: 60; } — Defaults for {@link AudioOcclusionConfig}.
528
+ - `AudioOcclusionConfig` (interface): interface AudioOcclusionConfig — Geometry occlusion for retained positional loops: a cheap line test between the listener and an emitter behind blocking geometry, bounded so a busy scene can never turn audio into a raycast pass. The shell runs the test against the scene's blockable colliders; the engine applies the resulting attenuation and low-pass to the loop it already owns.
529
+ - `ResolvedAudioOcclusionConfig` (interface): interface ResolvedAudioOcclusionConfig — A fully specified occlusion policy, with every unset field replaced by its default.
530
+ - `resolveOcclusionConfig` (function): function resolveOcclusionConfig(config: AudioOcclusionConfig | false = {}): ResolvedAudioOcclusionConfig — Fill in the occlusion defaults and clamp each field to a workable range: a finite test budget, a non-negative retest interval and distance cap, and an attenuation in `0..1`.
531
+
516
532
  ## @jgengine/core/audio/stepCadence
517
533
 
518
534
  - `StepCadenceConfig` (interface): interface StepCadenceConfig — Distance-driven footstep cadence. Pure state-in/state-out: feed it how far a mover travelled this frame and it tells you how many steps landed, so foley stays locked to ground distance instead of a wall-clock timer that desyncs when speed changes.
@@ -2859,7 +2875,7 @@
2859
2875
  - `SnapMode` (type): type SnapMode = "grid" | "free" | "surface" — ⚠ undocumented
2860
2876
  - `SnowEnvironmentDescriptor` (type): type SnowEnvironmentDescriptor = { kind: "snow" } & Required< Pick<SnowEnvironmentConfig, "area" | "density" | "speed" | "flakeSize" | "drift" | "wind" | "color" | "opacity"> > — ⚠ undocumented · used by `snow`: Declares a snowfall weather effect for `environment()` — area, density, drift, wind, and flake opacity.
2861
2877
  - `SoilRules` (interface): interface SoilRules — Fully-defaulted soil params parsed from a volume's `meta`.
2862
- - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; positional?: boolean; falloff?: AudioFalloffConfig; doppler?: number; spatial?: {panning: "hrtf" | "equal… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
2878
+ - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; duck?: {buses?: readonly string[]; amount?: number; seconds?: number;}; positional?: boolean; falloff?: A… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
2863
2879
  - `SpatialGrid` (class): class SpatialGrid — A uniform-grid broad-phase over the x/z plane, separate from the rigid-body sim, for cheap same-tick proximity across hundreds–thousands of simple movers (swarm enemies). Rebuild each tick from the caller's own position arrays, then `queryCircle` (enemies hitting the player / an AoE) or `forEachPair` (mutual separation). Both are precise: no false negatives, no false positives beyond the exact distance test.
2864
2880
  - `SpawnDirectorConfig` (interface): interface SpawnDirectorConfig { waves: readonly WaveManifest[]; maxAlive?: number; escalationPerSecond?: number; alertBudgetPerSecond?: number; alertDecayPerSecond?: number; playerBudgetPerSecond?: number; maxSpawnsPerTick?: number; loop?: boolean; seed?: number; spawnP… — ⚠ undocumented
2865
2881
  - `SpawnDirectorState` (interface): interface SpawnDirectorState { wave: number; elapsed: number; waveElapsed: number; budget: number; alert: number; spawnedThisWave: number; spawnedTotal: number; rng: RandomSeed; done: boolean } — ⚠ undocumented
@@ -28,6 +28,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
28
28
 
29
29
  - `measureAir` (function) · `import { measureAir } from "@jgengine/core/physics/handlingProbe"`
30
30
 
31
+ ## ambience-zones — a scene declares looping sound beds keyed to world volumes; walking between them crossfades bed levels on the existing buses instead of cutting.
32
+
33
+ - `AmbienceZoneDef` (interface) · `import { AmbienceZoneDef } from "@jgengine/core/audio/ambience"`
34
+
31
35
  ## anim-graph — data-first animation state machine with blend trees, crossfades, layers, and clip events
32
36
 
33
37
  - `createAnimGraphRuntime` (function) · `import { createAnimGraphRuntime } from "@jgengine/core/anim/animGraph"`
@@ -109,6 +113,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
109
113
 
110
114
  - `defineAttackMoveOrder` (function) · `import { defineAttackMoveOrder } from "@jgengine/core/world"`
111
115
 
116
+ ## audio-occlusion — an emitter behind blocking world geometry is attenuated and darkened by a budgeted line test, and falls back to distance-only once the tick's budget is spent.
117
+
118
+ - `AudioOcclusionConfig` (interface) · `import { AudioOcclusionConfig } from "@jgengine/core/audio/occlusion"`
119
+
112
120
  ## audio-voice-budget — bound simultaneous sound playback with per-sound caps, priorities, and deterministic stealing
113
121
 
114
122
  - `createVoiceAllocator` (function) · `import { createVoiceAllocator } from "@jgengine/core/audio/voiceAllocator"`