@jgengine/core 0.18.1 → 0.19.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.
Files changed (220) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/ai/decisionGraph.d.ts +48 -4
  3. package/dist/ai/decisionGraph.js +153 -23
  4. package/dist/ai/groupAssist.d.ts +10 -0
  5. package/dist/ai/groupAssist.js +11 -0
  6. package/dist/ai/pursuit.d.ts +1 -1
  7. package/dist/ai/pursuit.js +1 -1
  8. package/dist/ai/threat.d.ts +8 -0
  9. package/dist/ai/threat.js +10 -0
  10. package/dist/anim/animGraph.d.ts +11 -0
  11. package/dist/anim/animGraph.js +128 -0
  12. package/dist/anim/boneTexture.d.ts +55 -0
  13. package/dist/anim/boneTexture.js +43 -0
  14. package/dist/anim/footPlacement.d.ts +57 -0
  15. package/dist/anim/footPlacement.js +67 -0
  16. package/dist/anim/locomotionGraph.d.ts +9 -0
  17. package/dist/anim/locomotionGraph.js +29 -0
  18. package/dist/audio/audioFalloff.d.ts +2 -0
  19. package/dist/audio/doppler.d.ts +21 -0
  20. package/dist/audio/doppler.js +32 -0
  21. package/dist/audio/engineLayers.d.ts +83 -0
  22. package/dist/audio/engineLayers.js +115 -0
  23. package/dist/combat/abilityKit.d.ts +1 -0
  24. package/dist/combat/abilityKit.js +1 -0
  25. package/dist/combat/death.d.ts +7 -0
  26. package/dist/combat/shotOrigin.d.ts +6 -0
  27. package/dist/combat/shotOrigin.js +6 -0
  28. package/dist/combat/weaponFeedback.d.ts +36 -0
  29. package/dist/combat/weaponFeedback.js +42 -0
  30. package/dist/combat/weaponHandling.d.ts +161 -0
  31. package/dist/combat/weaponHandling.js +202 -0
  32. package/dist/combat/weaponPresentation.d.ts +115 -0
  33. package/dist/combat/weaponPresentation.js +96 -0
  34. package/dist/devtools/warnOnce.d.ts +8 -0
  35. package/dist/devtools/warnOnce.js +18 -0
  36. package/dist/editor/document.js +3 -1
  37. package/dist/editor/environment.js +8 -0
  38. package/dist/editor/types.d.ts +4 -0
  39. package/dist/faction/reputation.d.ts +6 -0
  40. package/dist/faction/reputation.js +11 -0
  41. package/dist/game/cameraConfig.d.ts +51 -1
  42. package/dist/game/events.d.ts +6 -0
  43. package/dist/game/lootTable.d.ts +4 -0
  44. package/dist/game/lootTable.js +16 -0
  45. package/dist/game/playableGame.d.ts +84 -17
  46. package/dist/game/toasts.d.ts +8 -0
  47. package/dist/game/toasts.js +7 -0
  48. package/dist/game/vfxInstance.d.ts +9 -0
  49. package/dist/game/vfxInstance.js +12 -1
  50. package/dist/game/viewports.d.ts +43 -0
  51. package/dist/game/viewports.js +46 -0
  52. package/dist/game/worldItem.d.ts +5 -1
  53. package/dist/game/worldItem.js +0 -1
  54. package/dist/input/actionBindings.d.ts +15 -1
  55. package/dist/input/actionBindings.js +14 -3
  56. package/dist/input/actionContexts.d.ts +25 -1
  57. package/dist/input/actionContexts.js +82 -25
  58. package/dist/input/axisInput.d.ts +4 -0
  59. package/dist/input/axisInput.js +4 -0
  60. package/dist/input/axisShaper.d.ts +73 -0
  61. package/dist/input/axisShaper.js +105 -0
  62. package/dist/input/gamepadModel.d.ts +36 -2
  63. package/dist/input/gamepadModel.js +69 -30
  64. package/dist/input/haptics.d.ts +50 -0
  65. package/dist/input/haptics.js +74 -0
  66. package/dist/input/inputBuffer.d.ts +40 -0
  67. package/dist/input/inputBuffer.js +59 -0
  68. package/dist/input/lookChannel.d.ts +10 -0
  69. package/dist/input/lookChannel.js +6 -0
  70. package/dist/input/pointer.d.ts +2 -0
  71. package/dist/input/pointer.js +4 -0
  72. package/dist/item/affix.d.ts +6 -0
  73. package/dist/item/affix.js +6 -0
  74. package/dist/meta/changelog.d.ts +1 -1
  75. package/dist/meta/changelog.js +112 -1
  76. package/dist/movement/glideModel.d.ts +3 -0
  77. package/dist/movement/glideModel.js +6 -0
  78. package/dist/movement/grappleSwing.d.ts +2 -0
  79. package/dist/movement/grappleSwing.js +5 -1
  80. package/dist/movement/movementModel.d.ts +39 -0
  81. package/dist/movement/movementModel.js +62 -19
  82. package/dist/movement/movementProbe.d.ts +51 -0
  83. package/dist/movement/movementProbe.js +119 -0
  84. package/dist/movement/playerMovement.d.ts +21 -2
  85. package/dist/movement/playerMovement.js +89 -6
  86. package/dist/movement/solidObstacles.d.ts +9 -1
  87. package/dist/movement/solidObstacles.js +44 -2
  88. package/dist/movement/voxelController.js +7 -7
  89. package/dist/nav/navFromEnvironment.d.ts +9 -3
  90. package/dist/nav/navFromEnvironment.js +18 -12
  91. package/dist/nav/navMesh.d.ts +43 -2
  92. package/dist/nav/navMesh.js +534 -128
  93. package/dist/physics/aircraftDynamics.d.ts +390 -0
  94. package/dist/physics/aircraftDynamics.js +704 -0
  95. package/dist/physics/boatDynamics.d.ts +116 -0
  96. package/dist/physics/boatDynamics.js +160 -0
  97. package/dist/physics/drivableVehicle.d.ts +22 -8
  98. package/dist/physics/drivableVehicle.js +4 -2
  99. package/dist/physics/flightDynamics.d.ts +21 -4
  100. package/dist/physics/flightDynamics.js +28 -5
  101. package/dist/physics/handlingProbe.d.ts +249 -0
  102. package/dist/physics/handlingProbe.js +606 -0
  103. package/dist/physics/kinematicVehicle.d.ts +32 -2
  104. package/dist/physics/kinematicVehicle.js +15 -0
  105. package/dist/physics/physicsBackend.d.ts +12 -0
  106. package/dist/physics/physicsBackendConformance.js +34 -0
  107. package/dist/physics/physicsWorldBackend.d.ts +1 -0
  108. package/dist/physics/physicsWorldBackend.js +33 -0
  109. package/dist/physics/vehicleBackendLink.d.ts +49 -0
  110. package/dist/physics/vehicleBackendLink.js +102 -0
  111. package/dist/physics/vehicleDynamics.d.ts +374 -0
  112. package/dist/physics/vehicleDynamics.js +737 -0
  113. package/dist/physics/worldColliders.d.ts +1 -1
  114. package/dist/physics/worldColliders.js +35 -3
  115. package/dist/puzzle/cellGrid.d.ts +15 -9
  116. package/dist/puzzle/cellGrid.js +13 -9
  117. package/dist/render/lookPreset.d.ts +9 -2
  118. package/dist/render/lookPreset.js +29 -2
  119. package/dist/render/postProcessing.d.ts +30 -0
  120. package/dist/render/postProcessing.js +1 -1
  121. package/dist/runtime/cameraDirector.d.ts +17 -3
  122. package/dist/runtime/cameraDirector.js +25 -0
  123. package/dist/runtime/commandInput.d.ts +37 -0
  124. package/dist/runtime/commandInput.js +58 -0
  125. package/dist/runtime/commandRunner.d.ts +27 -0
  126. package/dist/runtime/commandRunner.js +31 -3
  127. package/dist/runtime/context/combat.d.ts +1 -0
  128. package/dist/runtime/context/combat.js +2 -1
  129. package/dist/runtime/context/deathLoot.d.ts +2 -0
  130. package/dist/runtime/context/deathLoot.js +1 -0
  131. package/dist/runtime/gameContext.js +7 -0
  132. package/dist/runtime/gameContextTypes.d.ts +19 -3
  133. package/dist/runtime/gameRuntime.d.ts +3 -1
  134. package/dist/runtime/gameRuntime.js +4 -0
  135. package/dist/runtime/inputSnapshot.d.ts +6 -0
  136. package/dist/runtime/inputSnapshot.js +10 -0
  137. package/dist/runtime/localPlayers.d.ts +73 -0
  138. package/dist/runtime/localPlayers.js +119 -0
  139. package/dist/runtime/transport.d.ts +9 -0
  140. package/dist/runtime/transport.js +7 -1
  141. package/dist/scene/behaviorRuntime.d.ts +12 -2
  142. package/dist/scene/behaviorRuntime.js +30 -14
  143. package/dist/scene/behaviors.d.ts +5 -1
  144. package/dist/scene/sceneKinds.d.ts +9 -0
  145. package/dist/scene/vehicleSeat.d.ts +5 -1
  146. package/dist/scene/vehicleSeat.js +5 -1
  147. package/dist/sensor/concealment.d.ts +6 -0
  148. package/dist/sensor/concealment.js +8 -0
  149. package/dist/sensor/freezeMonitor.d.ts +6 -0
  150. package/dist/sensor/freezeMonitor.js +8 -0
  151. package/dist/sensor/perception.d.ts +11 -1
  152. package/dist/sensor/perception.js +57 -10
  153. package/dist/sensor/recordingBuffer.d.ts +3 -0
  154. package/dist/sensor/recordingBuffer.js +8 -0
  155. package/dist/session/roundState.d.ts +4 -0
  156. package/dist/session/roundState.js +11 -0
  157. package/dist/settings/frameRateLimit.d.ts +16 -0
  158. package/dist/settings/frameRateLimit.js +37 -0
  159. package/dist/settings/graphicsProfile.d.ts +2 -0
  160. package/dist/settings/graphicsProfile.js +12 -0
  161. package/dist/settings/graphicsSettings.d.ts +40 -0
  162. package/dist/settings/graphicsSettings.js +81 -0
  163. package/dist/settings/settingsModel.d.ts +9 -1
  164. package/dist/settings/settingsModel.js +10 -0
  165. package/dist/stats/eventMeter.d.ts +1 -1
  166. package/dist/stats/eventMeter.js +1 -1
  167. package/dist/vfx/feedbackMixer.d.ts +61 -0
  168. package/dist/vfx/feedbackMixer.js +106 -0
  169. package/dist/visibility/simulationCulling.d.ts +8 -1
  170. package/dist/visibility/simulationCulling.js +7 -0
  171. package/dist/visibility/spatialIndex.d.ts +21 -0
  172. package/dist/visibility/spatialIndex.js +16 -0
  173. package/dist/world/authoredSolids.d.ts +46 -0
  174. package/dist/world/authoredSolids.js +75 -0
  175. package/dist/world/buildingGenerator.js +2 -2
  176. package/dist/world/buildingKit.d.ts +3 -1
  177. package/dist/world/buildings.d.ts +34 -2
  178. package/dist/world/buildings.js +11 -1
  179. package/dist/world/cityKind.d.ts +7 -0
  180. package/dist/world/cityKind.js +52 -1
  181. package/dist/world/features.d.ts +29 -2
  182. package/dist/world/features.js +6 -0
  183. package/dist/world/footprintGrid.d.ts +7 -0
  184. package/dist/world/footprintGrid.js +16 -0
  185. package/dist/world/interiors.d.ts +1 -0
  186. package/dist/world/interiors.js +5 -3
  187. package/dist/world/lod.d.ts +6 -0
  188. package/dist/world/lod.js +8 -0
  189. package/dist/world/terraform.d.ts +8 -0
  190. package/dist/world/terraform.js +7 -0
  191. package/dist/world/walls.d.ts +7 -0
  192. package/dist/world/walls.js +7 -0
  193. package/dist/world/worldSolids.d.ts +84 -0
  194. package/dist/world/worldSolids.js +240 -0
  195. package/dist/world.d.ts +2 -2
  196. package/dist/world.js +1 -1
  197. package/package.json +1 -1
  198. package/skills/ce-handoff/SKILL.md +1 -1
  199. package/skills/game-design/SKILL.md +2 -2
  200. package/skills/jgengine/SKILL.md +7 -5
  201. package/skills/jgengine/api.md +118 -89
  202. package/skills/jgengine/capabilities.md +28 -0
  203. package/skills/jgengine/recipes/minimal-game.md +3 -0
  204. package/skills/jgengine-combat/SKILL.md +5 -0
  205. package/skills/jgengine-combat/api.md +138 -103
  206. package/skills/jgengine-combat/capabilities.md +29 -1
  207. package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
  208. package/skills/jgengine-gameplay/api.md +462 -436
  209. package/skills/jgengine-gameplay/capabilities.md +24 -0
  210. package/skills/jgengine-gameplay/reference-systems.md +4 -0
  211. package/skills/jgengine-world/SKILL.md +15 -3
  212. package/skills/jgengine-world/api.md +873 -626
  213. package/skills/jgengine-world/capabilities.md +130 -6
  214. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  215. package/skills/jgengine-world/recipes/controls.md +44 -0
  216. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  217. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  218. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  219. package/skills/jgengine-world/reference.md +31 -2
  220. package/skills/jgengine-world/visibility.md +112 -0
@@ -113,7 +113,10 @@ export interface AnimGraphOutput {
113
113
  name: string;
114
114
  clip: string;
115
115
  }[];
116
+ /** Root-bone travel over this advance from `rootMotion` states, in the rig's local units. */
116
117
  rootDelta?: [number, number, number];
118
+ /** `true` while any layer's current state has `rootMotion`, even on an advance with no travel. */
119
+ rootMotion?: true;
117
120
  }
118
121
  /** The evaluator handle: arm triggers, advance, inspect, snapshot and restore. */
119
122
  export interface AnimGraphRuntime {
@@ -141,4 +144,12 @@ export declare function stateClipWeights(state: AnimState, params: AnimParams):
141
144
  * @capability anim-graph data-first animation state machine with blend trees, crossfades, layers, and clip events
142
145
  */
143
146
  export declare function createAnimGraphRuntime(initial: AnimGraph): AnimGraphRuntime;
147
+ /**
148
+ * Validates untrusted JSON (a saved scene document, a network payload) as an {@link AnimGraph}.
149
+ * Malformed states, transitions to unknown states, and bad conditions are dropped; a layer whose
150
+ * entry state is missing is dropped; the result is `undefined` when no layer survives.
151
+ *
152
+ * @capability anim-graph load an animation graph from saved JSON without trusting its shape
153
+ */
154
+ export declare function parseAnimGraph(value: unknown): AnimGraph | undefined;
144
155
  export {};
@@ -197,6 +197,7 @@ export function createAnimGraphRuntime(initial) {
197
197
  advance(dt, params, clips) {
198
198
  const output = { clips: [], events: [] };
199
199
  const rootDelta = [0, 0, 0];
200
+ let rootMotion = false;
200
201
  for (const layer of graph.layers) {
201
202
  const state = layers[layer.id];
202
203
  if (state === undefined)
@@ -205,6 +206,8 @@ export function createAnimGraphRuntime(initial) {
205
206
  if (def === undefined)
206
207
  continue;
207
208
  const layerWeight = layer.weight ?? 1;
209
+ if (def.rootMotion === true && layerWeight > 0)
210
+ rootMotion = true;
208
211
  const before = state.time;
209
212
  state.time += dt * stateSpeed(def);
210
213
  const weights = stateClipWeights(def, params);
@@ -280,7 +283,132 @@ export function createAnimGraphRuntime(initial) {
280
283
  triggers.clear();
281
284
  if (rootDelta[0] !== 0 || rootDelta[1] !== 0 || rootDelta[2] !== 0)
282
285
  output.rootDelta = rootDelta;
286
+ if (rootMotion)
287
+ output.rootMotion = true;
283
288
  return output;
284
289
  },
285
290
  };
286
291
  }
292
+ const COMPARES = [">", "<", ">=", "<=", "==", "!="];
293
+ function record(value) {
294
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
295
+ }
296
+ function optionalNumber(value) {
297
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
298
+ }
299
+ function parseState(value) {
300
+ const raw = record(value);
301
+ if (raw === null)
302
+ return null;
303
+ const extra = {
304
+ ...(optionalNumber(raw.speed) === undefined ? {} : { speed: raw.speed }),
305
+ ...(typeof raw.loop === "boolean" ? { loop: raw.loop } : {}),
306
+ ...(typeof raw.rootMotion === "boolean" ? { rootMotion: raw.rootMotion } : {}),
307
+ };
308
+ if (raw.kind === "clip" && typeof raw.clip === "string")
309
+ return { kind: "clip", clip: raw.clip, ...extra };
310
+ if (!Array.isArray(raw.points))
311
+ return null;
312
+ if (raw.kind === "blend1D" && typeof raw.param === "string") {
313
+ const points = raw.points.flatMap((point) => {
314
+ const entry = record(point);
315
+ return entry !== null && typeof entry.clip === "string" && optionalNumber(entry.at) !== undefined ? [{ at: entry.at, clip: entry.clip }] : [];
316
+ });
317
+ return { kind: "blend1D", param: raw.param, points, ...extra };
318
+ }
319
+ if (raw.kind === "blend2D" && Array.isArray(raw.params) && raw.params.length === 2 && raw.params.every((param) => typeof param === "string")) {
320
+ const points = raw.points.flatMap((point) => {
321
+ const entry = record(point);
322
+ const at = entry?.at;
323
+ return entry !== null && typeof entry.clip === "string" && Array.isArray(at) && at.length === 2 && at.every((n) => optionalNumber(n) !== undefined)
324
+ ? [{ at: [at[0], at[1]], clip: entry.clip }]
325
+ : [];
326
+ });
327
+ return { kind: "blend2D", params: [raw.params[0], raw.params[1]], points, ...extra };
328
+ }
329
+ return null;
330
+ }
331
+ function parseTransition(value, states) {
332
+ const raw = record(value);
333
+ if (raw === null || typeof raw.from !== "string" || typeof raw.to !== "string")
334
+ return null;
335
+ if (states[raw.to] === undefined || (raw.from !== "*" && states[raw.from] === undefined))
336
+ return null;
337
+ const when = Array.isArray(raw.when)
338
+ ? raw.when.flatMap((condition) => {
339
+ const entry = record(condition);
340
+ return entry !== null &&
341
+ typeof entry.param === "string" &&
342
+ COMPARES.includes(entry.op) &&
343
+ (typeof entry.value === "number" || typeof entry.value === "boolean")
344
+ ? [{ param: entry.param, op: entry.op, value: entry.value }]
345
+ : [];
346
+ })
347
+ : undefined;
348
+ return {
349
+ from: raw.from,
350
+ to: raw.to,
351
+ ...(when === undefined || when.length === 0 ? {} : { when }),
352
+ ...(typeof raw.trigger === "string" ? { trigger: raw.trigger } : {}),
353
+ ...(optionalNumber(raw.duration) === undefined ? {} : { duration: Math.max(0, raw.duration) }),
354
+ ...(optionalNumber(raw.exitTime) === undefined ? {} : { exitTime: raw.exitTime }),
355
+ };
356
+ }
357
+ /**
358
+ * Validates untrusted JSON (a saved scene document, a network payload) as an {@link AnimGraph}.
359
+ * Malformed states, transitions to unknown states, and bad conditions are dropped; a layer whose
360
+ * entry state is missing is dropped; the result is `undefined` when no layer survives.
361
+ *
362
+ * @capability anim-graph load an animation graph from saved JSON without trusting its shape
363
+ */
364
+ export function parseAnimGraph(value) {
365
+ const raw = record(value);
366
+ if (raw === null || !Array.isArray(raw.layers))
367
+ return undefined;
368
+ const layers = [];
369
+ const seen = new Set();
370
+ for (const candidate of raw.layers) {
371
+ const layer = record(candidate);
372
+ if (layer === null || typeof layer.id !== "string" || seen.has(layer.id) || typeof layer.entry !== "string")
373
+ continue;
374
+ const rawStates = record(layer.states);
375
+ if (rawStates === null)
376
+ continue;
377
+ const states = {};
378
+ for (const [name, state] of Object.entries(rawStates)) {
379
+ const parsed = parseState(state);
380
+ if (parsed !== null)
381
+ states[name] = parsed;
382
+ }
383
+ if (states[layer.entry] === undefined)
384
+ continue;
385
+ const transitions = Array.isArray(layer.transitions)
386
+ ? layer.transitions.flatMap((transition) => {
387
+ const parsed = parseTransition(transition, states);
388
+ return parsed === null ? [] : [parsed];
389
+ })
390
+ : [];
391
+ const mask = Array.isArray(layer.mask) ? layer.mask.filter((prefix) => typeof prefix === "string") : undefined;
392
+ seen.add(layer.id);
393
+ layers.push({
394
+ id: layer.id,
395
+ entry: layer.entry,
396
+ states,
397
+ transitions,
398
+ ...(mask === undefined ? {} : { mask }),
399
+ ...(typeof layer.additive === "boolean" ? { additive: layer.additive } : {}),
400
+ ...(optionalNumber(layer.weight) === undefined ? {} : { weight: Math.min(1, Math.max(0, layer.weight)) }),
401
+ });
402
+ }
403
+ if (layers.length === 0)
404
+ return undefined;
405
+ const events = Array.isArray(raw.events)
406
+ ? raw.events.flatMap((event) => {
407
+ const entry = record(event);
408
+ return entry !== null && typeof entry.clip === "string" && typeof entry.name === "string" && optionalNumber(entry.atSec) !== undefined
409
+ ? [{ clip: entry.clip, atSec: entry.atSec, name: entry.name }]
410
+ : [];
411
+ })
412
+ : [];
413
+ return { layers, ...(events.length === 0 ? {} : { events }) };
414
+ }
@@ -0,0 +1,55 @@
1
+ /** A clip to bake: its name and length in seconds. */
2
+ export interface BoneTextureClipInput {
3
+ name: string;
4
+ duration: number;
5
+ }
6
+ /** One baked clip's rows in a {@link BoneTextureLayout}. */
7
+ export interface BoneTextureClip {
8
+ name: string;
9
+ /** First texture row of the clip. */
10
+ startFrame: number;
11
+ /** Rows baked for the clip, first and last pose included. */
12
+ frameCount: number;
13
+ duration: number;
14
+ }
15
+ /**
16
+ * Where every bone matrix of every baked frame lives in a vertex-animation texture: one row per
17
+ * frame, four RGBA texels (matrix columns) per bone. Plain data, so a baked crowd can be cached,
18
+ * shipped or checked in a test without a renderer.
19
+ */
20
+ export interface BoneTextureLayout {
21
+ boneCount: number;
22
+ fps: number;
23
+ width: number;
24
+ height: number;
25
+ clips: BoneTextureClip[];
26
+ }
27
+ /** Inputs for {@link planBoneTexture}. */
28
+ export interface BoneTextureInput {
29
+ boneCount: number;
30
+ clips: readonly BoneTextureClipInput[];
31
+ /** Baked samples per second. Default 30. */
32
+ fps?: number;
33
+ /** Largest texture side the target GPU accepts. Default 4096. */
34
+ maxTextureSize?: number;
35
+ }
36
+ /**
37
+ * Plans a bone-matrix texture for a rig's clips. Throws when the rig or the clip set does not fit
38
+ * `maxTextureSize`; lower `fps` or bake fewer clips.
39
+ *
40
+ * @capability animation bake many clips of a rig into one bone-matrix texture for instanced crowds
41
+ */
42
+ export declare function planBoneTexture(input: BoneTextureInput): BoneTextureLayout;
43
+ /** Two texture rows around a playback time and the blend between them. */
44
+ export interface BoneTextureSample {
45
+ rowA: number;
46
+ rowB: number;
47
+ t: number;
48
+ }
49
+ /**
50
+ * Finds the baked rows around `time` in a clip, wrapping when `loop` and holding the last pose
51
+ * otherwise. This is the CPU mirror of the crowd shader's lookup.
52
+ *
53
+ * @capability animation look up the baked frames and blend for a crowd member's playback time
54
+ */
55
+ export declare function sampleBoneTexture(clip: BoneTextureClip, fps: number, time: number, loop: boolean, out?: BoneTextureSample): BoneTextureSample;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Plans a bone-matrix texture for a rig's clips. Throws when the rig or the clip set does not fit
3
+ * `maxTextureSize`; lower `fps` or bake fewer clips.
4
+ *
5
+ * @capability animation bake many clips of a rig into one bone-matrix texture for instanced crowds
6
+ */
7
+ export function planBoneTexture(input) {
8
+ const fps = input.fps ?? 30;
9
+ const maxSize = input.maxTextureSize ?? 4096;
10
+ if (!(fps > 0))
11
+ throw new Error(`planBoneTexture: fps must be positive, got ${fps}`);
12
+ const width = Math.max(1, input.boneCount) * 4;
13
+ if (width > maxSize)
14
+ throw new Error(`planBoneTexture: ${input.boneCount} bones need ${width} texels per row, over the ${maxSize} limit`);
15
+ const clips = [];
16
+ let row = 0;
17
+ for (const clip of input.clips) {
18
+ const duration = Math.max(0, clip.duration);
19
+ const frameCount = Math.max(2, Math.ceil(duration * fps) + 1);
20
+ clips.push({ name: clip.name, startFrame: row, frameCount, duration });
21
+ row += frameCount;
22
+ }
23
+ if (row > maxSize)
24
+ throw new Error(`planBoneTexture: ${row} frames exceed the ${maxSize}-row limit; lower fps or bake fewer clips`);
25
+ return { boneCount: input.boneCount, fps, width, height: Math.max(1, row), clips };
26
+ }
27
+ /**
28
+ * Finds the baked rows around `time` in a clip, wrapping when `loop` and holding the last pose
29
+ * otherwise. This is the CPU mirror of the crowd shader's lookup.
30
+ *
31
+ * @capability animation look up the baked frames and blend for a crowd member's playback time
32
+ */
33
+ export function sampleBoneTexture(clip, fps, time, loop, out) {
34
+ const result = out ?? { rowA: 0, rowB: 0, t: 0 };
35
+ const span = clip.frameCount - 1;
36
+ const raw = time * fps;
37
+ const frame = loop ? ((raw % span) + span) % span : Math.min(Math.max(raw, 0), span);
38
+ const a = Math.floor(frame);
39
+ result.rowA = clip.startFrame + a;
40
+ result.rowB = clip.startFrame + Math.min(a + 1, span);
41
+ result.t = frame - a;
42
+ return result;
43
+ }
@@ -0,0 +1,57 @@
1
+ /** One foot's animated ankle height and the ground found under it, both in world units. */
2
+ export interface FootGroundSample {
3
+ /** Animated ankle (foot bone) height this frame, before any correction. */
4
+ ankleY: number;
5
+ /** Ground height under the foot, or `null` when the probe found nothing. */
6
+ groundY: number | null;
7
+ }
8
+ /** Inputs for {@link placeFeet}. All heights are world units. */
9
+ export interface FootPlacementInput {
10
+ feet: readonly FootGroundSample[];
11
+ /** Height of the model origin, which the clips were authored to stand on. */
12
+ originY: number;
13
+ /** Ankle height above the sole in the rig's rest pose; the sole never ends below the ground. */
14
+ ankleHeight: number;
15
+ /** Largest correction applied to a foot or the pelvis, up or down. */
16
+ maxAdjust: number;
17
+ /** A lowest sole this far above its ground reads as airborne. */
18
+ airborneGap: number;
19
+ }
20
+ /** Result of {@link placeFeet}: where each ankle should go and how far the pelvis drops. */
21
+ export interface FootPlacement {
22
+ /** Vertical pelvis shift, `<= 0`, so the lowest planted foot can reach its ground. */
23
+ pelvisOffset: number;
24
+ /** Target ankle height per foot, or `null` when that foot has no ground or no correction. */
25
+ ankleTargets: (number | null)[];
26
+ /** `false` while the feet are clear of the ground, so callers fade the correction out. */
27
+ grounded: boolean;
28
+ }
29
+ /**
30
+ * Resolves foot placement for a rig whose clips were authored on flat ground at its origin.
31
+ * Each foot keeps its animated lift and moves by its ground's height above or below the origin,
32
+ * the sole is never left under the ground, and the pelvis drops by the deepest reach so both legs
33
+ * stay within length. Pure and allocation-light; the renderer applies the targets with
34
+ * {@link solveTwoBone}.
35
+ *
36
+ * @capability animation ground rigged feet on slopes and steps without hiding the swing leg
37
+ */
38
+ export declare function placeFeet(input: FootPlacementInput, out?: FootPlacement): FootPlacement;
39
+ /** A bone as {@link inferLegChains} sees it: its name and its parent's name. */
40
+ export interface RigBone {
41
+ name: string;
42
+ parent: string | null;
43
+ }
44
+ /** Thigh → shin → foot bone names for one leg. */
45
+ export interface LegChain {
46
+ root: string;
47
+ mid: string;
48
+ tip: string;
49
+ }
50
+ /**
51
+ * Finds leg chains on a humanoid rig by bone name: a thigh-like bone (`UpLeg`, `UpperLeg`, `Thigh`)
52
+ * with its first child and grandchild as shin and foot. Control, IK-target and twist bones are
53
+ * skipped. Returns chains in rig order; an empty array means the rig has no recognizable legs.
54
+ *
55
+ * @capability animation find thigh, shin and foot bones on a humanoid rig for foot IK
56
+ */
57
+ export declare function inferLegChains(bones: readonly RigBone[]): LegChain[];
@@ -0,0 +1,67 @@
1
+ function clamp(value, limit) {
2
+ return Math.max(-limit, Math.min(limit, value));
3
+ }
4
+ /**
5
+ * Resolves foot placement for a rig whose clips were authored on flat ground at its origin.
6
+ * Each foot keeps its animated lift and moves by its ground's height above or below the origin,
7
+ * the sole is never left under the ground, and the pelvis drops by the deepest reach so both legs
8
+ * stay within length. Pure and allocation-light; the renderer applies the targets with
9
+ * {@link solveTwoBone}.
10
+ *
11
+ * @capability animation ground rigged feet on slopes and steps without hiding the swing leg
12
+ */
13
+ export function placeFeet(input, out) {
14
+ const result = out ?? { pelvisOffset: 0, ankleTargets: [], grounded: false };
15
+ result.ankleTargets.length = input.feet.length;
16
+ const limit = Math.max(0, input.maxAdjust);
17
+ let lowestGap = Infinity;
18
+ let pelvis = 0;
19
+ for (let i = 0; i < input.feet.length; i += 1) {
20
+ const foot = input.feet[i];
21
+ if (foot.groundY === null) {
22
+ result.ankleTargets[i] = null;
23
+ continue;
24
+ }
25
+ lowestGap = Math.min(lowestGap, foot.ankleY - input.ankleHeight - foot.groundY);
26
+ const followed = foot.ankleY + clamp(foot.groundY - input.originY, limit);
27
+ const target = Math.min(foot.ankleY + limit, Math.max(followed, foot.groundY + input.ankleHeight));
28
+ result.ankleTargets[i] = target;
29
+ pelvis = Math.min(pelvis, target - foot.ankleY);
30
+ }
31
+ result.grounded = lowestGap <= input.airborneGap;
32
+ result.pelvisOffset = result.grounded ? Math.max(-limit, pelvis) : 0;
33
+ return result;
34
+ }
35
+ const THIGH = /(upleg|upperleg|thigh|femur)/;
36
+ const HELPER = /(ik|control|ctrl|twist|pole|target|roll)/;
37
+ /**
38
+ * Finds leg chains on a humanoid rig by bone name: a thigh-like bone (`UpLeg`, `UpperLeg`, `Thigh`)
39
+ * with its first child and grandchild as shin and foot. Control, IK-target and twist bones are
40
+ * skipped. Returns chains in rig order; an empty array means the rig has no recognizable legs.
41
+ *
42
+ * @capability animation find thigh, shin and foot bones on a humanoid rig for foot IK
43
+ */
44
+ export function inferLegChains(bones) {
45
+ const children = new Map();
46
+ for (const bone of bones) {
47
+ if (bone.parent === null)
48
+ continue;
49
+ const list = children.get(bone.parent);
50
+ if (list === undefined)
51
+ children.set(bone.parent, [bone.name]);
52
+ else
53
+ list.push(bone.name);
54
+ }
55
+ const firstChild = (name) => children.get(name)?.find((child) => !HELPER.test(child.toLowerCase()));
56
+ const chains = [];
57
+ for (const bone of bones) {
58
+ const key = bone.name.toLowerCase().replace(/[^a-z0-9]/g, "");
59
+ if (!THIGH.test(key) || HELPER.test(key))
60
+ continue;
61
+ const mid = firstChild(bone.name);
62
+ const tip = mid === undefined ? undefined : firstChild(mid);
63
+ if (mid !== undefined && tip !== undefined)
64
+ chains.push({ root: bone.name, mid, tip });
65
+ }
66
+ return chains;
67
+ }
@@ -1,3 +1,4 @@
1
+ import type { ModelAnimationConfig } from "../game/playableGame.js";
1
2
  import type { AnimGraph } from "./animGraph.js";
2
3
  /** Inputs for {@link locomotionGraph}: the idle/walk/run clip names and the one-shot table a rig config already carries. */
3
4
  export interface LocomotionGraphInput {
@@ -24,3 +25,11 @@ export declare const LOCOMOTION_LAYER = "base";
24
25
  * @capability locomotion-graph build the default idle/walk/run plus one-shot graph from clip names
25
26
  */
26
27
  export declare function locomotionGraph(input: LocomotionGraphInput): AnimGraph;
28
+ /**
29
+ * The graph a model animation config plays: its `graph`, or the {@link locomotionGraph} its
30
+ * `states` and `oneShots` describe (a `string[]` one-shot uses its first variant). `undefined` for a
31
+ * single-clip config. The shell plays this and the editor inspects it, so both see the same graph.
32
+ *
33
+ * @capability locomotion-graph resolve the animation graph a model config plays
34
+ */
35
+ export declare function animGraphFromConfig(config: ModelAnimationConfig): AnimGraph | undefined;
@@ -31,3 +31,32 @@ export function locomotionGraph(input) {
31
31
  layers: [{ id: LOCOMOTION_LAYER, entry: "locomotion", states, transitions }],
32
32
  };
33
33
  }
34
+ /**
35
+ * The graph a model animation config plays: its `graph`, or the {@link locomotionGraph} its
36
+ * `states` and `oneShots` describe (a `string[]` one-shot uses its first variant). `undefined` for a
37
+ * single-clip config. The shell plays this and the editor inspects it, so both see the same graph.
38
+ *
39
+ * @capability locomotion-graph resolve the animation graph a model config plays
40
+ */
41
+ export function animGraphFromConfig(config) {
42
+ if (config.graph !== undefined)
43
+ return config.graph;
44
+ const states = config.states;
45
+ if (states === undefined)
46
+ return undefined;
47
+ const oneShots = {};
48
+ for (const [event, spec] of Object.entries(config.oneShots ?? {})) {
49
+ const clip = typeof spec === "string" ? spec : spec[0];
50
+ if (clip !== undefined)
51
+ oneShots[event] = clip;
52
+ }
53
+ return locomotionGraph({
54
+ idle: states.idle,
55
+ walk: states.walk,
56
+ ...(states.run === undefined ? {} : { run: states.run }),
57
+ walkSpeed: states.walkSpeed,
58
+ runSpeed: states.runSpeed,
59
+ fadeSec: states.fadeSec,
60
+ ...(Object.keys(oneShots).length === 0 ? {} : { oneShots }),
61
+ });
62
+ }
@@ -27,6 +27,8 @@ export interface SoundDef {
27
27
  /** Positional emitters attenuate by distance from the listener; non-positional sounds (UI, music) play at flat gain. Default true. */
28
28
  positional?: boolean;
29
29
  falloff?: AudioFalloffConfig;
30
+ /** Doppler strength for retained loops: 1 is physical, 0 or absent is off. Emitter velocity comes from `setLoop({ velocity })`, listener velocity from the camera. */
31
+ doppler?: number;
30
32
  /** Web Audio spatial panning configuration. When present, the shell uses a PannerNode. */
31
33
  spatial?: {
32
34
  panning: "hrtf" | "equalpower";
@@ -0,0 +1,21 @@
1
+ /** Speed of sound in air at 20 °C, metres per second — the default for {@link dopplerRate}. */
2
+ export declare const SPEED_OF_SOUND = 343;
3
+ type Vec3Tuple = readonly [number, number, number];
4
+ /** Options for {@link dopplerRate}. */
5
+ export interface DopplerOptions {
6
+ /** Scales both velocities; 1 is physical, 0 disables the shift. Default 1. */
7
+ factor?: number;
8
+ /** World units per second sound travels. Default {@link SPEED_OF_SOUND}. */
9
+ speedOfSound?: number;
10
+ /** Clamp window for the returned multiplier. Default `[0.5, 2]`. */
11
+ range?: readonly [number, number];
12
+ }
13
+ /**
14
+ * Doppler pitch multiplier for an emitter heard by a listener: `(c + vListener) / (c - vEmitter)`,
15
+ * where each speed is the component along the line between them, positive when closing. Multiply a
16
+ * loop's playback rate by it. Pure and allocation-free.
17
+ *
18
+ * @capability doppler-pitch pitch a moving sound up as it approaches and down as it passes, from listener and emitter velocity
19
+ */
20
+ export declare function dopplerRate(listenerPosition: Vec3Tuple, listenerVelocity: Vec3Tuple, emitterPosition: Vec3Tuple, emitterVelocity: Vec3Tuple, options?: DopplerOptions): number;
21
+ export {};
@@ -0,0 +1,32 @@
1
+ /** Speed of sound in air at 20 °C, metres per second — the default for {@link dopplerRate}. */
2
+ export const SPEED_OF_SOUND = 343;
3
+ /**
4
+ * Doppler pitch multiplier for an emitter heard by a listener: `(c + vListener) / (c - vEmitter)`,
5
+ * where each speed is the component along the line between them, positive when closing. Multiply a
6
+ * loop's playback rate by it. Pure and allocation-free.
7
+ *
8
+ * @capability doppler-pitch pitch a moving sound up as it approaches and down as it passes, from listener and emitter velocity
9
+ */
10
+ export function dopplerRate(listenerPosition, listenerVelocity, emitterPosition, emitterVelocity, options = {}) {
11
+ const factor = options.factor ?? 1;
12
+ const c = options.speedOfSound ?? SPEED_OF_SOUND;
13
+ const min = options.range?.[0] ?? 0.5;
14
+ const max = options.range?.[1] ?? 2;
15
+ const dx = emitterPosition[0] - listenerPosition[0];
16
+ const dy = emitterPosition[1] - listenerPosition[1];
17
+ const dz = emitterPosition[2] - listenerPosition[2];
18
+ const distance = Math.hypot(dx, dy, dz);
19
+ if (factor === 0 || c <= 0 || distance < 1e-6)
20
+ return 1;
21
+ const ux = dx / distance;
22
+ const uy = dy / distance;
23
+ const uz = dz / distance;
24
+ const listenerClosing = factor * (listenerVelocity[0] * ux + listenerVelocity[1] * uy + listenerVelocity[2] * uz);
25
+ const emitterClosing = -factor * (emitterVelocity[0] * ux + emitterVelocity[1] * uy + emitterVelocity[2] * uz);
26
+ const numerator = c + Math.max(-c * 0.99, listenerClosing);
27
+ const denominator = c - Math.min(c * 0.99, emitterClosing);
28
+ const rate = numerator / denominator;
29
+ if (!Number.isFinite(rate))
30
+ return 1;
31
+ return rate < min ? min : rate > max ? max : rate;
32
+ }
@@ -0,0 +1,83 @@
1
+ /** One recorded engine sample, keyed by the rpm it was recorded at. */
2
+ export interface EngineLayer {
3
+ /** Catalog sound id of the loop. */
4
+ sound: string;
5
+ /** Rpm the sample was recorded at; playback rate is `rpm / layer.rpm`. */
6
+ rpm: number;
7
+ /** Which load set the layer belongs to: `"on"` (throttle), `"off"` (coast/overrun) or `"any"` (always). Default `"any"`. */
8
+ load?: "on" | "off" | "any";
9
+ /** Extra gain for this layer. Default 1. */
10
+ gain?: number;
11
+ }
12
+ /** Config for {@link createEngineLayers}. */
13
+ export interface EngineLayersConfig {
14
+ /** Prefix for the retained loop ids, one per layer: `${id}:${index}`. */
15
+ id: string;
16
+ layers: readonly EngineLayer[];
17
+ /** Load window the off-load set crossfades to the on-load set across. Default `[0.15, 0.6]`. */
18
+ loadCrossfade?: readonly [number, number];
19
+ /** Exponential rate the load follows `engineLoad` with, so a lifted throttle does not click between sets. Default 10. */
20
+ loadResponse?: number;
21
+ }
22
+ /** One layer's live mix. */
23
+ export interface EngineLayerMix {
24
+ /** Retained loop id. */
25
+ id: string;
26
+ sound: string;
27
+ gain: number;
28
+ rate: number;
29
+ }
30
+ /** Serializable {@link EngineLayers} state. */
31
+ export interface EngineLayersState {
32
+ load: number;
33
+ }
34
+ /** Per-tick parameters {@link EngineLayers.play} forwards to every layer's loop. */
35
+ export interface EngineLayersPlayOptions {
36
+ at?: readonly [number, number, number];
37
+ velocity?: readonly [number, number, number];
38
+ /** Overall gain multiplier. Default 1. */
39
+ gain?: number;
40
+ lowpass?: number;
41
+ highpass?: number;
42
+ }
43
+ /** The part of `ctx.game.audio` {@link EngineLayers} drives. */
44
+ export interface EngineLayersAudio {
45
+ loop(id: string, sound: string, options?: {
46
+ at?: readonly [number, number, number];
47
+ }): void;
48
+ setLoop(id: string, params: {
49
+ rate?: number;
50
+ gain?: number;
51
+ at?: readonly [number, number, number];
52
+ velocity?: readonly [number, number, number];
53
+ lowpass?: number;
54
+ highpass?: number;
55
+ }): void;
56
+ stopLoop(id: string): void;
57
+ }
58
+ /** A layered engine loop: N rpm-keyed samples crossfaded by rpm, with on- and off-load sets picked by engine load. */
59
+ export interface EngineLayers {
60
+ /** Advance by `dt` and return each layer's gain and rate. The returned array and its entries are reused between calls. */
61
+ update(dt: number, signals: {
62
+ rpm: number;
63
+ load: number;
64
+ }): readonly EngineLayerMix[];
65
+ /** Start (idempotently) and update every layer's retained loop from the last {@link update}. */
66
+ play(audio: EngineLayersAudio, options?: EngineLayersPlayOptions): void;
67
+ /** Stop every layer's loop. */
68
+ stop(audio: EngineLayersAudio): void;
69
+ mix(): readonly EngineLayerMix[];
70
+ retune(next: EngineLayersConfig): void;
71
+ snapshot(): EngineLayersState;
72
+ restore(state: EngineLayersState): void;
73
+ reset(): void;
74
+ }
75
+ /**
76
+ * Layered engine sound: each set of rpm-keyed samples is equal-power crossfaded between the two
77
+ * samples bracketing the current rpm and pitched by `rpm / layer.rpm`; the on- and off-load sets are
78
+ * equal-power crossfaded by smoothed `engineLoad`. A config with only one load set plays it at every
79
+ * load. `play` drives `ctx.game.audio` directly, so a game needs no per-layer glue.
80
+ *
81
+ * @capability engine-layers layered engine audio: rpm-keyed samples crossfaded, with on-load and off-load sets picked by engine load
82
+ */
83
+ export declare function createEngineLayers(initial: EngineLayersConfig): EngineLayers;