@genex-ai/cli-demo 0.37.0 → 0.38.0

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.
@@ -68,6 +68,16 @@ export interface FootIKOptions {
68
68
  * so airborne legs keep their jump pose). Default: always active.
69
69
  */
70
70
  isActive?: () => boolean;
71
+ /**
72
+ * Gate for the terrain-relative reach (downward offsets + pelvis drop, and
73
+ * full plant-up). Return false while a full-body one-shot is playing
74
+ * (`() => !anims.oneShotActive`): those clips are choreography — a foot
75
+ * swinging over a lower step must not drag the pelvis down into a staircase,
76
+ * and a raised stance foot must not be yanked a full step up. While gated,
77
+ * feet are lifted only as much as needed to keep the sole out of the contact
78
+ * under them (anti dig-in). Default: always allowed.
79
+ */
80
+ allowReachDown?: () => boolean;
71
81
  }
72
82
 
73
83
  // three-vrm's own node type — same THREE.Object3D at runtime, but using the
@@ -96,6 +106,12 @@ const NORMAL_DAMPING = 12; // ground-normal smoothing
96
106
  const PLANTED_LIFT_MIN = 0.04; // below this lift the foot is fully planted (align at full weight)
97
107
  const PLANTED_LIFT_MAX = 0.16; // above this lift the foot is fully lifted (no align)
98
108
  const MAX_FOOT_TILT = 0.6; // clamp foot-to-slope tilt (rad)
109
+ const PENETRATION_SLACK = 0.02; // ankle may dip this far below rest before the no-sink lift kicks in
110
+ // Max anti-dig-in lift while reach-down is gated (one-shots). Retarget dips are
111
+ // a few cm; a hard cap keeps a stance foot carried INTO a stair riser by the
112
+ // choreography from being lifted a whole step ("knee to the chest") — beyond
113
+ // the cap it stays buried, which the step itself mostly occludes.
114
+ const GATED_LIFT_CAP = 0.12;
99
115
  const MIN_BONE_LENGTH = 1e-4;
100
116
  const IK_EPSILON = 1e-4;
101
117
 
@@ -136,6 +152,7 @@ export class FootIK {
136
152
  #pelvisDrop: boolean;
137
153
  #alignFeet: boolean;
138
154
  #isActive: (() => boolean) | undefined;
155
+ #allowReachDown: (() => boolean) | undefined;
139
156
  #enabled = true;
140
157
  #weight = 0;
141
158
  #restFootHeight = 0;
@@ -148,6 +165,7 @@ export class FootIK {
148
165
  this.#pelvisDrop = options.pelvisDrop ?? true;
149
166
  this.#alignFeet = options.alignFeet ?? true;
150
167
  this.#isActive = options.isActive;
168
+ this.#allowReachDown = options.allowReachDown;
151
169
  this.#modelRoot = vrm.scene;
152
170
 
153
171
  const h = vrm.humanoid;
@@ -157,7 +175,14 @@ export class FootIK {
157
175
  const lower = h.getNormalizedBoneNode(l);
158
176
  const foot = h.getNormalizedBoneNode(f);
159
177
  return upper && lower && foot
160
- ? { upper, lower, foot, offset: 0, normal: new THREE.Vector3(0, 1, 0), animFootPos: new THREE.Vector3() }
178
+ ? {
179
+ upper,
180
+ lower,
181
+ foot,
182
+ offset: 0,
183
+ normal: new THREE.Vector3(0, 1, 0),
184
+ animFootPos: new THREE.Vector3(),
185
+ }
161
186
  : null;
162
187
  };
163
188
  const left = mk(VRMHumanBoneName.LeftUpperLeg, VRMHumanBoneName.LeftLowerLeg, VRMHumanBoneName.LeftFoot);
@@ -192,14 +217,27 @@ export class FootIK {
192
217
 
193
218
  const k = 1 - Math.exp(-this.#smoothing * dt);
194
219
  const kNormal = 1 - Math.exp(-NORMAL_DAMPING * dt);
220
+ const reachDown = this.#allowReachDown?.() ?? true;
195
221
 
196
222
  // 1. Sample the ground under each ANIMATED foot; smooth offset + normal.
197
- // The offset is TERRAIN-relative — the ground height under the foot vs the
198
- // body root (the VRM origin = its floor/sole level), NOT vs the animated
199
- // foot. That's the load-bearing choice: it's independent of the foot's
200
- // stride phase, so a lifted swing foot keeps its animation (no dragging /
201
- // sinking while running) while a planted foot still lands on its step, and
202
- // it self-corrects any residual capsule-float gap (feet reach true ground).
223
+ // Two lift signals, merged:
224
+ // - TERRAIN-relative plant: the ground height under the foot vs the body
225
+ // root (the VRM origin = its floor/sole level), NOT vs the animated
226
+ // foot. Stride-phase independent a lifted swing foot keeps its
227
+ // animation (no dragging/sinking while running) while a planted foot
228
+ // still lands on its step, and it self-corrects any residual
229
+ // capsule-float gap (feet reach true ground).
230
+ // - needLift: raise the ankle ONLY as much as needed so the sole clears
231
+ // the contact under it — the anti-dig-in floor for poses that dip the
232
+ // animated feet below the clip's own ground (weapon recoil etc. on
233
+ // differently-proportioned avatars).
234
+ // With reach-down gated off (full-body one-shots), ONLY needLift
235
+ // applies, capped at GATED_LIFT_CAP: choreography feet are left alone
236
+ // unless they'd clip into a step, and a foot the stance carries INTO a
237
+ // riser gets at most a small hop, not a knee-to-the-chest fold. The
238
+ // lift-only offsets also mean min(0,offset)=0 keeps the pelvis drop
239
+ // off. Smoothing eases every transition (one-shot start/end, a ray
240
+ // crossing a step edge) without pops.
203
241
  for (const leg of this.#legs) {
204
242
  leg.foot.getWorldPosition(leg.animFootPos);
205
243
  const sample = active ? this.#query(leg.animFootPos) : null;
@@ -207,14 +245,30 @@ export class FootIK {
207
245
  _normalTarget.copy(
208
246
  sample !== null && typeof sample !== "number" && sample.normal ? sample.normal : UP,
209
247
  );
210
- const desired =
211
- groundY === null
212
- ? 0
213
- : THREE.MathUtils.clamp(
214
- groundY + this.#soleClearance - _rootPos.y,
215
- -this.#maxOffset,
216
- this.#maxOffset,
217
- );
248
+ let desired = 0;
249
+ if (groundY !== null) {
250
+ // Anti-dig-in signal: how much lift (if any) the ankle needs so the
251
+ // sole clears the contact under it. NEGATIVE when the foot is safely
252
+ // above the contact — and it must stay negative: flooring it at zero
253
+ // here would win every Math.max below and silently kill the downward
254
+ // reach (and with it the pelvis drop) for the whole planter.
255
+ const needLift =
256
+ groundY +
257
+ this.#soleClearance +
258
+ this.#restFootHeight -
259
+ PENETRATION_SLACK -
260
+ leg.animFootPos.y;
261
+ desired = reachDown
262
+ ? Math.max(
263
+ THREE.MathUtils.clamp(
264
+ groundY + this.#soleClearance - _rootPos.y,
265
+ -this.#maxOffset,
266
+ this.#maxOffset,
267
+ ),
268
+ Math.min(needLift, this.#maxOffset),
269
+ )
270
+ : THREE.MathUtils.clamp(needLift, 0, GATED_LIFT_CAP);
271
+ }
218
272
  leg.offset += (desired - leg.offset) * k;
219
273
  leg.normal.lerp(_normalTarget, kNormal).normalize();
220
274
  }
@@ -229,6 +283,8 @@ export class FootIK {
229
283
  }
230
284
 
231
285
  // 3. Per-leg two-bone IK to the grounded target, then flatten planted feet.
286
+ // (Anti-dig-in is already folded into the smoothed offset — needLift in
287
+ // step 1 — so the target needs no extra instant clamp here.)
232
288
  for (const leg of this.#legs) {
233
289
  _target.copy(leg.animFootPos);
234
290
  _target.y += leg.offset * this.#weight;
@@ -1,6 +1,9 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Retarget Quaternius Universal Animation Library clips (Blender Rigify `DEF-`
3
- // rig) onto a three-vrm normalized humanoid rig (Genex AG-747). Adapted from the
2
+ // Retarget Quaternius Universal Animation Library clips onto a three-vrm
3
+ // normalized humanoid rig (Genex AG-747/AG-775). Two source rigs are supported
4
+ // and auto-detected: the free UAL's Blender Rigify `DEF-*` skeleton and the UAL
5
+ // Pro's UE-mannequin-style skeleton (`pelvis`/`spine_01`/…) that the bundled
6
+ // core library and every CDN animation pack use. Adapted from the
4
7
  // official three-vrm Mixamo retarget recipe (@pixiv/three-vrm examples, MIT):
5
8
  // rewrite each bone track into the VRM's normalized-bone local space using the
6
9
  // SOURCE rig's rest-pose world rotations, and scale the hips translation by the
@@ -56,26 +59,81 @@ const DEF_TO_VRM: Record<string, VrmBone> = {
56
59
  "DEF-toe.R": VRMHumanBoneName.RightToes,
57
60
  };
58
61
 
62
+ // Quaternius UAL Pro (UE-mannequin-style skeleton) -> VRM humanoid bone. Bone
63
+ // names verified against the UAL1 master GLB ("Head" really is capitalized).
64
+ // Fingers are omitted, same policy as the DEF map.
65
+ const UE_TO_VRM: Record<string, VrmBone> = {
66
+ pelvis: VRMHumanBoneName.Hips,
67
+ spine_01: VRMHumanBoneName.Spine,
68
+ spine_02: VRMHumanBoneName.Chest,
69
+ spine_03: VRMHumanBoneName.UpperChest,
70
+ neck_01: VRMHumanBoneName.Neck,
71
+ Head: VRMHumanBoneName.Head,
72
+ clavicle_l: VRMHumanBoneName.LeftShoulder,
73
+ upperarm_l: VRMHumanBoneName.LeftUpperArm,
74
+ lowerarm_l: VRMHumanBoneName.LeftLowerArm,
75
+ hand_l: VRMHumanBoneName.LeftHand,
76
+ clavicle_r: VRMHumanBoneName.RightShoulder,
77
+ upperarm_r: VRMHumanBoneName.RightUpperArm,
78
+ lowerarm_r: VRMHumanBoneName.RightLowerArm,
79
+ hand_r: VRMHumanBoneName.RightHand,
80
+ thigh_l: VRMHumanBoneName.LeftUpperLeg,
81
+ calf_l: VRMHumanBoneName.LeftLowerLeg,
82
+ foot_l: VRMHumanBoneName.LeftFoot,
83
+ ball_l: VRMHumanBoneName.LeftToes,
84
+ thigh_r: VRMHumanBoneName.RightUpperLeg,
85
+ calf_r: VRMHumanBoneName.RightLowerLeg,
86
+ foot_r: VRMHumanBoneName.RightFoot,
87
+ ball_r: VRMHumanBoneName.RightToes,
88
+ };
89
+
90
+ /**
91
+ * Pick the source-rig bone map by looking for each rig's hips bone in the
92
+ * scene: `DEF-hips` -> Rigify (free UAL), `pelvis` -> UE-style (UAL Pro).
93
+ * Returns null for unrecognized rigs.
94
+ */
95
+ export function detectBoneMap(sourceRoot: THREE.Object3D): Record<string, VrmBone> | null {
96
+ let map: Record<string, VrmBone> | null = null;
97
+ sourceRoot.traverse((object) => {
98
+ if (map !== null) return;
99
+ if (object.name === "DEF-hips") map = DEF_TO_VRM;
100
+ else if (object.name === "pelvis") map = UE_TO_VRM;
101
+ });
102
+ return map;
103
+ }
104
+
59
105
  /**
60
106
  * Retarget UAL clips onto `vrm`.
61
107
  * @param vrm the loaded VRM (already through {@link loadVrm}).
62
- * @param animationRoot the animation-library GLB's scene — its `DEF-` bones in
63
- * rest pose supply the source frame the tracks are relative to.
64
- * @param clips that GLB's animations (all 46 UAL clips).
108
+ * @param animationRoot the animation GLB's scene — its bones in rest pose
109
+ * supply the source frame the tracks are relative to.
110
+ * @param clips that GLB's animations.
111
+ * @param boneMap source-bone-name -> VRM humanoid bone. Defaults to
112
+ * {@link detectBoneMap} on `animationRoot` (Rigify or
113
+ * UE-style rigs bind automatically).
65
114
  * @returns new clips whose tracks target the VRM's normalized humanoid bones.
66
115
  */
67
116
  export function retargetClips(
68
117
  vrm: VRM,
69
118
  animationRoot: THREE.Object3D,
70
119
  clips: THREE.AnimationClip[],
120
+ boneMap?: Record<string, VrmBone>,
71
121
  ): THREE.AnimationClip[] {
122
+ const sourceToVrm = boneMap ?? detectBoneMap(animationRoot);
123
+ if (sourceToVrm === null) {
124
+ console.warn(
125
+ "[vrm-retarget] unrecognized animation rig (no DEF-hips or pelvis bone found) — returning no clips; pass an explicit boneMap to retargetClips.",
126
+ );
127
+ return [];
128
+ }
129
+
72
130
  animationRoot.updateWorldMatrix(true, true);
73
131
  vrm.scene.updateWorldMatrix(true, true);
74
132
 
75
133
  // three's GLTFLoader SANITIZES node names in animation track targets
76
134
  // (PropertyBinding strips `[].:/ ` and turns spaces into `_`), so a Rigify bone
77
135
  // "DEF-upper_arm.L" shows up in tracks as "DEF-upper_armL". Map those sanitized
78
- // names back to the real bones, whose actual names carry the dots DEF_TO_VRM
136
+ // names back to the real bones, whose actual names carry the dots the map
79
137
  // keys on. (Mixamo names are dotless, so the upstream recipe never needed this.)
80
138
  const sanitize = (name: string): string => name.replace(/\s/g, "_").replace(/[[\]./:]/g, "");
81
139
  const sourceByTrackName = new Map<string, THREE.Object3D>();
@@ -83,9 +141,9 @@ export function retargetClips(
83
141
  if (o.name) sourceByTrackName.set(sanitize(o.name), o);
84
142
  });
85
143
  // glTF load sanitizes bone names too, so `source.name` is already dot-stripped —
86
- // key the VRM-bone lookup by the sanitized DEF name, matching the track's nodeName.
87
- const sanitizedDefToVrm: Record<string, VrmBone> = {};
88
- for (const [def, bone] of Object.entries(DEF_TO_VRM)) sanitizedDefToVrm[sanitize(def)] = bone;
144
+ // key the VRM-bone lookup by the sanitized source name, matching the track's nodeName.
145
+ const sanitizedSourceToVrm: Record<string, VrmBone> = {};
146
+ for (const [src, bone] of Object.entries(sourceToVrm)) sanitizedSourceToVrm[sanitize(src)] = bone;
89
147
 
90
148
  // Reusable bind-pose quaternions. Both rigs are at rest here (nothing has
91
149
  // animated them yet), so getWorldQuaternion reads the bind pose.
@@ -99,8 +157,10 @@ export function retargetClips(
99
157
  // hips' vertical bob/crouch on the target hips as a rest-relative DELTA, so
100
158
  // the physics controller still owns the whole-body base translation while the
101
159
  // pose keeps the pelvis (and therefore the feet) at the right height.
102
- const HIPS_SANITIZED = sanitize("DEF-hips");
103
- const srcHips = sourceByTrackName.get(HIPS_SANITIZED) ?? null;
160
+ const hipsSourceName = Object.entries(sanitizedSourceToVrm).find(
161
+ ([, bone]) => bone === VRMHumanBoneName.Hips,
162
+ )?.[0];
163
+ const srcHips = (hipsSourceName ? sourceByTrackName.get(hipsSourceName) : undefined) ?? null;
104
164
  const tgtHips = vrm.humanoid.getNormalizedBoneNode(VRMHumanBoneName.Hips);
105
165
  const hipsSrcParentWorld = new THREE.Quaternion();
106
166
  const hipsTgtParentWorldInv = new THREE.Quaternion();
@@ -128,7 +188,7 @@ export function retargetClips(
128
188
  const nodeName = track.name.slice(0, lastDot);
129
189
  const prop = track.name.slice(lastDot + 1);
130
190
  const source = sourceByTrackName.get(nodeName);
131
- const vrmBone = sanitizedDefToVrm[nodeName];
191
+ const vrmBone = sanitizedSourceToVrm[nodeName];
132
192
  if (!source || !vrmBone) continue;
133
193
 
134
194
  const target = vrm.humanoid.getNormalizedBoneNode(vrmBone);
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: genex-threejs-character-controller
3
- description: Add a tuned physics character controller to a Genex Three.js game with `npx genex controller character` — dynamic-capsule movement (walk/run/jump, slopes, stairs, moving platforms), follow camera, keyboard + touch input, and animation binding. Use for any on-foot player or third-person movement, and whenever the user asks for ecctrl — this is that controller, ported to plain Three.js.
3
+ description: Add a tuned physics character controller to a Genex Three.js game with `npx genex controller character` — dynamic-capsule walk/run/jump/crouch (slopes, stairs, moving platforms), follow camera, keyboard + touch input, animation binding. Animation packs (sword, pistol, magic, climb, swim, emotes…) install via `npx genex controller anims <tags>`. Use for any on-foot player or third-person movement, and whenever the user asks for ecctrl — this is that controller, ported to plain Three.js.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Character Controller
@@ -14,9 +14,13 @@ npm i @dimforge/rapier3d-compat @pixiv/three-vrm # three is already in the sca
14
14
 
15
15
  The command vendors tested, tuned controller code into the game: TypeScript
16
16
  modules into `src/controllers/` (including `character/vrm/` — VRM loading,
17
- animation retargeting, capsule auto-fit, foot IK) and the 46-clip
18
- `animation-library.glb` into `public/assets/`. It also writes the player's
19
- avatar to `public/assets/avatar.vrm` **your** avatar when you're signed in,
17
+ animation retargeting, capsule auto-fit, foot IK) and the 12-clip core
18
+ `animation-library.glb` (idle/walk/run/jump/crouch + hit/death/interact,
19
+ ~1.3 MB) into `public/assets/`. Need more swords, pistols, magic, climbing,
20
+ swimming, emotes? Install exactly what the game uses with
21
+ `npx genex controller anims <tags|clip names…>` (see Animations below). The
22
+ command also writes the player's avatar to `public/assets/avatar.vrm` —
23
+ **your** avatar when you're signed in,
20
24
  otherwise a bundled CC0 default (attribution in `src/controllers/NOTICE.md`).
21
25
  The character plays as that VRM. The copied files are then owned by the game —
22
26
  edit them freely; re-running skips existing files unless `--force`. Do not write
@@ -36,27 +40,28 @@ platforms, climbs stairs and slides on too-steep slopes out of the box.
36
40
  | `character/keyboard-input.ts` | `KeyboardInput` | WASD/arrows/Shift/Space/F state, no per-frame polling setup |
37
41
  | `character/touch-joystick.ts` | `TouchJoystick`, `VirtualButton` | mobile controls |
38
42
  | `character/character-animations.ts` | `CharacterAnimations` | animation state machine + fuzzy clip binding + `playOneShot` + procedural fallback |
39
- | `character/vrm/*` | `loadVrm`, `retargetClips`, `capsuleFromModel`, `FootIK` | load the VRM avatar, retarget the 46 UAL clips onto its humanoid rig, auto-fit the capsule, ground the feet |
43
+ | `character/animation-packs.ts` | `loadCharacterClips` | one call loads the core library + every installed `genex controller anims` pack, retargeted onto the VRM |
44
+ | `character/vrm/*` | `loadVrm`, `retargetClips`, `capsuleFromModel`, `FootIK` | load the VRM avatar, retarget library clips onto its humanoid rig, auto-fit the capsule, ground the feet |
40
45
 
41
46
  ## Minimal wiring
42
47
 
43
48
  ```ts
44
- import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
45
49
  import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
46
50
  import { CharacterController } from "./controllers/character/character-controller.ts";
47
51
  import { CharacterAnimations } from "./controllers/character/character-animations.ts";
52
+ import { loadCharacterClips } from "./controllers/character/animation-packs.ts";
48
53
  import { characterPresets } from "./controllers/character/presets.ts";
49
54
  import { FollowCamera } from "./controllers/character/follow-camera.ts";
50
55
  import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
51
56
  import { loadVrm } from "./controllers/character/vrm/vrm-loader.ts";
52
- import { retargetClips } from "./controllers/character/vrm/vrm-retarget.ts";
53
57
  import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
54
58
 
55
59
  const physics = await PhysicsWorld.create(); // nothing RAPIER-related may run before this resolves
56
60
 
57
- // Load the player's avatar + the animation library, then retarget onto the VRM.
61
+ // Load the player's avatar, then every animation the game has (the bundled core
62
+ // library + any packs installed by `genex controller anims`), retargeted onto the VRM.
58
63
  const { scene: avatar, vrm } = await loadVrm("./assets/avatar.vrm");
59
- const lib = await new GLTFLoader().loadAsync("./assets/animation-library.glb");
64
+ const clips = await loadCharacterClips(vrm);
60
65
 
61
66
  const fit = capsuleFromModel(avatar); // collider fits THIS avatar's bounds
62
67
  const character = new CharacterController(physics.world, camera, {
@@ -70,7 +75,7 @@ character.root.add(avatar); // parent the avatar under the c
70
75
  avatar.position.y = fit.modelOffsetY; // root = capsule CENTER; drop the model so feet touch the floor
71
76
  physics.registerBody(character.body, character.root); // root now follows the body, interpolated
72
77
 
73
- const anims = new CharacterAnimations(avatar, retargetClips(vrm, lib.scene, lib.animations));
78
+ const anims = new CharacterAnimations(avatar, clips);
74
79
  addEventListener("pointerdown", () => anims.playOneShot("Punch_Jab")); // punch on click
75
80
 
76
81
  const kb = new KeyboardInput();
@@ -127,17 +132,39 @@ preset table with provenance, the density/spring scaling rule, and the
127
132
  - The capsule ships with friction `-0.5` **on purpose** (grip is synthesized by
128
133
  the controller). Do not "fix" it to a positive value.
129
134
 
130
- ## Animations
135
+ ## Animations + animation packs
131
136
 
132
- `CharacterAnimations` resolves seven locomotion states (IDLE / WALK / RUN /
133
- JUMP_START / JUMP_IDLE / JUMP_FALL / JUMP_LAND) from the controller's live flags
134
- and crossfades mixer actions. Every OTHER library clip — punches, sword swings,
135
- pistol fire, spells, sit, dance, hit reactions — plays through
136
- `anims.playOneShot("Punch_Jab")`, which layers over locomotion and returns to it
137
- when done (punch-on-click is the default). Read
138
- [references/animations.md](references/animations.md) for the VRM load + retarget
139
- wiring, the **full 46-clip catalog** with genre hints, `playOneShot` options,
140
- overrides, foot IK, and remote-player animation.
137
+ `CharacterAnimations` resolves nine locomotion states (IDLE / WALK / RUN /
138
+ CROUCH_IDLE / CROUCH_MOVE / JUMP_START / JUMP_IDLE / JUMP_FALL / JUMP_LAND)
139
+ from the controller's live flags and crossfades mixer actions. Every OTHER
140
+ clip — punches, sword swings, pistol fire, spells, sit, dance, hit reactions —
141
+ plays through `anims.playOneShot("Punch_Jab")`, which layers over locomotion
142
+ and returns to it when done (punch-on-click is the default).
143
+
144
+ The bundled library carries only the 12 core clips. **Install what the game's
145
+ theme needs** from the 120-clip catalog, by tag or exact clip name:
146
+
147
+ ```bash
148
+ npx genex controller anims sword pistol # a sword+shooter game
149
+ npx genex controller anims stealth climb crawl # a ninja game
150
+ npx genex controller anims --list # browse tags; --list <tag> for per-clip details
151
+ ```
152
+
153
+ Clips land in `public/assets/anims/` and `loadCharacterClips(vrm)` picks them
154
+ up automatically — re-run with more tags any time (additive). Read
155
+ [references/animations.md](references/animations.md) for the tag catalog with
156
+ genre hints, `playOneShot` options, overrides, foot IK, and remote-player
157
+ animation.
158
+
159
+ ## Crouch (built in)
160
+
161
+ `C` toggles crouch (capsule shrinks, speed drops to `crouchSpeedRatio ×
162
+ maxWalkVel`, CROUCH_IDLE/CROUCH_MOVE play). Standing back up is
163
+ ceiling-checked: under a low obstacle the character STAYS crouched and pops up
164
+ automatically once clear; jumping while crouched requests a stand instead of
165
+ jumping. `crouchMode: "hold"` makes it hold-to-crouch;
166
+ `character.setCrouch(bool)` and `character.crouchActive` are the programmatic
167
+ hooks (that's also how a touch button wires in — see Mobile below).
141
168
 
142
169
  ## Mobile: TouchJoystick + VirtualButton (wire by default)
143
170
 
@@ -151,6 +178,11 @@ import { TouchJoystick, VirtualButton } from "./controllers/character/touch-joys
151
178
 
152
179
  const joy = new TouchJoystick({ wrapperStyle: { left: "20px", bottom: "20px" } }); // position is REQUIRED
153
180
  const btnJump = new VirtualButton({ label: "Jump", wrapperStyle: { right: "30px", bottom: "30px" } });
181
+ const btnCrouch = new VirtualButton({
182
+ label: "Crouch",
183
+ wrapperStyle: { right: "100px", bottom: "30px" },
184
+ onPress: () => character.setCrouch(!character.crouchActive), // tap = toggle
185
+ });
154
186
 
155
187
  physics.onBeforeStep(() => {
156
188
  character.setMovement({
@@ -178,8 +210,9 @@ players' physics locally guarantees divergence — every client would compute a
178
210
  different world.
179
211
 
180
212
  - Publish your own `currPos` + yaw on the fixed 10–20 Hz tick, not per frame.
181
- - To animate remotes, sync the five animation booleans and feed them to a
182
- per-remote `CharacterAnimations` see the animations reference.
213
+ - To animate remotes, sync the six animation booleans (`isOnGround`,
214
+ `isFalling`, `isMoving`, `runActive`, `jumpActive`, `crouchActive`) and feed
215
+ them to a per-remote `CharacterAnimations` — see the animations reference.
183
216
  - Load `$genex-threejs-multiplayer` before writing any networking code; it is
184
217
  mandatory for any 2+ player game.
185
218