@jgengine/shell 0.18.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.
- package/CHANGELOG.md +114 -2
- package/dist/GamePlayerShell.js +10 -4
- package/dist/JoinGate.d.ts +17 -0
- package/dist/JoinGate.js +14 -0
- package/dist/Shell3dPresentation.d.ts +8 -1
- package/dist/Shell3dPresentation.js +34 -5
- package/dist/ShellChrome.d.ts +2 -0
- package/dist/ShellChrome.js +6 -0
- package/dist/TerritoryOverlay.d.ts +21 -0
- package/dist/TerritoryOverlay.js +9 -0
- package/dist/audio/AudioComponents.js +8 -1
- package/dist/audio/audioEngine.d.ts +7 -1
- package/dist/audio/audioEngine.js +67 -5
- package/dist/camera/cameraRigs.js +7 -0
- package/dist/defineGame.js +21 -6
- package/dist/devtools/KeysPanel.js +3 -3
- package/dist/drivers/FrameDriver.d.ts +5 -1
- package/dist/drivers/FrameDriver.js +61 -24
- package/dist/drivers/HudOnlyDriver.js +9 -4
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/input/gamepadMerge.d.ts +10 -0
- package/dist/input/gamepadMerge.js +12 -0
- package/dist/input/gamepadSource.d.ts +12 -0
- package/dist/input/gamepadSource.js +107 -0
- package/dist/materialOverride.d.ts +4 -1
- package/dist/materialOverride.js +10 -1
- package/dist/postfx/PostProcessing.d.ts +10 -7
- package/dist/postfx/PostProcessing.js +33 -15
- package/dist/postfx/gradeShader.js +13 -0
- package/dist/render/CascadedShadows.d.ts +11 -0
- package/dist/render/CascadedShadows.js +19 -8
- package/dist/render/EnvironmentLighting.d.ts +4 -1
- package/dist/render/EnvironmentLighting.js +45 -9
- package/dist/render/SceneLighting.d.ts +16 -2
- package/dist/render/SceneLighting.js +56 -6
- package/dist/render/SceneModels.js +45 -9
- package/dist/render/modelLoad.d.ts +10 -0
- package/dist/render/modelLoad.js +28 -0
- package/dist/render/modelRender.d.ts +3 -0
- package/dist/render/modelRender.js +6 -2
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +15 -0
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +30 -0
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +13 -0
- package/dist/render/testFixtures/proceduralTileLayer.js +30 -0
- package/dist/render/useFootIk.d.ts +9 -0
- package/dist/render/useFootIk.js +89 -0
- package/dist/render/useModelAnimation.d.ts +2 -0
- package/dist/render/useModelAnimation.js +163 -107
- package/dist/scene/CityRenderer.js +661 -236
- package/dist/scene/cityWindowMaterial.d.ts +8 -6
- package/dist/scene/cityWindowMaterial.js +80 -21
- package/dist/settings/appliedSettings.d.ts +3 -1
- package/dist/settings/appliedSettings.js +5 -2
- package/dist/terrain/terrainDetailMaterial.js +7 -3
- package/dist/useShellMultiplayerSync.d.ts +8 -1
- package/dist/useShellMultiplayerSync.js +25 -5
- package/dist/visibility/CullingProvider.d.ts +2 -1
- package/dist/visibility/CullingProvider.js +7 -2
- package/dist/world/GridWorldScene.js +4 -0
- package/dist/world/TileLayerRenderer.d.ts +9 -0
- package/dist/world/TileLayerRenderer.js +64 -0
- package/dist/world/WorldParticles.d.ts +31 -0
- package/dist/world/WorldParticles.js +128 -0
- package/dist/world/WorldScene.d.ts +2 -1
- package/dist/world/WorldScene.js +46 -4
- package/dist/world/entityPose.d.ts +4 -0
- package/dist/world/entityPose.js +9 -0
- package/dist/world/tileLayerInstances.d.ts +11 -0
- package/dist/world/tileLayerInstances.js +28 -0
- package/dist/worldSync.d.ts +5 -1
- package/dist/worldSync.js +25 -4
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-ui/SKILL.md +8 -0
- package/skills/jgengine-ui/api.md +127 -16
- package/skills/jgengine-ui/capabilities.md +21 -0
- package/skills/jgengine-ui/reference.md +3 -0
- package/skills/jgengine-world/SKILL.md +6 -0
- package/skills/jgengine-world/api.md +312 -12
- package/skills/jgengine-world/capabilities.md +102 -0
- package/skills/level-design/SKILL.md +1 -1
- 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 {
|
|
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
|
|
147
|
+
const graphRef = useRef(null);
|
|
64
148
|
const states = animation?.states;
|
|
65
149
|
const oneShots = animation?.oneShots;
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
-
|
|
174
|
+
graphRef.current = null;
|
|
72
175
|
return;
|
|
73
176
|
}
|
|
74
177
|
warnMissingClips(animation, clips);
|
|
75
178
|
const mixer = new THREE.AnimationMixer(scene);
|
|
76
|
-
if (
|
|
77
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
207
|
-
if (
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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);
|