@jgengine/shell 0.18.0 → 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 (148) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/GamePlayerShell.js +27 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +9 -8
  6. package/dist/Shell3dPresentation.js +57 -8
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/ShellHudPresentation.d.ts +9 -1
  10. package/dist/ShellHudPresentation.js +21 -0
  11. package/dist/TerritoryOverlay.d.ts +21 -0
  12. package/dist/TerritoryOverlay.js +9 -0
  13. package/dist/audio/AudioComponents.js +13 -2
  14. package/dist/audio/audioEngine.d.ts +15 -1
  15. package/dist/audio/audioEngine.js +132 -11
  16. package/dist/audio/audioWire.d.ts +3 -0
  17. package/dist/audio/audioWire.js +7 -1
  18. package/dist/audio/loopParams.d.ts +39 -0
  19. package/dist/audio/loopParams.js +43 -0
  20. package/dist/camera/GameCameraRig.js +1 -1
  21. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  22. package/dist/camera/GameFirstPersonCamera.js +52 -15
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +77 -62
  26. package/dist/camera/orbitCameraMath.js +2 -2
  27. package/dist/camera/rigMath.d.ts +82 -4
  28. package/dist/camera/rigMath.js +133 -15
  29. package/dist/defineGame.d.ts +9 -0
  30. package/dist/defineGame.js +38 -6
  31. package/dist/devtools/DevtoolsOverlay.js +2 -0
  32. package/dist/devtools/KeysPanel.js +3 -3
  33. package/dist/devtools/agentBridge.d.ts +6 -0
  34. package/dist/devtools/agentBridge.js +15 -1
  35. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  36. package/dist/diagnostics/FpsCounter.js +32 -0
  37. package/dist/drivers/FrameDriver.d.ts +5 -1
  38. package/dist/drivers/FrameDriver.js +74 -26
  39. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  40. package/dist/drivers/FrameRateLimiter.js +39 -0
  41. package/dist/drivers/HudOnlyDriver.js +10 -5
  42. package/dist/environment/DayNightSky.js +5 -5
  43. package/dist/environment/Daylight.d.ts +35 -1
  44. package/dist/environment/Daylight.js +98 -28
  45. package/dist/environment/EnvironmentScene.js +1 -1
  46. package/dist/environment/VolumetricClouds.js +20 -2
  47. package/dist/environment/daylightCycle.d.ts +20 -0
  48. package/dist/environment/daylightCycle.js +59 -7
  49. package/dist/environment/sunShadowMath.d.ts +45 -0
  50. package/dist/environment/sunShadowMath.js +50 -0
  51. package/dist/input/gamepadMerge.d.ts +10 -0
  52. package/dist/input/gamepadMerge.js +12 -0
  53. package/dist/input/gamepadPoll.d.ts +47 -0
  54. package/dist/input/gamepadPoll.js +142 -0
  55. package/dist/input/gamepadSource.d.ts +22 -0
  56. package/dist/input/gamepadSource.js +153 -0
  57. package/dist/input/mouseLook.js +2 -1
  58. package/dist/input/padHaptics.d.ts +17 -0
  59. package/dist/input/padHaptics.js +32 -0
  60. package/dist/input/pointerLock.d.ts +11 -0
  61. package/dist/input/pointerLock.js +19 -0
  62. package/dist/materialOverride.d.ts +4 -1
  63. package/dist/materialOverride.js +10 -1
  64. package/dist/postfx/PostProcessing.d.ts +11 -8
  65. package/dist/postfx/PostProcessing.js +38 -16
  66. package/dist/postfx/gradeShader.js +13 -0
  67. package/dist/postfx/stylizePass.d.ts +23 -0
  68. package/dist/postfx/stylizePass.js +216 -0
  69. package/dist/render/CascadedShadows.d.ts +11 -0
  70. package/dist/render/CascadedShadows.js +19 -8
  71. package/dist/render/EnvironmentLighting.d.ts +4 -1
  72. package/dist/render/EnvironmentLighting.js +45 -9
  73. package/dist/render/SceneLighting.d.ts +16 -2
  74. package/dist/render/SceneLighting.js +56 -6
  75. package/dist/render/SceneModels.js +45 -9
  76. package/dist/render/SkinnedInstances.d.ts +91 -0
  77. package/dist/render/SkinnedInstances.js +249 -0
  78. package/dist/render/TextureFiltering.d.ts +16 -0
  79. package/dist/render/TextureFiltering.js +64 -0
  80. package/dist/render/displayColor.d.ts +33 -0
  81. package/dist/render/displayColor.js +138 -0
  82. package/dist/render/modelLoad.d.ts +39 -0
  83. package/dist/render/modelLoad.js +89 -1
  84. package/dist/render/modelRender.d.ts +3 -0
  85. package/dist/render/modelRender.js +6 -2
  86. package/dist/render/resolveModel.js +4 -0
  87. package/dist/render/useFootIk.d.ts +58 -0
  88. package/dist/render/useFootIk.js +216 -0
  89. package/dist/render/useModelAnimation.d.ts +26 -0
  90. package/dist/render/useModelAnimation.js +184 -107
  91. package/dist/scene/AuthoredScene.d.ts +9 -0
  92. package/dist/scene/AuthoredScene.js +17 -1
  93. package/dist/scene/CityRenderer.js +666 -241
  94. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  95. package/dist/scene/cityWindowMaterial.js +80 -21
  96. package/dist/settings/QuickControls.js +1 -1
  97. package/dist/settings/SettingsMenu.js +1 -1
  98. package/dist/settings/appliedSettings.d.ts +10 -2
  99. package/dist/settings/appliedSettings.js +9 -5
  100. package/dist/settings/settingsController.d.ts +3 -0
  101. package/dist/settings/settingsController.js +47 -3
  102. package/dist/structures/BuildingKitBatch.js +26 -22
  103. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  104. package/dist/structures/GeneratedBuilding.js +21 -15
  105. package/dist/structures/buildingSurface.d.ts +21 -0
  106. package/dist/structures/buildingSurface.js +134 -0
  107. package/dist/terrain/terrainDetailMaterial.js +7 -3
  108. package/dist/useShellMultiplayerSync.d.ts +9 -2
  109. package/dist/useShellMultiplayerSync.js +30 -8
  110. package/dist/visibility/CullingProvider.d.ts +2 -1
  111. package/dist/visibility/CullingProvider.js +7 -2
  112. package/dist/world/GridWorldScene.js +4 -0
  113. package/dist/world/TileLayerRenderer.d.ts +9 -0
  114. package/dist/world/TileLayerRenderer.js +64 -0
  115. package/dist/world/WorldParticles.d.ts +31 -0
  116. package/dist/world/WorldParticles.js +128 -0
  117. package/dist/world/WorldScene.d.ts +2 -1
  118. package/dist/world/WorldScene.js +46 -4
  119. package/dist/world/entityPose.d.ts +4 -0
  120. package/dist/world/entityPose.js +9 -0
  121. package/dist/world/tileLayerInstances.d.ts +11 -0
  122. package/dist/world/tileLayerInstances.js +28 -0
  123. package/dist/worldSync.d.ts +5 -1
  124. package/dist/worldSync.js +25 -4
  125. package/package.json +4 -4
  126. package/skills/ce-handoff/SKILL.md +2 -2
  127. package/skills/game-design/SKILL.md +11 -1
  128. package/skills/jgengine/SKILL.md +9 -5
  129. package/skills/jgengine/api.md +205 -101
  130. package/skills/jgengine/capabilities.md +56 -0
  131. package/skills/jgengine/recipes/minimal-game.md +3 -0
  132. package/skills/jgengine-ui/SKILL.md +20 -15
  133. package/skills/jgengine-ui/api.md +538 -326
  134. package/skills/jgengine-ui/capabilities.md +37 -0
  135. package/skills/jgengine-ui/reference-primitives.md +89 -0
  136. package/skills/jgengine-ui/reference.md +2 -78
  137. package/skills/jgengine-world/SKILL.md +21 -3
  138. package/skills/jgengine-world/api.md +1172 -625
  139. package/skills/jgengine-world/capabilities.md +229 -3
  140. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  141. package/skills/jgengine-world/recipes/controls.md +44 -0
  142. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  143. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  144. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  145. package/skills/jgengine-world/reference.md +31 -2
  146. package/skills/jgengine-world/visibility.md +112 -0
  147. package/skills/level-design/SKILL.md +1 -1
  148. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -0,0 +1,216 @@
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
+ import { useMemo, useRef } from "react";
3
+ import * as THREE from "three";
4
+ import { inferLegChains, placeFeet } from "@jgengine/core/anim/footPlacement";
5
+ import { lookAt, solveTwoBone } from "@jgengine/core/anim/ikSolver";
6
+ const DEFAULT_MAX_ADJUST = 0.4;
7
+ const UP = new THREE.Vector3(0, 1, 0);
8
+ const scratch = {
9
+ root: new THREE.Vector3(),
10
+ mid: new THREE.Vector3(),
11
+ tip: new THREE.Vector3(),
12
+ a: new THREE.Vector3(),
13
+ b: new THREE.Vector3(),
14
+ q: new THREE.Quaternion(),
15
+ q2: new THREE.Quaternion(),
16
+ parent: new THREE.Quaternion(),
17
+ m: new THREE.Matrix4(),
18
+ };
19
+ const solved = { mid: [0, 0, 0], tip: [0, 0, 0] };
20
+ function bindPosition(skeleton, bone, out) {
21
+ const index = skeleton.bones.indexOf(bone);
22
+ if (index < 0)
23
+ return null;
24
+ return out.setFromMatrixPosition(scratch.m.copy(skeleton.boneInverses[index]).invert());
25
+ }
26
+ function restAnkleRatio(scene, leg) {
27
+ let soleY = Infinity;
28
+ let ratio = 0;
29
+ const root = new THREE.Vector3();
30
+ const mid = new THREE.Vector3();
31
+ const tip = new THREE.Vector3();
32
+ scene.traverse((object) => {
33
+ const mesh = object;
34
+ if (mesh.isSkinnedMesh !== true || !(leg.tip instanceof THREE.Bone) || !(leg.mid instanceof THREE.Bone) || !(leg.root instanceof THREE.Bone))
35
+ return;
36
+ if (bindPosition(mesh.skeleton, leg.tip, tip) === null || bindPosition(mesh.skeleton, leg.mid, mid) === null || bindPosition(mesh.skeleton, leg.root, root) === null)
37
+ return;
38
+ if (mesh.geometry.boundingBox === null)
39
+ mesh.geometry.computeBoundingBox();
40
+ const box = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.bindMatrix);
41
+ soleY = Math.min(soleY, box.min.y);
42
+ const length = root.distanceTo(mid) + mid.distanceTo(tip);
43
+ if (length > 1e-6)
44
+ ratio = Math.max(0, tip.y - soleY) / length;
45
+ });
46
+ return ratio;
47
+ }
48
+ /**
49
+ * Resolves foot-IK bones on a loaded rig: explicit chains, or legs found by bone name for `"auto"`
50
+ * and configs without `feet`. Returns `null` when no leg resolves.
51
+ */
52
+ export function resolveFootIkRig(scene, config) {
53
+ const options = config === "auto" ? {} : config;
54
+ let chains = options.feet;
55
+ if (chains === undefined) {
56
+ const bones = [];
57
+ scene.traverse((object) => {
58
+ if (object.isBone === true)
59
+ bones.push({ name: object.name, parent: object.parent?.isBone === true ? object.parent.name : null });
60
+ });
61
+ chains = inferLegChains(bones);
62
+ }
63
+ const legs = [];
64
+ for (const chain of chains) {
65
+ const root = scene.getObjectByName(chain.root);
66
+ const mid = scene.getObjectByName(chain.mid);
67
+ const tip = scene.getObjectByName(chain.tip);
68
+ if (root === undefined || mid === undefined || tip === undefined)
69
+ continue;
70
+ legs.push({ root, mid, tip, ankleRatio: restAnkleRatio(scene, { root, mid, tip }) });
71
+ }
72
+ const head = options.lookAt === undefined ? null : (scene.getObjectByName(options.lookAt.bone) ?? null);
73
+ if (legs.length === 0 && head === null)
74
+ return null;
75
+ const found = options.pelvis !== undefined ? (scene.getObjectByName(options.pelvis) ?? null) : (legs[0]?.root.parent ?? null);
76
+ const pelvis = found === scene ? null : found;
77
+ const bones = new Set(legs.flatMap((leg) => [leg.root, leg.mid, leg.tip]));
78
+ if (pelvis !== null)
79
+ bones.add(pelvis);
80
+ if (head !== null)
81
+ bones.add(head);
82
+ return {
83
+ legs,
84
+ pelvis,
85
+ head,
86
+ maxAdjust: options.maxAdjust ?? DEFAULT_MAX_ADJUST,
87
+ alignToGround: Math.max(0, Math.min(1, options.alignToGround ?? 1)),
88
+ poses: [...bones].map((bone) => ({
89
+ bone,
90
+ animated: { q: bone.quaternion.clone(), p: bone.position.clone() },
91
+ written: { q: bone.quaternion.clone(), p: bone.position.clone() },
92
+ })),
93
+ };
94
+ }
95
+ function writeWorldQuaternion(bone, world) {
96
+ if (bone.parent === null)
97
+ bone.quaternion.copy(world);
98
+ else
99
+ bone.quaternion.copy(bone.parent.getWorldQuaternion(scratch.parent).invert().multiply(world));
100
+ bone.updateMatrixWorld(true);
101
+ }
102
+ function aimSegment(bone, from, currentTo, solvedTo) {
103
+ const current = scratch.a.copy(currentTo).sub(from);
104
+ const desired = scratch.b.set(solvedTo[0] - from.x, solvedTo[1] - from.y, solvedTo[2] - from.z);
105
+ if (current.lengthSq() < 1e-10 || desired.lengthSq() < 1e-10)
106
+ return;
107
+ const correction = scratch.q.setFromUnitVectors(current.normalize(), desired.normalize());
108
+ writeWorldQuaternion(bone, correction.multiply(bone.getWorldQuaternion(scratch.q2)));
109
+ }
110
+ function legLength(leg) {
111
+ leg.root.getWorldPosition(scratch.root);
112
+ leg.mid.getWorldPosition(scratch.mid);
113
+ leg.tip.getWorldPosition(scratch.tip);
114
+ return scratch.root.distanceTo(scratch.mid) + scratch.mid.distanceTo(scratch.tip);
115
+ }
116
+ /**
117
+ * Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
118
+ * resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
119
+ * toward the animated knee, and tilts planted feet to the ground. Returns whether the feet are
120
+ * grounded; `state` carries the faded weight between frames. Exported for tests and custom hosts.
121
+ */
122
+ export function applyFootIk(rig, originY, probe, state, delta, cameraTarget) {
123
+ for (const record of rig.poses) {
124
+ if (record.bone.quaternion.equals(record.written.q))
125
+ record.bone.quaternion.copy(record.animated.q);
126
+ if (record.bone.position.equals(record.written.p))
127
+ record.bone.position.copy(record.animated.p);
128
+ record.animated.q.copy(record.bone.quaternion);
129
+ record.animated.p.copy(record.bone.position);
130
+ }
131
+ const samples = [];
132
+ const normals = [];
133
+ let length = 0;
134
+ let ankleHeight = 0;
135
+ for (const leg of rig.legs) {
136
+ leg.root.updateWorldMatrix(true, true);
137
+ const legSize = legLength(leg);
138
+ length = Math.max(length, legSize);
139
+ ankleHeight = Math.max(ankleHeight, leg.ankleRatio * legSize);
140
+ const from = Math.max(scratch.tip.y, originY) + legSize * 0.5;
141
+ const hit = probe({ origin: [scratch.tip.x, from, scratch.tip.z], direction: [0, -1, 0], maxDistance: legSize * 2 });
142
+ samples.push({ ankleY: scratch.tip.y, groundY: hit === null ? null : hit.point[1] });
143
+ normals.push(hit === null ? null : hit.normal);
144
+ }
145
+ const placement = placeFeet({ feet: samples, originY, ankleHeight, maxAdjust: rig.maxAdjust * length, airborneGap: length * 0.3 });
146
+ const blend = Math.min(1, delta * (placement.grounded ? 12 : 6));
147
+ state.weight += ((placement.grounded ? 1 : 0) - state.weight) * blend;
148
+ state.pelvis += (placement.pelvisOffset - state.pelvis) * Math.min(1, delta * 15);
149
+ const weight = state.weight;
150
+ if (rig.pelvis !== null && weight > 1e-3 && Math.abs(state.pelvis) > 1e-6) {
151
+ const world = rig.pelvis.getWorldPosition(scratch.a);
152
+ world.y += state.pelvis * weight;
153
+ rig.pelvis.position.copy(rig.pelvis.parent === null ? world : rig.pelvis.parent.worldToLocal(world));
154
+ rig.pelvis.updateMatrixWorld(true);
155
+ }
156
+ for (let i = 0; i < rig.legs.length && weight > 1e-3; i += 1) {
157
+ const target = placement.ankleTargets[i];
158
+ if (target === null || target === undefined)
159
+ continue;
160
+ const leg = rig.legs[i];
161
+ const root = leg.root.getWorldPosition(scratch.root);
162
+ const mid = leg.mid.getWorldPosition(scratch.mid);
163
+ const tip = leg.tip.getWorldPosition(scratch.tip);
164
+ const bend = scratch.a.copy(root).add(tip).multiplyScalar(0.5);
165
+ bend.subVectors(mid, bend);
166
+ if (bend.lengthSq() < 1e-8)
167
+ bend.set(0, 0, 1).applyQuaternion(leg.root.parent?.getWorldQuaternion(scratch.q) ?? scratch.q.identity());
168
+ const pole = bend.normalize().multiplyScalar(length).add(mid);
169
+ const targetY = tip.y + (target - tip.y) * weight;
170
+ solveTwoBone({ root: [root.x, root.y, root.z], mid: [mid.x, mid.y, mid.z], tip: [tip.x, tip.y, tip.z], target: [tip.x, targetY, tip.z], pole: [pole.x, pole.y, pole.z] }, solved);
171
+ aimSegment(leg.root, root, mid, solved.mid);
172
+ // Rotating the thigh also moved the tip; aim the shin from that updated pose.
173
+ aimSegment(leg.mid, leg.mid.getWorldPosition(scratch.mid), leg.tip.getWorldPosition(scratch.tip), solved.tip);
174
+ const normal = normals[i];
175
+ if (normal !== null && normal !== undefined && rig.alignToGround > 0) {
176
+ const planted = 1 - Math.min(1, Math.max(0, samples[i].ankleY - ankleHeight - samples[i].groundY) / Math.max(ankleHeight, length * 0.05));
177
+ const tilt = scratch.q.setFromUnitVectors(UP, scratch.b.set(normal[0], normal[1], normal[2]).normalize());
178
+ const current = leg.tip.getWorldQuaternion(scratch.q2);
179
+ writeWorldQuaternion(leg.tip, current.slerp(tilt.multiply(current), planted * weight * rig.alignToGround));
180
+ }
181
+ }
182
+ if (rig.head !== null && cameraTarget !== undefined) {
183
+ const from = rig.head.getWorldPosition(scratch.a);
184
+ const q = lookAt({ from: [from.x, from.y, from.z], target: cameraTarget, up: [0, 1, 0] });
185
+ const current = rig.head.getWorldQuaternion(scratch.q2);
186
+ writeWorldQuaternion(rig.head, current.slerp(scratch.q.set(q[0], q[1], q[2], q[3]), Math.max(weight, rig.legs.length === 0 ? 1 : 0)));
187
+ }
188
+ for (const record of rig.poses) {
189
+ record.written.q.copy(record.bone.quaternion);
190
+ record.written.p.copy(record.bone.position);
191
+ }
192
+ return placement.grounded;
193
+ }
194
+ /**
195
+ * Runs foot IK after the model's animation mixer for `ModelConfig.ik`. Ground probes hit terrain and
196
+ * blocking physical objects, never the model's own entity. `groundOffset` is the model-space height
197
+ * its soles rest on (`ModelConfig.y`).
198
+ */
199
+ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
200
+ const camera = useThree((three) => three.camera);
201
+ const rig = useMemo(() => (config === undefined ? null : resolveFootIkRig(scene, config)), [scene, config]);
202
+ const state = useRef({ weight: 0, pelvis: 0 });
203
+ useFrame((_three, delta) => {
204
+ if (rig === null || ctx === null || instanceId === undefined)
205
+ return;
206
+ const parent = scene.parent;
207
+ const originY = parent === null ? scene.position.y : parent.localToWorld(scratch.a.set(0, groundOffset, 0)).y;
208
+ const exclude = [instanceId];
209
+ applyFootIk(rig, originY, (input) => ctx.scene.raycast({
210
+ ...input,
211
+ excludeInstanceIds: exclude,
212
+ filter: { entities: false, objects: true, walls: false, terrain: true },
213
+ accept: (hit) => hit.blocks && hit.purpose === "physical",
214
+ }), state.current, delta, rig.head === null ? undefined : camera.getWorldPosition(scratch.b).toArray());
215
+ });
216
+ }
@@ -1,10 +1,36 @@
1
1
  import * as THREE from "three";
2
2
  import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
3
+ import { type AnimClipOutput, type AnimGraph } from "@jgengine/core/anim/animGraph";
4
+ /**
5
+ * For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
6
+ * its bind pose, so the clip plays in place, and returns that step's root travel as a world-space
7
+ * horizontal delta (through the rig's parent transform, so the entity's facing and the model's
8
+ * scale apply). The root bone's vertical motion stays in the clip.
9
+ */
10
+ export declare function takeRootMotion(rootBone: THREE.Object3D, bind: THREE.Vector3, localDelta: readonly [number, number, number] | undefined, out: THREE.Vector3): THREE.Vector3;
11
+ /** A mixer set up to show {@link AnimGraph} output on a rig; see {@link createGraphPose}. */
12
+ export interface GraphPose {
13
+ /** Clip durations read from the rig, the `clips` argument `runtime.advance` expects. */
14
+ durations: Readonly<Record<string, {
15
+ duration: number;
16
+ }>>;
17
+ /** Poses the rig with one advance's clip weights and times. */
18
+ apply(clips: readonly AnimClipOutput[]): void;
19
+ dispose(): void;
20
+ }
21
+ /**
22
+ * Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
23
+ * clips, additive layers additive ones) so a host that runs its own `createAnimGraphRuntime`, such as
24
+ * the editor's graph preview, poses the rig from the runtime's output.
25
+ */
26
+ export declare function createGraphPose(scene: THREE.Object3D, graph: AnimGraph, clips: THREE.AnimationClip[]): GraphPose;
3
27
  /**
4
28
  * The engine's model animation driver as a standalone hook — the same mixer `EntityModel` runs,
5
29
  * for games that render a cloned scene themselves (custom materials, procedural composition).
6
30
  * Handles `"auto"` derivation from the GLB's clip names, speed-driven idle/walk/run crossfades
7
31
  * read from the entity's live position when `instanceId` is set, one-shots fired from
8
32
  * `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
33
+ * With `animation.graph` set, the headless `AnimGraph` runtime owns every clip's time and weight
34
+ * and the mixer only applies them; clip events surface as `animation.event`.
9
35
  */
10
36
  export declare function useModelAnimation(scene: THREE.Object3D, clips: THREE.AnimationClip[], animationInput: ModelAnimationConfig | "auto" | "none" | undefined, instanceId?: string): void;
@@ -2,8 +2,22 @@ import { useFrame } 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
- import { resolveOneShotClip } from "@jgengine/core/game/modelAnimation";
5
+ import { ANIM_PARAMS_KEY, createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
6
+ import { animGraphFromConfig, LOCOMOTION_SPEED_PARAM } from "@jgengine/core/anim/locomotionGraph";
6
7
  import { useOptionalGameContext } from "@jgengine/react/provider";
8
+ function graphClipNames(graph) {
9
+ const names = new Set();
10
+ for (const layer of graph.layers) {
11
+ for (const state of Object.values(layer.states)) {
12
+ if (state.kind === "clip")
13
+ names.add(state.clip);
14
+ else
15
+ for (const point of state.points)
16
+ names.add(point.clip);
17
+ }
18
+ }
19
+ return names;
20
+ }
7
21
  /**
8
22
  * An explicit `animation` config always wins over `"auto"`, and both the state machine and the
9
23
  * one-shot table degrade quietly when a named clip is absent: states fall back to `clips[0]` (often
@@ -19,7 +33,7 @@ function warnMissingClips(animation, clips) {
19
33
  return;
20
34
  const available = new Set(clips.map((clip) => clip.name));
21
35
  const missing = new Set();
22
- const { states, oneShots } = animation;
36
+ const { states, oneShots, graph } = animation;
23
37
  if (states !== undefined) {
24
38
  for (const name of [states.idle, states.walk, states.run]) {
25
39
  if (name !== undefined && !available.has(name))
@@ -34,6 +48,11 @@ function warnMissingClips(animation, clips) {
34
48
  }
35
49
  }
36
50
  }
51
+ if (graph !== undefined) {
52
+ for (const name of graphClipNames(graph))
53
+ if (!available.has(name))
54
+ missing.add(name);
55
+ }
37
56
  if (animation.clip !== undefined && !available.has(animation.clip))
38
57
  missing.add(animation.clip);
39
58
  if (missing.size === 0)
@@ -43,12 +62,127 @@ function warnMissingClips(animation, clips) {
43
62
  `States fall back to the first clip and one-shots are skipped — set animation: "auto" to ` +
44
63
  `derive states/one-shots from the rig's own clip names.`);
45
64
  }
65
+ /** Per-layer action key: a masked or additive layer needs its own clip variant even for a clip another layer plays. */
66
+ function actionKey(layer, clip) {
67
+ return `${layer}:${clip}`;
68
+ }
69
+ const rootTravel = new THREE.Vector3();
70
+ const rootBasis = new THREE.Matrix3();
71
+ /**
72
+ * For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
73
+ * its bind pose, so the clip plays in place, and returns that step's root travel as a world-space
74
+ * horizontal delta (through the rig's parent transform, so the entity's facing and the model's
75
+ * scale apply). The root bone's vertical motion stays in the clip.
76
+ */
77
+ export function takeRootMotion(rootBone, bind, localDelta, out) {
78
+ rootBone.position.x = bind.x;
79
+ rootBone.position.z = bind.z;
80
+ out.set(0, 0, 0);
81
+ if (localDelta === undefined || rootBone.parent === null)
82
+ return out;
83
+ rootBone.parent.updateWorldMatrix(true, false);
84
+ out.set(localDelta[0], localDelta[1], localDelta[2]).applyMatrix3(rootBasis.setFromMatrix4(rootBone.parent.matrixWorld));
85
+ out.y = 0;
86
+ return out;
87
+ }
88
+ function applyGraphClips(actions, clips) {
89
+ for (const action of actions.values())
90
+ action.weight = 0;
91
+ for (const entry of clips) {
92
+ const action = actions.get(actionKey(entry.layer, entry.clip));
93
+ if (action === undefined)
94
+ continue;
95
+ action.weight += entry.weight;
96
+ action.time = entry.time;
97
+ }
98
+ }
99
+ /**
100
+ * Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
101
+ * clips, additive layers additive ones) so a host that runs its own `createAnimGraphRuntime`, such as
102
+ * the editor's graph preview, poses the rig from the runtime's output.
103
+ */
104
+ export function createGraphPose(scene, graph, clips) {
105
+ const mixer = new THREE.AnimationMixer(scene);
106
+ const playback = buildGraphPlayback(scene, mixer, graph, clips);
107
+ return {
108
+ durations: playback.durations,
109
+ apply(output) {
110
+ applyGraphClips(playback.actions, output);
111
+ mixer.update(0);
112
+ },
113
+ dispose() {
114
+ mixer.stopAllAction();
115
+ mixer.uncacheRoot(scene);
116
+ },
117
+ };
118
+ }
119
+ function buildGraphPlayback(scene, mixer, graph, clips) {
120
+ const actions = new Map();
121
+ const durations = {};
122
+ let resolvedRootBone = null;
123
+ scene.traverse((object) => {
124
+ if (resolvedRootBone === null && object instanceof THREE.Bone)
125
+ resolvedRootBone = object;
126
+ });
127
+ const rootBone = resolvedRootBone;
128
+ for (const clip of clips) {
129
+ const track = rootBone === null ? undefined : clip.tracks.find((candidate) => candidate.name === `${rootBone.name}.position`);
130
+ durations[clip.name] = {
131
+ duration: clip.duration,
132
+ ...(track === undefined ? {} : { rootTrack: { times: new Float32Array(track.times), values: new Float32Array(track.values) } }),
133
+ };
134
+ }
135
+ for (const layer of graph.layers) {
136
+ const names = new Set();
137
+ for (const state of Object.values(layer.states)) {
138
+ if (state.kind === "clip")
139
+ names.add(state.clip);
140
+ else
141
+ for (const point of state.points)
142
+ names.add(point.clip);
143
+ }
144
+ for (const name of names) {
145
+ const source = THREE.AnimationClip.findByName(clips, name);
146
+ if (source === null)
147
+ continue;
148
+ let clip = source;
149
+ if (layer.mask !== undefined) {
150
+ const mask = layer.mask;
151
+ clip = new THREE.AnimationClip(`${source.name}|${layer.id}`, source.duration, source.tracks.filter((track) => mask.some((prefix) => track.name.startsWith(prefix))));
152
+ }
153
+ if (layer.additive === true) {
154
+ clip = clip === source ? clip.clone() : clip;
155
+ THREE.AnimationUtils.makeClipAdditive(clip);
156
+ }
157
+ const action = mixer.clipAction(clip);
158
+ action.setLoop(THREE.LoopRepeat, Infinity);
159
+ action.enabled = true;
160
+ action.paused = true;
161
+ action.weight = 0;
162
+ if (layer.additive === true)
163
+ action.blendMode = THREE.AdditiveAnimationBlendMode;
164
+ action.play();
165
+ actions.set(actionKey(layer.id, name), action);
166
+ }
167
+ }
168
+ return {
169
+ runtime: createAnimGraphRuntime(graph),
170
+ actions,
171
+ durations,
172
+ rootBone,
173
+ rootBindPosition: rootBone?.position.clone() ?? null,
174
+ lastPos: null,
175
+ smoothedSpeed: 0,
176
+ };
177
+ }
46
178
  /**
47
179
  * The engine's model animation driver as a standalone hook — the same mixer `EntityModel` runs,
48
180
  * for games that render a cloned scene themselves (custom materials, procedural composition).
49
181
  * Handles `"auto"` derivation from the GLB's clip names, speed-driven idle/walk/run crossfades
50
182
  * read from the entity's live position when `instanceId` is set, one-shots fired from
51
183
  * `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
184
+ * With `animation.graph` set, the headless `AnimGraph` runtime owns every clip's time and weight
185
+ * and the mixer only applies them; clip events surface as `animation.event`.
52
186
  */
53
187
  export function useModelAnimation(scene, clips, animationInput, instanceId) {
54
188
  // Optional: a model must still animate its bind pose / auto clip in a preview or inspector that
@@ -60,95 +194,28 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
60
194
  const animation = useMemo(() => resolveAnimationConfig(animationInput, clips.map((clip) => clip.name)), [animationInput, clips]);
61
195
  const mixerRef = useRef(null);
62
196
  const animationPausedRef = useRef(false);
63
- const stateActionsRef = useRef(null);
197
+ const graphRef = useRef(null);
64
198
  const states = animation?.states;
65
199
  const oneShots = animation?.oneShots;
66
- const oneShotPlayRef = useRef(null);
67
- const activeOneShotRef = useRef(null);
200
+ const authoredGraph = animation?.graph;
201
+ const graph = useMemo(() => animGraphFromConfig({ graph: authoredGraph, states, oneShots }), [authoredGraph, states, oneShots]);
68
202
  useEffect(() => {
69
203
  if (animation === undefined || clips.length === 0) {
70
204
  mixerRef.current = null;
71
- stateActionsRef.current = null;
205
+ graphRef.current = null;
72
206
  return;
73
207
  }
74
208
  warnMissingClips(animation, clips);
75
209
  const mixer = new THREE.AnimationMixer(scene);
76
- if (states !== undefined) {
77
- const clipFor = (name) => THREE.AnimationClip.findByName(clips, name) ?? clips[0];
78
- const actions = {
79
- idle: mixer.clipAction(clipFor(states.idle)),
80
- walk: mixer.clipAction(clipFor(states.walk)),
81
- ...(states.run === undefined ? {} : { run: mixer.clipAction(clipFor(states.run)) }),
82
- };
83
- for (const action of Object.values(actions)) {
84
- action.setLoop(THREE.LoopRepeat, Infinity);
85
- action.timeScale = animation.timeScale ?? 1;
86
- action.enabled = true;
87
- }
88
- actions.idle.play();
210
+ if (graph !== undefined) {
211
+ graphRef.current = buildGraphPlayback(scene, mixer, graph, clips);
89
212
  mixer.update(0);
90
213
  mixerRef.current = mixer;
91
- stateActionsRef.current = { actions, active: "idle", lastPos: null, smoothedSpeed: 0 };
92
214
  animationPausedRef.current = false;
93
- let onOneShotFinished = null;
94
- if (oneShots !== undefined) {
95
- const clipNames = new Set();
96
- for (const spec of Object.values(oneShots)) {
97
- if (typeof spec === "string")
98
- clipNames.add(spec);
99
- else
100
- for (const name of spec)
101
- clipNames.add(name);
102
- }
103
- const oneShotActions = new Map();
104
- for (const name of clipNames) {
105
- const found = THREE.AnimationClip.findByName(clips, name);
106
- if (found === null)
107
- continue;
108
- const oneShotAction = mixer.clipAction(found);
109
- oneShotAction.setLoop(THREE.LoopOnce, 1);
110
- oneShotAction.enabled = true;
111
- oneShotActions.set(name, oneShotAction);
112
- }
113
- onOneShotFinished = ({ action }) => {
114
- const active = activeOneShotRef.current;
115
- if (active === null || action !== active.action || active.isDeath)
116
- return;
117
- const machine = stateActionsRef.current;
118
- const back = machine?.actions[machine.active];
119
- action.fadeOut(0.15);
120
- if (back !== undefined)
121
- back.reset().fadeIn(0.15).play();
122
- activeOneShotRef.current = null;
123
- };
124
- mixer.addEventListener("finished", onOneShotFinished);
125
- oneShotPlayRef.current = (event) => {
126
- const active = activeOneShotRef.current;
127
- if (active !== null && active.isDeath)
128
- return;
129
- const clipName = resolveOneShotClip(oneShots, event, Math.random());
130
- if (clipName === null)
131
- return;
132
- const oneShotAction = oneShotActions.get(clipName);
133
- if (oneShotAction === undefined)
134
- return;
135
- const machine = stateActionsRef.current;
136
- machine?.actions[machine.active]?.fadeOut(0.1);
137
- if (active !== null && active.action !== oneShotAction)
138
- active.action.stop();
139
- oneShotAction.clampWhenFinished = event === "death";
140
- oneShotAction.reset().fadeIn(0.1).play();
141
- activeOneShotRef.current = { action: oneShotAction, isDeath: event === "death" };
142
- };
143
- }
144
215
  return () => {
145
- if (onOneShotFinished !== null)
146
- mixer.removeEventListener("finished", onOneShotFinished);
147
- oneShotPlayRef.current = null;
148
- activeOneShotRef.current = null;
149
216
  mixer.stopAllAction();
150
217
  mixerRef.current = null;
151
- stateActionsRef.current = null;
218
+ graphRef.current = null;
152
219
  };
153
220
  }
154
221
  const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(clips, animation.clip) : undefined) ??
@@ -179,11 +246,16 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
179
246
  animation?.time,
180
247
  states,
181
248
  oneShots,
249
+ graph,
182
250
  ]);
183
251
  useEffect(() => {
184
- if (ctx === null || instanceId === undefined || oneShots === undefined)
252
+ if (ctx === null || instanceId === undefined || (oneShots === undefined && graph === undefined))
185
253
  return;
186
- const fire = (event) => oneShotPlayRef.current?.(event);
254
+ const fire = (event) => {
255
+ const playback = graphRef.current;
256
+ if (playback !== null)
257
+ playback.runtime.trigger(event);
258
+ };
187
259
  const offAnimation = ctx.game.events.on("entity.animation", (event) => {
188
260
  if (event.instanceId === instanceId)
189
261
  fire(event.event);
@@ -201,39 +273,44 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
201
273
  offHit();
202
274
  offDied();
203
275
  };
204
- }, [ctx, instanceId, oneShots]);
276
+ }, [ctx, instanceId, oneShots, graph]);
205
277
  useFrame((_state, delta) => {
206
- const stateMachine = stateActionsRef.current;
207
- if (stateMachine !== null && states !== undefined && instanceId !== undefined && delta > 0 && ctx !== null) {
208
- const entity = ctx.scene.entity.get(instanceId);
209
- if (entity !== null) {
210
- const [x, , z] = entity.position;
211
- if (stateMachine.lastPos !== null) {
212
- const instantSpeed = Math.hypot(x - stateMachine.lastPos[0], z - stateMachine.lastPos[2]) / delta;
213
- stateMachine.smoothedSpeed +=
214
- (instantSpeed - stateMachine.smoothedSpeed) * Math.min(1, delta * 12);
215
- }
216
- stateMachine.lastPos = [x, entity.position[1], z];
217
- const walkSpeed = states.walkSpeed ?? 0.5;
218
- const runSpeed = states.runSpeed ?? 6;
219
- const next = stateMachine.smoothedSpeed < walkSpeed
220
- ? "idle"
221
- : stateMachine.actions.run !== undefined && stateMachine.smoothedSpeed >= runSpeed
222
- ? "run"
223
- : "walk";
224
- if (next !== stateMachine.active) {
225
- if (activeOneShotRef.current === null) {
226
- const fade = states.fadeSec ?? 0.2;
227
- const from = stateMachine.actions[stateMachine.active];
228
- const to = stateMachine.actions[next];
229
- if (from !== undefined && to !== undefined) {
230
- to.reset().fadeIn(fade).play();
231
- from.fadeOut(fade);
232
- }
278
+ const playback = graphRef.current;
279
+ if (playback !== null && mixerRef.current !== null) {
280
+ const params = {};
281
+ if (ctx !== null && instanceId !== undefined) {
282
+ const entity = ctx.scene.entity.get(instanceId);
283
+ if (entity !== null && delta > 0) {
284
+ const [x, , z] = entity.position;
285
+ 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);
233
288
  }
234
- stateMachine.active = next;
289
+ playback.lastPos = [x, entity.position[1], z];
235
290
  }
291
+ const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
292
+ if (extra !== undefined)
293
+ Object.assign(params, extra);
236
294
  }
295
+ params[LOCOMOTION_SPEED_PARAM] = playback.smoothedSpeed;
296
+ const out = playback.runtime.advance(delta * (animation?.timeScale ?? 1), params, playback.durations);
297
+ applyGraphClips(playback.actions, out.clips);
298
+ mixerRef.current.update(0);
299
+ 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
+ }
308
+ }
309
+ if (ctx !== null && instanceId !== undefined) {
310
+ for (const event of out.events)
311
+ ctx.game.events.emit("animation.event", { instanceId, name: event.name, clip: event.clip });
312
+ }
313
+ return;
237
314
  }
238
315
  if (mixerRef.current !== null && !animationPausedRef.current)
239
316
  mixerRef.current.update(delta);
@@ -40,6 +40,15 @@ export interface AuthoredObjectsProps {
40
40
  * headless twin: `resolveAuthoredObjects` / `placeAuthoredObjects`.
41
41
  */
42
42
  export declare function AuthoredObjects({ document, field, verticalOffset, onExisting, }: AuthoredObjectsProps): null;
43
+ /**
44
+ * Writes the document's studio solids (city buildings and any kind with a `solids` hook) into
45
+ * `ctx.world.solids`, so what the studios draw also blocks the player, NPCs and physics.
46
+ * @capability authored-solids collision for studio-authored world content such as city volumes
47
+ */
48
+ export declare function AuthoredSolids({ document, field }: {
49
+ document: EditorDocument;
50
+ field: TerrainField;
51
+ }): null;
43
52
  /** Props for {@link AuthoredScene}: the document to render and the ground field to drape/ground on. */
44
53
  export interface AuthoredSceneProps {
45
54
  document: EditorDocument;