@jgengine/shell 0.19.0 → 0.21.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 (203) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +113 -15
  5. package/dist/Shell3dPresentation.d.ts +2 -2
  6. package/dist/Shell3dPresentation.js +49 -7
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +11 -1
  10. package/dist/ShellHudPresentation.js +20 -6
  11. package/dist/audio/audioEngine.d.ts +16 -0
  12. package/dist/audio/audioEngine.js +178 -89
  13. package/dist/audio/audioWire.js +2 -2
  14. package/dist/audio/synthEngine.d.ts +7 -1
  15. package/dist/audio/synthEngine.js +103 -38
  16. package/dist/camera/GameCameraRig.js +2 -1
  17. package/dist/camera/GameFirstPersonCamera.d.ts +2 -2
  18. package/dist/camera/GameFirstPersonCamera.js +17 -13
  19. package/dist/camera/cameraRigs.js +8 -2
  20. package/dist/camera/chaseHeading.d.ts +9 -0
  21. package/dist/camera/chaseHeading.js +10 -0
  22. package/dist/camera/customMuzzle.d.ts +14 -0
  23. package/dist/camera/customMuzzle.js +68 -0
  24. package/dist/camera/index.d.ts +1 -0
  25. package/dist/camera/index.js +1 -0
  26. package/dist/camera/rigResolve.d.ts +3 -0
  27. package/dist/camera/rigResolve.js +11 -0
  28. package/dist/combat/ProjectileModels.d.ts +31 -0
  29. package/dist/combat/ProjectileModels.js +70 -0
  30. package/dist/commandSink.d.ts +1 -2
  31. package/dist/commandSink.js +3 -2
  32. package/dist/defineGame.d.ts +12 -5
  33. package/dist/defineGame.js +24 -9
  34. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  35. package/dist/devtools/DevtoolsOverlay.js +14 -168
  36. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  37. package/dist/devtools/DevtoolsRuntime.js +61 -0
  38. package/dist/devtools/agentBridge.js +1 -1
  39. package/dist/devtools/devtoolsReports.d.ts +68 -0
  40. package/dist/devtools/devtoolsReports.js +100 -0
  41. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  42. package/dist/devtools/latencyInstrumentation.js +11 -0
  43. package/dist/devtools/playtestProbe.d.ts +4 -0
  44. package/dist/devtools/playtestProbe.js +22 -0
  45. package/dist/drivers/FrameDriver.js +25 -21
  46. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  47. package/dist/drivers/HudOnlyDriver.js +32 -14
  48. package/dist/environment/EnvironmentScene.js +11 -10
  49. package/dist/inputSink.d.ts +6 -6
  50. package/dist/inputSink.js +60 -21
  51. package/dist/lazyShell3dPresentation.d.ts +9 -0
  52. package/dist/lazyShell3dPresentation.js +24 -0
  53. package/dist/materialOverride.d.ts +42 -0
  54. package/dist/materialOverride.js +95 -36
  55. package/dist/multiplayer.d.ts +33 -3
  56. package/dist/multiplayer.js +105 -22
  57. package/dist/pointer/pointerService.d.ts +2 -0
  58. package/dist/pointer/pointerService.js +12 -2
  59. package/dist/postfx/PostProcessing.d.ts +10 -0
  60. package/dist/postfx/PostProcessing.js +9 -41
  61. package/dist/postfx/gradeShader.d.ts +1 -1
  62. package/dist/postfx/gradeShader.js +30 -2
  63. package/dist/postfx/shadowPrepass.d.ts +3 -0
  64. package/dist/postfx/shadowPrepass.js +18 -0
  65. package/dist/presentationRecovery.d.ts +75 -0
  66. package/dist/presentationRecovery.js +204 -0
  67. package/dist/presentationRecovery3d.d.ts +4 -0
  68. package/dist/presentationRecovery3d.js +32 -0
  69. package/dist/presentationResolve.d.ts +8 -1
  70. package/dist/presentationResolve.js +5 -2
  71. package/dist/render/CascadedShadows.d.ts +7 -2
  72. package/dist/render/CascadedShadows.js +98 -20
  73. package/dist/render/EnvironmentLighting.js +74 -35
  74. package/dist/render/HairCards.d.ts +7 -0
  75. package/dist/render/HairCards.js +23 -0
  76. package/dist/render/SceneModels.js +51 -77
  77. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  78. package/dist/render/authoredSurfaceMaterial.js +108 -0
  79. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  80. package/dist/render/materialAppearanceSignals.js +58 -0
  81. package/dist/render/materialAsset.d.ts +98 -0
  82. package/dist/render/materialAsset.js +454 -0
  83. package/dist/render/measureBounds.js +16 -3
  84. package/dist/render/measureCollisionMesh.js +8 -1
  85. package/dist/render/modelAssets.d.ts +8 -0
  86. package/dist/render/modelAssets.js +43 -0
  87. package/dist/render/modelBindPose.d.ts +5 -0
  88. package/dist/render/modelBindPose.js +35 -0
  89. package/dist/render/modelLoad.d.ts +3 -0
  90. package/dist/render/modelLoad.js +9 -0
  91. package/dist/render/modelNodeVisibility.d.ts +6 -0
  92. package/dist/render/modelNodeVisibility.js +18 -0
  93. package/dist/render/modelRender.d.ts +18 -1
  94. package/dist/render/modelRender.js +81 -3
  95. package/dist/render/projectedOverdraw.d.ts +35 -0
  96. package/dist/render/projectedOverdraw.js +204 -0
  97. package/dist/render/resolveModel.d.ts +12 -2
  98. package/dist/render/resolveModel.js +21 -4
  99. package/dist/render/rigNode.d.ts +26 -0
  100. package/dist/render/rigNode.js +32 -0
  101. package/dist/render/spriteRender.d.ts +4 -0
  102. package/dist/render/spriteRender.js +5 -0
  103. package/dist/render/staticShapeInstances.d.ts +34 -0
  104. package/dist/render/staticShapeInstances.js +78 -0
  105. package/dist/render/useFootIk.js +76 -11
  106. package/dist/render/useModelAnimation.d.ts +28 -2
  107. package/dist/render/useModelAnimation.js +194 -69
  108. package/dist/render/useModelInstance.d.ts +25 -0
  109. package/dist/render/useModelInstance.js +43 -0
  110. package/dist/scatter/scatterModels.d.ts +3 -2
  111. package/dist/scatter/scatterModels.js +15 -7
  112. package/dist/scene/AuthoredScene.d.ts +14 -6
  113. package/dist/scene/AuthoredScene.js +30 -9
  114. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  115. package/dist/settings/SettingsMenu.js +11 -18
  116. package/dist/settings/SettingsRuntime.d.ts +5 -0
  117. package/dist/settings/SettingsRuntime.js +29 -2
  118. package/dist/shellControlSuspension.d.ts +18 -0
  119. package/dist/shellControlSuspension.js +28 -0
  120. package/dist/shellInputPublication.d.ts +28 -0
  121. package/dist/shellInputPublication.js +49 -0
  122. package/dist/shellPointerInput.d.ts +18 -0
  123. package/dist/shellPointerInput.js +49 -0
  124. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  125. package/dist/structures/BuildingKitBatch.js +34 -59
  126. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  127. package/dist/structures/BuildingRenderBudget.js +54 -0
  128. package/dist/structures/GeneratedBuilding.js +13 -8
  129. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  130. package/dist/structures/buildingKitMaterials.js +70 -0
  131. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  132. package/dist/structures/buildingSpatialBatch.js +45 -0
  133. package/dist/terrain/GrassField.d.ts +2 -1
  134. package/dist/terrain/GrassField.js +59 -5
  135. package/dist/terrain/grassGeometry.d.ts +10 -0
  136. package/dist/terrain/grassGeometry.js +55 -0
  137. package/dist/terrain/grassMaterial.js +2 -2
  138. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  139. package/dist/terrain/terrainDetailMaterial.js +7 -7
  140. package/dist/touch/TouchControlsOverlay.js +4 -4
  141. package/dist/useShellMultiplayerSync.js +4 -0
  142. package/dist/vfx/ParticleField.d.ts +23 -20
  143. package/dist/vfx/ParticleField.js +168 -50
  144. package/dist/visibility/CullingProvider.js +2 -62
  145. package/dist/visibility/cullingDriver.d.ts +9 -0
  146. package/dist/visibility/cullingDriver.js +68 -0
  147. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  148. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  149. package/dist/weather/DustField.d.ts +2 -1
  150. package/dist/weather/DustField.js +17 -3
  151. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  152. package/dist/weather/FireSpreadLayer.js +5 -11
  153. package/dist/weather/LightningStrike.d.ts +4 -1
  154. package/dist/weather/LightningStrike.js +13 -17
  155. package/dist/weather/RainField.d.ts +2 -1
  156. package/dist/weather/RainField.js +17 -3
  157. package/dist/weather/RainImpactField.d.ts +16 -0
  158. package/dist/weather/RainImpactField.js +89 -0
  159. package/dist/weather/SnowField.d.ts +2 -1
  160. package/dist/weather/SnowField.js +17 -3
  161. package/dist/weather/WeatherLayer.d.ts +11 -2
  162. package/dist/weather/WeatherLayer.js +30 -6
  163. package/dist/weather/index.d.ts +2 -0
  164. package/dist/weather/index.js +2 -0
  165. package/dist/weather/weatherMath.d.ts +2 -0
  166. package/dist/weather/weatherMath.js +8 -0
  167. package/dist/weather/weatherShelter.d.ts +18 -0
  168. package/dist/weather/weatherShelter.js +75 -0
  169. package/dist/weather/weatherUniforms.d.ts +40 -1
  170. package/dist/weather/weatherUniforms.js +29 -9
  171. package/dist/world/AuthoredFlocks.d.ts +22 -0
  172. package/dist/world/AuthoredFlocks.js +87 -0
  173. package/dist/world/StaticObjectModels.d.ts +8 -0
  174. package/dist/world/StaticObjectModels.js +75 -0
  175. package/dist/world/WorldHud.d.ts +14 -6
  176. package/dist/world/WorldHud.js +25 -12
  177. package/dist/world/WorldParticles.d.ts +10 -23
  178. package/dist/world/WorldParticles.js +209 -61
  179. package/dist/world/WorldScene.d.ts +3 -1
  180. package/dist/world/WorldScene.js +41 -26
  181. package/dist/world/staticObjectBatches.d.ts +35 -0
  182. package/dist/world/staticObjectBatches.js +163 -0
  183. package/dist/world/worldBarSamples.d.ts +37 -33
  184. package/dist/world/worldBarSamples.js +117 -75
  185. package/package.json +4 -4
  186. package/skills/ce-handoff/SKILL.md +1 -1
  187. package/skills/jgengine/SKILL.md +22 -2
  188. package/skills/jgengine/api.md +28 -17
  189. package/skills/jgengine/recipes/minimal-game.md +7 -2
  190. package/skills/jgengine-ui/SKILL.md +3 -1
  191. package/skills/jgengine-ui/api.md +204 -67
  192. package/skills/jgengine-ui/capabilities.md +115 -1
  193. package/skills/jgengine-ui/reference-primitives.md +93 -3
  194. package/skills/jgengine-ui/reference.md +2 -0
  195. package/skills/jgengine-world/SKILL.md +12 -3
  196. package/skills/jgengine-world/api.md +200 -57
  197. package/skills/jgengine-world/capabilities.md +116 -3
  198. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  199. package/skills/jgengine-world/recipes/controls.md +4 -0
  200. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  201. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  202. package/skills/jgengine-world/reference.md +152 -5
  203. package/skills/jgengine-world/visibility.md +28 -1
@@ -1,16 +1,21 @@
1
- import { useFrame } from "@react-three/fiber";
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
5
5
  import { ANIM_PARAMS_KEY, createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
6
- import { animGraphFromConfig, LOCOMOTION_SPEED_PARAM } from "@jgengine/core/anim/locomotionGraph";
6
+ import { animGraphFromConfig, LOCOMOTION_CROUCHED_PARAM, LOCOMOTION_GROUNDED_PARAM, LOCOMOTION_SPEED_PARAM, LOCOMOTION_VERTICAL_SPEED_PARAM } from "@jgengine/core/anim/locomotionGraph";
7
+ import { playerMovementTelemetry } from "@jgengine/core/movement/playerMovement";
8
+ import { seededRng } from "@jgengine/core/random/rng";
7
9
  import { useOptionalGameContext } from "@jgengine/react/provider";
8
10
  function graphClipNames(graph) {
9
11
  const names = new Set();
10
12
  for (const layer of graph.layers) {
11
13
  for (const state of Object.values(layer.states)) {
12
- if (state.kind === "clip")
14
+ if (state.kind === "clip") {
13
15
  names.add(state.clip);
16
+ for (const variant of state.variants ?? [])
17
+ names.add(variant);
18
+ }
14
19
  else
15
20
  for (const point of state.points)
16
21
  names.add(point.clip);
@@ -19,54 +24,110 @@ function graphClipNames(graph) {
19
24
  return names;
20
25
  }
21
26
  /**
22
- * An explicit `animation` config always wins over `"auto"`, and both the state machine and the
23
- * one-shot table degrade quietly when a named clip is absent: states fall back to `clips[0]` (often
24
- * an attack clip, so an actor "idles" mid-swing) and one-shots are dropped, so deaths and attacks
25
- * never play. Neither leaves a trace, which makes a mistyped or wrong-pack clip name one of the
26
- * hardest render bugs to see — the model loads, animates, and is simply wrong forever.
27
+ * Checks clip mappings, masked layers and root-motion assumptions without starting a mixer.
27
28
  *
28
- * Warn once per mount with the names that missed and the names the rig actually has, so the fix
29
- * (usually deleting the hand-written config and letting `"auto"` derive it) is obvious.
29
+ * @capability model-animation-diagnostics inspect imported clip mappings, layer masks and root-track compatibility
30
30
  */
31
- function warnMissingClips(animation, clips) {
32
- if (typeof console === "undefined")
33
- return;
31
+ export function diagnoseModelAnimation(scene, animation, clips) {
32
+ const diagnostics = [];
33
+ if (animation.auto === true) {
34
+ const automatic = resolveAnimationConfig(animation, clips.map((clip) => clip.name));
35
+ if (automatic === undefined)
36
+ return [{
37
+ code: "missing-auto-idle",
38
+ message: `explicit automatic playback needs an identifiable idle clip on this rig. Available: ${clips.length === 0 ? "none" : clips.map((clip) => clip.name).join(", ")}. The bind pose is retained; use a supported animated asset or disable automatic derivation and author this rig's clip mapping.`,
39
+ }];
40
+ animation = automatic;
41
+ }
34
42
  const available = new Set(clips.map((clip) => clip.name));
35
43
  const missing = new Set();
36
- const { states, oneShots, graph } = animation;
37
- if (states !== undefined) {
38
- for (const name of [states.idle, states.walk, states.run]) {
39
- if (name !== undefined && !available.has(name))
40
- missing.add(name);
41
- }
42
- }
43
- if (oneShots !== undefined) {
44
- for (const spec of Object.values(oneShots)) {
45
- for (const name of typeof spec === "string" ? [spec] : spec) {
46
- if (!available.has(name))
47
- missing.add(name);
48
- }
49
- }
44
+ const resolved = animGraphFromConfig(animation);
45
+ const { states } = animation;
46
+ if (animation.graph === undefined && states !== undefined) {
47
+ if (typeof states.idle !== "string" || states.idle.trim().length === 0)
48
+ diagnostics.push({
49
+ code: "incomplete-locomotion", message: `locomotion needs a nonempty idle clip. Configure this rig's idle role; ${animation.clip === undefined ? "the bind pose is retained" : `single clip "${animation.clip}" remains active`} until then.`,
50
+ });
51
+ else if (typeof states.walk !== "string" || states.walk.trim().length === 0)
52
+ diagnostics.push({
53
+ code: "incomplete-locomotion", message: `locomotion has no walk clip; idle "${states.idle}" is held at walking speeds until this rig's walk role is configured.`,
54
+ });
50
55
  }
51
- if (graph !== undefined) {
52
- for (const name of graphClipNames(graph))
56
+ if (resolved !== undefined) {
57
+ for (const name of graphClipNames(resolved))
53
58
  if (!available.has(name))
54
59
  missing.add(name);
55
60
  }
56
- if (animation.clip !== undefined && !available.has(animation.clip))
61
+ else if (animation.clip !== undefined && !available.has(animation.clip))
57
62
  missing.add(animation.clip);
58
- if (missing.size === 0)
63
+ if (missing.size > 0)
64
+ diagnostics.push({ code: "missing-clip", message: `clip(s) ${[...missing].map((name) => `"${name}"`).join(", ")} ` +
65
+ `not found on this rig. Available: ${clips.map((clip) => clip.name).join(", ")}. ` +
66
+ `Missing clips contribute no pose; an invalid single-clip config retains the bind pose. Set animation: "auto" to ` +
67
+ `derive states/one-shots from the rig's own clip names.`,
68
+ });
69
+ if (resolved !== undefined) {
70
+ let root;
71
+ scene.traverse((node) => { if (root === undefined && node.isBone === true)
72
+ root = node; });
73
+ const rootTrackName = root === undefined ? undefined : `${root.name}.position`;
74
+ for (const layer of resolved.layers) {
75
+ const layerNames = graphClipNames({ layers: [layer] });
76
+ if (layer.mask !== undefined && !clips.some((clip) => layerNames.has(clip.name) && clip.tracks.some((track) => layer.mask.some((prefix) => track.name.startsWith(prefix))))) {
77
+ diagnostics.push({ code: "empty-layer-mask", message: `layer "${layer.id}" mask matches no imported animation tracks. Use this rig's bone-name prefixes.` });
78
+ }
79
+ for (const [id, state] of Object.entries(layer.states)) {
80
+ if (state.rootMotion !== true)
81
+ continue;
82
+ const names = state.kind === "clip" ? state.variants ?? [state.clip] : state.points.map((point) => point.clip);
83
+ const missingTracks = names.filter((name) => {
84
+ const clip = clips.find((candidate) => candidate.name === name);
85
+ return clip !== undefined && (rootTrackName === undefined || !clip.tracks.some((track) => track.name === rootTrackName && (layer.mask === undefined || layer.mask.some((prefix) => track.name.startsWith(prefix)))));
86
+ });
87
+ if (missingTracks.length > 0)
88
+ diagnostics.push({ code: "missing-root-track", message: `root-motion state "${layer.id}/${id}" has no assigned position track for ${root === undefined ? "a root bone" : `first bone "${root.name}"`} in ${missingTracks.join(", ")}. ` +
89
+ `Root travel cannot be extracted; use an in-place state or supply a supported root-bone track.`,
90
+ });
91
+ }
92
+ }
93
+ }
94
+ return diagnostics;
95
+ }
96
+ function warnAnimationDiagnostics(scene, animation, clips) {
97
+ if (typeof console === "undefined")
59
98
  return;
60
- console.warn(`[jgengine] model animation: clip(s) ${[...missing].map((name) => `"${name}"`).join(", ")} ` +
61
- `not found on this rig. Available: ${clips.map((clip) => clip.name).join(", ")}. ` +
62
- `States fall back to the first clip and one-shots are skipped — set animation: "auto" to ` +
63
- `derive states/one-shots from the rig's own clip names.`);
99
+ for (const diagnostic of diagnoseModelAnimation(scene, animation, clips))
100
+ console.warn(`[jgengine] model animation: ${diagnostic.message}`);
101
+ }
102
+ /**
103
+ * Refreshes a caller-owned graph parameter dictionary from authored extras and known physical movement.
104
+ * Grounded, verticalSpeed and crouched use the shared motor's resolved state when available; custom movers
105
+ * retain their authored values. Speed always uses the supplied measured ground speed. Does not write game state.
106
+ *
107
+ * @capability animation-motion-params read resolved player movement into reusable animation graph parameters
108
+ */
109
+ export function readModelAnimationParams(ctx, instanceId, speed, out) {
110
+ const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
111
+ const motion = playerMovementTelemetry(ctx, instanceId);
112
+ if (out === extra || out === motion)
113
+ throw new Error("readModelAnimationParams requires a caller-owned output dictionary distinct from the entity blackboard and movement telemetry");
114
+ for (const key in out)
115
+ if (Object.hasOwn(out, key))
116
+ delete out[key];
117
+ if (extra !== undefined)
118
+ Object.assign(out, extra);
119
+ if (motion !== null) {
120
+ out[LOCOMOTION_GROUNDED_PARAM] = motion.grounded;
121
+ out[LOCOMOTION_VERTICAL_SPEED_PARAM] = motion.verticalVelocity;
122
+ out[LOCOMOTION_CROUCHED_PARAM] = motion.crouching;
123
+ }
124
+ out[LOCOMOTION_SPEED_PARAM] = speed;
125
+ return out;
64
126
  }
65
127
  /** Per-layer action key: a masked or additive layer needs its own clip variant even for a clip another layer plays. */
66
128
  function actionKey(layer, clip) {
67
129
  return `${layer}:${clip}`;
68
130
  }
69
- const rootTravel = new THREE.Vector3();
70
131
  const rootBasis = new THREE.Matrix3();
71
132
  /**
72
133
  * For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
@@ -102,13 +163,18 @@ function applyGraphClips(actions, clips) {
102
163
  * the editor's graph preview, poses the rig from the runtime's output.
103
164
  */
104
165
  export function createGraphPose(scene, graph, clips) {
166
+ warnAnimationDiagnostics(scene, { graph }, clips);
105
167
  const mixer = new THREE.AnimationMixer(scene);
106
168
  const playback = buildGraphPlayback(scene, mixer, graph, clips);
107
169
  return {
108
170
  durations: playback.durations,
109
- apply(output) {
171
+ apply(output, rootMotion = false) {
110
172
  applyGraphClips(playback.actions, output);
111
173
  mixer.update(0);
174
+ if (rootMotion && playback.rootBone !== null && playback.rootBindPosition !== null) {
175
+ playback.rootBone.position.x = playback.rootBindPosition.x;
176
+ playback.rootBone.position.z = playback.rootBindPosition.z;
177
+ }
112
178
  },
113
179
  dispose() {
114
180
  mixer.stopAllAction();
@@ -116,7 +182,7 @@ export function createGraphPose(scene, graph, clips) {
116
182
  },
117
183
  };
118
184
  }
119
- function buildGraphPlayback(scene, mixer, graph, clips) {
185
+ function buildGraphPlayback(scene, mixer, graph, clips, rng) {
120
186
  const actions = new Map();
121
187
  const durations = {};
122
188
  let resolvedRootBone = null;
@@ -135,8 +201,11 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
135
201
  for (const layer of graph.layers) {
136
202
  const names = new Set();
137
203
  for (const state of Object.values(layer.states)) {
138
- if (state.kind === "clip")
204
+ if (state.kind === "clip") {
139
205
  names.add(state.clip);
206
+ for (const variant of state.variants ?? [])
207
+ names.add(variant);
208
+ }
140
209
  else
141
210
  for (const point of state.points)
142
211
  names.add(point.clip);
@@ -145,13 +214,13 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
145
214
  const source = THREE.AnimationClip.findByName(clips, name);
146
215
  if (source === null)
147
216
  continue;
148
- let clip = source;
217
+ // Mixer actions are cached by clip UUID; each layer needs independent time and weight.
218
+ let clip = source.clone();
149
219
  if (layer.mask !== undefined) {
150
220
  const mask = layer.mask;
151
221
  clip = new THREE.AnimationClip(`${source.name}|${layer.id}`, source.duration, source.tracks.filter((track) => mask.some((prefix) => track.name.startsWith(prefix))));
152
222
  }
153
223
  if (layer.additive === true) {
154
- clip = clip === source ? clip.clone() : clip;
155
224
  THREE.AnimationUtils.makeClipAdditive(clip);
156
225
  }
157
226
  const action = mixer.clipAction(clip);
@@ -166,13 +235,14 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
166
235
  }
167
236
  }
168
237
  return {
169
- runtime: createAnimGraphRuntime(graph),
238
+ runtime: createAnimGraphRuntime(graph, { rng }),
170
239
  actions,
171
240
  durations,
172
241
  rootBone,
173
242
  rootBindPosition: rootBone?.position.clone() ?? null,
174
243
  lastPos: null,
175
244
  smoothedSpeed: 0,
245
+ params: {},
176
246
  };
177
247
  }
178
248
  /**
@@ -183,43 +253,67 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
183
253
  * `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
184
254
  * With `animation.graph` set, the headless `AnimGraph` runtime owns every clip's time and weight
185
255
  * and the mixer only applies them; clip events surface as `animation.event`.
256
+ * Equivalent same-order config data retains playback across rerenders; actual data edits reset it.
257
+ * Entity-bound playback holds while context game time is frozen; unbound previews keep playing.
186
258
  */
187
259
  export function useModelAnimation(scene, clips, animationInput, instanceId) {
260
+ const invalidate = useThree((state) => state.invalidate);
188
261
  // Optional: a model must still animate its bind pose / auto clip in a preview or inspector that
189
262
  // has no running game — a hard context requirement made every part composition unviewable outside
190
263
  // the world, which is half of why #1588 took a session to see.
191
264
  const ctx = useOptionalGameContext();
192
265
  // "auto" (stamped by catalog resolution, or set inline) derives states/one-shots from the
193
266
  // loaded GLB's actual clip names; "none" and absent render the bind pose.
194
- const animation = useMemo(() => resolveAnimationConfig(animationInput, clips.map((clip) => clip.name)), [animationInput, clips]);
267
+ // Equivalent fresh JSON data retains playback, pending triggers and visual RNG.
268
+ // The private snapshot observes edits on render, including reused nested objects.
269
+ // Preserve one-shot insertion order: simultaneous non-death triggers depend on it.
270
+ const animationKey = JSON.stringify(animationInput);
271
+ const animation = useMemo(() => resolveAnimationConfig(animationKey === undefined ? undefined : JSON.parse(animationKey), clips.map((clip) => clip.name)), [animationKey, clips]);
195
272
  const mixerRef = useRef(null);
273
+ const actionRef = useRef(null);
196
274
  const animationPausedRef = useRef(false);
197
275
  const graphRef = useRef(null);
276
+ const advancedSecondsRef = useRef(undefined);
198
277
  const states = animation?.states;
199
278
  const oneShots = animation?.oneShots;
200
279
  const authoredGraph = animation?.graph;
201
280
  const graph = useMemo(() => animGraphFromConfig({ graph: authoredGraph, states, oneShots }), [authoredGraph, states, oneShots]);
281
+ const usesGameClock = animation?.clock === "game" && ctx !== null && instanceId !== undefined;
202
282
  useEffect(() => {
283
+ advancedSecondsRef.current = usesGameClock ? ctx.time.advancedSeconds?.() : undefined;
284
+ if (usesGameClock && advancedSecondsRef.current === undefined) {
285
+ console.warn('[jgengine] model animation: bound game playback requires time.advancedSeconds() accepted progress. Playback is held; provide an engine clock or select clock: "real".');
286
+ }
287
+ if (animation !== undefined)
288
+ warnAnimationDiagnostics(scene, animation, clips);
289
+ else if (typeof animationInput === "object" && animationInput.auto === true)
290
+ warnAnimationDiagnostics(scene, animationInput, clips);
203
291
  if (animation === undefined || clips.length === 0) {
204
292
  mixerRef.current = null;
293
+ actionRef.current = null;
205
294
  graphRef.current = null;
206
295
  return;
207
296
  }
208
- warnMissingClips(animation, clips);
209
297
  const mixer = new THREE.AnimationMixer(scene);
210
298
  if (graph !== undefined) {
211
- graphRef.current = buildGraphPlayback(scene, mixer, graph, clips);
299
+ // Rendering/culling must never advance simulation randomness. Each model has its own
300
+ // reproducible visual stream, independent of other mounted models and gameplay draws.
301
+ const visualRng = seededRng(JSON.stringify(["model-animation", instanceId ?? "preview", clips.map((clip) => clip.name).sort()]));
302
+ graphRef.current = buildGraphPlayback(scene, mixer, graph, clips, visualRng);
212
303
  mixer.update(0);
213
304
  mixerRef.current = mixer;
214
- animationPausedRef.current = false;
305
+ animationPausedRef.current = animation.paused === true;
306
+ invalidate();
215
307
  return () => {
216
308
  mixer.stopAllAction();
309
+ mixer.uncacheRoot(scene);
217
310
  mixerRef.current = null;
218
311
  graphRef.current = null;
219
312
  };
220
313
  }
221
- const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(clips, animation.clip) : undefined) ??
222
- clips[0];
314
+ const clip = animation.clip !== undefined ? THREE.AnimationClip.findByName(clips, animation.clip) : states === undefined ? clips[0] : undefined;
315
+ if (clip === null || clip === undefined)
316
+ return;
223
317
  const action = mixer.clipAction(clip);
224
318
  action.setLoop(animation.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
225
319
  action.clampWhenFinished = animation.loop === false;
@@ -231,14 +325,19 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
231
325
  action.time = animation.time;
232
326
  mixer.update(0);
233
327
  mixerRef.current = mixer;
328
+ actionRef.current = action;
234
329
  animationPausedRef.current = animation.paused === true;
330
+ invalidate();
235
331
  return () => {
236
332
  mixer.stopAllAction();
333
+ mixer.uncacheRoot(scene);
237
334
  mixerRef.current = null;
335
+ actionRef.current = null;
238
336
  };
239
337
  }, [
240
338
  scene,
241
339
  clips,
340
+ animationKey,
242
341
  animation?.clip,
243
342
  animation?.loop,
244
343
  animation?.timeScale,
@@ -247,14 +346,19 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
247
346
  states,
248
347
  oneShots,
249
348
  graph,
349
+ ctx,
350
+ instanceId,
351
+ invalidate,
250
352
  ]);
251
353
  useEffect(() => {
252
354
  if (ctx === null || instanceId === undefined || (oneShots === undefined && graph === undefined))
253
355
  return;
254
356
  const fire = (event) => {
255
357
  const playback = graphRef.current;
256
- if (playback !== null)
358
+ if (playback !== null) {
257
359
  playback.runtime.trigger(event);
360
+ invalidate();
361
+ }
258
362
  };
259
363
  const offAnimation = ctx.game.events.on("entity.animation", (event) => {
260
364
  if (event.instanceId === instanceId)
@@ -273,38 +377,58 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
273
377
  offHit();
274
378
  offDied();
275
379
  };
276
- }, [ctx, instanceId, oneShots, graph]);
380
+ }, [ctx, instanceId, oneShots, graph, invalidate]);
277
381
  useFrame((_state, delta) => {
382
+ // Checkpoint held frames too, so resuming never replays their accepted time.
383
+ const advancedSeconds = usesGameClock ? ctx.time.advancedSeconds?.() : undefined;
384
+ const previousSeconds = advancedSecondsRef.current;
385
+ advancedSecondsRef.current = advancedSeconds;
386
+ if (animationPausedRef.current || animation?.timeScale === 0)
387
+ return;
388
+ if (ctx !== null && instanceId !== undefined && (ctx.time.speed() === 0 || ctx.time.timescale() === 0))
389
+ return;
390
+ let playbackDelta = delta;
391
+ if (usesGameClock) {
392
+ if (advancedSeconds === undefined || previousSeconds === undefined || advancedSeconds <= previousSeconds)
393
+ return;
394
+ playbackDelta = advancedSeconds - previousSeconds;
395
+ }
278
396
  const playback = graphRef.current;
279
397
  if (playback !== null && mixerRef.current !== null) {
280
- const params = {};
398
+ const params = playback.params;
281
399
  if (ctx !== null && instanceId !== undefined) {
282
400
  const entity = ctx.scene.entity.get(instanceId);
283
- if (entity !== null && delta > 0) {
401
+ if (entity !== null && playbackDelta > 0) {
284
402
  const [x, , z] = entity.position;
285
403
  if (playback.lastPos !== null) {
286
- const instantSpeed = Math.hypot(x - playback.lastPos[0], z - playback.lastPos[2]) / delta;
287
- playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, delta * 12);
404
+ const dx = x - playback.lastPos[0];
405
+ const dy = entity.position[1] - playback.lastPos[1];
406
+ const dz = z - playback.lastPos[2];
407
+ const snapDistance = ctx.sim.loop.config().snapDistance;
408
+ if (dx * dx + dy * dy + dz * dz > snapDistance * snapDistance) {
409
+ playback.smoothedSpeed = 0;
410
+ }
411
+ else {
412
+ const instantSpeed = Math.hypot(dx, dz) / playbackDelta;
413
+ playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, playbackDelta * 12);
414
+ }
288
415
  }
289
416
  playback.lastPos = [x, entity.position[1], z];
290
417
  }
291
- const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
292
- if (extra !== undefined)
293
- Object.assign(params, extra);
418
+ readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params);
294
419
  }
295
- params[LOCOMOTION_SPEED_PARAM] = playback.smoothedSpeed;
296
- const out = playback.runtime.advance(delta * (animation?.timeScale ?? 1), params, playback.durations);
420
+ else {
421
+ for (const key in params)
422
+ if (Object.hasOwn(params, key))
423
+ delete params[key];
424
+ params[LOCOMOTION_SPEED_PARAM] = playback.smoothedSpeed;
425
+ }
426
+ const out = playback.runtime.advance(playbackDelta * (animation?.timeScale ?? 1), params, playback.durations);
297
427
  applyGraphClips(playback.actions, out.clips);
298
428
  mixerRef.current.update(0);
299
429
  if (out.rootMotion === true && playback.rootBone !== null && playback.rootBindPosition !== null) {
300
- const travel = takeRootMotion(playback.rootBone, playback.rootBindPosition, out.rootDelta, rootTravel);
301
- const entity = ctx === null || instanceId === undefined || (travel.x === 0 && travel.z === 0) ? null : ctx.scene.entity.get(instanceId);
302
- if (entity !== null && ctx !== null && instanceId !== undefined) {
303
- ctx.scene.entity.setPose(instanceId, {
304
- position: [entity.position[0] + travel.x, entity.position[1], entity.position[2] + travel.z],
305
- dt: delta,
306
- });
307
- }
430
+ playback.rootBone.position.x = playback.rootBindPosition.x;
431
+ playback.rootBone.position.z = playback.rootBindPosition.z;
308
432
  }
309
433
  if (ctx !== null && instanceId !== undefined) {
310
434
  for (const event of out.events)
@@ -312,7 +436,8 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
312
436
  }
313
437
  return;
314
438
  }
315
- if (mixerRef.current !== null && !animationPausedRef.current)
316
- mixerRef.current.update(delta);
439
+ if (mixerRef.current !== null && actionRef.current?.isRunning()) {
440
+ mixerRef.current.update(playbackDelta);
441
+ }
317
442
  });
318
443
  }
@@ -0,0 +1,25 @@
1
+ import { Group, type Object3D } from "three";
2
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
3
+ /** Model loading, placement, shadows and animation for a custom renderer. */
4
+ export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes">;
5
+ /** Caller-owned styling runs on the isolated clone, never the loader cache. */
6
+ export interface ModelInstanceOptions {
7
+ instanceId?: string;
8
+ /** Configure each new clone before placement measurement. Memoize this callback; changing it replaces the instance. Materials are isolated; geometry/textures stay borrowed. */
9
+ configure?: (content: Object3D) => void;
10
+ }
11
+ /** Render `scene` with `position` and uniform `scale`; animate or deform `content` inside that placement frame. */
12
+ export interface ModelInstance {
13
+ content: Object3D;
14
+ scene: Group;
15
+ scale: number;
16
+ position: [number, number, number];
17
+ }
18
+ /**
19
+ * Load and own an isolated model instance, preserving imported transforms and measuring placement at bind pose.
20
+ * Apply hiddenNodes to the configured clone before measurement; equivalent name lists retain the instance.
21
+ * Mount `scene` with the returned position/scale. Custom materials, deformation and surrounding geometry stay caller-owned.
22
+ * Must run inside an R3F canvas and Suspense boundary; a game provider is optional.
23
+ * @capability model-instance own loading, clone cleanup, placement and animation for a custom model renderer
24
+ */
25
+ export declare function useModelInstance(model: ModelInstanceConfig, options?: ModelInstanceOptions): ModelInstance;
@@ -0,0 +1,43 @@
1
+ import { useLoader, useThree } from "@react-three/fiber";
2
+ import { useEffect, useMemo } from "react";
3
+ import { Group } from "three";
4
+ import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
5
+ import { measureLocalBounds } from "./measureBounds.js";
6
+ import { detectKtx2Support, sharedGltfLoader } from "./modelLoad.js";
7
+ import { cloneModelScene, disposeModelScene, modelPlacementTransform } from "./modelRender.js";
8
+ import { useModelAnimation } from "./useModelAnimation.js";
9
+ import { applyModelNodeVisibility, modelHiddenNodesKey } from "./modelNodeVisibility.js";
10
+ /**
11
+ * Load and own an isolated model instance, preserving imported transforms and measuring placement at bind pose.
12
+ * Apply hiddenNodes to the configured clone before measurement; equivalent name lists retain the instance.
13
+ * Mount `scene` with the returned position/scale. Custom materials, deformation and surrounding geometry stay caller-owned.
14
+ * Must run inside an R3F canvas and Suspense boundary; a game provider is optional.
15
+ * @capability model-instance own loading, clone cleanup, placement and animation for a custom model renderer
16
+ */
17
+ export function useModelInstance(model, options = {}) {
18
+ const hiddenNodes = parseModelHiddenNodes(model.hiddenNodes);
19
+ const renderer = useThree(state => state.gl);
20
+ if (renderer.isWebGLRenderer)
21
+ detectKtx2Support(renderer);
22
+ const gltf = useLoader(sharedGltfLoader, model.url);
23
+ const { configure, instanceId } = options;
24
+ const hiddenNodesKey = modelHiddenNodesKey(hiddenNodes);
25
+ const { content, scene, bindBounds } = useMemo(() => {
26
+ const content = cloneModelScene(gltf.scene, { shadows: model.shadows });
27
+ try {
28
+ configure?.(content);
29
+ for (const diagnostic of applyModelNodeVisibility(content, hiddenNodes))
30
+ console.warn(`Model ${model.url} hiddenNodes: ${diagnostic.message}`);
31
+ const scene = new Group().add(content);
32
+ return { content, scene, bindBounds: measureLocalBounds(scene) };
33
+ }
34
+ catch (error) {
35
+ disposeModelScene(content);
36
+ throw error;
37
+ }
38
+ }, [gltf.scene, model.shadows, configure, hiddenNodesKey]);
39
+ const placement = useMemo(() => modelPlacementTransform(scene, model, bindBounds), [scene, bindBounds, model.scale, model.targetHeight, model.y, model.anchor, model.dims]);
40
+ useEffect(() => () => disposeModelScene(content), [content]);
41
+ useModelAnimation(content, gltf.animations, model.animation, instanceId);
42
+ return { content, scene, ...placement };
43
+ }
@@ -5,6 +5,8 @@ export interface ScatterModelSource {
5
5
  geometry: THREE.BufferGeometry;
6
6
  material: THREE.Material | THREE.Material[];
7
7
  localMatrix: THREE.Matrix4;
8
+ castShadow?: boolean;
9
+ receiveShadow?: boolean;
8
10
  }
9
11
  /**
10
12
  * Clones a loaded GLTF scene (so shared cache materials stay untouched), applies the model's material
@@ -18,8 +20,7 @@ export declare function buildScatterModelSources(gltfScene: THREE.Object3D, mode
18
20
  root: THREE.Object3D;
19
21
  };
20
22
  /**
21
- * Disposes the cloned materials harvested by {@link buildScatterModelSources}; never disposes geometry
22
- * (still owned by the loader cache).
23
+ * Releases the instance resources harvested by {@link buildScatterModelSources}, retaining cached assets.
23
24
  * @internal
24
25
  */
25
26
  export declare function disposeScatterModelSources(root: THREE.Object3D): void;
@@ -1,6 +1,7 @@
1
1
  import * as THREE from "three";
2
+ import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
2
3
  import { applyMaterialOverride } from "../materialOverride.js";
3
- import { cloneModelScene, disposeClonedMaterials } from "../render/modelRender.js";
4
+ import { cloneModelScene, disposeModelScene, ownModelMaterial } from "../render/modelRender.js";
4
5
  /** The scale + position offset a `ModelConfig` applies before any per-instance transform — same anchor/targetHeight rules as the single-instance entity renderer. */
5
6
  function scatterModelBaseTransform(scene, model) {
6
7
  let scale = model.scale ?? 1;
@@ -33,13 +34,19 @@ function scatterModelBaseTransform(scene, model) {
33
34
  * @internal — the GLB-harvesting behind `InstancedScatter`'s `resolveItem` override.
34
35
  */
35
36
  export function buildScatterModelSources(gltfScene, model) {
36
- const root = cloneModelScene(gltfScene);
37
+ if (parseModelHiddenNodes(model.hiddenNodes)?.length)
38
+ throw new Error("Instanced model node visibility requires the individual model renderer; use EntityModel for hidden nodes");
39
+ if (model.materialAssignments?.length)
40
+ throw new Error("Instanced model material assignments require the individual model renderer; use EntityModel for authored slots");
41
+ if (model.material?.maps !== undefined)
42
+ throw new Error("Instanced model override maps require an owned texture loader; use EntityModel for authored maps");
43
+ const root = cloneModelScene(gltfScene, { shadows: model.shadows });
37
44
  if (model.material !== undefined)
38
- applyMaterialOverride(root, model.material, { clone: false });
45
+ applyMaterialOverride(root, model.material, { clone: false, ownMaterial: material => ownModelMaterial(root, material) });
39
46
  root.updateMatrixWorld(true);
40
47
  const base = scatterModelBaseTransform(root, model);
41
48
  const sources = [];
42
- root.traverse((node) => {
49
+ root.traverseVisible((node) => {
43
50
  const mesh = node;
44
51
  if (!mesh.isMesh)
45
52
  return;
@@ -47,15 +54,16 @@ export function buildScatterModelSources(gltfScene, model) {
47
54
  geometry: mesh.geometry,
48
55
  material: mesh.material,
49
56
  localMatrix: base.clone().multiply(mesh.matrixWorld),
57
+ castShadow: mesh.castShadow,
58
+ receiveShadow: mesh.receiveShadow,
50
59
  });
51
60
  });
52
61
  return { sources, root };
53
62
  }
54
63
  /**
55
- * Disposes the cloned materials harvested by {@link buildScatterModelSources}; never disposes geometry
56
- * (still owned by the loader cache).
64
+ * Releases the instance resources harvested by {@link buildScatterModelSources}, retaining cached assets.
57
65
  * @internal
58
66
  */
59
67
  export function disposeScatterModelSources(root) {
60
- disposeClonedMaterials(root);
68
+ disposeModelScene(root);
61
69
  }