@jgengine/core 0.34.0 → 0.36.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,65 @@ 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.36.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.36.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.33.0`; `@jgengine/github` is `0.23.0`.
34
+
35
+ ### Added
36
+
37
+ - `createAgentSystem` (`@jgengine/core/agents/system`) takes a game's needs, options, mood weights, members and
38
+ schedules once and returns a running cast: one `tick(dt)` for everyone, one target selector for the whole system,
39
+ a fallback declared once, and a plain-JSON `save()`/`load()` that rides the game's own save.
40
+ - `--quick` on `bun run shoot` and `bun run drive` is the 960x540 iteration preset: one settled frame (a 2s settle window in drive), no video, no perf pass, written as `<name>-quick.png` and timed on its own line. It refuses a 1600x900 pixel claim, a frame-time claim, a non-desktop device and a recording flag by name instead of producing weaker evidence; full stays the default for PR shots. On a warm daemon a two-shot `drive` of ember-command went 9.3s to 8.2s and 4316KB to 1383KB of PNG.
41
+ - `createPlayScript` / `runPlayScript` (`@jgengine/shell/play`) play a real `PlayableGame` headlessly from a tick-based script: `move`, `aim`, `fire`, `use`, `wait` and `command` steps plus `setup`/`onTick` hooks, `expect` assertions read after setup and on their own tick, a tick timeline, and an FNV digest for replay comparison. It replays the shell's own frame pipeline — held movement through the walk controller, a bound action resolving to its command on press and repeat, interact prompts, hotbar — so a script proves the same code path a player's hands do. No browser, no wall clock, fixed `dt`.
42
+ - `bun run simulate <game> <script>` runs a committed `<game>/scripts/*.play.ts` against a game under `./Games`, prints the timeline, names a failed assertion and exits 1. It merges the game's own tsconfig `paths` with this checkout's source packages, so the game imports and the engine under test are one module graph.
43
+ - `HeadlessRunner.aim()` steers the walk controller mid-run; `HeadlessRunnerOptions` gains `seed` (deterministic `ctx.rng`) and `collision` (the shell's voxel config, previously dropped on the headless path).
44
+
45
+ Play scripts prove play in seconds instead of minutes of browser play, and the three proofs land beside their games: Scrap Signal (an enemy takes damage and closes in), Beacon Bastion (a composed wave is reached, its bounty scales, a boss phase flips) and Harbor Heat (heat is witness-gated, the search area is set, a courier is swept).
46
+
47
+ ### Changed
48
+
49
+ - The graphics quality tier now costs what it claims on the render path. A tier's post chain renders at its own resolution scale (`low` 0.7, `medium` 0.85, `high`/`ultra` native) instead of always at the canvas size, and a game's authored `shadowMapSize` and `cascades` are a ceiling the tier caps rather than a value it ignored — every reference game authored 3 cascades and 1024/2048, so no tier changed a shadow before this. `low` and `medium` also stretch the far cascades' refresh interval, and the smallest caster worth a depth draw grows as the map shrinks. `high` and `ultra` keep native resolution, the authored shadow sizes and the measured cadence, so their frames are unchanged.
50
+ - Measured uncapped at 1600x900: whole-frame draws at `low` fell 53% in harbor-heat (515 → 242) and 63% in drift-foundry (1,098 → 408), with the shadow pass there down from 911 ms to 344 ms over a 6 s window; `high` and `ultra` held their draw counts.
51
+
52
+ ### Fixed
53
+
54
+ - Terrain build no longer scans every road and path segment for every vertex. A baked path corridor, a draped road and a painted polyline region each answer "how far is this point?" from a precomputed segment grid, so Scrap Signal's world build drops from 16.6 s to 0.51 s on the same machine and its longest boot stall from 11.6–18.4 s to 1.2 s. Sampled heights and palettes are bit-identical to the previous full scan, and `withPathProfiles` still composes profiles in order.
55
+ - Load-time shader warmup compiles against the render state the first frame actually draws with. A scene whose post chain renders into its own buffer was linking two programs per material, one linear untone-mapped set the frame draws and one canvas set nothing ever drew; the cover no longer waits for the second set. Harbor Heat links 84 programs to 58 and Scrap Signal 124 to 83 before the cover lifts, with no visual change.
56
+
57
+ ### Performance
58
+
59
+ - `createTerrainPaletteSampler` (`@jgengine/core/world/terrain`) tests only the material regions whose painted core or blend ring covers a sample, so a world with hundreds of authored regions no longer pays for all of them at every vertex.
60
+
61
+ ## 0.35.0
62
+
63
+ ### Migrate
64
+
65
+ - **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`.
66
+
67
+ ### Added
68
+
69
+ - `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.
70
+ - `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.
71
+ - `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.
72
+ - `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.
73
+ - `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.
74
+ - `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.
75
+ - `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.
76
+
77
+ ### Changed
78
+
79
+ - 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.
80
+ - 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.
81
+
82
+ ### Fixed
83
+
84
+ - 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.
85
+ - 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.
86
+ - A crowd batch's clock now follows the game clock when one is bound, so a paused game no longer keeps its crowd walking.
87
+
29
88
  ## 0.34.0
30
89
 
31
90
  ### Migrate
@@ -34,6 +93,7 @@ At publish, rename this heading to the new version and mirror the entries into
34
93
 
35
94
  ### Added
36
95
 
96
+ - `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
97
  - `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
98
 
39
99
  ## 0.33.0
@@ -0,0 +1,135 @@
1
+ import type { UtilityInterrupt, UtilityOption, UtilityTunables } from "../ai/utilityBrain.js";
2
+ import type { NeedDefinition } from "../sim/needs.js";
3
+ import type { Vec3 } from "../world/geometry.js";
4
+ import { type Agent, type AgentFallback, type AgentFrame, type AgentSnapshot, type AgentTarget } from "./agent.js";
5
+ import type { Mood, MoodTier, MoodWeight } from "./mood.js";
6
+ import type { AgentScheduleConfig } from "./schedule.js";
7
+ export type { Agent, AgentAction, AgentFallback, AgentFrame, AgentSnapshot, AgentTarget } from "./agent.js";
8
+ export type { AgentScheduleConfig, AgentScheduleStop, AgentSchedulePhase } from "./schedule.js";
9
+ export type { Mood, MoodTier, MoodWeight } from "./mood.js";
10
+ export type { NeedDefinition } from "../sim/needs.js";
11
+ export type { UtilityInterrupt, UtilityOption, UtilityTunables } from "../ai/utilityBrain.js";
12
+ /** The mood policy a member runs on: which needs matter, and the game's own words for the bands. */
13
+ export interface AgentMoodConfig {
14
+ readonly weights?: readonly MoodWeight[];
15
+ readonly tiers?: readonly MoodTier[];
16
+ }
17
+ /** What a selector is told about the member asking: who it is, its live agent, and where it stands. */
18
+ export interface AgentSelectView {
19
+ readonly id: string;
20
+ /** The live agent, for a selector that reads needs, mood or the held action. */
21
+ readonly agent: Agent;
22
+ /** Body position from the system's `position` reader, or `null` when the game has no body for it. */
23
+ readonly at: Vec3 | null;
24
+ readonly now: number;
25
+ /** True while the member is off camera and takes no utility pass this tick. */
26
+ readonly far: boolean;
27
+ }
28
+ /** Everything the member could do right now, as the game's world offers it. Declared once per system. */
29
+ export type AgentSelector = (view: AgentSelectView) => readonly AgentTarget[];
30
+ /** Where a system keeps its save data: a key-value store, a save slot, or a test's map. */
31
+ export interface AgentSystemStorage {
32
+ read(): AgentSystemState | undefined;
33
+ write(next: AgentSystemState): void;
34
+ }
35
+ /** One member's saved state: its agent snapshot plus the off-camera flag the snapshot does not carry. */
36
+ export interface AgentMemberState {
37
+ readonly agent: AgentSnapshot;
38
+ readonly far: boolean;
39
+ }
40
+ /** The whole system as plain JSON - drop it in a save, read it back with {@link AgentSystem.load}. */
41
+ export interface AgentSystemState {
42
+ readonly version: 1;
43
+ readonly members: Readonly<Record<string, AgentMemberState>>;
44
+ }
45
+ /** One member: an id, and only what differs from the system's defaults. */
46
+ export interface AgentMemberSpec extends UtilityTunables {
47
+ readonly id: string;
48
+ /** Needs for this member; defaults to the system's. */
49
+ readonly needs?: readonly NeedDefinition[];
50
+ /** Scored actions for this member; defaults to the system's. */
51
+ readonly options?: readonly UtilityOption[];
52
+ readonly interrupts?: readonly UtilityInterrupt[];
53
+ /** The member's authored day; omit for one that only reacts to targets. */
54
+ readonly schedule?: AgentScheduleConfig;
55
+ readonly mood?: AgentMoodConfig;
56
+ /** Targets for this member instead of the system's selector. */
57
+ readonly select?: AgentSelector;
58
+ /** `[0,1)` source; defaults to the system's `rng`. */
59
+ readonly rng?: () => number;
60
+ readonly devtoolsName?: string;
61
+ /** Start off camera: schedule and needs only, no utility pass, until `setFar(id, false)`. */
62
+ readonly far?: boolean;
63
+ }
64
+ /** The game's data, in one place: what its people need, what they can do, and how they feel. */
65
+ export interface AgentSystemConfig {
66
+ /** Needs every member carries unless it declares its own. */
67
+ readonly needs: readonly NeedDefinition[];
68
+ /** Scored actions every member chooses between unless it declares its own. */
69
+ readonly options: readonly UtilityOption[];
70
+ readonly interrupts?: readonly UtilityInterrupt[];
71
+ readonly mood?: AgentMoodConfig;
72
+ /** Members present at creation; more join later with {@link AgentSystem.add}. */
73
+ readonly members?: readonly AgentMemberSpec[];
74
+ /** Resolve every member's targets. Called once per on-camera member per tick, never per option. */
75
+ readonly select?: AgentSelector;
76
+ /** Fallback given to every target that declares none - one fallback per system, not per target. */
77
+ readonly fallback?: AgentFallback;
78
+ /** The world clock, so `tick(dt)` needs no `now` argument. */
79
+ readonly clock?: {
80
+ now(): number;
81
+ };
82
+ /** Where a member's body is, handed to the selector as `at`. */
83
+ readonly position?: (id: string) => Vec3 | null;
84
+ /** Save storage, hydrated on the first tick and written by {@link AgentSystem.save}. */
85
+ readonly storage?: AgentSystemStorage;
86
+ /** Deterministic source shared by members that do not declare their own. */
87
+ readonly rng?: () => number;
88
+ /** Dials every member starts with; a member may override any of them. */
89
+ readonly defaults?: UtilityTunables;
90
+ /** Devtools prefix: member `x` registers as `<devtoolsName>.x`. */
91
+ readonly devtoolsName?: string;
92
+ }
93
+ /**
94
+ * A whole cast of agents behind one factory and one tick.
95
+ *
96
+ * Feed it a game's data once - needs, options, mood weights, the members and their schedules - and it
97
+ * owns the parts a game should not write: the roster, lazy creation, one tick for everyone, where a
98
+ * target came from, and a save blob that is plain JSON. Everything an individual {@link Agent} can do
99
+ * stays reachable through {@link get}.
100
+ */
101
+ export interface AgentSystem {
102
+ /** Create and register one member; a member saved under this id resumes instead of starting fresh. */
103
+ add(spec: AgentMemberSpec): Agent;
104
+ get(id: string): Agent | null;
105
+ has(id: string): boolean;
106
+ remove(id: string): void;
107
+ /** Member ids in creation order. */
108
+ ids(): readonly string[];
109
+ size(): number;
110
+ /** One call per game tick: decay, day plan and decision for every member, near or far. */
111
+ tick(dt: number, now?: number): void;
112
+ /** What the last tick produced for a member, or `null` before it has ticked. */
113
+ frame(id: string): AgentFrame | null;
114
+ /** A member's mood, readable before its first tick. */
115
+ mood(id: string): Mood | null;
116
+ /** Mean mood across members, or `null` for an empty system. */
117
+ moodMean(): number | null;
118
+ /** Action of a member, `null` before its first tick. */
119
+ action(id: string): AgentFrame["action"] | null;
120
+ setFar(id: string, far: boolean): void;
121
+ isFar(id: string): boolean;
122
+ /** Plain-JSON state, also written to `storage` when one was given. */
123
+ save(): AgentSystemState;
124
+ /** Restore members saved under ids this system knows, and hold the rest until they join. */
125
+ load(next: AgentSystemState | undefined): void;
126
+ dispose(): void;
127
+ }
128
+ /**
129
+ * One call for a whole cast: a game hands over its needs, options, mood weights, schedules and one
130
+ * target selector, and gets back a running system with a single `tick(dt)` and a save blob that goes
131
+ * into the game's own save.
132
+ *
133
+ * @capability agents a needs, thought, action and mood loop an agent runs on the world clock
134
+ */
135
+ export declare function createAgentSystem(config: AgentSystemConfig): AgentSystem;
@@ -0,0 +1,195 @@
1
+ import { createAgent, } from "./agent.js";
2
+ /**
3
+ * One call for a whole cast: a game hands over its needs, options, mood weights, schedules and one
4
+ * target selector, and gets back a running system with a single `tick(dt)` and a save blob that goes
5
+ * into the game's own save.
6
+ *
7
+ * @capability agents a needs, thought, action and mood loop an agent runs on the world clock
8
+ */
9
+ export function createAgentSystem(config) {
10
+ const agents = new Map();
11
+ const frames = new Map();
12
+ const selectors = new Map();
13
+ const saved = new Map();
14
+ const order = [];
15
+ let hydrated = false;
16
+ let lastNow = 0;
17
+ function selectFor(id) {
18
+ return selectors.get(id) ?? config.select;
19
+ }
20
+ function restoreInto(agent, state) {
21
+ agent.restore(state.agent);
22
+ if (state.far)
23
+ agent.setFar(true);
24
+ }
25
+ function hydrate() {
26
+ if (hydrated)
27
+ return;
28
+ const state = config.storage?.read();
29
+ if (state === undefined)
30
+ return;
31
+ hydrated = true;
32
+ loadState(state);
33
+ }
34
+ function loadState(next) {
35
+ if (next === undefined)
36
+ return;
37
+ for (const [id, state] of Object.entries(next.members)) {
38
+ const agent = agents.get(id);
39
+ if (agent === undefined) {
40
+ saved.set(id, state);
41
+ continue;
42
+ }
43
+ restoreInto(agent, state);
44
+ saved.delete(id);
45
+ }
46
+ }
47
+ function create(spec) {
48
+ const defaults = config.defaults;
49
+ const tuned = (key) => spec[key] ?? defaults?.[key];
50
+ const devtoolsName = spec.devtoolsName ?? (config.devtoolsName === undefined ? undefined : `${config.devtoolsName}.${spec.id}`);
51
+ const agent = createAgent({
52
+ id: spec.id,
53
+ needs: spec.needs ?? config.needs,
54
+ options: spec.options ?? config.options,
55
+ ...optional("interrupts", spec.interrupts ?? config.interrupts),
56
+ ...optional("schedule", spec.schedule),
57
+ mood: {
58
+ ...optional("weights", spec.mood?.weights ?? config.mood?.weights),
59
+ ...optional("tiers", spec.mood?.tiers ?? config.mood?.tiers),
60
+ },
61
+ ...optional("switchMargin", tuned("switchMargin")),
62
+ ...optional("minHoldSeconds", tuned("minHoldSeconds")),
63
+ ...optional("costWeight", tuned("costWeight")),
64
+ ...optional("jitter", tuned("jitter")),
65
+ ...optional("rng", spec.rng ?? config.rng),
66
+ ...optional("devtoolsName", devtoolsName),
67
+ });
68
+ agents.set(agent.id, agent);
69
+ order.push(agent.id);
70
+ if (spec.select !== undefined)
71
+ selectors.set(agent.id, spec.select);
72
+ if (spec.far === true)
73
+ agent.setFar(true);
74
+ const pending = saved.get(spec.id);
75
+ if (pending !== undefined) {
76
+ restoreInto(agent, pending);
77
+ saved.delete(spec.id);
78
+ }
79
+ return agent;
80
+ }
81
+ const system = {
82
+ add(spec) {
83
+ const existing = agents.get(spec.id);
84
+ if (existing !== undefined)
85
+ return existing;
86
+ return create(spec);
87
+ },
88
+ get: (id) => agents.get(id) ?? null,
89
+ has: (id) => agents.has(id),
90
+ remove(id) {
91
+ const agent = agents.get(id);
92
+ if (agent === undefined)
93
+ return;
94
+ agent.dispose();
95
+ agents.delete(id);
96
+ frames.delete(id);
97
+ selectors.delete(id);
98
+ saved.delete(id);
99
+ const at = order.indexOf(id);
100
+ if (at >= 0)
101
+ order.splice(at, 1);
102
+ },
103
+ ids: () => order,
104
+ size: () => order.length,
105
+ tick(dt, now) {
106
+ hydrate();
107
+ const time = now ?? config.clock?.now() ?? lastNow;
108
+ lastNow = time;
109
+ for (const id of order) {
110
+ const agent = agents.get(id);
111
+ if (agent === undefined)
112
+ continue;
113
+ let targets = [];
114
+ if (!agent.isFar()) {
115
+ const selector = selectFor(id);
116
+ if (selector !== undefined) {
117
+ targets = selector({
118
+ id,
119
+ agent,
120
+ at: config.position?.(id) ?? null,
121
+ now: time,
122
+ far: false,
123
+ });
124
+ if (config.fallback !== undefined)
125
+ targets = withFallback(targets, config.fallback);
126
+ }
127
+ }
128
+ frames.set(id, agent.update({ dt, now: time, targets }));
129
+ }
130
+ },
131
+ frame: (id) => frames.get(id) ?? null,
132
+ mood: (id) => agents.get(id)?.mood() ?? null,
133
+ moodMean() {
134
+ if (order.length === 0)
135
+ return null;
136
+ let total = 0;
137
+ for (const id of order) {
138
+ const agent = agents.get(id);
139
+ if (agent !== undefined)
140
+ total += agent.mood().score;
141
+ }
142
+ return total / order.length;
143
+ },
144
+ action: (id) => frames.get(id)?.action ?? null,
145
+ setFar(id, far) {
146
+ const agent = agents.get(id);
147
+ if (agent === undefined)
148
+ return;
149
+ agent.setFar(far);
150
+ if (far)
151
+ frames.delete(id);
152
+ },
153
+ isFar: (id) => agents.get(id)?.isFar() ?? false,
154
+ save() {
155
+ hydrate();
156
+ const members = {};
157
+ for (const id of order) {
158
+ const agent = agents.get(id);
159
+ if (agent !== undefined)
160
+ members[id] = { agent: agent.snapshot(), far: agent.isFar() };
161
+ }
162
+ const state = { version: 1, members };
163
+ config.storage?.write(state);
164
+ return state;
165
+ },
166
+ load: loadState,
167
+ dispose() {
168
+ for (const agent of agents.values())
169
+ agent.dispose();
170
+ agents.clear();
171
+ frames.clear();
172
+ saved.clear();
173
+ order.length = 0;
174
+ },
175
+ };
176
+ hydrate();
177
+ for (const spec of config.members ?? [])
178
+ create(spec);
179
+ return system;
180
+ }
181
+ /** Targets keep their own fallback; the rest inherit the system's one. */
182
+ function withFallback(targets, fallback) {
183
+ let changed = false;
184
+ const next = targets.map((target) => {
185
+ if (target.fallback !== undefined)
186
+ return target;
187
+ changed = true;
188
+ return { ...target, fallback };
189
+ });
190
+ return changed ? next : targets;
191
+ }
192
+ /** Spread a config field only when the game set it, so `createAgent`'s own default stands otherwise. */
193
+ function optional(key, value) {
194
+ return value === undefined ? {} : { [key]: value };
195
+ }
@@ -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>;