@genex-ai/cli-demo 0.12.1 → 0.14.2
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/README.md +1 -0
- package/dist/index.js +203 -4
- package/package.json +7 -2
- package/templates/controllers/NOTICE.md +65 -0
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/character.glb +0 -0
- package/templates/controllers/assets/default-avatar.vrm +0 -0
- package/templates/controllers/character/character-animations.ts +682 -0
- package/templates/controllers/character/character-controller.ts +1636 -0
- package/templates/controllers/character/follow-camera.ts +644 -0
- package/templates/controllers/character/keyboard-input.ts +277 -0
- package/templates/controllers/character/presets.ts +176 -0
- package/templates/controllers/character/touch-joystick.ts +387 -0
- package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
- package/templates/controllers/character/vrm/foot-ik.ts +341 -0
- package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
- package/templates/controllers/drone/drone-controller.ts +1073 -0
- package/templates/controllers/drone/presets.ts +225 -0
- package/templates/controllers/interact/enter-exit.ts +502 -0
- package/templates/controllers/shared/colliders.ts +456 -0
- package/templates/controllers/shared/math.ts +230 -0
- package/templates/controllers/shared/physics-world.ts +622 -0
- package/templates/controllers/vehicle/presets.ts +297 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
- package/templates/controllers/vehicle/wheel.ts +1200 -0
- package/templates/skills/genex-getting-started/SKILL.md +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
- package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
- package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TS port of the ecctrl character controller's animation layer (React/R3F/zustand shells removed).
|
|
4
|
+
//
|
|
5
|
+
// Port notes / deliberate deviations from upstream:
|
|
6
|
+
// - De-branding renames: `EcctrlAnimationState` -> `CharacterAnimationState`,
|
|
7
|
+
// `resolveEcctrlAnimationState` -> `resolveAnimationState`, `EcctrlAnimationStateContext`
|
|
8
|
+
// -> `AnimationStateContext` (the `handle` field is dropped so the resolver stays pure).
|
|
9
|
+
// - The zustand animation store + `EcctrlAnimationStateController` component + the demo's
|
|
10
|
+
// `AnimatedCharacterModel` playback effect are folded into one `CharacterAnimations` class;
|
|
11
|
+
// drei `useAnimations`'s implicit mixer tick becomes an explicit `mixer.update(dt)`.
|
|
12
|
+
// - Upstream reads `e.action._clip` (private API); this port uses `e.action.getClip().name`.
|
|
13
|
+
// - Upstream sets the store (crossfade via re-render) and THEN calls `onChange`; this port
|
|
14
|
+
// applies the transition then fires `onChange` — same observable order. The transition
|
|
15
|
+
// attempt (and the stuck-lock release checks) run every update, guarded and idempotent:
|
|
16
|
+
// the non-React equivalent of upstream's playback effect re-running on `canPlayNext`
|
|
17
|
+
// changes (this is what fades Jump_Loop/Idle_Loop in after a one-shot finishes).
|
|
18
|
+
// - `crossFadeFrom`'s `warp` argument is passed explicitly as `false` (upstream omits it), and
|
|
19
|
+
// the crossfade is skipped (plain `.play()`) when no previous action is cached.
|
|
20
|
+
// - NEW (no upstream source): `buildClipMap` alias-based fuzzy clip lookup + hole-filling
|
|
21
|
+
// chaining, and the procedural bob/lean fallback (PROC_* constants) for rig-less models.
|
|
22
|
+
// - NEW: the IDLE-bound action starts playing in the constructor (upstream leaves the rig in
|
|
23
|
+
// bind pose until the first state change).
|
|
24
|
+
// - RUN's default clip is `Jog_Fwd_Loop` (the value the upstream demo actually runs);
|
|
25
|
+
// `Sprint_Loop` is the second exact choice and also binds via alias.
|
|
26
|
+
// - The state controller's `enabled` prop and the `timeScale` RefObject variant are dropped in
|
|
27
|
+
// favor of `setPaused(boolean)` / `setTimeScale(number)`.
|
|
28
|
+
|
|
29
|
+
import * as THREE from "three";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// State resolver (pure port)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** The seven animation states the resolver can produce. */
|
|
36
|
+
export type CharacterAnimationState =
|
|
37
|
+
| "IDLE"
|
|
38
|
+
| "WALK"
|
|
39
|
+
| "RUN"
|
|
40
|
+
| "JUMP_START"
|
|
41
|
+
| "JUMP_IDLE"
|
|
42
|
+
| "JUMP_FALL"
|
|
43
|
+
| "JUMP_LAND";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The plain state snapshot the animation system consumes each frame.
|
|
47
|
+
* Defined HERE, never imported from the controller — `CharacterController`'s readonly getters
|
|
48
|
+
* satisfy it structurally, so you can pass the controller instance straight into `update()`.
|
|
49
|
+
* Any object with these five booleans works (handy for networked remote players).
|
|
50
|
+
*/
|
|
51
|
+
export interface CharacterStateSnapshot {
|
|
52
|
+
/** Ground contact within the float-ray forgiveness window. */
|
|
53
|
+
readonly isOnGround: boolean;
|
|
54
|
+
/** Airborne and moving downward (velocity·up < 0). */
|
|
55
|
+
readonly isFalling: boolean;
|
|
56
|
+
/** INPUT-based, not velocity-based: true while the player is steering the character. */
|
|
57
|
+
readonly isMoving: boolean;
|
|
58
|
+
/** Run key held (or toggled, when the controller uses toggle-run). */
|
|
59
|
+
readonly runActive: boolean;
|
|
60
|
+
/** True during the short jump window (default 0.1 s), not for the whole airborne arc. */
|
|
61
|
+
readonly jumpActive: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Snapshot plus the previous frame's ground flag (derived internally by CharacterAnimations). */
|
|
65
|
+
export interface AnimationStateContext extends CharacterStateSnapshot {
|
|
66
|
+
readonly wasOnGround: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Custom state-resolver signature — must stay a pure function over the context. */
|
|
70
|
+
export type AnimationStateResolver = (ctx: AnimationStateContext) => CharacterAnimationState;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pure animation-state resolver (exact upstream logic). Check order is load-bearing:
|
|
74
|
+
* JUMP_START outranks everything (fires on the ground frame where the jump window opened);
|
|
75
|
+
* JUMP_LAND outranks IDLE/WALK/RUN for exactly one evaluation after touchdown.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveAnimationState(ctx: AnimationStateContext): CharacterAnimationState {
|
|
78
|
+
const { isOnGround, wasOnGround, isFalling, isMoving, runActive, jumpActive } = ctx;
|
|
79
|
+
|
|
80
|
+
if (jumpActive && wasOnGround) return "JUMP_START";
|
|
81
|
+
|
|
82
|
+
if (isOnGround) {
|
|
83
|
+
if (!wasOnGround) return "JUMP_LAND";
|
|
84
|
+
if (!isMoving) return "IDLE";
|
|
85
|
+
return runActive ? "RUN" : "WALK";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return isFalling ? "JUMP_FALL" : "JUMP_IDLE";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Clip lookup (NEW — alias-based fuzzy binding so UAL, Mixamo-named, or arbitrary rigs work)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** Resolved clip NAME per state; null = unbound (procedural fallback may take over). */
|
|
96
|
+
export type ClipMap = Record<CharacterAnimationState, string | null>;
|
|
97
|
+
|
|
98
|
+
const ALL_STATES: readonly CharacterAnimationState[] = [
|
|
99
|
+
"IDLE",
|
|
100
|
+
"WALK",
|
|
101
|
+
"RUN",
|
|
102
|
+
"JUMP_START",
|
|
103
|
+
"JUMP_IDLE",
|
|
104
|
+
"JUMP_FALL",
|
|
105
|
+
"JUMP_LAND",
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
type ClipSearchSpec = {
|
|
109
|
+
/** Quaternius Universal Animation Library names, tried first (exact, then case-insensitive). */
|
|
110
|
+
exact: readonly string[];
|
|
111
|
+
/** Case-insensitive substring aliases, in priority order. */
|
|
112
|
+
aliases: readonly string[];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const CLIP_SEARCH: Record<CharacterAnimationState, ClipSearchSpec> = {
|
|
116
|
+
IDLE: { exact: ["Idle_Loop"], aliases: ["idle", "stand", "breath"] },
|
|
117
|
+
WALK: { exact: ["Walk_Loop"], aliases: ["walk"] },
|
|
118
|
+
RUN: { exact: ["Jog_Fwd_Loop", "Sprint_Loop"], aliases: ["run", "jog", "sprint"] },
|
|
119
|
+
// Only the LOOP jump states (JUMP_IDLE/JUMP_FALL) end with a bare "jump" alias (lowest priority
|
|
120
|
+
// — the specific compound tokens above always win when present) so a rig whose only airborne clip
|
|
121
|
+
// is named "Jump" / "Jumping" still animates mid-air instead of playing the ground loop.
|
|
122
|
+
// The one-shot states (JUMP_START/JUMP_LAND) deliberately OMIT the bare "jump" alias: binding them
|
|
123
|
+
// to a shared loop clip (e.g. "Jump_Loop") makes them one-shot-clamp that clip, freezing the rig on
|
|
124
|
+
// its last frame through the whole airborne arc — the same reason the hole-filling below leaves
|
|
125
|
+
// them null. Without a specific start/land clip they stay null and the previous loop keeps playing.
|
|
126
|
+
JUMP_START: {
|
|
127
|
+
exact: ["Jump_Start"],
|
|
128
|
+
aliases: ["jump_start", "jumpstart", "jump start", "jump_up", "takeoff"],
|
|
129
|
+
},
|
|
130
|
+
JUMP_IDLE: { exact: ["Jump_Loop"], aliases: ["jump_loop", "jump_idle", "air", "fall", "jump"] },
|
|
131
|
+
JUMP_FALL: { exact: ["Jump_Loop"], aliases: ["jump_loop", "fall", "falling", "air", "jump"] },
|
|
132
|
+
JUMP_LAND: { exact: ["Jump_Land"], aliases: ["jump_land", "land"] },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Bind animation clips to states by fuzzy name lookup.
|
|
137
|
+
*
|
|
138
|
+
* Per state, first match wins: explicit override (exact, then case-insensitive) → UAL exact
|
|
139
|
+
* names → UAL case-insensitive → each alias as a case-insensitive SUBSTRING (shortest matching
|
|
140
|
+
* clip name wins, so `Walk_Loop` beats `Walk_Bwd_Loop` and Mixamo's `walking` beats
|
|
141
|
+
* `walking_backwards`). Afterwards LOOP-state holes are chained (RUN↔WALK, JUMP_IDLE↔JUMP_FALL)
|
|
142
|
+
* so partially-animated rigs still move; one-shot states (JUMP_START/JUMP_LAND) stay null when
|
|
143
|
+
* unbound so the previous loop keeps playing (upstream behavior) instead of clamping a loop clip.
|
|
144
|
+
*
|
|
145
|
+
* If your rig's names don't bind, pass `overrides` — e.g. `{ RUN: "MyFastRun" }`.
|
|
146
|
+
*/
|
|
147
|
+
export function buildClipMap(
|
|
148
|
+
clips: THREE.AnimationClip[],
|
|
149
|
+
overrides?: Partial<Record<CharacterAnimationState, string>>
|
|
150
|
+
): ClipMap {
|
|
151
|
+
const names = clips.map((clip) => clip.name);
|
|
152
|
+
const lowerNames = names.map((name) => name.toLowerCase());
|
|
153
|
+
|
|
154
|
+
const findExact = (name: string): string | null => (names.includes(name) ? name : null);
|
|
155
|
+
const findCiExact = (name: string): string | null => {
|
|
156
|
+
const index = lowerNames.indexOf(name.toLowerCase());
|
|
157
|
+
return index >= 0 ? names[index] : null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const resolveClip = (state: CharacterAnimationState): string | null => {
|
|
161
|
+
const override = overrides?.[state];
|
|
162
|
+
if (override !== undefined) {
|
|
163
|
+
const hit = findExact(override) ?? findCiExact(override);
|
|
164
|
+
if (hit !== null) return hit;
|
|
165
|
+
console.warn(
|
|
166
|
+
`[CharacterAnimations] clip override "${override}" for state ${state} matches no clip; falling back to fuzzy lookup.`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const spec = CLIP_SEARCH[state];
|
|
170
|
+
for (const exactName of spec.exact) {
|
|
171
|
+
const hit = findExact(exactName);
|
|
172
|
+
if (hit !== null) return hit;
|
|
173
|
+
}
|
|
174
|
+
for (const exactName of spec.exact) {
|
|
175
|
+
const hit = findCiExact(exactName);
|
|
176
|
+
if (hit !== null) return hit;
|
|
177
|
+
}
|
|
178
|
+
for (const alias of spec.aliases) {
|
|
179
|
+
let best: string | null = null;
|
|
180
|
+
for (let i = 0; i < names.length; i++) {
|
|
181
|
+
if (lowerNames[i].includes(alias) && (best === null || names[i].length < best.length)) {
|
|
182
|
+
best = names[i];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (best !== null) return best;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const map: ClipMap = {
|
|
191
|
+
IDLE: null,
|
|
192
|
+
WALK: null,
|
|
193
|
+
RUN: null,
|
|
194
|
+
JUMP_START: null,
|
|
195
|
+
JUMP_IDLE: null,
|
|
196
|
+
JUMP_FALL: null,
|
|
197
|
+
JUMP_LAND: null,
|
|
198
|
+
};
|
|
199
|
+
for (const state of ALL_STATES) map[state] = resolveClip(state);
|
|
200
|
+
|
|
201
|
+
// Hole-filling chaining so partial rigs still animate — LOOP states only.
|
|
202
|
+
// JUMP_START/JUMP_LAND are deliberately NOT hole-filled: they get one-shot playback
|
|
203
|
+
// (LoopOnce + clampWhenFinished), and aliasing a loop clip (idle, fall) onto them would
|
|
204
|
+
// freeze the rig in a clamped pose after every stop/landing. Left null they reproduce
|
|
205
|
+
// upstream's missing-action early return: the previous loop keeps playing.
|
|
206
|
+
if (map.RUN === null) map.RUN = map.WALK;
|
|
207
|
+
if (map.WALK === null) map.WALK = map.RUN;
|
|
208
|
+
if (map.JUMP_FALL === null) map.JUMP_FALL = map.JUMP_IDLE;
|
|
209
|
+
if (map.JUMP_IDLE === null) map.JUMP_IDLE = map.JUMP_FALL;
|
|
210
|
+
return map;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Playback constants (upstream demo values)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/** One-shot (JUMP_START/JUMP_LAND) actions play sped up so they finish inside the hop. */
|
|
218
|
+
const ONE_SHOT_TIME_SCALE = 1.6;
|
|
219
|
+
/** Crossfade into a one-shot action, seconds (scaled by the effective mixer timeScale). */
|
|
220
|
+
const ONE_SHOT_FADE_DURATION = 0.1;
|
|
221
|
+
/** Crossfade between looping actions, seconds (scaled by the effective mixer timeScale). */
|
|
222
|
+
const LOOP_FADE_DURATION = 0.2;
|
|
223
|
+
/** Floor for the fade timeScale factor — prevents a zero-length fade while paused. */
|
|
224
|
+
const FADE_TIME_SCALE_FLOOR = 0.05;
|
|
225
|
+
|
|
226
|
+
// Procedural fallback constants (NEW — no upstream source). Tuning hints:
|
|
227
|
+
// raise the *_BOB_FREQ values for a more frantic gait, the *_BOB_AMP values for a bouncier
|
|
228
|
+
// one; *_LEAN tips the model forward while moving; PROC_SMOOTHING is the blend rate
|
|
229
|
+
// (higher = snappier transitions between offsets).
|
|
230
|
+
const PROC_WALK_BOB_FREQ = 8; // rad/s
|
|
231
|
+
const PROC_RUN_BOB_FREQ = 12; // rad/s
|
|
232
|
+
const PROC_WALK_BOB_AMP = 0.03; // m
|
|
233
|
+
const PROC_RUN_BOB_AMP = 0.05; // m
|
|
234
|
+
const PROC_WALK_LEAN = 0.06; // rad, forward-positive about local X
|
|
235
|
+
const PROC_RUN_LEAN = 0.12; // rad
|
|
236
|
+
const PROC_AIR_LEAN = -0.08; // rad
|
|
237
|
+
const PROC_LAND_DIP = 0.06; // m
|
|
238
|
+
const PROC_SMOOTHING = 10; // in k = 1 - exp(-PROC_SMOOTHING * dt)
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Mixer state machine
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
export interface CharacterAnimationsOptions {
|
|
245
|
+
/** Per-state clip-name overrides; passed through to {@link buildClipMap}. */
|
|
246
|
+
clipMap?: Partial<Record<CharacterAnimationState, string>>;
|
|
247
|
+
/** Custom state resolver; default {@link resolveAnimationState}. */
|
|
248
|
+
resolver?: AnimationStateResolver;
|
|
249
|
+
/** Fired once per state CHANGE, after the transition is applied. Receives a context copy. */
|
|
250
|
+
onChange?: (state: CharacterAnimationState, ctx: AnimationStateContext) => void;
|
|
251
|
+
/**
|
|
252
|
+
* "auto" (default): procedural bob/lean when no usable clips bind;
|
|
253
|
+
* "procedural": force the procedural fallback even when clips exist;
|
|
254
|
+
* "none": do nothing when unbound (model stays static).
|
|
255
|
+
*/
|
|
256
|
+
fallback?: "auto" | "procedural" | "none";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Options for {@link CharacterAnimations.playOneShot}. */
|
|
260
|
+
export interface PlayOneShotOptions {
|
|
261
|
+
/** Crossfade-in seconds from the current motion. Default 0.1. */
|
|
262
|
+
fadeIn?: number;
|
|
263
|
+
/** Playback speed. Default 1. */
|
|
264
|
+
timeScale?: number;
|
|
265
|
+
/** Clamp the final pose instead of returning to the loop. Default false. */
|
|
266
|
+
clamp?: boolean;
|
|
267
|
+
/** Fired when the clip finishes (skipped if a newer one-shot interrupts it). */
|
|
268
|
+
onDone?: () => void;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
type MutableAnimationStateContext = {
|
|
272
|
+
-readonly [Key in keyof AnimationStateContext]: AnimationStateContext[Key];
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Animation state machine for the character: resolves a state from the controller's snapshot,
|
|
277
|
+
* crossfades `THREE.AnimationMixer` actions (one-shot jump start/land, looping everything
|
|
278
|
+
* else), and falls back to a procedural bob/lean for rig-less models.
|
|
279
|
+
*
|
|
280
|
+
* Call `update(controller, renderDelta)` once per render frame AFTER the physics stepping
|
|
281
|
+
* loop — never inside it, and always with the render-clock delta (the mixer's own timeScale
|
|
282
|
+
* handles pause/slow-motion).
|
|
283
|
+
*/
|
|
284
|
+
export class CharacterAnimations {
|
|
285
|
+
/** Escape hatch for playing extra clips (e.g. `Sitting_Enter` on vehicle entry). */
|
|
286
|
+
readonly mixer: THREE.AnimationMixer;
|
|
287
|
+
|
|
288
|
+
#model: THREE.Object3D;
|
|
289
|
+
#clipMap: ClipMap;
|
|
290
|
+
#resolver: AnimationStateResolver;
|
|
291
|
+
#onChange: ((state: CharacterAnimationState, ctx: AnimationStateContext) => void) | undefined;
|
|
292
|
+
#actions = new Map<string, THREE.AnimationAction>();
|
|
293
|
+
// Every provided clip by name — lets playOneShot reach clips beyond the 7
|
|
294
|
+
// locomotion states (the full 46-clip UAL catalog: Punch_*, Sword_*, Sitting_*…).
|
|
295
|
+
#clipsByName = new Map<string, THREE.AnimationClip>();
|
|
296
|
+
#oneShotAction: THREE.AnimationAction | null = null;
|
|
297
|
+
#oneShotOnDone: (() => void) | undefined;
|
|
298
|
+
// When true, the active one-shot holds its final pose on finish (clamp:true —
|
|
299
|
+
// e.g. Death01) instead of crossfading back to locomotion.
|
|
300
|
+
#oneShotHoldPose = false;
|
|
301
|
+
|
|
302
|
+
#state: CharacterAnimationState = "IDLE";
|
|
303
|
+
#prevActionName: string | null;
|
|
304
|
+
#canPlayNext = true;
|
|
305
|
+
#initialized = false;
|
|
306
|
+
#previousIsOnGround = false;
|
|
307
|
+
#ctx: MutableAnimationStateContext;
|
|
308
|
+
|
|
309
|
+
#timeScale = 1;
|
|
310
|
+
#paused = false;
|
|
311
|
+
#prevMixerTimeScale = -1;
|
|
312
|
+
|
|
313
|
+
#usingProceduralFallback: boolean;
|
|
314
|
+
#basePositionY: number;
|
|
315
|
+
#baseRotationX: number;
|
|
316
|
+
#procPhase = 0;
|
|
317
|
+
#bobOffset = 0;
|
|
318
|
+
#leanOffset = 0;
|
|
319
|
+
|
|
320
|
+
#disposed = false;
|
|
321
|
+
#onFinished: (event: { action: THREE.AnimationAction }) => void;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @param model root Object3D of the character visual (mixer root; also the transform target
|
|
325
|
+
* for the procedural fallback).
|
|
326
|
+
* @param clips animation clips (e.g. `gltf.animations` from the bundled animation library, a
|
|
327
|
+
* Mixamo export, or `[]` — an empty array triggers the procedural fallback
|
|
328
|
+
* under the default `"auto"` mode).
|
|
329
|
+
*/
|
|
330
|
+
constructor(
|
|
331
|
+
model: THREE.Object3D,
|
|
332
|
+
clips: THREE.AnimationClip[],
|
|
333
|
+
options: CharacterAnimationsOptions = {}
|
|
334
|
+
) {
|
|
335
|
+
this.#model = model;
|
|
336
|
+
this.#clipMap = buildClipMap(clips, options.clipMap);
|
|
337
|
+
this.#resolver = options.resolver ?? resolveAnimationState;
|
|
338
|
+
this.#onChange = options.onChange;
|
|
339
|
+
this.mixer = new THREE.AnimationMixer(model);
|
|
340
|
+
for (const clip of clips) this.#clipsByName.set(clip.name, clip);
|
|
341
|
+
|
|
342
|
+
// Cache one AnimationAction per bound clip so repeated lookups are free and
|
|
343
|
+
// crossFadeFrom always finds the previous action still alive.
|
|
344
|
+
const boundNames = new Set<string>();
|
|
345
|
+
for (const state of ALL_STATES) {
|
|
346
|
+
const name = this.#clipMap[state];
|
|
347
|
+
if (name !== null) boundNames.add(name);
|
|
348
|
+
}
|
|
349
|
+
for (const clip of clips) {
|
|
350
|
+
if (boundNames.has(clip.name) && !this.#actions.has(clip.name)) {
|
|
351
|
+
this.#actions.set(clip.name, this.mixer.clipAction(clip));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const fallback = options.fallback ?? "auto";
|
|
356
|
+
const rigUnbound = this.#clipMap.IDLE === null && this.#clipMap.WALK === null;
|
|
357
|
+
this.#usingProceduralFallback =
|
|
358
|
+
fallback === "procedural" || (fallback === "auto" && rigUnbound);
|
|
359
|
+
|
|
360
|
+
// Procedural fallback is additive over the transform captured here — never cumulative.
|
|
361
|
+
this.#basePositionY = model.position.y;
|
|
362
|
+
this.#baseRotationX = model.rotation.x;
|
|
363
|
+
|
|
364
|
+
this.#prevActionName = this.#clipMap.IDLE;
|
|
365
|
+
if (this.#prevActionName !== null) {
|
|
366
|
+
// NEW vs upstream: start the idle clip immediately (upstream stayed in bind pose
|
|
367
|
+
// until the first state change).
|
|
368
|
+
this.#actions.get(this.#prevActionName)?.play();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.#ctx = {
|
|
372
|
+
isOnGround: false,
|
|
373
|
+
wasOnGround: false,
|
|
374
|
+
isFalling: false,
|
|
375
|
+
isMoving: false,
|
|
376
|
+
runActive: false,
|
|
377
|
+
jumpActive: false,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
this.#onFinished = (event) => {
|
|
381
|
+
// One-shot (punch/gesture) finished.
|
|
382
|
+
if (this.#oneShotAction && event.action === this.#oneShotAction) {
|
|
383
|
+
const finished = this.#oneShotAction;
|
|
384
|
+
const done = this.#oneShotOnDone;
|
|
385
|
+
if (this.#oneShotHoldPose) {
|
|
386
|
+
// clamp:true — keep the clamped final frame and stay locked until a
|
|
387
|
+
// later playOneShot() (or an explicit change) replaces it.
|
|
388
|
+
this.#oneShotOnDone = undefined;
|
|
389
|
+
done?.();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// Default: hand control back to locomotion by crossfading the current
|
|
393
|
+
// state's loop in FROM the just-finished one-shot, which is clamped on
|
|
394
|
+
// its last frame (see playOneShot). Doing this here — in the same mixer
|
|
395
|
+
// tick that fires "finished", from a still-posing clamped action —
|
|
396
|
+
// is what prevents the one-frame unposed T-pose flash the old deferred
|
|
397
|
+
// reset().play() left between finish and the next update.
|
|
398
|
+
this.#oneShotAction = null;
|
|
399
|
+
this.#oneShotOnDone = undefined;
|
|
400
|
+
this.#canPlayNext = true;
|
|
401
|
+
this.#recoverFromOneShot(finished);
|
|
402
|
+
done?.();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const clipName = event.action.getClip().name;
|
|
406
|
+
if (
|
|
407
|
+
!this.#canPlayNext &&
|
|
408
|
+
(clipName === this.#clipMap.JUMP_START || clipName === this.#clipMap.JUMP_LAND)
|
|
409
|
+
) {
|
|
410
|
+
this.#canPlayNext = true;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
this.mixer.addEventListener("finished", this.#onFinished);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Current resolved animation state (starts `"IDLE"`). */
|
|
417
|
+
get state(): CharacterAnimationState {
|
|
418
|
+
return this.#state;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The resolved state→clip-name binding (for debugging; treat as read-only). */
|
|
422
|
+
get clipMap(): ClipMap {
|
|
423
|
+
return this.#clipMap;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** True when the procedural bob/lean drives the model instead of animation clips. */
|
|
427
|
+
get usingProceduralFallback(): boolean {
|
|
428
|
+
return this.#usingProceduralFallback;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Call once per render frame AFTER the physics stepping loop.
|
|
433
|
+
* @param snapshot anything satisfying {@link CharacterStateSnapshot} — typically the
|
|
434
|
+
* CharacterController instance itself.
|
|
435
|
+
* @param dt RAW render-clock delta in seconds (do NOT pre-multiply by timeScale).
|
|
436
|
+
*/
|
|
437
|
+
update(snapshot: CharacterStateSnapshot, dt: number): void {
|
|
438
|
+
if (this.#disposed) return;
|
|
439
|
+
|
|
440
|
+
const ctx = this.#ctx;
|
|
441
|
+
ctx.isOnGround = snapshot.isOnGround;
|
|
442
|
+
// First-ever frame uses the CURRENT value so a character that spawns grounded
|
|
443
|
+
// does not fire JUMP_LAND.
|
|
444
|
+
ctx.wasOnGround = this.#initialized ? this.#previousIsOnGround : snapshot.isOnGround;
|
|
445
|
+
ctx.isFalling = snapshot.isFalling;
|
|
446
|
+
ctx.isMoving = snapshot.isMoving;
|
|
447
|
+
ctx.runActive = snapshot.runActive;
|
|
448
|
+
ctx.jumpActive = snapshot.jumpActive;
|
|
449
|
+
|
|
450
|
+
const next = this.#resolver(ctx);
|
|
451
|
+
const stateChanged = next !== this.#state;
|
|
452
|
+
if (stateChanged) this.#state = next;
|
|
453
|
+
// Attempt the transition EVERY update, not only on state change — the non-React
|
|
454
|
+
// equivalent of upstream's effect re-running on canPlayNext changes: after a one-shot
|
|
455
|
+
// (Jump_Start/Jump_Land) finishes and unlocks, the pending loop clip must still fade in
|
|
456
|
+
// even though the state did not change again. Internal guards make this a no-op otherwise.
|
|
457
|
+
this.#applyTransition();
|
|
458
|
+
if (stateChanged) {
|
|
459
|
+
// Context copy: the live ctx object is reused every frame.
|
|
460
|
+
this.#onChange?.(next, { ...ctx });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
this.#previousIsOnGround = snapshot.isOnGround;
|
|
464
|
+
this.#initialized = true;
|
|
465
|
+
|
|
466
|
+
this.#releaseStuckLocks();
|
|
467
|
+
|
|
468
|
+
if (this.#usingProceduralFallback) this.#updateProcedural(dt);
|
|
469
|
+
|
|
470
|
+
const effectiveTimeScale = this.#paused ? 0 : this.#timeScale;
|
|
471
|
+
if (this.#prevMixerTimeScale !== effectiveTimeScale) {
|
|
472
|
+
this.mixer.timeScale = effectiveTimeScale;
|
|
473
|
+
this.#prevMixerTimeScale = effectiveTimeScale;
|
|
474
|
+
}
|
|
475
|
+
// Raw render delta — mixer.timeScale does the scaling internally.
|
|
476
|
+
this.mixer.update(dt);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Global playback speed (default 1). Fade durations stretch with it, so slow-motion
|
|
481
|
+
* transitions stay smooth instead of popping.
|
|
482
|
+
*/
|
|
483
|
+
setTimeScale(timeScale: number): void {
|
|
484
|
+
this.#timeScale = timeScale;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Paused ⇒ effective mixer timeScale 0 (state resolution keeps running). */
|
|
488
|
+
setPaused(paused: boolean): void {
|
|
489
|
+
this.#paused = paused;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Play a full-body one-shot clip (punch, wave, pick-up, cast…) over the current
|
|
494
|
+
* locomotion, then hand control back to the state machine when it finishes. Any
|
|
495
|
+
* NON-locomotion clip from the set passed to the constructor works — see the
|
|
496
|
+
* character-controller skill's 46-clip catalog (Punch_Jab/Cross, Sword_Attack,
|
|
497
|
+
* Pistol_Shoot, Interact, Hit_Chest, …). Returns false if the clip name is unknown.
|
|
498
|
+
*
|
|
499
|
+
* @example
|
|
500
|
+
* // punch on click:
|
|
501
|
+
* addEventListener('pointerdown', () => anims.playOneShot('Punch_Jab'));
|
|
502
|
+
*/
|
|
503
|
+
playOneShot(clipName: string, options: PlayOneShotOptions = {}): boolean {
|
|
504
|
+
if (this.#disposed) return false;
|
|
505
|
+
const clip = this.#clipsByName.get(clipName);
|
|
506
|
+
if (!clip) return false;
|
|
507
|
+
|
|
508
|
+
const action = this.mixer.clipAction(clip);
|
|
509
|
+
|
|
510
|
+
// Crossfade the new one-shot FROM whatever is currently posing: a still-playing
|
|
511
|
+
// previous one-shot (rapid re-punch) takes priority over the cached locomotion
|
|
512
|
+
// action, so re-punching blends punch→punch instead of dipping toward bind pose.
|
|
513
|
+
const prevOneShot =
|
|
514
|
+
this.#oneShotAction && this.#oneShotAction !== action ? this.#oneShotAction : null;
|
|
515
|
+
const current =
|
|
516
|
+
prevOneShot ??
|
|
517
|
+
(this.#prevActionName !== null ? this.#actions.get(this.#prevActionName) : undefined);
|
|
518
|
+
|
|
519
|
+
action.enabled = true;
|
|
520
|
+
action.setLoop(THREE.LoopOnce, 1);
|
|
521
|
+
// ALWAYS clamp the final frame: the pose must be held from the "finished"
|
|
522
|
+
// event until #recoverFromOneShot's crossfade takes over (or forever, when
|
|
523
|
+
// clamp:true). With clampWhenFinished=false the action disables itself on
|
|
524
|
+
// finish and the rig snaps to bind pose for a frame — the T-pose flash.
|
|
525
|
+
action.clampWhenFinished = true;
|
|
526
|
+
this.#oneShotHoldPose = options.clamp ?? false;
|
|
527
|
+
action.timeScale = options.timeScale ?? 1;
|
|
528
|
+
action.reset();
|
|
529
|
+
if (current && current !== action) {
|
|
530
|
+
action.crossFadeFrom(current, options.fadeIn ?? 0.1, false);
|
|
531
|
+
}
|
|
532
|
+
action.play();
|
|
533
|
+
|
|
534
|
+
this.#oneShotAction = action;
|
|
535
|
+
this.#oneShotOnDone = options.onDone;
|
|
536
|
+
// Freeze the locomotion transition until the one-shot's 'finished' event
|
|
537
|
+
// reopens it; marking the one-shot as the "current" action makes
|
|
538
|
+
// #applyTransition a no-op meanwhile.
|
|
539
|
+
this.#canPlayNext = false;
|
|
540
|
+
this.#prevActionName = clipName;
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Stop all actions, uncache clips, remove the mixer's 'finished' listener. */
|
|
545
|
+
dispose(): void {
|
|
546
|
+
if (this.#disposed) return;
|
|
547
|
+
this.#disposed = true;
|
|
548
|
+
this.mixer.removeEventListener("finished", this.#onFinished);
|
|
549
|
+
this.mixer.stopAllAction();
|
|
550
|
+
for (const action of this.#actions.values()) {
|
|
551
|
+
this.mixer.uncacheClip(action.getClip());
|
|
552
|
+
}
|
|
553
|
+
this.#actions.clear();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Return to locomotion after a one-shot finishes: crossfade the current
|
|
558
|
+
* state's loop in FROM the just-finished (still-posing, clamped) one-shot.
|
|
559
|
+
* Called from the "finished" handler so the blend starts in the same tick —
|
|
560
|
+
* no unposed frame. Falls back to a plain reset()/play() only when the finished
|
|
561
|
+
* action can't be a fade source, and to prevActionName=null when the state has
|
|
562
|
+
* no bound loop (procedural fallback / previous clip keeps the rig posed).
|
|
563
|
+
*/
|
|
564
|
+
#recoverFromOneShot(finished: THREE.AnimationAction): void {
|
|
565
|
+
const name = this.#clipMap[this.#state];
|
|
566
|
+
const loopAction = name !== null ? this.#actions.get(name) : undefined;
|
|
567
|
+
if (!loopAction) {
|
|
568
|
+
this.#prevActionName = null;
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const effectiveTimeScale = this.#paused ? 0 : this.#timeScale;
|
|
572
|
+
const fade = LOOP_FADE_DURATION * Math.max(effectiveTimeScale, FADE_TIME_SCALE_FLOOR);
|
|
573
|
+
loopAction.enabled = true;
|
|
574
|
+
loopAction.timeScale = 1;
|
|
575
|
+
loopAction.reset();
|
|
576
|
+
if (loopAction !== finished) loopAction.crossFadeFrom(finished, fade, false);
|
|
577
|
+
loopAction.play();
|
|
578
|
+
this.#prevActionName = name;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Crossfade into the clip bound to the current state. Runs every update (guards make it a
|
|
583
|
+
* no-op unless the target clip differs and the one-shot lock is open) — mirrors upstream's
|
|
584
|
+
* effect re-running on both state and canPlayNext changes.
|
|
585
|
+
*/
|
|
586
|
+
#applyTransition(): void {
|
|
587
|
+
const nextName = this.#clipMap[this.#state];
|
|
588
|
+
// Unbound state: keep the previous action playing (upstream's `if (!nextAction) return`);
|
|
589
|
+
// on a fully rig-less model the procedural fallback drives the transform instead.
|
|
590
|
+
if (nextName === null) return;
|
|
591
|
+
const nextAction = this.#actions.get(nextName);
|
|
592
|
+
if (!nextAction) return;
|
|
593
|
+
|
|
594
|
+
const effectiveTimeScale = this.#paused ? 0 : this.#timeScale;
|
|
595
|
+
const getFadeDuration = (duration: number): number =>
|
|
596
|
+
duration * Math.max(effectiveTimeScale, FADE_TIME_SCALE_FLOOR);
|
|
597
|
+
|
|
598
|
+
const prevActionName = this.#prevActionName;
|
|
599
|
+
// Only crossfade when switching to a NEW clip (JUMP_IDLE→JUMP_FALL share Jump_Loop —
|
|
600
|
+
// comparing clip names, not states, keeps the loop from restarting mid-air).
|
|
601
|
+
if (nextName !== prevActionName && this.#canPlayNext) {
|
|
602
|
+
const prevAction =
|
|
603
|
+
prevActionName !== null ? this.#actions.get(prevActionName) : undefined;
|
|
604
|
+
|
|
605
|
+
// One-shot detection is by CLIP NAME, not state, so a shared clip inherits
|
|
606
|
+
// one-shot behavior exactly like upstream's name-keyed actions.
|
|
607
|
+
if (nextName === this.#clipMap.JUMP_START || nextName === this.#clipMap.JUMP_LAND) {
|
|
608
|
+
this.#canPlayNext = false;
|
|
609
|
+
nextAction.timeScale = ONE_SHOT_TIME_SCALE;
|
|
610
|
+
nextAction.reset();
|
|
611
|
+
if (prevAction) {
|
|
612
|
+
nextAction.crossFadeFrom(prevAction, getFadeDuration(ONE_SHOT_FADE_DURATION), false);
|
|
613
|
+
}
|
|
614
|
+
nextAction.setLoop(THREE.LoopOnce, 1).play();
|
|
615
|
+
nextAction.clampWhenFinished = true;
|
|
616
|
+
} else {
|
|
617
|
+
this.#canPlayNext = true;
|
|
618
|
+
nextAction.timeScale = 1;
|
|
619
|
+
nextAction.reset();
|
|
620
|
+
if (prevAction) {
|
|
621
|
+
nextAction.crossFadeFrom(prevAction, getFadeDuration(LOOP_FADE_DURATION), false);
|
|
622
|
+
}
|
|
623
|
+
nextAction.play();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
this.#prevActionName = nextName;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Release the one-shot lock if the state moved past the one-shot's natural follow-up
|
|
632
|
+
* (runs every update — the non-React equivalent of upstream's effect re-runs).
|
|
633
|
+
*/
|
|
634
|
+
#releaseStuckLocks(): void {
|
|
635
|
+
const prevActionName = this.#prevActionName;
|
|
636
|
+
if (prevActionName === null) return;
|
|
637
|
+
if (
|
|
638
|
+
!this.#canPlayNext &&
|
|
639
|
+
prevActionName === this.#clipMap.JUMP_START &&
|
|
640
|
+
this.#state !== "JUMP_IDLE" &&
|
|
641
|
+
this.#state !== "JUMP_START"
|
|
642
|
+
) {
|
|
643
|
+
this.#canPlayNext = true;
|
|
644
|
+
}
|
|
645
|
+
if (
|
|
646
|
+
!this.#canPlayNext &&
|
|
647
|
+
prevActionName === this.#clipMap.JUMP_LAND &&
|
|
648
|
+
this.#state !== "IDLE" &&
|
|
649
|
+
this.#state !== "JUMP_LAND"
|
|
650
|
+
) {
|
|
651
|
+
this.#canPlayNext = true;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Procedural bob/lean for rig-less models — additive over the captured base transform. */
|
|
656
|
+
#updateProcedural(dt: number): void {
|
|
657
|
+
const state = this.#state;
|
|
658
|
+
|
|
659
|
+
if (state === "WALK") this.#procPhase += PROC_WALK_BOB_FREQ * dt;
|
|
660
|
+
else if (state === "RUN") this.#procPhase += PROC_RUN_BOB_FREQ * dt;
|
|
661
|
+
|
|
662
|
+
let targetBob = 0;
|
|
663
|
+
if (state === "WALK") targetBob = PROC_WALK_BOB_AMP * Math.abs(Math.sin(this.#procPhase));
|
|
664
|
+
else if (state === "RUN") targetBob = PROC_RUN_BOB_AMP * Math.abs(Math.sin(this.#procPhase));
|
|
665
|
+
else if (state === "JUMP_LAND") targetBob = -PROC_LAND_DIP;
|
|
666
|
+
|
|
667
|
+
let targetLean = 0;
|
|
668
|
+
if (state === "WALK") targetLean = PROC_WALK_LEAN;
|
|
669
|
+
else if (state === "RUN") targetLean = PROC_RUN_LEAN;
|
|
670
|
+
else if (state === "JUMP_START" || state === "JUMP_IDLE" || state === "JUMP_FALL") {
|
|
671
|
+
targetLean = PROC_AIR_LEAN;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Frame-rate-independent smoothing (same idiom as the controller's gravityDirLerpSpeed).
|
|
675
|
+
const k = 1 - Math.exp(-PROC_SMOOTHING * dt);
|
|
676
|
+
this.#bobOffset += (targetBob - this.#bobOffset) * k;
|
|
677
|
+
this.#leanOffset += (targetLean - this.#leanOffset) * k;
|
|
678
|
+
|
|
679
|
+
this.#model.position.y = this.#basePositionY + this.#bobOffset;
|
|
680
|
+
this.#model.rotation.x = this.#baseRotationX + this.#leanOffset;
|
|
681
|
+
}
|
|
682
|
+
}
|