@jgengine/core 0.31.0 → 0.33.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,73 @@ 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.33.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.33.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.30.0`; `@jgengine/github` is `0.20.0`.
34
+ - `useSceneObjects` (`@jgengine/react/hooks`) keeps one list identity across `move` and `rotate`, so a record's `position`/`rotationY` is what it was at the last render. Read the live placement with `ctx.scene.object.get(instanceId)` (a `useFrame` loop or a rAF-ticked HUD) when a mesh or readout must follow a moving object.
35
+ - A read of a mismatched stored value now converts losslessly where it can (a number as its digits, a boolean as `"true"`/`"false"`, those strings back) and throws where it cannot. No in-repo caller relied on the old fallback; `useSetting` (`@jgengine/react/settings`) is the generic path that could, so a game that stored a number under a key it reads with a string fallback now gets the digits.
36
+
37
+ ### Added
38
+
39
+ - `releasePostfxOverlayTracking(scene)` (`@jgengine/shell/postfx/postfxOverlay`) drops the cached prepass overlay set and its listeners for a scene that is being torn down.
40
+ - `canvasCover` (`@jgengine/react/canvasCover`): one page-wide hold a covering surface takes, plus `useCanvasCover(declared)` for a React overlay. While it is held the shell stops drawing the world, its shadow passes and postfx; input, audio and the DOM HUD keep running, and the first frame after release draws the untouched scene. A scrim or side panel must not take it.
41
+ - `MenuScreen` and `TitleMenu` take `coversCanvas` (default false) to declare that the screen paints over the whole canvas. A menu with only a translucent scrim leaves the live scene drawing.
42
+ - The dev runner pins the cover with `?cover=1`, and the devtools agent bridge answers `canvas_cover`, so a perf run or `bun run drive` can measure a covered frame on any game.
43
+ - `combat.damage` carries an optional `position`: the effect path fills it from the target's own spot, and `createDamageRuntime` takes one per hit (`hit({ position })`) or from a `positionOf` reader, so a status tick is placed too. A cue with a position pans and attenuates; one without stays centred.
44
+ - `createHapticsRoute` (`@jgengine/shell/audio/hapticsRoute`) sends a feedback pulse to a connected gamepad, and to `navigator.vibrate` on a phone with no pad. `FeedbackHost` wires it; reduced motion still drops the camera shake only.
45
+ - `PerfReport.render.drawsFrame` is every draw a frame issues — shadow, main, postfx prepasses, AO and DOF together — as the 95th percentile of the measured window, with `drawsFrames` naming how many frames it covers. `bun run perf:gate` compares it against each game's re-based `drawsFrame` budget; the renderer's own single-sample `draws` is printed beside it and is no longer gated.
46
+ - `ObjectStore.version()` / `ObjectStore.markSpatialChange()` (`ctx.scene.object`) — a counter that moves whenever membership, a position, a rotation or a consumer-owned collider set changes, so derived spells spatial data can share one cache key.
47
+ - `SolidObstacleSource.version?()` (`@jgengine/core/movement/solidObstacles`) — a source that reports one gets its blocking obstacles resolved once per version into a coarse XZ index; a source without it keeps the per-query `inBox` gather.
48
+ - `SettingsTypeError` and `asSettingType` (`@jgengine/core/settings/settingsModel`) report and resolve a stored setting read as another type.
49
+
50
+ ### Changed
51
+
52
+ - `bun run land` queues landings on one lock per repo instead of running them beside each other, so two lanes no longer stack two pull requests' sixteen jobs against the account's 20-job cap. A waiting landing names the branch and pid it waits on; a lock whose process died, or that is older than 30 minutes, is broken with a warning.
53
+ - The scene io browser test skips Playwright's two-frame click stability check and lets three rendered frames pass instead of ten: on a runner its second test went from 53s to 11-24s and the file from 76s to 50s, of which 16-36s is the fixture's own start.
54
+ - `check-game-front-end` recognizes the shipped `CreditsPanel`, so a game that renders credits through the engine's panel is no longer flagged as having no credits screen.
55
+ - The shell's frame loop switches to `frameloop="never"` under a cover instead of drawing a world nobody can see (measured on Brightway Park's pause menu: 1,095 whole-frame draws and 5.2 ms/frame before, 0 draws and 0.11 ms/frame covered). A frame-rate cap no longer advances a covered canvas.
56
+ - `FeedbackDeps.play` takes an optional `position`, which `FeedbackHost` forwards to `ctx.game.audio.play`, so a positioned damage or death cue reaches the spatial engine.
57
+ - Each game's `perf-budget.json` entry now budgets `drawsFrame` and records the budget it replaced as `previousDraws`; an entry that still carries only `draws` fails by name instead of gating a count from a different measurement.
58
+ - The in-page report reads the devtools render probe's frame sample instead of the live renderer counter, so `window.__JG.report()` and the devtools panel no longer report a mid-frame partial count.
59
+ - Movement gathers obstacles from that index instead of re-resolving every nearby object every frame. On Harbor Heat the query fell from 0.62-0.65 ms to 0.10 ms a frame (2.0-2.1 ms to 1.4 ms of simulation a frame).
60
+ - `objectStore.inBox` walks a coarse grid for big boxes instead of scanning every placed object; a 1 m cell lookup costs a string hash, so the switch happens past 32 cells rather than 4096.
61
+ - Ambient occlusion reads the beauty pass's depth buffer instead of rendering the scene a second time for its normal/depth GBuffer. That prepass cost 537 extra draws and 2.6-2.9 ms/frame in Harbor Heat and 2,105 extra draws in Drift Foundry at 1600x900; AO now draws nothing of its own. A post chain whose antialiasing is `"smaa"` (the default) keeps SMAA and drops the beauty target's extra MSAA resolve, because WebGL2 cannot sample a multisampled depth texture; `"msaa"` chains keep the old prepass. Fixed-viewpoint captures differ only on geometry edges (#2173).
62
+
63
+ ### Fixed
64
+
65
+ - Postfx prepasses no longer walk the whole scene twice a frame to hide overlays, points and lines: the set is cached per scene and rescanned when the scene graph changes. Drift Foundry 2.11 ms/frame and Harbor Heat 0.66 ms/frame of `traverse` removed.
66
+ - A rig posed from an animation graph no longer allocates a motion sample, a position triple and a fresh pose record every frame, and foot IK no longer allocates a probe input, filter, closure and per-leg records every frame.
67
+ - The Graphics tab lists a post-processing stage toggle only when the game's resolved chain can build that stage. Depth of field, which no reference game authors, hid behind a row that switched a pass the composer never created, so the toggle changed nothing. The rule covers every stage: a chain with `ao: false`, `bloom: false` or `aa: "msaa"`/`false` no longer offers those rows, and a game with no post chain gets none of them. Every other settings row is unchanged (`@jgengine/shell/settings/settingsController`, `availablePostfxStages` in `@jgengine/shell/postfx/PostProcessing`).
68
+ - Positional loops declared through `entitySounds`/`objectSounds` are tracked instead of rebuilt: spawn, despawn and a moved emitter update one retained loop each, and a frame where nothing moved reads only the emitters that already sound. On scrap-signal's 560 placed props (69 sounding), the tracking path drops from 16.7 µs to 1.2 µs per frame and a steady frame allocates no Set, array or record.
69
+ - `useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` show a setting written before the component subscribed — a saved profile or the demo path seeding values before first paint — instead of waiting for the player's next change (#2180).
70
+ - `useFeed`, `useQuestJournal` and `useFriends` (`@jgengine/react/hooks`) keep one list identity while membership holds instead of allocating a new array on every store write and comparing it with `Object.is`, so a HUD reading them no longer re-renders on every entity pose write. A pushed feed entry, an accepted, progressed or abandoned quest, and an accepted or removed friend each re-render once. Measured on the three read sites (`ToastStack`, `FriendsList`, `JournalQuestTracker`) with 15 quests, 20 friends and 20 feed rows: 900 renders → 0 across 300 pose writes, 0.14 ms/frame → 0.04 ms/frame.
71
+ - `useWorldItems` (`@jgengine/react/hooks`) keeps one list identity until an item spawns, is picked up or is removed. It read `worldItem.list()`, which allocates a new array per call, against a store signal that also fires on every entity pose write, so a HUD re-rendered every dropped-item marker each frame (0.97 ms/frame of React work in Harbor Heat).
72
+ - `WorldItems` (`@jgengine/shell`) reads each marker's live pose in `useFrame` instead of taking it as a prop, so a moving item still follows without re-rendering its marker.
73
+ - `useSceneEntities` and `useSceneObjects` (`@jgengine/react/hooks`) keep one list identity while membership holds, instead of allocating a new array on every store write and comparing it with `Object.is`. A pose write no longer re-renders every HUD that reads them (30-entity roster: 0.066 ms/frame → 0; the whole loop 0.24 → 0.05 ms/frame), while a spawn, despawn, replace, place or remove still re-renders. Non-pose object writes (state, visual, animation, slots, catalog id) still re-render.
74
+ - `RevealHighlights` (`@jgengine/shell/vision`) drives its own per-frame refresh only while Dark Sight is enabled, so a disabled reveal overlay no longer re-renders (0.019 ms/frame → 0) and an enabled one still follows the origin and its targets.
75
+ - `createSettingsStore().get` returns a stored setting of another type instead of quietly returning the fallback, so a numeric setting read with a string fallback applies instead of vanishing (#2189). Root cause: `coerce` treated the fallback's type as the stored type and replaced every other type with the fallback. A stored value that cannot be read as the asked-for type now throws `SettingsTypeError` naming the key, the stored type and the fallback type.
76
+
77
+ ## 0.32.0
78
+
79
+ ### Migrate
80
+
81
+ - **Bump lockstep SDK packages to `^0.32.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.29.0`; `@jgengine/github` is `0.19.0`.
82
+
83
+ ### Added
84
+
85
+ - Audio mixing grew a mute that composes with gain: `AudioEngine.setBusMute` / `setMasterMute` keep the player's volume while silencing a bus, `masterLevel()` / `busLevel()` report the composed value, and Settings carries a mute row per bus plus "Mute all" (`sound.muted`, `sound.bus.<id>.muted`). `AudioEngine.play` and `playOneShot` hold cues in a bounded 16-slot queue while the browser keeps the context suspended and play them on the first gesture instead of dropping them; `installGestureResume` wires that gesture for both presentation paths.
86
+ - `AudioEngine.measure()` reads master RMS and peak from a lazily built analyser, and `installAudioProbe` publishes `window.__jgengineAudio` so a headless capture can prove sound with a measured level rather than a screenshot.
87
+ - `@jgengine/core/audio/feedbackCues` ships seven procedural reactions - `hit`, `hurt`, `kill`, `pickup`, `objective`, `wave`, `deny` - on an `sfx` bus, merged into every game's catalog as `feel.<cue>`. A game sound with that id replaces one; `audio.feedbackCues` replaces or silences them.
88
+ - `@jgengine/shell/audio/eventFeedback` maps the events the engine already announces to those reactions: `combat.damage` (local player reads as hurt, anything else as a hit), `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp` and `command.refused`. Each row carries a sound, a gamepad pulse, camera trauma and a caption; the mapping and reactions are data (`DEFAULT_FEEDBACK_BINDINGS`, `DEFAULT_FEEDBACK_REACTIONS`) a game can replace, cooldowns fold a burst into one impact, reduced motion keeps sound and rumble but drops the camera kick, and the resolved reaction is pooled so handling an event allocates nothing per frame. `attachWaveFeedback(runner, ctx.game.feedback)` announces each new wave.
89
+ - `ctx.game.feedback.cue(cue, caption?)` announces a moment no engine event covers and emits `feedback.cue`.
90
+ - `@jgengine/shell/audio/captions` and `CaptionOverlay` render a bounded, deduping, self-expiring caption line under the accessibility captions setting; `FeedbackHost` mounts the whole layer - feedback mapping, gesture resume, audio probe and captions - for both the 3D and HUD presentation paths.
91
+
92
+ ### Changed
93
+
94
+ - The CI gate runs nine test shards instead of ten, and a `types` job whose stages overlap, so a docs-only pull request fits the account's 20-job runner cap with room for another run beside it. `scripts/test-shard.ts` owns which files each shard runs and what it prepares first; `bun scripts/type-shard.ts` runs the `check-types` plan two stages at a time and then the tsc fan-out alone.
95
+
29
96
  ## 0.31.0
30
97
 
31
98
  ### Migrate
@@ -0,0 +1,30 @@
1
+ import type { AudioBusDef, SoundDef } from "./audioFalloff.js";
2
+ /**
3
+ * A gameplay moment the engine reacts to: taking or dealing a hit, a kill, a pickup, an objective,
4
+ * a wave starting, or a refused action. The game names the moment; the engine owns the reaction.
5
+ */
6
+ export type FeedbackCue = "hit" | "hurt" | "kill" | "pickup" | "objective" | "wave" | "deny";
7
+ /** Every {@link FeedbackCue}, in a stable order. */
8
+ export declare const FEEDBACK_CUES: readonly FeedbackCue[];
9
+ /** Bus the default feedback cues mix through; the settings menu shows its volume and mute. */
10
+ export declare const FEEDBACK_CUE_BUS = "sfx";
11
+ /** Catalog id a cue plays under: `feel.hit`, `feel.kill`, ... A game sound with that id replaces the default. */
12
+ export declare function feedbackCueSoundId(cue: FeedbackCue): string;
13
+ /**
14
+ * Short procedural reactions - no samples to load, so every game has impact feedback without
15
+ * authoring audio. Loud enough to read, quiet enough to sit under a weapon's own sound.
16
+ */
17
+ export declare const DEFAULT_FEEDBACK_CUE_SOUNDS: Readonly<Record<FeedbackCue, SoundDef>>;
18
+ /** Per-cue override: a replacement sound, or `false` to silence that cue. `false` for the whole map silences all. */
19
+ export type FeedbackCueOverrides = false | Partial<Record<FeedbackCue, SoundDef | false>>;
20
+ /**
21
+ * Merge the default feedback reactions into a game's sound catalog. A game sound already named
22
+ * `feel.<cue>` wins, then `overrides`, then the default. Adds the `sfx` bus (gain 0.9) when the game
23
+ * did not declare one, so the impact layer has its own volume and mute row.
24
+ *
25
+ * @capability feedback-cue-sounds default procedural hit/hurt/kill/pickup/objective/wave/deny reactions on an "sfx" bus, overridable per game
26
+ */
27
+ export declare function withFeedbackCueSounds(sounds: Readonly<Record<string, SoundDef>> | undefined, buses: Readonly<Record<string, AudioBusDef>> | undefined, overrides?: FeedbackCueOverrides): {
28
+ sounds: Record<string, SoundDef>;
29
+ buses: Record<string, AudioBusDef> | undefined;
30
+ };
@@ -0,0 +1,119 @@
1
+ /** Every {@link FeedbackCue}, in a stable order. */
2
+ export const FEEDBACK_CUES = ["hit", "hurt", "kill", "pickup", "objective", "wave", "deny"];
3
+ /** Bus the default feedback cues mix through; the settings menu shows its volume and mute. */
4
+ export const FEEDBACK_CUE_BUS = "sfx";
5
+ /** Catalog id a cue plays under: `feel.hit`, `feel.kill`, ... A game sound with that id replaces the default. */
6
+ export function feedbackCueSoundId(cue) {
7
+ return `feel.${cue}`;
8
+ }
9
+ function feelSound(cue, def) {
10
+ return { id: feedbackCueSoundId(cue), bus: FEEDBACK_CUE_BUS, positional: false, priority: 5, ...def };
11
+ }
12
+ /**
13
+ * Short procedural reactions - no samples to load, so every game has impact feedback without
14
+ * authoring audio. Loud enough to read, quiet enough to sit under a weapon's own sound.
15
+ */
16
+ export const DEFAULT_FEEDBACK_CUE_SOUNDS = {
17
+ hit: feelSound("hit", {
18
+ gain: 0.55,
19
+ maxVoices: 4,
20
+ synth: {
21
+ voices: [
22
+ { kind: "noise", filterType: "bandpass", filterFreq: 1800, duration: 0.05, gain: 0.3 },
23
+ { kind: "tone", wave: "triangle", freq: 210, slideTo: 150, duration: 0.07, gain: 0.24, attack: 0.002 },
24
+ ],
25
+ },
26
+ }),
27
+ hurt: feelSound("hurt", {
28
+ gain: 0.65,
29
+ maxVoices: 2,
30
+ synth: {
31
+ voices: [
32
+ { kind: "tone", wave: "triangle", freq: 165, slideTo: 110, duration: 0.16, gain: 0.32, attack: 0.003 },
33
+ { kind: "noise", filterType: "lowpass", filterFreq: 900, duration: 0.1, gain: 0.14 },
34
+ ],
35
+ },
36
+ }),
37
+ kill: feelSound("kill", {
38
+ gain: 0.6,
39
+ maxVoices: 2,
40
+ synth: {
41
+ voices: [
42
+ { kind: "noise", filterType: "lowpass", filterFreq: 1400, duration: 0.12, gain: 0.24 },
43
+ { kind: "tone", wave: "sawtooth", freq: 330, slideTo: 220, duration: 0.14, gain: 0.18 },
44
+ { kind: "tone", wave: "sine", freq: 110, slideTo: 78, duration: 0.2, gain: 0.22, delay: 0.03 },
45
+ ],
46
+ },
47
+ }),
48
+ pickup: feelSound("pickup", {
49
+ gain: 0.5,
50
+ maxVoices: 3,
51
+ synth: {
52
+ voices: [
53
+ { kind: "tone", wave: "sine", freq: 880, duration: 0.09, gain: 0.2, attack: 0.003 },
54
+ { kind: "tone", wave: "sine", freq: 1320, duration: 0.11, gain: 0.16, delay: 0.05, attack: 0.003 },
55
+ ],
56
+ },
57
+ }),
58
+ objective: feelSound("objective", {
59
+ gain: 0.55,
60
+ maxVoices: 1,
61
+ synth: {
62
+ voices: [
63
+ { kind: "tone", wave: "triangle", freq: 523, duration: 0.14, gain: 0.22 },
64
+ { kind: "tone", wave: "triangle", freq: 659, duration: 0.14, gain: 0.2, delay: 0.08 },
65
+ { kind: "tone", wave: "triangle", freq: 784, duration: 0.26, gain: 0.2, delay: 0.16 },
66
+ ],
67
+ },
68
+ }),
69
+ wave: feelSound("wave", {
70
+ gain: 0.6,
71
+ maxVoices: 1,
72
+ synth: {
73
+ voices: [
74
+ { kind: "tone", wave: "sawtooth", freq: 98, duration: 0.5, gain: 0.16, attack: 0.04, sustain: 0.24 },
75
+ { kind: "tone", wave: "sawtooth", freq: 147, duration: 0.5, gain: 0.12, attack: 0.05, sustain: 0.24 },
76
+ { kind: "noise", filterType: "lowpass", filterFreq: 600, duration: 0.45, gain: 0.12, decay: 1 },
77
+ ],
78
+ },
79
+ }),
80
+ deny: feelSound("deny", {
81
+ gain: 0.5,
82
+ maxVoices: 1,
83
+ synth: {
84
+ voices: [
85
+ { kind: "tone", wave: "square", freq: 196, slideTo: 170, duration: 0.07, gain: 0.16, attack: 0.002 },
86
+ { kind: "tone", wave: "square", freq: 155, slideTo: 130, duration: 0.1, gain: 0.16, delay: 0.07, attack: 0.002 },
87
+ ],
88
+ },
89
+ }),
90
+ };
91
+ /**
92
+ * Merge the default feedback reactions into a game's sound catalog. A game sound already named
93
+ * `feel.<cue>` wins, then `overrides`, then the default. Adds the `sfx` bus (gain 0.9) when the game
94
+ * did not declare one, so the impact layer has its own volume and mute row.
95
+ *
96
+ * @capability feedback-cue-sounds default procedural hit/hurt/kill/pickup/objective/wave/deny reactions on an "sfx" bus, overridable per game
97
+ */
98
+ export function withFeedbackCueSounds(sounds, buses, overrides) {
99
+ if (overrides === false)
100
+ return { sounds: { ...sounds }, buses: buses === undefined ? undefined : { ...buses } };
101
+ const merged = {};
102
+ let usesBus = false;
103
+ for (const cue of FEEDBACK_CUES) {
104
+ const id = feedbackCueSoundId(cue);
105
+ if (sounds?.[id] !== undefined)
106
+ continue;
107
+ const override = overrides?.[cue];
108
+ if (override === false)
109
+ continue;
110
+ const def = override ?? DEFAULT_FEEDBACK_CUE_SOUNDS[cue];
111
+ merged[id] = { ...def, id };
112
+ if (def.bus === FEEDBACK_CUE_BUS)
113
+ usesBus = true;
114
+ }
115
+ const nextBuses = usesBus && buses?.[FEEDBACK_CUE_BUS] === undefined
116
+ ? { ...buses, [FEEDBACK_CUE_BUS]: { id: FEEDBACK_CUE_BUS, gain: 0.9 } }
117
+ : buses;
118
+ return { sounds: { ...merged, ...sounds }, buses: nextBuses };
119
+ }
@@ -27,6 +27,8 @@ export interface DamageRuntimeHitInput {
27
27
  zone?: string;
28
28
  source?: string;
29
29
  statuses?: readonly (string | DamageStatusRequest)[];
30
+ /** Where the blow landed, when the caller knows it; overridable per hit. */
31
+ position?: readonly [number, number, number];
30
32
  }
31
33
  /** Every stage of one resolved hit, so a UI or a test can read why the final number is what it is. */
32
34
  export interface DamageRuntimeResolution {
@@ -87,6 +89,12 @@ export interface DamageRuntimeConfig {
87
89
  * reader and every event reports `remaining: null` and `lethal: false`.
88
90
  */
89
91
  healthOf?: (targetId: string) => number | null;
92
+ /**
93
+ * Where a target stands, read once per application. Gives the event a `position` so a cue pans
94
+ * and attenuates, including status ticks and hits whose input named none; omit it and the event
95
+ * carries no position and feedback stays centred.
96
+ */
97
+ positionOf?: (targetId: string) => readonly [number, number, number] | null | undefined;
90
98
  /** Register a one-line devtools probe under this name. */
91
99
  devtoolsName?: string;
92
100
  }
@@ -156,7 +164,8 @@ export declare class UnknownTargetProfileError extends Error {
156
164
  * `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
157
165
  * same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
158
166
  * — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
159
- * amount, absorbed, remaining health and a lethal flag, so a HUD names the blow instead of guessing.
167
+ * amount, absorbed, remaining health, a lethal flag and, when `positionOf` or the hit names one,
168
+ * where the blow landed, so a HUD names the blow and a cue pans instead of guessing.
160
169
  *
161
170
  * @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
162
171
  */
@@ -23,7 +23,8 @@ export class UnknownTargetProfileError extends Error {
23
23
  * `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
24
24
  * same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
25
25
  * — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
26
- * amount, absorbed, remaining health and a lethal flag, so a HUD names the blow instead of guessing.
26
+ * amount, absorbed, remaining health, a lethal flag and, when `positionOf` or the hit names one,
27
+ * where the blow landed, so a HUD names the blow and a cue pans instead of guessing.
27
28
  *
28
29
  * @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
29
30
  */
@@ -33,6 +34,7 @@ export function createDamageRuntime(config) {
33
34
  const rng = resolveRng(config.rng, "createDamageRuntime");
34
35
  const bus = config.events;
35
36
  const healthOf = config.healthOf;
37
+ const positionOf = config.positionOf;
36
38
  let table = config.table;
37
39
  let zones = config.zones;
38
40
  let armorStat = config.armorStat ?? "armor";
@@ -77,6 +79,7 @@ export function createDamageRuntime(config) {
77
79
  dealt,
78
80
  ...(tick.source !== undefined ? { source: tick.source } : {}),
79
81
  status: tick.status,
82
+ ...(positionOf === undefined ? {} : { position: positionOf(tick.target) ?? undefined }),
80
83
  });
81
84
  return dealt;
82
85
  }
@@ -92,6 +95,7 @@ export function createDamageRuntime(config) {
92
95
  remaining,
93
96
  lethal: remaining !== null && remaining <= 0,
94
97
  ...(input.status !== undefined ? { status: input.status } : {}),
98
+ ...(input.position === undefined ? {} : { position: input.position }),
95
99
  };
96
100
  recent.push(event);
97
101
  if (recent.length > RECENT_DAMAGE_LIMIT)
@@ -160,6 +164,9 @@ export function createDamageRuntime(config) {
160
164
  incoming,
161
165
  dealt: damage,
162
166
  ...(input.source !== undefined ? { source: input.source } : {}),
167
+ ...(input.position !== undefined
168
+ ? { position: input.position }
169
+ : positionOf === undefined ? {} : { position: positionOf(input.target) ?? undefined }),
163
170
  });
164
171
  return {
165
172
  target: input.target,
@@ -93,6 +93,8 @@ export interface EffectDamageApplication {
93
93
  remaining: number | null;
94
94
  /** True when the last pool reached its minimum. */
95
95
  lethal: boolean;
96
+ /** The target's position when the spatial seam knows it, for a panned cue or a decal at the hit. */
97
+ at?: EntityPosition;
96
98
  }
97
99
  export interface EffectSystemDeps {
98
100
  resolveReceive(instanceId: string): ReceiveMap | null | undefined;
@@ -109,6 +109,7 @@ export function createEffectSystem(deps) {
109
109
  if (delta.delta < 0)
110
110
  dealt -= delta.delta;
111
111
  const lastStatId = rule.order[rule.order.length - 1];
112
+ const at = deps.spatial.positionOf(instanceId);
112
113
  deps.onDamage({
113
114
  target: instanceId,
114
115
  source: from,
@@ -117,6 +118,7 @@ export function createEffectSystem(deps) {
117
118
  dealt,
118
119
  remaining: statPools.get(instanceId, lastStatId)?.current ?? null,
119
120
  lethal: result.lethal,
121
+ ...(at === undefined ? {} : { at }),
120
122
  });
121
123
  }
122
124
  if (result.lethal) {
@@ -22,7 +22,20 @@ export interface FrameWindow {
22
22
  }
23
23
  /** Renderer-side counts for the measured window; `null` where the source could not read them. */
24
24
  export interface RenderReport {
25
+ /**
26
+ * The renderer's own draw counter as the devtools probe samples it. three accumulates this across
27
+ * every pass in a frame (the probe sets `info.autoReset = false`), but one sample is one frame, so
28
+ * two runs of the same build can differ by a pass that runs on a cadence.
29
+ */
25
30
  draws: number | null;
31
+ /**
32
+ * Every draw issued in a frame, all passes (shadow, main, postfx, AO, DOF), as the 95th percentile
33
+ * of the measured window: the count the budget gate compares. Only the CDP harness can wrap the
34
+ * draw calls, so the in-page sampler reports `null` here.
35
+ */
36
+ drawsFrame: number | null;
37
+ /** Frames the whole-frame count was taken over; 0 where nothing measured it. */
38
+ drawsFrames: number;
26
39
  triangles: number | null;
27
40
  geometries: number | null;
28
41
  textures: number | null;
@@ -2,6 +2,7 @@ import type { EffectResult } from "../combat/effects.js";
2
2
  import type { DeathReason } from "../combat/deathReason.js";
3
3
  import type { TelegraphShape } from "../combat/telegraph.js";
4
4
  import type { CameraShake } from "../combat/hitReaction.js";
5
+ import type { FeedbackCue } from "../audio/feedbackCues.js";
5
6
  import type { CombatVfxInstanceEvent } from "./vfxInstance.js";
6
7
  export type { DeathReason };
7
8
  export interface EntityDiedEvent {
@@ -38,6 +39,12 @@ export interface DamageAppliedEvent {
38
39
  lethal: boolean;
39
40
  /** The status that ticked, when the damage came from a carried status rather than a direct hit. */
40
41
  status?: string;
42
+ /**
43
+ * Where the blow landed, for spatial feedback (a panned cue, a decal, a floating number that
44
+ * belongs on the body rather than on screen). Omitted by an emitter that knows no position, so a
45
+ * listener must fall back to a centred, unattenuated reaction.
46
+ */
47
+ position?: readonly [number, number, number];
41
48
  }
42
49
  /** Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. With an `animation.graph`, `event` arms the trigger of that name. */
43
50
  export interface EntityAnimationEvent {
@@ -242,6 +249,15 @@ export interface AudioPlayEvent {
242
249
  }
243
250
  /** Request that the shell's audio engine resume its (browser-gesture-suspended) context; carries no payload. */
244
251
  export type AudioResumeEvent = Record<string, never>;
252
+ /**
253
+ * A gameplay moment the game announces itself, for the moments no engine event covers (a wave
254
+ * starting, a scripted beat). The shell reacts with the cue's sound, rumble, camera kick and caption.
255
+ */
256
+ export interface FeedbackCueEvent {
257
+ cue: FeedbackCue;
258
+ /** Caption text that replaces the cue's default label ("WAVE 3"). */
259
+ caption?: string;
260
+ }
245
261
  /** Crossfade the procedural soundtrack to `theme` (null fades out), optionally transposing the incoming theme by `transpose` semitones. */
246
262
  export interface AudioMusicEvent {
247
263
  theme: string | null;
@@ -325,6 +341,7 @@ export interface GameEventMap {
325
341
  "audio.loopStop": AudioLoopStopEvent;
326
342
  "entity.animation": EntityAnimationEvent;
327
343
  "animation.event": AnimationClipEvent;
344
+ "feedback.cue": FeedbackCueEvent;
328
345
  "command.refused": CommandRefusedEvent;
329
346
  }
330
347
  export type GameEventHandler<TPayload> = (payload: TPayload) => void;
@@ -2,6 +2,7 @@ import type { AnimGraph } from "../anim/animGraph.js";
2
2
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
3
3
  import type { MusicTheme } from "../audio/music.js";
4
4
  import type { UiCueOverrides } from "../audio/uiCues.js";
5
+ import type { FeedbackCueOverrides } from "../audio/feedbackCues.js";
5
6
  import type { NoticeFeedConfig } from "./notices.js";
6
7
  import type { MaterialAsset, MaterialAssignment, MaterialSurfaceParameters, MaterialTextureRole } from "../material/materialAsset.js";
7
8
  import type { EditorCatalogDefinition, EditorDocument } from "../editor/types.js";
@@ -786,6 +787,8 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
786
787
  hudCues?: boolean;
787
788
  /** Default UI cues (`ui.click`, `ui.deny`, ...) on a "ui" bus. A sound named `ui.<cue>` in `sounds` replaces one; a map entry replaces or (`false`) silences one; `false` silences all. */
788
789
  uiCues?: UiCueOverrides;
790
+ /** 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
+ feedbackCues?: FeedbackCueOverrides;
789
792
  };
790
793
  /** 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. */
791
794
  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.31.0";
2
+ export declare const VERSION = "0.33.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,7 +1,67 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.31.0";
2
+ export const VERSION = "0.33.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.33.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.33.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.30.0`; `@jgengine/github` is `0.20.0`.",
8
+ "`useSceneObjects` (`@jgengine/react/hooks`) keeps one list identity across `move` and `rotate`, so a record's `position`/`rotationY` is what it was at the last render. Read the live placement with `ctx.scene.object.get(instanceId)` (a `useFrame` loop or a rAF-ticked HUD) when a mesh or readout must follow a moving object.",
9
+ "A read of a mismatched stored value now converts losslessly where it can (a number as its digits, a boolean as `\"true\"`/`\"false\"`, those strings back) and throws where it cannot. No in-repo caller relied on the old fallback; `useSetting` (`@jgengine/react/settings`) is the generic path that could, so a game that stored a number under a key it reads with a string fallback now gets the digits.",
10
+ ],
11
+ added: [
12
+ "`releasePostfxOverlayTracking(scene)` (`@jgengine/shell/postfx/postfxOverlay`) drops the cached prepass overlay set and its listeners for a scene that is being torn down.",
13
+ "`canvasCover` (`@jgengine/react/canvasCover`): one page-wide hold a covering surface takes, plus `useCanvasCover(declared)` for a React overlay. While it is held the shell stops drawing the world, its shadow passes and postfx; input, audio and the DOM HUD keep running, and the first frame after release draws the untouched scene. A scrim or side panel must not take it.",
14
+ "`MenuScreen` and `TitleMenu` take `coversCanvas` (default false) to declare that the screen paints over the whole canvas. A menu with only a translucent scrim leaves the live scene drawing.",
15
+ "The dev runner pins the cover with `?cover=1`, and the devtools agent bridge answers `canvas_cover`, so a perf run or `bun run drive` can measure a covered frame on any game.",
16
+ "`combat.damage` carries an optional `position`: the effect path fills it from the target's own spot, and `createDamageRuntime` takes one per hit (`hit({ position })`) or from a `positionOf` reader, so a status tick is placed too. A cue with a position pans and attenuates; one without stays centred.",
17
+ "`createHapticsRoute` (`@jgengine/shell/audio/hapticsRoute`) sends a feedback pulse to a connected gamepad, and to `navigator.vibrate` on a phone with no pad. `FeedbackHost` wires it; reduced motion still drops the camera shake only.",
18
+ "`PerfReport.render.drawsFrame` is every draw a frame issues — shadow, main, postfx prepasses, AO and DOF together — as the 95th percentile of the measured window, with `drawsFrames` naming how many frames it covers. `bun run perf:gate` compares it against each game's re-based `drawsFrame` budget; the renderer's own single-sample `draws` is printed beside it and is no longer gated.",
19
+ "`ObjectStore.version()` / `ObjectStore.markSpatialChange()` (`ctx.scene.object`) — a counter that moves whenever membership, a position, a rotation or a consumer-owned collider set changes, so derived spells spatial data can share one cache key.",
20
+ "`SolidObstacleSource.version?()` (`@jgengine/core/movement/solidObstacles`) — a source that reports one gets its blocking obstacles resolved once per version into a coarse XZ index; a source without it keeps the per-query `inBox` gather.",
21
+ "`SettingsTypeError` and `asSettingType` (`@jgengine/core/settings/settingsModel`) report and resolve a stored setting read as another type.",
22
+ ],
23
+ changed: [
24
+ "`bun run land` queues landings on one lock per repo instead of running them beside each other, so two lanes no longer stack two pull requests' sixteen jobs against the account's 20-job cap. A waiting landing names the branch and pid it waits on; a lock whose process died, or that is older than 30 minutes, is broken with a warning.",
25
+ "The scene io browser test skips Playwright's two-frame click stability check and lets three rendered frames pass instead of ten: on a runner its second test went from 53s to 11-24s and the file from 76s to 50s, of which 16-36s is the fixture's own start.",
26
+ "`check-game-front-end` recognizes the shipped `CreditsPanel`, so a game that renders credits through the engine's panel is no longer flagged as having no credits screen.",
27
+ "The shell's frame loop switches to `frameloop=\"never\"` under a cover instead of drawing a world nobody can see (measured on Brightway Park's pause menu: 1,095 whole-frame draws and 5.2 ms/frame before, 0 draws and 0.11 ms/frame covered). A frame-rate cap no longer advances a covered canvas.",
28
+ "`FeedbackDeps.play` takes an optional `position`, which `FeedbackHost` forwards to `ctx.game.audio.play`, so a positioned damage or death cue reaches the spatial engine.",
29
+ "Each game's `perf-budget.json` entry now budgets `drawsFrame` and records the budget it replaced as `previousDraws`; an entry that still carries only `draws` fails by name instead of gating a count from a different measurement.",
30
+ "The in-page report reads the devtools render probe's frame sample instead of the live renderer counter, so `window.__JG.report()` and the devtools panel no longer report a mid-frame partial count.",
31
+ "Movement gathers obstacles from that index instead of re-resolving every nearby object every frame. On Harbor Heat the query fell from 0.62-0.65 ms to 0.10 ms a frame (2.0-2.1 ms to 1.4 ms of simulation a frame).",
32
+ "`objectStore.inBox` walks a coarse grid for big boxes instead of scanning every placed object; a 1 m cell lookup costs a string hash, so the switch happens past 32 cells rather than 4096.",
33
+ "Ambient occlusion reads the beauty pass's depth buffer instead of rendering the scene a second time for its normal/depth GBuffer. That prepass cost 537 extra draws and 2.6-2.9 ms/frame in Harbor Heat and 2,105 extra draws in Drift Foundry at 1600x900; AO now draws nothing of its own. A post chain whose antialiasing is `\"smaa\"` (the default) keeps SMAA and drops the beauty target's extra MSAA resolve, because WebGL2 cannot sample a multisampled depth texture; `\"msaa\"` chains keep the old prepass. Fixed-viewpoint captures differ only on geometry edges (#2173).",
34
+ "Postfx prepasses no longer walk the whole scene twice a frame to hide overlays, points and lines: the set is cached per scene and rescanned when the scene graph changes. Drift Foundry 2.11 ms/frame and Harbor Heat 0.66 ms/frame of `traverse` removed.",
35
+ "A rig posed from an animation graph no longer allocates a motion sample, a position triple and a fresh pose record every frame, and foot IK no longer allocates a probe input, filter, closure and per-leg records every frame.",
36
+ "The Graphics tab lists a post-processing stage toggle only when the game's resolved chain can build that stage. Depth of field, which no reference game authors, hid behind a row that switched a pass the composer never created, so the toggle changed nothing. The rule covers every stage: a chain with `ao: false`, `bloom: false` or `aa: \"msaa\"`/`false` no longer offers those rows, and a game with no post chain gets none of them. Every other settings row is unchanged (`@jgengine/shell/settings/settingsController`, `availablePostfxStages` in `@jgengine/shell/postfx/PostProcessing`).",
37
+ "Positional loops declared through `entitySounds`/`objectSounds` are tracked instead of rebuilt: spawn, despawn and a moved emitter update one retained loop each, and a frame where nothing moved reads only the emitters that already sound. On scrap-signal's 560 placed props (69 sounding), the tracking path drops from 16.7 µs to 1.2 µs per frame and a steady frame allocates no Set, array or record.",
38
+ "`useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` show a setting written before the component subscribed — a saved profile or the demo path seeding values before first paint — instead of waiting for the player's next change (#2180).",
39
+ "`useFeed`, `useQuestJournal` and `useFriends` (`@jgengine/react/hooks`) keep one list identity while membership holds instead of allocating a new array on every store write and comparing it with `Object.is`, so a HUD reading them no longer re-renders on every entity pose write. A pushed feed entry, an accepted, progressed or abandoned quest, and an accepted or removed friend each re-render once. Measured on the three read sites (`ToastStack`, `FriendsList`, `JournalQuestTracker`) with 15 quests, 20 friends and 20 feed rows: 900 renders → 0 across 300 pose writes, 0.14 ms/frame → 0.04 ms/frame.",
40
+ "`useWorldItems` (`@jgengine/react/hooks`) keeps one list identity until an item spawns, is picked up or is removed. It read `worldItem.list()`, which allocates a new array per call, against a store signal that also fires on every entity pose write, so a HUD re-rendered every dropped-item marker each frame (0.97 ms/frame of React work in Harbor Heat).",
41
+ "`WorldItems` (`@jgengine/shell`) reads each marker's live pose in `useFrame` instead of taking it as a prop, so a moving item still follows without re-rendering its marker.",
42
+ "`useSceneEntities` and `useSceneObjects` (`@jgengine/react/hooks`) keep one list identity while membership holds, instead of allocating a new array on every store write and comparing it with `Object.is`. A pose write no longer re-renders every HUD that reads them (30-entity roster: 0.066 ms/frame → 0; the whole loop 0.24 → 0.05 ms/frame), while a spawn, despawn, replace, place or remove still re-renders. Non-pose object writes (state, visual, animation, slots, catalog id) still re-render.",
43
+ "`RevealHighlights` (`@jgengine/shell/vision`) drives its own per-frame refresh only while Dark Sight is enabled, so a disabled reveal overlay no longer re-renders (0.019 ms/frame → 0) and an enabled one still follows the origin and its targets.",
44
+ "`createSettingsStore().get` returns a stored setting of another type instead of quietly returning the fallback, so a numeric setting read with a string fallback applies instead of vanishing (#2189). Root cause: `coerce` treated the fallback's type as the stored type and replaced every other type with the fallback. A stored value that cannot be read as the asked-for type now throws `SettingsTypeError` naming the key, the stored type and the fallback type.",
45
+ ],
46
+ removed: [],
47
+ },
48
+ "0.32.0": {
49
+ migrate: [
50
+ "Bump lockstep SDK packages to `^0.32.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.29.0`; `@jgengine/github` is `0.19.0`.",
51
+ ],
52
+ added: [
53
+ "Audio mixing grew a mute that composes with gain: `AudioEngine.setBusMute` / `setMasterMute` keep the player's volume while silencing a bus, `masterLevel()` / `busLevel()` report the composed value, and Settings carries a mute row per bus plus \"Mute all\" (`sound.muted`, `sound.bus.<id>.muted`). `AudioEngine.play` and `playOneShot` hold cues in a bounded 16-slot queue while the browser keeps the context suspended and play them on the first gesture instead of dropping them; `installGestureResume` wires that gesture for both presentation paths.",
54
+ "`AudioEngine.measure()` reads master RMS and peak from a lazily built analyser, and `installAudioProbe` publishes `window.__jgengineAudio` so a headless capture can prove sound with a measured level rather than a screenshot.",
55
+ "`@jgengine/core/audio/feedbackCues` ships seven procedural reactions - `hit`, `hurt`, `kill`, `pickup`, `objective`, `wave`, `deny` - on an `sfx` bus, merged into every game's catalog as `feel.<cue>`. A game sound with that id replaces one; `audio.feedbackCues` replaces or silences them.",
56
+ "`@jgengine/shell/audio/eventFeedback` maps the events the engine already announces to those reactions: `combat.damage` (local player reads as hurt, anything else as a hit), `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp` and `command.refused`. Each row carries a sound, a gamepad pulse, camera trauma and a caption; the mapping and reactions are data (`DEFAULT_FEEDBACK_BINDINGS`, `DEFAULT_FEEDBACK_REACTIONS`) a game can replace, cooldowns fold a burst into one impact, reduced motion keeps sound and rumble but drops the camera kick, and the resolved reaction is pooled so handling an event allocates nothing per frame. `attachWaveFeedback(runner, ctx.game.feedback)` announces each new wave.",
57
+ "`ctx.game.feedback.cue(cue, caption?)` announces a moment no engine event covers and emits `feedback.cue`.",
58
+ "`@jgengine/shell/audio/captions` and `CaptionOverlay` render a bounded, deduping, self-expiring caption line under the accessibility captions setting; `FeedbackHost` mounts the whole layer - feedback mapping, gesture resume, audio probe and captions - for both the 3D and HUD presentation paths.",
59
+ ],
60
+ changed: [
61
+ "The CI gate runs nine test shards instead of ten, and a `types` job whose stages overlap, so a docs-only pull request fits the account's 20-job runner cap with room for another run beside it. `scripts/test-shard.ts` owns which files each shard runs and what it prepares first; `bun scripts/type-shard.ts` runs the `check-types` plan two stages at a time and then the tsc fan-out alone.",
62
+ ],
63
+ removed: [],
64
+ },
5
65
  "0.31.0": {
6
66
  migrate: [
7
67
  "Bump lockstep SDK packages to `^0.31.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.",
@@ -29,11 +29,35 @@ export interface SolidObstacleSource {
29
29
  rotationY: number;
30
30
  }[];
31
31
  collidersOf(instanceId: string): Parameters<typeof resolveColliders>[0] | null;
32
+ /**
33
+ * Bumps whenever membership, a position, a rotation or a collider set changes. A source that reports
34
+ * one is indexed once per version instead of re-resolving every object on every query.
35
+ */
36
+ version?(): number;
32
37
  }
33
- /** Mutable reach cache; one per source, invalidated by object count. */
38
+ /** A placed object's blocking geometry, resolved once per index build and shared read-only by queries. */
39
+ interface ObstacleGroup {
40
+ x: number;
41
+ y: number;
42
+ z: number;
43
+ obstacles: CollisionObstacle[];
44
+ }
45
+ /**
46
+ * Version-keyed obstacle index: object centre → its prebuilt obstacles, bucketed in coarse XZ cells so
47
+ * a city-scale movement box walks a few populated cells rather than every object in the world.
48
+ */
49
+ interface ObstacleIndex {
50
+ cells: Map<number, ObstacleGroup[]>;
51
+ groups: ObstacleGroup[];
52
+ }
53
+ /** Mutable reach cache; one per source, invalidated by object count or by the source's version. */
34
54
  export interface ObstacleReachCache {
35
55
  count: number;
36
56
  value: ObstacleReach;
57
+ /** Version the value and index were built from; absent for a source that reports none. */
58
+ version?: number | undefined;
59
+ /** Built index, present only for a versioned source. */
60
+ index?: ObstacleIndex | undefined;
37
61
  }
38
62
  /** A fresh {@link ObstacleReachCache} for a caller that owns its own source. */
39
63
  export declare function createObstacleReachCache(): ObstacleReachCache;
@@ -71,15 +95,19 @@ export declare function obstacleFromSolid(solid: WorldSolid): CollisionObstacle;
71
95
  * @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
72
96
  */
73
97
  export declare function solidObstaclesNear(ctx: GameContext, position: EntityPosition, reachX: number, reachZ: number, height?: number): CollisionObstacle[];
74
- /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. */
75
- export declare function sourceObstaclesNear(source: SolidObstacleSource, reach: ObstacleReach, position: EntityPosition, reachX: number, reachZ: number, height?: number): CollisionObstacle[];
98
+ /**
99
+ * {@link solidObstaclesNear} against a bare source and an already-resolved reach. Pass the source's
100
+ * {@link ObstacleReachCache} to use the version-keyed obstacle index; a source that reports no
101
+ * `version()` keeps the per-query `inBox` gather.
102
+ */
103
+ export declare function sourceObstaclesNear(source: SolidObstacleSource, reach: ObstacleReach, position: EntityPosition, reachX: number, reachZ: number, height?: number, cache?: ObstacleReachCache): CollisionObstacle[];
76
104
  /**
77
105
  * Slide a walker's horizontal step against the same solid geometry that stops the player, returning the
78
106
  * X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
79
107
  * other, exactly as {@link stepPlayerMovement} resolves the player's.
80
108
  *
81
- * Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
82
- * full-world scan, so it stays affordable per NPC per tick.
109
+ * Bounded by the same broadphase as the player's gather: one query sized to this step, answered from the
110
+ * source's obstacle index, never a full-world scan, so it stays affordable per NPC per tick.
83
111
  *
84
112
  * The sliding is already axis-separated: X and Z are resolved independently inside, so a caller that
85
113
  * also splits its own movement by axis must hand this exactly one axis per call (`stepZ: 0`, then
@@ -118,3 +146,4 @@ export declare function slideStep(position: EntityPosition, stepX: number, stepZ
118
146
  stepX: number;
119
147
  stepZ: number;
120
148
  };
149
+ export {};