@genex-ai/cli-demo 0.53.0-dev.121 → 0.54.0-dev.123
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 +32 -1
- package/dist/index.js +9535 -162
- package/package.json +2 -1
- package/templates/controllers/character/animation-packs.ts +3 -23
- package/templates/controllers/character/character-animations.ts +197 -8
- package/templates/controllers/character/character-controller.ts +56 -1
- package/templates/controllers/character/meshy/meshy-loader.ts +137 -0
- package/templates/controllers/character/motion-actions.ts +135 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +4 -4
- package/templates/skills/genex-ai-character/SKILL.md +97 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +39 -10
- package/templates/skills/genex-threejs-character-controller/references/animations.md +116 -16
- package/templates/skills/genex-threejs-skill-router/SKILL.md +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0-dev.123",
|
|
4
4
|
"description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@dimforge/rapier3d-compat": "^0.19.3",
|
|
44
|
+
"@genex/meshy-animation-catalog": "workspace:*",
|
|
44
45
|
"@genex-ai/multiplayer": "workspace:*",
|
|
45
46
|
"@pixiv/three-vrm": "^3.5.4",
|
|
46
47
|
"@types/pngjs": "^6.0.5",
|
|
@@ -1,26 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// One-call
|
|
3
|
-
//
|
|
4
|
-
// crouch + hit/death clips) PLUS every animation pack installed by
|
|
5
|
-
// `genex controller anims <tags|clips…>` (./assets/anims/<Clip>.glb, listed in
|
|
6
|
-
// ./assets/anims/manifest.json). Everything is retargeted onto the given VRM —
|
|
7
|
-
// the per-file rig auto-detect in vrm-retarget.ts handles both the UE-style
|
|
8
|
-
// UAL Pro rig and the older Rigify DEF-* rig — and returned as one flat clip
|
|
9
|
-
// array ready for `new CharacterAnimations(scene, clips)`.
|
|
10
|
-
//
|
|
11
|
-
// const { scene, vrm } = await loadVrm("./assets/avatar.vrm");
|
|
12
|
-
// const clips = await loadCharacterClips(vrm);
|
|
13
|
-
// const anims = new CharacterAnimations(scene, clips);
|
|
14
|
-
//
|
|
15
|
-
// Games that never ran `genex controller anims` just get the core clips (the
|
|
16
|
-
// manifest fetch 404s and is ignored). Packs added later are picked up on the
|
|
17
|
-
// next page load — no code change.
|
|
2
|
+
// One-call loading for the proven VRM + UAL lane. Meshy-native characters load
|
|
3
|
+
// their exact-rig clips from meshy/meshy-loader.ts and never enter this retargeter.
|
|
18
4
|
import type * as THREE from "three";
|
|
19
5
|
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
20
6
|
import type { VRM } from "@pixiv/three-vrm";
|
|
21
7
|
import { retargetClips } from "./vrm/vrm-retarget.ts";
|
|
22
8
|
|
|
23
|
-
/** Shape of ./assets/anims/manifest.json (written by `genex controller anims`). */
|
|
24
9
|
interface AnimsManifest {
|
|
25
10
|
schema: number;
|
|
26
11
|
clips: string[];
|
|
@@ -31,11 +16,7 @@ export interface LoadCharacterClipsOptions {
|
|
|
31
16
|
base?: string;
|
|
32
17
|
}
|
|
33
18
|
|
|
34
|
-
/**
|
|
35
|
-
* Load + retarget every animation the game has: the bundled core library and
|
|
36
|
-
* all installed pack clips. Missing pack files are skipped with a console
|
|
37
|
-
* warning (the game still runs on the clips that loaded).
|
|
38
|
-
*/
|
|
19
|
+
/** Load the bundled UAL core and every optional UAL pack onto a VRM avatar. */
|
|
39
20
|
export async function loadCharacterClips(
|
|
40
21
|
vrm: VRM,
|
|
41
22
|
options: LoadCharacterClipsOptions = {},
|
|
@@ -65,6 +46,5 @@ export async function loadCharacterClips(
|
|
|
65
46
|
if (pack !== null) clips.push(...retargetClips(vrm, pack.scene, pack.animations));
|
|
66
47
|
}
|
|
67
48
|
}
|
|
68
|
-
|
|
69
49
|
return clips;
|
|
70
50
|
}
|
|
@@ -69,6 +69,33 @@ export interface CharacterStateSnapshot {
|
|
|
69
69
|
readonly crouchActive?: boolean;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
export type LocomotionDirection =
|
|
73
|
+
| "forward"
|
|
74
|
+
| "forward-right"
|
|
75
|
+
| "right"
|
|
76
|
+
| "backward-right"
|
|
77
|
+
| "backward"
|
|
78
|
+
| "backward-left"
|
|
79
|
+
| "left"
|
|
80
|
+
| "forward-left";
|
|
81
|
+
|
|
82
|
+
export interface AdvancedCharacterStateSnapshot extends CharacterStateSnapshot {
|
|
83
|
+
readonly inputDir: THREE.Vector3;
|
|
84
|
+
readonly relativeVelOnPlane: THREE.Vector3;
|
|
85
|
+
readonly bodyXAxis: THREE.Vector3;
|
|
86
|
+
readonly bodyZAxis: THREE.Vector3;
|
|
87
|
+
readonly moveSpeed: number;
|
|
88
|
+
readonly lockForward: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface LocomotionProfile {
|
|
92
|
+
/** Explicit semantic slot → stable clip id. Unavailable slots use declared fallbacks. */
|
|
93
|
+
slots: Readonly<Record<string, string>>;
|
|
94
|
+
nominalSpeed?: Partial<Record<"walk" | "run" | "crouch", number>>;
|
|
95
|
+
playbackRate?: { min: number; max: number };
|
|
96
|
+
directionHysteresisDegrees?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
72
99
|
/** Snapshot plus the previous frame's ground flag (derived internally by CharacterAnimations). */
|
|
73
100
|
export interface AnimationStateContext extends CharacterStateSnapshot {
|
|
74
101
|
readonly wasOnGround: boolean;
|
|
@@ -103,7 +130,7 @@ export function resolveAnimationState(ctx: AnimationStateContext): CharacterAnim
|
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
// ---------------------------------------------------------------------------
|
|
106
|
-
// Clip lookup (
|
|
133
|
+
// Clip lookup (alias-based fuzzy naming for clips already compatible with the active rig)
|
|
107
134
|
// ---------------------------------------------------------------------------
|
|
108
135
|
|
|
109
136
|
/** Resolved clip NAME per state; null = unbound (procedural fallback may take over). */
|
|
@@ -160,7 +187,7 @@ const CLIP_SEARCH: Record<CharacterAnimationState, ClipSearchSpec> = {
|
|
|
160
187
|
*
|
|
161
188
|
* Per state, first match wins: explicit override (exact, then case-insensitive) → UAL exact
|
|
162
189
|
* names → UAL case-insensitive → each alias as a case-insensitive SUBSTRING (shortest matching
|
|
163
|
-
* clip name wins, so `Walk_Loop` beats `Walk_Bwd_Loop` and
|
|
190
|
+
* clip name wins, so `Walk_Loop` beats `Walk_Bwd_Loop` and a generic `walking` beats
|
|
164
191
|
* `walking_backwards`). Afterwards LOOP-state holes are chained (RUN↔WALK, JUMP_IDLE↔JUMP_FALL)
|
|
165
192
|
* so partially-animated rigs still move; one-shot states (JUMP_START/JUMP_LAND) stay null when
|
|
166
193
|
* unbound so the previous loop keeps playing (upstream behavior) instead of clamping a loop clip.
|
|
@@ -233,7 +260,7 @@ export function buildClipMap(
|
|
|
233
260
|
if (map.JUMP_FALL === null) map.JUMP_FALL = map.JUMP_IDLE;
|
|
234
261
|
if (map.JUMP_IDLE === null) map.JUMP_IDLE = map.JUMP_FALL;
|
|
235
262
|
// Crouch degrades to the standing loops on rigs without crouch clips (old
|
|
236
|
-
// 46-clip libraries
|
|
263
|
+
// 46-clip libraries and same-rig catalog exports) — wrong pose beats a frozen T-pose.
|
|
237
264
|
if (map.CROUCH_IDLE === null) map.CROUCH_IDLE = map.IDLE;
|
|
238
265
|
if (map.CROUCH_MOVE === null) map.CROUCH_MOVE = map.WALK ?? map.CROUCH_IDLE;
|
|
239
266
|
return map;
|
|
@@ -283,6 +310,8 @@ export interface CharacterAnimationsOptions {
|
|
|
283
310
|
* "none": do nothing when unbound (model stays static).
|
|
284
311
|
*/
|
|
285
312
|
fallback?: "auto" | "procedural" | "none";
|
|
313
|
+
/** Optional controller-aware directional locomotion profile. */
|
|
314
|
+
locomotionProfile?: LocomotionProfile;
|
|
286
315
|
}
|
|
287
316
|
|
|
288
317
|
/** Options for {@link CharacterAnimations.playOneShot}. */
|
|
@@ -323,6 +352,7 @@ export class CharacterAnimations {
|
|
|
323
352
|
// locomotion states (any installed pack clip: Punch_*, Sword_*, Sitting_*…).
|
|
324
353
|
#clipsByName = new Map<string, THREE.AnimationClip>();
|
|
325
354
|
#oneShotAction: THREE.AnimationAction | null = null;
|
|
355
|
+
#locomotionOneShotAction: THREE.AnimationAction | null = null;
|
|
326
356
|
#oneShotOnDone: (() => void) | undefined;
|
|
327
357
|
// When true, the active one-shot holds its final pose on finish (clamp:true —
|
|
328
358
|
// e.g. Death01) instead of crossfading back to locomotion.
|
|
@@ -348,12 +378,17 @@ export class CharacterAnimations {
|
|
|
348
378
|
|
|
349
379
|
#disposed = false;
|
|
350
380
|
#onFinished: (event: { action: THREE.AnimationAction }) => void;
|
|
381
|
+
#locomotionProfile: LocomotionProfile | undefined;
|
|
382
|
+
#desiredMotionName: string | null;
|
|
383
|
+
#lastDirection: LocomotionDirection = "forward";
|
|
384
|
+
#lastSpeedBand: "walk" | "run" | "crouch" = "walk";
|
|
385
|
+
#previousMoving = false;
|
|
351
386
|
|
|
352
387
|
/**
|
|
353
388
|
* @param model root Object3D of the character visual (mixer root; also the transform target
|
|
354
389
|
* for the procedural fallback).
|
|
355
390
|
* @param clips animation clips (e.g. `gltf.animations` from the bundled animation library, a
|
|
356
|
-
*
|
|
391
|
+
* source-biped export, or `[]` — an empty array triggers the procedural fallback
|
|
357
392
|
* under the default `"auto"` mode).
|
|
358
393
|
*/
|
|
359
394
|
constructor(
|
|
@@ -365,6 +400,7 @@ export class CharacterAnimations {
|
|
|
365
400
|
this.#clipMap = buildClipMap(clips, options.clipMap);
|
|
366
401
|
this.#resolver = options.resolver ?? resolveAnimationState;
|
|
367
402
|
this.#onChange = options.onChange;
|
|
403
|
+
this.#locomotionProfile = options.locomotionProfile;
|
|
368
404
|
this.mixer = new THREE.AnimationMixer(model);
|
|
369
405
|
for (const clip of clips) this.#clipsByName.set(clip.name, clip);
|
|
370
406
|
|
|
@@ -375,6 +411,10 @@ export class CharacterAnimations {
|
|
|
375
411
|
const name = this.#clipMap[state];
|
|
376
412
|
if (name !== null) boundNames.add(name);
|
|
377
413
|
}
|
|
414
|
+
for (const name of Object.values(this.#locomotionProfile?.slots ?? {})) {
|
|
415
|
+
if (this.#clipsByName.has(name)) boundNames.add(name);
|
|
416
|
+
else console.warn(`[CharacterAnimations] locomotion slot clip "${name}" is not installed.`);
|
|
417
|
+
}
|
|
378
418
|
for (const clip of clips) {
|
|
379
419
|
if (boundNames.has(clip.name) && !this.#actions.has(clip.name)) {
|
|
380
420
|
this.#actions.set(clip.name, this.mixer.clipAction(clip));
|
|
@@ -390,7 +430,9 @@ export class CharacterAnimations {
|
|
|
390
430
|
this.#basePositionY = model.position.y;
|
|
391
431
|
this.#baseRotationX = model.rotation.x;
|
|
392
432
|
|
|
393
|
-
this.#
|
|
433
|
+
this.#desiredMotionName =
|
|
434
|
+
this.#resolveProfileSlot("idle.default") ?? this.#clipMap.IDLE;
|
|
435
|
+
this.#prevActionName = this.#desiredMotionName;
|
|
394
436
|
if (this.#prevActionName !== null) {
|
|
395
437
|
// NEW vs upstream: start the idle clip immediately (upstream stayed in bind pose
|
|
396
438
|
// until the first state change).
|
|
@@ -432,6 +474,11 @@ export class CharacterAnimations {
|
|
|
432
474
|
done?.();
|
|
433
475
|
return;
|
|
434
476
|
}
|
|
477
|
+
if (this.#locomotionOneShotAction && event.action === this.#locomotionOneShotAction) {
|
|
478
|
+
this.#locomotionOneShotAction = null;
|
|
479
|
+
this.#canPlayNext = true;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
435
482
|
const clipName = event.action.getClip().name;
|
|
436
483
|
if (
|
|
437
484
|
!this.#canPlayNext &&
|
|
@@ -453,6 +500,11 @@ export class CharacterAnimations {
|
|
|
453
500
|
return this.#clipMap;
|
|
454
501
|
}
|
|
455
502
|
|
|
503
|
+
/** Clip currently posing the locomotion layer (stable id for advanced profiles). */
|
|
504
|
+
get activeClipName(): string | null {
|
|
505
|
+
return this.#prevActionName;
|
|
506
|
+
}
|
|
507
|
+
|
|
456
508
|
/**
|
|
457
509
|
* True while a {@link playOneShot} clip is playing (or holding its final
|
|
458
510
|
* pose with `holdPose`). Feed `() => !anims.oneShotActive` to foot IK's
|
|
@@ -496,13 +548,30 @@ export class CharacterAnimations {
|
|
|
496
548
|
// equivalent of upstream's effect re-running on canPlayNext changes: after a one-shot
|
|
497
549
|
// (Jump_Start/Jump_Land) finishes and unlocks, the pending loop clip must still fade in
|
|
498
550
|
// even though the state did not change again. Internal guards make this a no-op otherwise.
|
|
551
|
+
this.#desiredMotionName = this.#resolveLocomotionClip(snapshot, next) ?? this.#clipMap[next];
|
|
552
|
+
// A full-jump profile clip may outlast a very short physics hop. Release
|
|
553
|
+
// its one-shot lock as soon as the controller lands so idle/walk can
|
|
554
|
+
// crossfade immediately instead of waiting for the authored clip to end.
|
|
555
|
+
if (this.#locomotionOneShotAction && ctx.isOnGround && next !== "JUMP_START") {
|
|
556
|
+
this.#locomotionOneShotAction = null;
|
|
557
|
+
this.#canPlayNext = true;
|
|
558
|
+
}
|
|
559
|
+
const movingNow = ctx.isMoving && ctx.isOnGround;
|
|
560
|
+
if (this.#locomotionProfile && movingNow !== this.#previousMoving && !this.oneShotActive) {
|
|
561
|
+
const transition = movingNow
|
|
562
|
+
? this.#resolveProfileSlot(`start.${this.#lastSpeedBand}.${this.#lastDirection}`)
|
|
563
|
+
: this.#resolveProfileSlot(`stop.${this.#lastSpeedBand}.${this.#lastDirection}`);
|
|
564
|
+
if (transition) this.playOneShot(transition, { fadeIn: ONE_SHOT_FADE_DURATION });
|
|
565
|
+
}
|
|
499
566
|
this.#applyTransition();
|
|
567
|
+
this.#updateLocomotionRate(snapshot, next);
|
|
500
568
|
if (stateChanged) {
|
|
501
569
|
// Context copy: the live ctx object is reused every frame.
|
|
502
570
|
this.#onChange?.(next, { ...ctx });
|
|
503
571
|
}
|
|
504
572
|
|
|
505
573
|
this.#previousIsOnGround = snapshot.isOnGround;
|
|
574
|
+
this.#previousMoving = movingNow;
|
|
506
575
|
this.#initialized = true;
|
|
507
576
|
|
|
508
577
|
this.#releaseStuckLocks();
|
|
@@ -594,6 +663,7 @@ export class CharacterAnimations {
|
|
|
594
663
|
this.mixer.uncacheClip(action.getClip());
|
|
595
664
|
}
|
|
596
665
|
this.#actions.clear();
|
|
666
|
+
this.#locomotionOneShotAction = null;
|
|
597
667
|
}
|
|
598
668
|
|
|
599
669
|
/**
|
|
@@ -605,7 +675,7 @@ export class CharacterAnimations {
|
|
|
605
675
|
* no bound loop (procedural fallback / previous clip keeps the rig posed).
|
|
606
676
|
*/
|
|
607
677
|
#recoverFromOneShot(finished: THREE.AnimationAction): void {
|
|
608
|
-
const name = this.#
|
|
678
|
+
const name = this.#desiredMotionName;
|
|
609
679
|
const loopAction = name !== null ? this.#actions.get(name) : undefined;
|
|
610
680
|
if (!loopAction) {
|
|
611
681
|
this.#prevActionName = null;
|
|
@@ -627,7 +697,7 @@ export class CharacterAnimations {
|
|
|
627
697
|
* effect re-running on both state and canPlayNext changes.
|
|
628
698
|
*/
|
|
629
699
|
#applyTransition(): void {
|
|
630
|
-
const nextName = this.#
|
|
700
|
+
const nextName = this.#desiredMotionName;
|
|
631
701
|
// Unbound state: keep the previous action playing (upstream's `if (!nextAction) return`);
|
|
632
702
|
// on a fully rig-less model the procedural fallback drives the transform instead.
|
|
633
703
|
if (nextName === null) return;
|
|
@@ -647,8 +717,20 @@ export class CharacterAnimations {
|
|
|
647
717
|
|
|
648
718
|
// One-shot detection is by CLIP NAME, not state, so a shared clip inherits
|
|
649
719
|
// one-shot behavior exactly like upstream's name-keyed actions.
|
|
650
|
-
|
|
720
|
+
const profileJumpStart =
|
|
721
|
+
this.#state === "JUMP_START" && nextName === this.#resolveProfileSlot("jump.start");
|
|
722
|
+
const profileJumpLand =
|
|
723
|
+
this.#state === "JUMP_LAND" &&
|
|
724
|
+
nextName === this.#resolveProfileSlot("jump.land") &&
|
|
725
|
+
nextName !== this.#resolveProfileSlot("idle.default");
|
|
726
|
+
if (
|
|
727
|
+
nextName === this.#clipMap.JUMP_START ||
|
|
728
|
+
nextName === this.#clipMap.JUMP_LAND ||
|
|
729
|
+
profileJumpStart ||
|
|
730
|
+
profileJumpLand
|
|
731
|
+
) {
|
|
651
732
|
this.#canPlayNext = false;
|
|
733
|
+
this.#locomotionOneShotAction = nextAction;
|
|
652
734
|
nextAction.timeScale = ONE_SHOT_TIME_SCALE;
|
|
653
735
|
nextAction.reset();
|
|
654
736
|
if (prevAction) {
|
|
@@ -722,4 +804,111 @@ export class CharacterAnimations {
|
|
|
722
804
|
this.#model.position.y = this.#basePositionY + this.#bobOffset;
|
|
723
805
|
this.#model.rotation.x = this.#baseRotationX + this.#leanOffset;
|
|
724
806
|
}
|
|
807
|
+
|
|
808
|
+
#resolveProfileSlot(slot: string): string | null {
|
|
809
|
+
const name = this.#locomotionProfile?.slots[slot];
|
|
810
|
+
return name && this.#clipsByName.has(name) ? name : null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
#resolveLocomotionClip(
|
|
814
|
+
snapshot: CharacterStateSnapshot,
|
|
815
|
+
state: CharacterAnimationState,
|
|
816
|
+
): string | null {
|
|
817
|
+
if (!this.#locomotionProfile) return null;
|
|
818
|
+
if (state === "IDLE") return this.#resolveProfileSlot("idle.default");
|
|
819
|
+
if (state === "CROUCH_IDLE") {
|
|
820
|
+
return this.#resolveProfileSlot("crouch.idle") ?? this.#resolveProfileSlot("idle.default");
|
|
821
|
+
}
|
|
822
|
+
const jumpSlot: Partial<Record<CharacterAnimationState, string>> = {
|
|
823
|
+
JUMP_START: "jump.start",
|
|
824
|
+
JUMP_IDLE: "jump.rise",
|
|
825
|
+
JUMP_FALL: "jump.fall",
|
|
826
|
+
JUMP_LAND: "jump.land",
|
|
827
|
+
};
|
|
828
|
+
const airborne = jumpSlot[state];
|
|
829
|
+
if (airborne) return this.#resolveProfileSlot(airborne);
|
|
830
|
+
if (state !== "WALK" && state !== "RUN" && state !== "CROUCH_MOVE") return null;
|
|
831
|
+
if (!isAdvancedSnapshot(snapshot)) return null;
|
|
832
|
+
|
|
833
|
+
const band = state === "RUN" ? "run" : state === "CROUCH_MOVE" ? "crouch" : "walk";
|
|
834
|
+
const direction = snapshot.lockForward ? this.#resolveDirection(snapshot) : "forward";
|
|
835
|
+
this.#lastDirection = direction;
|
|
836
|
+
this.#lastSpeedBand = band;
|
|
837
|
+
const candidates = directionFallbacks(band, direction);
|
|
838
|
+
for (const slot of candidates) {
|
|
839
|
+
const clip = this.#resolveProfileSlot(slot);
|
|
840
|
+
if (clip) return clip;
|
|
841
|
+
}
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
#resolveDirection(snapshot: AdvancedCharacterStateSnapshot): LocomotionDirection {
|
|
846
|
+
const x = snapshot.inputDir.dot(snapshot.bodyXAxis);
|
|
847
|
+
const z = snapshot.inputDir.dot(snapshot.bodyZAxis);
|
|
848
|
+
if (x * x + z * z < 1e-6) return this.#lastDirection;
|
|
849
|
+
const angle = Math.atan2(x, z);
|
|
850
|
+
const directions: readonly LocomotionDirection[] = [
|
|
851
|
+
"forward",
|
|
852
|
+
"forward-right",
|
|
853
|
+
"right",
|
|
854
|
+
"backward-right",
|
|
855
|
+
"backward",
|
|
856
|
+
"backward-left",
|
|
857
|
+
"left",
|
|
858
|
+
"forward-left",
|
|
859
|
+
];
|
|
860
|
+
const candidate = directions[(Math.round(angle / (Math.PI / 4)) + 8) % 8]!;
|
|
861
|
+
const previousAngle = directions.indexOf(this.#lastDirection) * (Math.PI / 4);
|
|
862
|
+
const normalizedAngle = angle < 0 ? angle + Math.PI * 2 : angle;
|
|
863
|
+
const delta = Math.abs(Math.atan2(Math.sin(normalizedAngle - previousAngle), Math.cos(normalizedAngle - previousAngle)));
|
|
864
|
+
const threshold = THREE.MathUtils.degToRad(
|
|
865
|
+
22.5 + (this.#locomotionProfile?.directionHysteresisDegrees ?? 7.5),
|
|
866
|
+
);
|
|
867
|
+
return delta <= threshold ? this.#lastDirection : candidate;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
#updateLocomotionRate(
|
|
871
|
+
snapshot: CharacterStateSnapshot,
|
|
872
|
+
state: CharacterAnimationState,
|
|
873
|
+
): void {
|
|
874
|
+
if (!this.#locomotionProfile || !isAdvancedSnapshot(snapshot) || this.oneShotActive) return;
|
|
875
|
+
const band = state === "RUN" ? "run" : state === "CROUCH_MOVE" ? "crouch" : state === "WALK" ? "walk" : null;
|
|
876
|
+
if (!band || !this.#prevActionName) return;
|
|
877
|
+
const nominal = this.#locomotionProfile.nominalSpeed?.[band];
|
|
878
|
+
if (!nominal || nominal <= 0) return;
|
|
879
|
+
const limits = this.#locomotionProfile.playbackRate ?? { min: 0.75, max: 1.35 };
|
|
880
|
+
const rate = THREE.MathUtils.clamp(snapshot.moveSpeed / nominal, limits.min, limits.max);
|
|
881
|
+
this.#actions.get(this.#prevActionName)?.setEffectiveTimeScale(rate);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function isAdvancedSnapshot(
|
|
886
|
+
snapshot: CharacterStateSnapshot,
|
|
887
|
+
): snapshot is AdvancedCharacterStateSnapshot {
|
|
888
|
+
const value = snapshot as Partial<AdvancedCharacterStateSnapshot>;
|
|
889
|
+
return (
|
|
890
|
+
typeof value.moveSpeed === "number" &&
|
|
891
|
+
typeof value.lockForward === "boolean" &&
|
|
892
|
+
value.inputDir instanceof THREE.Vector3 &&
|
|
893
|
+
value.relativeVelOnPlane instanceof THREE.Vector3 &&
|
|
894
|
+
value.bodyXAxis instanceof THREE.Vector3 &&
|
|
895
|
+
value.bodyZAxis instanceof THREE.Vector3
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function directionFallbacks(
|
|
900
|
+
band: "walk" | "run" | "crouch",
|
|
901
|
+
direction: LocomotionDirection,
|
|
902
|
+
): string[] {
|
|
903
|
+
const exact = `${band}.${direction}`;
|
|
904
|
+
const cardinal = direction.includes("backward")
|
|
905
|
+
? `${band}.backward`
|
|
906
|
+
: direction.includes("right")
|
|
907
|
+
? `${band}.right`
|
|
908
|
+
: direction.includes("left")
|
|
909
|
+
? `${band}.left`
|
|
910
|
+
: `${band}.forward`;
|
|
911
|
+
const ownForward = `${band}.forward`;
|
|
912
|
+
const walkEquivalent = `walk.${direction}`;
|
|
913
|
+
return [...new Set([exact, cardinal, ownForward, walkEquivalent, "walk.forward"])];
|
|
725
914
|
}
|
|
@@ -53,6 +53,14 @@ export type MovementInput = {
|
|
|
53
53
|
crouch?: boolean;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
/** Local planar velocity for a collision-aware authored root action (m/s). */
|
|
57
|
+
export interface CharacterActionMotion {
|
|
58
|
+
/** Right/left velocity along the character's local X axis. */
|
|
59
|
+
x: number;
|
|
60
|
+
/** Forward/back velocity along the character's local Z axis. */
|
|
61
|
+
z: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
/** Read-only view of the merged movement state (returned by the `input` getter). */
|
|
57
65
|
export type ReadonlyMovementInput = Readonly<Omit<MovementInput, "joystick">> & {
|
|
58
66
|
readonly joystick?: Readonly<{ x: number; y: number }>;
|
|
@@ -433,6 +441,9 @@ export class CharacterController {
|
|
|
433
441
|
private readonly movingDirCrossAxis = new THREE.Vector3();
|
|
434
442
|
private readonly wantToMoveVel = new THREE.Vector3();
|
|
435
443
|
private readonly rejectVel = new THREE.Vector3();
|
|
444
|
+
private readonly actionLocalVelocity = new THREE.Vector2();
|
|
445
|
+
private readonly actionWorldVelocity = new THREE.Vector3();
|
|
446
|
+
private actionMotionEnabled = false;
|
|
436
447
|
|
|
437
448
|
// ── jump ──
|
|
438
449
|
private _isOnGround = false;
|
|
@@ -771,6 +782,10 @@ export class CharacterController {
|
|
|
771
782
|
get lockForward(): boolean {
|
|
772
783
|
return this.isLockForward;
|
|
773
784
|
}
|
|
785
|
+
/** True while an authored trajectory, rather than player input, owns planar velocity. */
|
|
786
|
+
get actionMotionActive(): boolean {
|
|
787
|
+
return this.actionMotionEnabled;
|
|
788
|
+
}
|
|
774
789
|
/** Per-step rotation of the platform under the character (identity when off-platform). Live quaternion. */
|
|
775
790
|
get turnOnYQuat(): THREE.Quaternion {
|
|
776
791
|
return this._turnOnYQuat;
|
|
@@ -803,6 +818,26 @@ export class CharacterController {
|
|
|
803
818
|
if (movement.crouch !== undefined) this.movementState.crouch = movement.crouch;
|
|
804
819
|
}
|
|
805
820
|
|
|
821
|
+
/**
|
|
822
|
+
* Give an authored action collision-aware planar motion without moving the visual root.
|
|
823
|
+
* Call with a fresh local velocity before each physics step; call with `null` to return
|
|
824
|
+
* authority to normal ECCTRL input. Vertical velocity, gravity, slopes, and collisions
|
|
825
|
+
* remain owned by the dynamic Rapier body.
|
|
826
|
+
*/
|
|
827
|
+
setActionMotion(motion: CharacterActionMotion | null): void {
|
|
828
|
+
if (motion === null) {
|
|
829
|
+
this.actionMotionEnabled = false;
|
|
830
|
+
this.actionLocalVelocity.set(0, 0);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (!Number.isFinite(motion.x) || !Number.isFinite(motion.z)) {
|
|
834
|
+
throw new Error("Character action motion must be finite");
|
|
835
|
+
}
|
|
836
|
+
this.actionMotionEnabled = true;
|
|
837
|
+
this.actionLocalVelocity.set(motion.x, motion.z);
|
|
838
|
+
this._body.wakeUp();
|
|
839
|
+
}
|
|
840
|
+
|
|
806
841
|
/**
|
|
807
842
|
* Programmatic crouch request (e.g. an on-screen crouch button:
|
|
808
843
|
* `btnCrouch.onPress = () => character.setCrouch(!character.crouchActive)`).
|
|
@@ -944,6 +979,7 @@ export class CharacterController {
|
|
|
944
979
|
jump ||
|
|
945
980
|
this.movementState.crouch ||
|
|
946
981
|
crouch !== crouchWasActive ||
|
|
982
|
+
this.actionMotionEnabled ||
|
|
947
983
|
Math.abs(joystick.x) > 1e-4 ||
|
|
948
984
|
Math.abs(joystick.y) > 1e-4;
|
|
949
985
|
|
|
@@ -967,7 +1003,7 @@ export class CharacterController {
|
|
|
967
1003
|
// movement use current-frame input.
|
|
968
1004
|
this.updateForwardDirection();
|
|
969
1005
|
this.setInputDirection({ forward, backward, rightward, leftward, joystick });
|
|
970
|
-
const hasMoveInput = this._inputDir.lengthSq() > 0;
|
|
1006
|
+
const hasMoveInput = this._inputDir.lengthSq() > 0 && !this.actionMotionEnabled;
|
|
971
1007
|
|
|
972
1008
|
// Update character auto balance
|
|
973
1009
|
// (NOTE: consumes LAST step's isZeroGravity — refreshed below; upstream parity)
|
|
@@ -1039,6 +1075,8 @@ export class CharacterController {
|
|
|
1039
1075
|
}
|
|
1040
1076
|
}
|
|
1041
1077
|
|
|
1078
|
+
if (this.actionMotionEnabled) this.applyActionMotion();
|
|
1079
|
+
|
|
1042
1080
|
// Update debug indicators
|
|
1043
1081
|
if (this.debugEnabled) this.updateDebugger();
|
|
1044
1082
|
}
|
|
@@ -1195,6 +1233,23 @@ export class CharacterController {
|
|
|
1195
1233
|
this._body.applyTorqueImpulse(torque.multiplyScalar(fpsCorr), false);
|
|
1196
1234
|
}
|
|
1197
1235
|
|
|
1236
|
+
/** Replace only relative planar velocity; the dynamic body still resolves contacts. */
|
|
1237
|
+
private applyActionMotion(): void {
|
|
1238
|
+
const liveVelocity = this._body.linvel();
|
|
1239
|
+
this.actionWorldVelocity
|
|
1240
|
+
.copy(this.characterXAxis)
|
|
1241
|
+
.multiplyScalar(this.actionLocalVelocity.x)
|
|
1242
|
+
.addScaledVector(this.characterZAxis, this.actionLocalVelocity.y)
|
|
1243
|
+
.projectOnPlane(this.referenceUpAxis)
|
|
1244
|
+
// Read the LIVE vertical component after float/jump impulses above; using
|
|
1245
|
+
// the cached start-of-step velocity here would erase those physics effects.
|
|
1246
|
+
.addScaledVector(this.referenceUpAxis, this.referenceUpAxis.dot(liveVelocity));
|
|
1247
|
+
if (this.isOnMovingObject && this.followPlatform) {
|
|
1248
|
+
this.actionWorldVelocity.add(this.movingObjectVelocityOnPlane);
|
|
1249
|
+
}
|
|
1250
|
+
this._body.setLinvel(this.actionWorldVelocity, true);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1198
1253
|
/**
|
|
1199
1254
|
* Ground-query collider filter (upstream l.539-542; userData key renamed
|
|
1200
1255
|
* `ecctrl` -> `controller`).
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
4
|
+
import type { LocomotionProfile } from "../character-animations.ts";
|
|
5
|
+
|
|
6
|
+
export type MeshyMotionPolicy =
|
|
7
|
+
| "controller-loop"
|
|
8
|
+
| "anchored-action"
|
|
9
|
+
| "planar-root-action"
|
|
10
|
+
| "choreography";
|
|
11
|
+
|
|
12
|
+
export interface MeshyCharacterManifest {
|
|
13
|
+
schema: 1;
|
|
14
|
+
characterId: string;
|
|
15
|
+
revision: number;
|
|
16
|
+
manifestVersion: number;
|
|
17
|
+
rig: "meshy-biped";
|
|
18
|
+
model: {
|
|
19
|
+
url: string;
|
|
20
|
+
heightMeters: number | null;
|
|
21
|
+
skeletonSignature: string;
|
|
22
|
+
};
|
|
23
|
+
clips: Array<{
|
|
24
|
+
actionId: number;
|
|
25
|
+
key: string;
|
|
26
|
+
name: string;
|
|
27
|
+
url: string;
|
|
28
|
+
duration: number | null;
|
|
29
|
+
loop: boolean;
|
|
30
|
+
motionPolicy: MeshyMotionPolicy;
|
|
31
|
+
rootMotionValidated: boolean;
|
|
32
|
+
controllerSlots: string[];
|
|
33
|
+
nominalSpeed: number | null;
|
|
34
|
+
trajectoryUrl: string | null;
|
|
35
|
+
requirements: Record<string, unknown>;
|
|
36
|
+
skeletonSignature: string;
|
|
37
|
+
}>;
|
|
38
|
+
locomotion: {
|
|
39
|
+
slots: Record<string, string>;
|
|
40
|
+
fallbacks: Record<string, string>;
|
|
41
|
+
nominalSpeed?: Partial<Record<"walk" | "run" | "crouch", number>>;
|
|
42
|
+
playbackRate: { min: number; max: number };
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MeshyCharacter {
|
|
47
|
+
kind: "meshy-native";
|
|
48
|
+
scene: THREE.Group;
|
|
49
|
+
clips: THREE.AnimationClip[];
|
|
50
|
+
manifest: MeshyCharacterManifest;
|
|
51
|
+
rigSignature: string;
|
|
52
|
+
locomotionProfile: LocomotionProfile;
|
|
53
|
+
update(delta: number): void;
|
|
54
|
+
dispose(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateManifest(value: unknown): MeshyCharacterManifest {
|
|
58
|
+
const manifest = value as Partial<MeshyCharacterManifest> | null;
|
|
59
|
+
if (
|
|
60
|
+
!manifest ||
|
|
61
|
+
manifest.schema !== 1 ||
|
|
62
|
+
manifest.rig !== "meshy-biped" ||
|
|
63
|
+
!manifest.model?.url ||
|
|
64
|
+
!manifest.model.skeletonSignature ||
|
|
65
|
+
!Array.isArray(manifest.clips) ||
|
|
66
|
+
!manifest.locomotion?.slots
|
|
67
|
+
) {
|
|
68
|
+
throw new Error("[meshy-character] invalid character manifest");
|
|
69
|
+
}
|
|
70
|
+
return manifest as MeshyCharacterManifest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compatibleTracks(model: THREE.Object3D, clips: THREE.AnimationClip[]): THREE.AnimationClip[] {
|
|
74
|
+
return clips.filter((clip) => clip.tracks.every((track) => {
|
|
75
|
+
const parsed = THREE.PropertyBinding.parseTrackName(track.name);
|
|
76
|
+
return parsed.nodeName !== undefined && model.getObjectByName(parsed.nodeName) !== undefined;
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadMeshyCharacter(manifestUrl: string): Promise<MeshyCharacter> {
|
|
81
|
+
const response = await fetch(manifestUrl);
|
|
82
|
+
if (!response.ok) throw new Error(`[meshy-character] ${manifestUrl} returned HTTP ${response.status}`);
|
|
83
|
+
const manifest = validateManifest(await response.json());
|
|
84
|
+
const loader = new GLTFLoader();
|
|
85
|
+
const base = await loader.loadAsync(manifest.model.url);
|
|
86
|
+
const clips: THREE.AnimationClip[] = [...base.animations];
|
|
87
|
+
const loaded = await Promise.all(manifest.clips.map(async (entry) => {
|
|
88
|
+
if (entry.skeletonSignature !== manifest.model.skeletonSignature) {
|
|
89
|
+
console.warn(`[meshy-character] ${entry.key} belongs to another rig revision — skipped.`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return { entry, gltf: await loader.loadAsync(entry.url) };
|
|
94
|
+
} catch {
|
|
95
|
+
console.warn(`[meshy-character] ${entry.key} failed to load — skipped.`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}));
|
|
99
|
+
for (const item of loaded) {
|
|
100
|
+
if (!item) continue;
|
|
101
|
+
const compatible = compatibleTracks(base.scene, item.gltf.animations);
|
|
102
|
+
if (compatible.length !== item.gltf.animations.length) {
|
|
103
|
+
console.warn(`[meshy-character] ${item.entry.key} has tracks missing from the active rig — skipped.`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (compatible.length === 1) compatible[0]!.name = item.entry.key;
|
|
107
|
+
clips.push(...compatible);
|
|
108
|
+
}
|
|
109
|
+
const slots = { ...manifest.locomotion.slots };
|
|
110
|
+
for (const [slot, fallback] of Object.entries(manifest.locomotion.fallbacks)) {
|
|
111
|
+
if (!slots[slot] && slots[fallback]) slots[slot] = slots[fallback];
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
kind: "meshy-native",
|
|
115
|
+
scene: base.scene,
|
|
116
|
+
clips,
|
|
117
|
+
manifest,
|
|
118
|
+
rigSignature: manifest.model.skeletonSignature,
|
|
119
|
+
locomotionProfile: {
|
|
120
|
+
slots,
|
|
121
|
+
nominalSpeed: manifest.locomotion.nominalSpeed,
|
|
122
|
+
playbackRate: manifest.locomotion.playbackRate,
|
|
123
|
+
},
|
|
124
|
+
update() {
|
|
125
|
+
// Native GLTF rigs do not need VRM's normalized-bone maintenance pass.
|
|
126
|
+
},
|
|
127
|
+
dispose() {
|
|
128
|
+
base.scene.traverse((object) => {
|
|
129
|
+
if (!(object instanceof THREE.Mesh)) return;
|
|
130
|
+
object.geometry.dispose();
|
|
131
|
+
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
|
132
|
+
for (const material of materials) material.dispose();
|
|
133
|
+
});
|
|
134
|
+
base.scene.removeFromParent();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|