@genex-ai/cli-demo 0.53.0-dev.120 → 0.54.0-dev.122

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.53.0-dev.120",
3
+ "version": "0.54.0-dev.122",
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 animation loading for the character controller (Genex AG-775):
3
- // the bundled core library (./assets/animation-library.glb, 12 locomotion +
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 (NEW — alias-based fuzzy binding so UAL, Mixamo-named, or arbitrary rigs work)
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 Mixamo's `walking` beats
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, Mixamo exports) — wrong pose beats a frozen T-pose.
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
- * Mixamo export, or `[]` — an empty array triggers the procedural fallback
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.#prevActionName = this.#clipMap.IDLE;
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.#clipMap[this.#state];
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.#clipMap[this.#state];
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
- if (nextName === this.#clipMap.JUMP_START || nextName === this.#clipMap.JUMP_LAND) {
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
+ }