@jgengine/shell 0.17.0 → 0.18.1

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 (86) hide show
  1. package/CHANGELOG.md +130 -2
  2. package/dist/GamePlayerShell.js +10 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +8 -1
  6. package/dist/Shell3dPresentation.js +34 -5
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/TerritoryOverlay.d.ts +21 -0
  10. package/dist/TerritoryOverlay.js +9 -0
  11. package/dist/audio/AudioComponents.js +8 -1
  12. package/dist/audio/audioEngine.d.ts +7 -1
  13. package/dist/audio/audioEngine.js +67 -5
  14. package/dist/camera/cameraRigs.js +7 -0
  15. package/dist/defineGame.js +21 -6
  16. package/dist/devtools/KeysPanel.js +3 -3
  17. package/dist/drivers/FrameDriver.d.ts +5 -1
  18. package/dist/drivers/FrameDriver.js +61 -24
  19. package/dist/drivers/HudOnlyDriver.js +9 -4
  20. package/dist/environment/EnvironmentScene.js +1 -1
  21. package/dist/input/gamepadMerge.d.ts +10 -0
  22. package/dist/input/gamepadMerge.js +12 -0
  23. package/dist/input/gamepadSource.d.ts +12 -0
  24. package/dist/input/gamepadSource.js +107 -0
  25. package/dist/materialOverride.d.ts +4 -1
  26. package/dist/materialOverride.js +10 -1
  27. package/dist/postfx/PostProcessing.d.ts +10 -7
  28. package/dist/postfx/PostProcessing.js +33 -15
  29. package/dist/postfx/gradeShader.js +13 -0
  30. package/dist/render/CascadedShadows.d.ts +11 -0
  31. package/dist/render/CascadedShadows.js +19 -8
  32. package/dist/render/EnvironmentLighting.d.ts +4 -1
  33. package/dist/render/EnvironmentLighting.js +45 -9
  34. package/dist/render/SceneLighting.d.ts +16 -2
  35. package/dist/render/SceneLighting.js +56 -6
  36. package/dist/render/SceneModels.js +45 -9
  37. package/dist/render/modelLoad.d.ts +10 -0
  38. package/dist/render/modelLoad.js +28 -0
  39. package/dist/render/modelRender.d.ts +3 -0
  40. package/dist/render/modelRender.js +6 -2
  41. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +15 -0
  42. package/dist/render/testFixtures/proceduralSpriteAtlas.js +30 -0
  43. package/dist/render/testFixtures/proceduralTileLayer.d.ts +13 -0
  44. package/dist/render/testFixtures/proceduralTileLayer.js +30 -0
  45. package/dist/render/useFootIk.d.ts +9 -0
  46. package/dist/render/useFootIk.js +89 -0
  47. package/dist/render/useModelAnimation.d.ts +2 -0
  48. package/dist/render/useModelAnimation.js +163 -107
  49. package/dist/scene/CityRenderer.js +661 -236
  50. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  51. package/dist/scene/cityWindowMaterial.js +80 -21
  52. package/dist/settings/appliedSettings.d.ts +3 -1
  53. package/dist/settings/appliedSettings.js +5 -2
  54. package/dist/terrain/terrainDetailMaterial.js +7 -3
  55. package/dist/useShellMultiplayerSync.d.ts +8 -1
  56. package/dist/useShellMultiplayerSync.js +25 -5
  57. package/dist/visibility/CullingProvider.d.ts +2 -1
  58. package/dist/visibility/CullingProvider.js +7 -2
  59. package/dist/world/GridWorldScene.js +4 -0
  60. package/dist/world/TileLayerRenderer.d.ts +9 -0
  61. package/dist/world/TileLayerRenderer.js +64 -0
  62. package/dist/world/WorldParticles.d.ts +31 -0
  63. package/dist/world/WorldParticles.js +128 -0
  64. package/dist/world/WorldScene.d.ts +2 -1
  65. package/dist/world/WorldScene.js +46 -4
  66. package/dist/world/entityPose.d.ts +4 -0
  67. package/dist/world/entityPose.js +9 -0
  68. package/dist/world/tileLayerInstances.d.ts +11 -0
  69. package/dist/world/tileLayerInstances.js +28 -0
  70. package/dist/worldSync.d.ts +5 -1
  71. package/dist/worldSync.js +25 -4
  72. package/package.json +4 -4
  73. package/skills/ce-handoff/SKILL.md +1 -1
  74. package/skills/game-design/SKILL.md +11 -1
  75. package/skills/jgengine/SKILL.md +2 -0
  76. package/skills/jgengine/api.md +98 -22
  77. package/skills/jgengine/capabilities.md +28 -0
  78. package/skills/jgengine-ui/SKILL.md +8 -0
  79. package/skills/jgengine-ui/api.md +127 -16
  80. package/skills/jgengine-ui/capabilities.md +21 -0
  81. package/skills/jgengine-ui/reference.md +3 -0
  82. package/skills/jgengine-world/SKILL.md +6 -0
  83. package/skills/jgengine-world/api.md +312 -12
  84. package/skills/jgengine-world/capabilities.md +102 -0
  85. package/skills/level-design/SKILL.md +1 -1
  86. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -0,0 +1,30 @@
1
+ /** Draw the deterministic two-frame atlas used by shell sprite-clip captures and tests.
2
+ * @internal
3
+ */
4
+ export function drawProceduralSpriteAtlas(canvas) {
5
+ canvas.width = 64;
6
+ canvas.height = 32;
7
+ const context = canvas.getContext("2d");
8
+ if (context === null)
9
+ throw new Error("A 2D canvas context is required.");
10
+ context.clearRect(0, 0, canvas.width, canvas.height);
11
+ for (const x of [0, 32]) {
12
+ context.fillStyle = "#172033";
13
+ context.fillRect(x, 0, 32, 32);
14
+ context.fillStyle = "#f4c95d";
15
+ context.beginPath();
16
+ context.arc(x + 16, 14, 9, 0, Math.PI * 2);
17
+ context.fill();
18
+ context.fillStyle = "#e76f51";
19
+ context.fillRect(x + 8, x === 0 ? 22 : 20, 16, 5);
20
+ }
21
+ return {
22
+ image: canvas.toDataURL(),
23
+ size: [64, 32],
24
+ frames: {
25
+ idle0: { x: 0, y: 0, w: 32, h: 32 },
26
+ idle1: { x: 32, y: 0, w: 32, h: 32 },
27
+ },
28
+ animations: { idle: { frames: ["idle0", "idle1"], fps: 2 } },
29
+ };
30
+ }
@@ -0,0 +1,13 @@
1
+ import type { TilemapWorldConfig } from "@jgengine/core/world/features";
2
+ type DrawingContext = Pick<CanvasRenderingContext2D, "clearRect" | "fillRect"> & {
3
+ fillStyle: string | CanvasGradient | CanvasPattern;
4
+ };
5
+ type CanvasFixture = {
6
+ width: number;
7
+ height: number;
8
+ getContext(contextId: "2d"): DrawingContext | null;
9
+ toDataURL(): string;
10
+ };
11
+ /** Draw the deterministic tile atlas used by textured tile-layer captures and tests. */
12
+ export declare function drawProceduralTileLayer(canvas: CanvasFixture): TilemapWorldConfig;
13
+ export {};
@@ -0,0 +1,30 @@
1
+ /** Draw the deterministic tile atlas used by textured tile-layer captures and tests. */
2
+ export function drawProceduralTileLayer(canvas) {
3
+ canvas.width = 48;
4
+ canvas.height = 16;
5
+ const context = canvas.getContext("2d");
6
+ if (context === null)
7
+ throw new Error("A 2D canvas context is required.");
8
+ context.clearRect(0, 0, canvas.width, canvas.height);
9
+ for (const [x, color] of [[0, "#355070"], [16, "#6b705c"], [32, "#3a86a8"]]) {
10
+ context.fillStyle = color;
11
+ context.fillRect(x, 0, 16, 16);
12
+ }
13
+ return {
14
+ map: "aba\nbcb\naba",
15
+ cellSize: 1,
16
+ tileSet: {
17
+ atlas: {
18
+ image: canvas.toDataURL(),
19
+ size: [48, 16],
20
+ frames: {
21
+ floor: { x: 0, y: 0, w: 16, h: 16 },
22
+ wall: { x: 16, y: 0, w: 16, h: 16 },
23
+ water: { x: 32, y: 0, w: 16, h: 16 },
24
+ },
25
+ animations: {},
26
+ },
27
+ tiles: { a: "floor", b: "wall", c: "water" },
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,9 @@
1
+ import * as THREE from "three";
2
+ import type { GameContext } from "@jgengine/core/runtime/gameContextTypes";
3
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
4
+ type FootIkConfig = NonNullable<ModelConfig["ik"]>;
5
+ /** Applies one frame of foot IK to a loaded rig. Exported for renderer tests and custom model hosts. */
6
+ export declare function applyFootIk(scene: THREE.Object3D, config: FootIkConfig, raycast: GameContext["scene"]["raycast"], weight: number, cameraTarget?: readonly [number, number, number]): boolean;
7
+ /** Runs foot IK after the model animation mixer, fading the correction out while the entity is airborne. */
8
+ export declare function useFootIk(scene: THREE.Object3D, config: FootIkConfig | undefined, ctx: GameContext | null, instanceId?: string): void;
9
+ export {};
@@ -0,0 +1,89 @@
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
+ import { useRef } from "react";
3
+ import * as THREE from "three";
4
+ import { lookAt, solveTwoBone } from "@jgengine/core/anim/ikSolver";
5
+ function tuple(value) {
6
+ return [value.x, value.y, value.z];
7
+ }
8
+ function worldQuaternion(bone) {
9
+ const result = new THREE.Quaternion();
10
+ bone.getWorldQuaternion(result);
11
+ return result;
12
+ }
13
+ function writeWorldQuaternion(bone, world) {
14
+ if (bone.parent === null)
15
+ bone.quaternion.copy(world);
16
+ else {
17
+ const parent = worldQuaternion(bone.parent).invert();
18
+ bone.quaternion.copy(parent.multiply(world));
19
+ }
20
+ bone.updateMatrixWorld(true);
21
+ }
22
+ function aimSegment(bone, from, currentTo, solvedTo, weight) {
23
+ const current = currentTo.clone().sub(from).normalize();
24
+ const solved = new THREE.Vector3(solvedTo[0] - from.x, solvedTo[1] - from.y, solvedTo[2] - from.z).normalize();
25
+ if (current.lengthSq() < 1e-8 || solved.lengthSq() < 1e-8)
26
+ return;
27
+ const correction = new THREE.Quaternion().setFromUnitVectors(current, solved);
28
+ const currentWorld = worldQuaternion(bone);
29
+ const desiredWorld = correction.multiply(currentWorld);
30
+ currentWorld.slerp(desiredWorld, weight);
31
+ writeWorldQuaternion(bone, currentWorld);
32
+ }
33
+ /** Applies one frame of foot IK to a loaded rig. Exported for renderer tests and custom model hosts. */
34
+ export function applyFootIk(scene, config, raycast, weight, cameraTarget) {
35
+ let grounded = false;
36
+ scene.updateMatrixWorld(true);
37
+ for (const foot of config.feet) {
38
+ const root = scene.getObjectByName(foot.root);
39
+ const mid = scene.getObjectByName(foot.mid);
40
+ const tip = scene.getObjectByName(foot.tip);
41
+ if (root === undefined || mid === undefined || tip === undefined)
42
+ continue;
43
+ const rootPosition = root.getWorldPosition(new THREE.Vector3());
44
+ const midPosition = mid.getWorldPosition(new THREE.Vector3());
45
+ const tipPosition = tip.getWorldPosition(new THREE.Vector3());
46
+ const hit = raycast({
47
+ origin: [tipPosition.x, tipPosition.y + 0.75, tipPosition.z],
48
+ direction: [0, -1, 0],
49
+ maxDistance: 2,
50
+ filter: { entities: false, objects: false, walls: false, terrain: true },
51
+ });
52
+ if (hit === null)
53
+ continue;
54
+ grounded = true;
55
+ const target = new THREE.Vector3(hit.point[0], hit.point[1], hit.point[2]);
56
+ const output = { mid: [0, 0, 0], tip: [0, 0, 0] };
57
+ solveTwoBone({ root: tuple(rootPosition), mid: tuple(midPosition), tip: tuple(tipPosition), target: tuple(target), pole: [rootPosition.x, rootPosition.y, rootPosition.z + 1] }, output);
58
+ aimSegment(root, rootPosition, midPosition, output.mid, weight);
59
+ root.updateMatrixWorld(true);
60
+ const solvedMid = mid.getWorldPosition(new THREE.Vector3());
61
+ // Rotating the root also moves the tip; aim the shin from that updated pose.
62
+ const currentTip = tip.getWorldPosition(new THREE.Vector3());
63
+ aimSegment(mid, solvedMid, currentTip, output.tip, weight);
64
+ }
65
+ if (config.lookAt !== undefined && cameraTarget !== undefined) {
66
+ const bone = scene.getObjectByName(config.lookAt.bone);
67
+ if (bone !== undefined) {
68
+ const from = bone.getWorldPosition(new THREE.Vector3());
69
+ const q = lookAt({ from: tuple(from), target: cameraTarget, up: [0, 1, 0] });
70
+ const desired = new THREE.Quaternion(q[0], q[1], q[2], q[3]);
71
+ const current = worldQuaternion(bone);
72
+ current.slerp(desired, weight);
73
+ writeWorldQuaternion(bone, current);
74
+ }
75
+ }
76
+ return grounded;
77
+ }
78
+ /** Runs foot IK after the model animation mixer, fading the correction out while the entity is airborne. */
79
+ export function useFootIk(scene, config, ctx, instanceId) {
80
+ const camera = useThree((state) => state.camera);
81
+ const weight = useRef(0);
82
+ useFrame((_state, delta) => {
83
+ if (config === undefined || ctx === null || instanceId === undefined)
84
+ return;
85
+ const grounded = applyFootIk(scene, config, (input) => ctx.scene.raycast({ ...input, excludeInstanceIds: [instanceId] }), weight.current, tuple(camera.getWorldPosition(new THREE.Vector3())));
86
+ const target = grounded ? 1 : 0;
87
+ weight.current += (target - weight.current) * Math.min(1, delta * (grounded ? 12 : 5));
88
+ });
89
+ }
@@ -6,5 +6,7 @@ import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
6
6
  * Handles `"auto"` derivation from the GLB's clip names, speed-driven idle/walk/run crossfades
7
7
  * read from the entity's live position when `instanceId` is set, one-shots fired from
8
8
  * `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
9
+ * With `animation.graph` set, the headless `AnimGraph` runtime owns every clip's time and weight
10
+ * and the mixer only applies them; clip events surface as `animation.event`.
9
11
  */
10
12
  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 { LOCOMOTION_SPEED_PARAM, locomotionGraph } 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,77 @@ 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
+ function buildGraphPlayback(scene, mixer, graph, clips) {
70
+ const actions = new Map();
71
+ const durations = {};
72
+ let resolvedRootBone = null;
73
+ scene.traverse((object) => {
74
+ if (resolvedRootBone === null && object instanceof THREE.Bone)
75
+ resolvedRootBone = object;
76
+ });
77
+ const rootBone = resolvedRootBone;
78
+ for (const clip of clips) {
79
+ const track = rootBone === null ? undefined : clip.tracks.find((candidate) => candidate.name === `${rootBone.name}.position`);
80
+ durations[clip.name] = {
81
+ duration: clip.duration,
82
+ ...(track === undefined ? {} : { rootTrack: { times: new Float32Array(track.times), values: new Float32Array(track.values) } }),
83
+ };
84
+ }
85
+ for (const layer of graph.layers) {
86
+ const names = new Set();
87
+ for (const state of Object.values(layer.states)) {
88
+ if (state.kind === "clip")
89
+ names.add(state.clip);
90
+ else
91
+ for (const point of state.points)
92
+ names.add(point.clip);
93
+ }
94
+ for (const name of names) {
95
+ const source = THREE.AnimationClip.findByName(clips, name);
96
+ if (source === null)
97
+ continue;
98
+ let clip = source;
99
+ if (layer.mask !== undefined) {
100
+ const mask = layer.mask;
101
+ clip = new THREE.AnimationClip(`${source.name}|${layer.id}`, source.duration, source.tracks.filter((track) => mask.some((prefix) => track.name.startsWith(prefix))));
102
+ }
103
+ if (layer.additive === true) {
104
+ clip = clip === source ? clip.clone() : clip;
105
+ THREE.AnimationUtils.makeClipAdditive(clip);
106
+ }
107
+ const action = mixer.clipAction(clip);
108
+ action.setLoop(THREE.LoopRepeat, Infinity);
109
+ action.enabled = true;
110
+ action.paused = true;
111
+ action.weight = 0;
112
+ if (layer.additive === true)
113
+ action.blendMode = THREE.AdditiveAnimationBlendMode;
114
+ action.play();
115
+ actions.set(actionKey(layer.id, name), action);
116
+ }
117
+ }
118
+ return {
119
+ runtime: createAnimGraphRuntime(graph),
120
+ actions,
121
+ durations,
122
+ rootBone,
123
+ rootBindPosition: rootBone?.position.clone() ?? null,
124
+ lastPos: null,
125
+ smoothedSpeed: 0,
126
+ };
127
+ }
46
128
  /**
47
129
  * The engine's model animation driver as a standalone hook — the same mixer `EntityModel` runs,
48
130
  * for games that render a cloned scene themselves (custom materials, procedural composition).
49
131
  * Handles `"auto"` derivation from the GLB's clip names, speed-driven idle/walk/run crossfades
50
132
  * read from the entity's live position when `instanceId` is set, one-shots fired from
51
133
  * `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
134
+ * With `animation.graph` set, the headless `AnimGraph` runtime owns every clip's time and weight
135
+ * and the mixer only applies them; clip events surface as `animation.event`.
52
136
  */
53
137
  export function useModelAnimation(scene, clips, animationInput, instanceId) {
54
138
  // Optional: a model must still animate its bind pose / auto clip in a preview or inspector that
@@ -60,95 +144,47 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
60
144
  const animation = useMemo(() => resolveAnimationConfig(animationInput, clips.map((clip) => clip.name)), [animationInput, clips]);
61
145
  const mixerRef = useRef(null);
62
146
  const animationPausedRef = useRef(false);
63
- const stateActionsRef = useRef(null);
147
+ const graphRef = useRef(null);
64
148
  const states = animation?.states;
65
149
  const oneShots = animation?.oneShots;
66
- const oneShotPlayRef = useRef(null);
67
- const activeOneShotRef = useRef(null);
150
+ const graph = useMemo(() => {
151
+ if (animation?.graph !== undefined)
152
+ return animation.graph;
153
+ if (states === undefined)
154
+ return undefined;
155
+ const graphOneShots = {};
156
+ for (const [event, spec] of Object.entries(oneShots ?? {})) {
157
+ const clip = typeof spec === "string" ? spec : spec[0];
158
+ if (clip !== undefined)
159
+ graphOneShots[event] = clip;
160
+ }
161
+ return locomotionGraph({
162
+ idle: states.idle,
163
+ walk: states.walk,
164
+ ...(states.run === undefined ? {} : { run: states.run }),
165
+ walkSpeed: states.walkSpeed,
166
+ runSpeed: states.runSpeed,
167
+ fadeSec: states.fadeSec,
168
+ ...(Object.keys(graphOneShots).length === 0 ? {} : { oneShots: graphOneShots }),
169
+ });
170
+ }, [animation?.graph, states, oneShots]);
68
171
  useEffect(() => {
69
172
  if (animation === undefined || clips.length === 0) {
70
173
  mixerRef.current = null;
71
- stateActionsRef.current = null;
174
+ graphRef.current = null;
72
175
  return;
73
176
  }
74
177
  warnMissingClips(animation, clips);
75
178
  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();
179
+ if (graph !== undefined) {
180
+ graphRef.current = buildGraphPlayback(scene, mixer, graph, clips);
89
181
  mixer.update(0);
90
182
  mixerRef.current = mixer;
91
- stateActionsRef.current = { actions, active: "idle", lastPos: null, smoothedSpeed: 0 };
92
183
  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
184
  return () => {
145
- if (onOneShotFinished !== null)
146
- mixer.removeEventListener("finished", onOneShotFinished);
147
- oneShotPlayRef.current = null;
148
- activeOneShotRef.current = null;
149
185
  mixer.stopAllAction();
150
186
  mixerRef.current = null;
151
- stateActionsRef.current = null;
187
+ graphRef.current = null;
152
188
  };
153
189
  }
154
190
  const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(clips, animation.clip) : undefined) ??
@@ -179,11 +215,16 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
179
215
  animation?.time,
180
216
  states,
181
217
  oneShots,
218
+ graph,
182
219
  ]);
183
220
  useEffect(() => {
184
- if (ctx === null || instanceId === undefined || oneShots === undefined)
221
+ if (ctx === null || instanceId === undefined || (oneShots === undefined && graph === undefined))
185
222
  return;
186
- const fire = (event) => oneShotPlayRef.current?.(event);
223
+ const fire = (event) => {
224
+ const playback = graphRef.current;
225
+ if (playback !== null)
226
+ playback.runtime.trigger(event);
227
+ };
187
228
  const offAnimation = ctx.game.events.on("entity.animation", (event) => {
188
229
  if (event.instanceId === instanceId)
189
230
  fire(event.event);
@@ -201,39 +242,54 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
201
242
  offHit();
202
243
  offDied();
203
244
  };
204
- }, [ctx, instanceId, oneShots]);
245
+ }, [ctx, instanceId, oneShots, graph]);
205
246
  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
- }
247
+ const playback = graphRef.current;
248
+ if (playback !== null && mixerRef.current !== null) {
249
+ const params = {};
250
+ if (ctx !== null && instanceId !== undefined) {
251
+ const entity = ctx.scene.entity.get(instanceId);
252
+ if (entity !== null && delta > 0) {
253
+ const [x, , z] = entity.position;
254
+ if (playback.lastPos !== null) {
255
+ const instantSpeed = Math.hypot(x - playback.lastPos[0], z - playback.lastPos[2]) / delta;
256
+ playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, delta * 12);
233
257
  }
234
- stateMachine.active = next;
258
+ playback.lastPos = [x, entity.position[1], z];
235
259
  }
260
+ const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
261
+ if (extra !== undefined)
262
+ Object.assign(params, extra);
263
+ }
264
+ params[LOCOMOTION_SPEED_PARAM] = playback.smoothedSpeed;
265
+ const out = playback.runtime.advance(delta * (animation?.timeScale ?? 1), params, playback.durations);
266
+ for (const action of playback.actions.values())
267
+ action.weight = 0;
268
+ for (const entry of out.clips) {
269
+ const action = playback.actions.get(actionKey(entry.layer, entry.clip));
270
+ if (action === undefined)
271
+ continue;
272
+ action.weight += entry.weight;
273
+ action.time = entry.time;
274
+ }
275
+ mixerRef.current.update(0);
276
+ const currentPlayback = graphRef.current;
277
+ if (currentPlayback !== null && currentPlayback.rootBone !== null && currentPlayback.rootBindPosition !== null) {
278
+ currentPlayback.rootBone.position.copy(currentPlayback.rootBindPosition);
236
279
  }
280
+ if (ctx !== null && instanceId !== undefined) {
281
+ const rootDelta = out.rootDelta;
282
+ const entity = rootDelta === undefined ? null : ctx.scene.entity.get(instanceId);
283
+ if (entity !== null && rootDelta !== undefined) {
284
+ ctx.scene.entity.setPose(instanceId, {
285
+ position: [entity.position[0] + rootDelta[0], entity.position[1] + rootDelta[1], entity.position[2] + rootDelta[2]],
286
+ dt: delta,
287
+ });
288
+ }
289
+ for (const event of out.events)
290
+ ctx.game.events.emit("animation.event", { instanceId, name: event.name, clip: event.clip });
291
+ }
292
+ return;
237
293
  }
238
294
  if (mixerRef.current !== null && !animationPausedRef.current)
239
295
  mixerRef.current.update(delta);