@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.
@@ -0,0 +1,70 @@
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.
18
+ import type * as THREE from "three";
19
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
20
+ import type { VRM } from "@pixiv/three-vrm";
21
+ import { retargetClips } from "./vrm/vrm-retarget.ts";
22
+
23
+ /** Shape of ./assets/anims/manifest.json (written by `genex controller anims`). */
24
+ interface AnimsManifest {
25
+ schema: number;
26
+ clips: string[];
27
+ }
28
+
29
+ export interface LoadCharacterClipsOptions {
30
+ /** Asset base URL — where animation-library.glb + anims/ live. Default "./assets/". */
31
+ base?: string;
32
+ }
33
+
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
+ */
39
+ export async function loadCharacterClips(
40
+ vrm: VRM,
41
+ options: LoadCharacterClipsOptions = {},
42
+ ): Promise<THREE.AnimationClip[]> {
43
+ const base = options.base ?? "./assets/";
44
+ const loader = new GLTFLoader();
45
+ const clips: THREE.AnimationClip[] = [];
46
+
47
+ const library = await loader.loadAsync(base + "animation-library.glb");
48
+ clips.push(...retargetClips(vrm, library.scene, library.animations));
49
+
50
+ const manifest = await fetch(base + "anims/manifest.json")
51
+ .then((res) => (res.ok ? (res.json() as Promise<AnimsManifest>) : null))
52
+ .catch(() => null);
53
+ if (manifest !== null && Array.isArray(manifest.clips) && manifest.clips.length > 0) {
54
+ const packs = await Promise.all(
55
+ manifest.clips.map(async (name) => {
56
+ try {
57
+ return await loader.loadAsync(`${base}anims/${name}.glb`);
58
+ } catch {
59
+ console.warn(`[animation-packs] ${name}.glb failed to load — skipped.`);
60
+ return null;
61
+ }
62
+ }),
63
+ );
64
+ for (const pack of packs) {
65
+ if (pack !== null) clips.push(...retargetClips(vrm, pack.scene, pack.animations));
66
+ }
67
+ }
68
+
69
+ return clips;
70
+ }
@@ -32,11 +32,13 @@ import * as THREE from "three";
32
32
  // State resolver (pure port)
33
33
  // ---------------------------------------------------------------------------
34
34
 
35
- /** The seven animation states the resolver can produce. */
35
+ /** The nine animation states the resolver can produce. */
36
36
  export type CharacterAnimationState =
37
37
  | "IDLE"
38
38
  | "WALK"
39
39
  | "RUN"
40
+ | "CROUCH_IDLE"
41
+ | "CROUCH_MOVE"
40
42
  | "JUMP_START"
41
43
  | "JUMP_IDLE"
42
44
  | "JUMP_FALL"
@@ -59,28 +61,40 @@ export interface CharacterStateSnapshot {
59
61
  readonly runActive: boolean;
60
62
  /** True during the short jump window (default 0.1 s), not for the whole airborne arc. */
61
63
  readonly jumpActive: boolean;
64
+ /**
65
+ * True while the capsule is crouched (AG-775). OPTIONAL for backward
66
+ * compatibility — a five-boolean snapshot (e.g. an older networked remote
67
+ * player) behaves exactly as before (treated as `false`).
68
+ */
69
+ readonly crouchActive?: boolean;
62
70
  }
63
71
 
64
72
  /** Snapshot plus the previous frame's ground flag (derived internally by CharacterAnimations). */
65
73
  export interface AnimationStateContext extends CharacterStateSnapshot {
66
74
  readonly wasOnGround: boolean;
75
+ /** Always present in the context (missing snapshot flag defaults to false). */
76
+ readonly crouchActive: boolean;
67
77
  }
68
78
 
69
79
  /** Custom state-resolver signature — must stay a pure function over the context. */
70
80
  export type AnimationStateResolver = (ctx: AnimationStateContext) => CharacterAnimationState;
71
81
 
72
82
  /**
73
- * Pure animation-state resolver (exact upstream logic). Check order is load-bearing:
83
+ * Pure animation-state resolver (upstream logic + crouch). Check order is load-bearing:
74
84
  * 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.
85
+ * JUMP_LAND outranks IDLE/WALK/RUN for exactly one evaluation after touchdown; crouch
86
+ * outranks the standing ground states (landing while crouched flashes JUMP_LAND for one
87
+ * evaluation, then settles into CROUCH_*).
76
88
  */
77
89
  export function resolveAnimationState(ctx: AnimationStateContext): CharacterAnimationState {
78
- const { isOnGround, wasOnGround, isFalling, isMoving, runActive, jumpActive } = ctx;
90
+ const { isOnGround, wasOnGround, isFalling, isMoving, runActive, jumpActive, crouchActive } =
91
+ ctx;
79
92
 
80
93
  if (jumpActive && wasOnGround) return "JUMP_START";
81
94
 
82
95
  if (isOnGround) {
83
96
  if (!wasOnGround) return "JUMP_LAND";
97
+ if (crouchActive) return isMoving ? "CROUCH_MOVE" : "CROUCH_IDLE";
84
98
  if (!isMoving) return "IDLE";
85
99
  return runActive ? "RUN" : "WALK";
86
100
  }
@@ -99,6 +113,8 @@ const ALL_STATES: readonly CharacterAnimationState[] = [
99
113
  "IDLE",
100
114
  "WALK",
101
115
  "RUN",
116
+ "CROUCH_IDLE",
117
+ "CROUCH_MOVE",
102
118
  "JUMP_START",
103
119
  "JUMP_IDLE",
104
120
  "JUMP_FALL",
@@ -116,6 +132,13 @@ const CLIP_SEARCH: Record<CharacterAnimationState, ClipSearchSpec> = {
116
132
  IDLE: { exact: ["Idle_Loop"], aliases: ["idle", "stand", "breath"] },
117
133
  WALK: { exact: ["Walk_Loop"], aliases: ["walk"] },
118
134
  RUN: { exact: ["Jog_Fwd_Loop", "Sprint_Loop"], aliases: ["run", "jog", "sprint"] },
135
+ // Aliases stay SPECIFIC on purpose: a bare "crouch" substring would greedily
136
+ // match any of the 9+ Crouch_* pack clips (Crouch_Enter, Crouch_Bwd_Loop, …).
137
+ CROUCH_IDLE: { exact: ["Crouch_Idle_Loop"], aliases: ["crouch_idle", "sneak_idle"] },
138
+ CROUCH_MOVE: {
139
+ exact: ["Crouch_Fwd_Loop"],
140
+ aliases: ["crouch_walk", "crouch_fwd", "sneak_walk", "sneak"],
141
+ },
119
142
  // Only the LOOP jump states (JUMP_IDLE/JUMP_FALL) end with a bare "jump" alias (lowest priority
120
143
  // — the specific compound tokens above always win when present) so a rig whose only airborne clip
121
144
  // is named "Jump" / "Jumping" still animates mid-air instead of playing the ground loop.
@@ -191,6 +214,8 @@ export function buildClipMap(
191
214
  IDLE: null,
192
215
  WALK: null,
193
216
  RUN: null,
217
+ CROUCH_IDLE: null,
218
+ CROUCH_MOVE: null,
194
219
  JUMP_START: null,
195
220
  JUMP_IDLE: null,
196
221
  JUMP_FALL: null,
@@ -207,6 +232,10 @@ export function buildClipMap(
207
232
  if (map.WALK === null) map.WALK = map.RUN;
208
233
  if (map.JUMP_FALL === null) map.JUMP_FALL = map.JUMP_IDLE;
209
234
  if (map.JUMP_IDLE === null) map.JUMP_IDLE = map.JUMP_FALL;
235
+ // 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.
237
+ if (map.CROUCH_IDLE === null) map.CROUCH_IDLE = map.IDLE;
238
+ if (map.CROUCH_MOVE === null) map.CROUCH_MOVE = map.WALK ?? map.CROUCH_IDLE;
210
239
  return map;
211
240
  }
212
241
 
@@ -290,8 +319,8 @@ export class CharacterAnimations {
290
319
  #resolver: AnimationStateResolver;
291
320
  #onChange: ((state: CharacterAnimationState, ctx: AnimationStateContext) => void) | undefined;
292
321
  #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_*…).
322
+ // Every provided clip by name — lets playOneShot reach clips beyond the 9
323
+ // locomotion states (any installed pack clip: Punch_*, Sword_*, Sitting_*…).
295
324
  #clipsByName = new Map<string, THREE.AnimationClip>();
296
325
  #oneShotAction: THREE.AnimationAction | null = null;
297
326
  #oneShotOnDone: (() => void) | undefined;
@@ -375,6 +404,7 @@ export class CharacterAnimations {
375
404
  isMoving: false,
376
405
  runActive: false,
377
406
  jumpActive: false,
407
+ crouchActive: false,
378
408
  };
379
409
 
380
410
  this.#onFinished = (event) => {
@@ -423,6 +453,16 @@ export class CharacterAnimations {
423
453
  return this.#clipMap;
424
454
  }
425
455
 
456
+ /**
457
+ * True while a {@link playOneShot} clip is playing (or holding its final
458
+ * pose with `holdPose`). Feed `() => !anims.oneShotActive` to foot IK's
459
+ * `allowReachDown` so one-shot choreography can't drag the pelvis down
460
+ * a step edge, and use it to block "fire again" spam in game code.
461
+ */
462
+ get oneShotActive(): boolean {
463
+ return this.#oneShotAction !== null;
464
+ }
465
+
426
466
  /** True when the procedural bob/lean drives the model instead of animation clips. */
427
467
  get usingProceduralFallback(): boolean {
428
468
  return this.#usingProceduralFallback;
@@ -446,6 +486,8 @@ export class CharacterAnimations {
446
486
  ctx.isMoving = snapshot.isMoving;
447
487
  ctx.runActive = snapshot.runActive;
448
488
  ctx.jumpActive = snapshot.jumpActive;
489
+ // Optional in the snapshot (older five-boolean remote-player objects).
490
+ ctx.crouchActive = snapshot.crouchActive ?? false;
449
491
 
450
492
  const next = this.#resolver(ctx);
451
493
  const stateChanged = next !== this.#state;
@@ -493,8 +535,9 @@ export class CharacterAnimations {
493
535
  * Play a full-body one-shot clip (punch, wave, pick-up, cast…) over the current
494
536
  * locomotion, then hand control back to the state machine when it finishes. Any
495
537
  * 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.
538
+ * character-controller skill's tagged pack catalog (Punch_Jab/Cross, Sword_Attack,
539
+ * Pistol_Shoot, Interact, Hit_Chest, …; install more with `genex controller anims`).
540
+ * Returns false if the clip name is unknown.
498
541
  *
499
542
  * @example
500
543
  * // punch on click:
@@ -42,6 +42,7 @@ export type MovementInput = {
42
42
  joystick?: { x: number; y: number };
43
43
  run?: boolean;
44
44
  jump?: boolean;
45
+ crouch?: boolean;
45
46
  };
46
47
 
47
48
  /** Read-only view of the merged movement state (returned by the `input` getter). */
@@ -65,8 +66,12 @@ type ResolvedMovementInput = {
65
66
  joystick: { x: number; y: number };
66
67
  run: boolean;
67
68
  jump: boolean;
69
+ crouch: boolean;
68
70
  };
69
71
 
72
+ /** Crouch input interpretation — see {@link CharacterControllerOptions.crouchMode}. */
73
+ export type CrouchMode = "toggle" | "hold";
74
+
70
75
  /**
71
76
  * Options for {@link CharacterController}. Every value has a tuned default —
72
77
  * start from a preset in `./presets.ts` and only override what feels wrong.
@@ -111,6 +116,22 @@ export interface CharacterControllerOptions {
111
116
  /** Capsule radius. Default `0.3`. */
112
117
  capsuleRadius?: number;
113
118
 
119
+ // ── crouch (NEW, no upstream source — AG-775) ──
120
+ /**
121
+ * Crouch input interpretation: `"toggle"` (press flips crouch; browser-friendly,
122
+ * holding C while WASD-ing is awkward) or `"hold"` (crouch while held). Default `"toggle"`.
123
+ */
124
+ crouchMode?: CrouchMode;
125
+ /** Crouched target speed as a fraction of `maxWalkVel` (run is ignored while crouched). Default `0.45`. */
126
+ crouchSpeedRatio?: number;
127
+ /**
128
+ * Crouched capsule cylinder half-height as a fraction of the standing one.
129
+ * The capsule bottom keeps its float clearance (the ray origin shifts with
130
+ * the shrink), so the character's HEAD drops by `2*(1-scale)*capsuleHalfHeight`.
131
+ * Default `0.6`.
132
+ */
133
+ crouchCapsuleScale?: number;
134
+
114
135
  // ── forward direction ──
115
136
  /** `true` = always face the camera/custom forward (strafe mode). Default `false`. */
116
137
  lockForward?: boolean;
@@ -269,6 +290,8 @@ export class CharacterController {
269
290
  private readonly fallingGravityScale: number;
270
291
  private readonly fallingMaxVel: number;
271
292
  private readonly enableToggleRun: boolean;
293
+ private readonly crouchMode: CrouchMode;
294
+ private readonly crouchSpeedRatio: number;
272
295
  private groundDetectionMode: GroundDetectionMode;
273
296
  private readonly slopeMaxAngle: number;
274
297
  private readonly floatHeight: number;
@@ -301,6 +324,7 @@ export class CharacterController {
301
324
  joystick: { x: 0, y: 0 },
302
325
  run: false,
303
326
  jump: false,
327
+ crouch: false,
304
328
  };
305
329
  private jumpElapsedTime = 0;
306
330
  private _jumpActive = false;
@@ -308,6 +332,19 @@ export class CharacterController {
308
332
  private _runActive = false;
309
333
  private canRunAgain = false;
310
334
 
335
+ // ── crouch state (NEW, no upstream source — AG-775) ──
336
+ private _crouchActive = false;
337
+ /** What the player WANTS (toggle latch / hold level); `_crouchActive` lags it while a ceiling blocks standing. */
338
+ private crouchIntent = false;
339
+ private canCrouchAgain = false;
340
+ private readonly standingHalfHeight: number;
341
+ private readonly crouchedHalfHeight: number;
342
+ /** Slightly-slimmed standing capsule for the stand-up ceiling check (the 2%/5%
343
+ * shrink keeps grazing contacts the crouched capsule already tolerates from
344
+ * false-blocking the stand). */
345
+ private readonly standCheckShape: RAPIER.Capsule;
346
+ private readonly standCheckPos = new THREE.Vector3();
347
+
311
348
  // ── fixed axes ──
312
349
  private readonly fixedZero = new THREE.Vector3(0, 0, 0);
313
350
  private readonly fixedOrigin = new THREE.Vector3(0, 0, 0);
@@ -469,6 +506,10 @@ export class CharacterController {
469
506
  this.fallingGravityScale = options.fallingGravityScale ?? 3;
470
507
  this.fallingMaxVel = options.fallingMaxVel ?? 20;
471
508
  this.enableToggleRun = options.enableToggleRun ?? true;
509
+ this.crouchMode = options.crouchMode ?? "toggle";
510
+ this.crouchSpeedRatio = options.crouchSpeedRatio ?? 0.45;
511
+ this.standingHalfHeight = capsuleHalfHeight;
512
+ this.crouchedHalfHeight = capsuleHalfHeight * (options.crouchCapsuleScale ?? 0.6);
472
513
  this.groundDetectionMode = options.groundDetection ?? "shapeCast";
473
514
  this.slopeMaxAngle = options.slopeMaxAngle ?? Math.PI / 2.5;
474
515
  this.floatHeight = options.floatHeight ?? 0.2;
@@ -517,6 +558,7 @@ export class CharacterController {
517
558
  // Ground-query scratch shapes (reused every step).
518
559
  this.rayShape = new RAPIER.Ball(this.rayRadius);
519
560
  this.ray = new RAPIER.Ray(this.rayOrigin, this.rayDirection);
561
+ this.standCheckShape = new RAPIER.Capsule(this.standingHalfHeight * 0.98, this.capsuleRadius * 0.95);
520
562
 
521
563
  // Visual root (the JSX children slot).
522
564
  this.root = new THREE.Group();
@@ -679,6 +721,13 @@ export class CharacterController {
679
721
  get jumpActive(): boolean {
680
722
  return this._jumpActive;
681
723
  }
724
+ /**
725
+ * `true` while the capsule is crouched. May stay `true` after the player asks
726
+ * to stand — standing is ceiling-gated and retried automatically.
727
+ */
728
+ get crouchActive(): boolean {
729
+ return this._crouchActive;
730
+ }
682
731
  /** `true` while the character always faces the forward direction (strafe mode). */
683
732
  get lockForward(): boolean {
684
733
  return this.isLockForward;
@@ -712,6 +761,17 @@ export class CharacterController {
712
761
  }
713
762
  if (movement.run !== undefined) this.movementState.run = movement.run;
714
763
  if (movement.jump !== undefined) this.movementState.jump = movement.jump;
764
+ if (movement.crouch !== undefined) this.movementState.crouch = movement.crouch;
765
+ }
766
+
767
+ /**
768
+ * Programmatic crouch request (e.g. an on-screen crouch button:
769
+ * `btnCrouch.onPress = () => character.setCrouch(!character.crouchActive)`).
770
+ * Sets the INTENT — the capsule change applies on the next `update()`, and
771
+ * standing still waits for ceiling clearance.
772
+ */
773
+ setCrouch(active: boolean): void {
774
+ this.crouchIntent = active;
715
775
  }
716
776
 
717
777
  /** Toggle strafe mode (always face the camera/custom forward direction). */
@@ -752,6 +812,7 @@ export class CharacterController {
752
812
  this.movementState.joystick.y = 0;
753
813
  this.movementState.run = false;
754
814
  this.movementState.jump = false;
815
+ this.movementState.crouch = false;
755
816
  }
756
817
 
757
818
  /**
@@ -804,7 +865,15 @@ export class CharacterController {
804
865
  const leftward = this.movementState.leftward;
805
866
  const rightward = this.movementState.rightward;
806
867
  const run = this.getRunState(this.movementState.run || false);
807
- const jump = this.getJumpState(this.movementState.jump || false);
868
+ // Crouch runs BEFORE jump: jumping while crouched first requests a stand
869
+ // (ceiling-checked); until the character actually stands, the jump input is
870
+ // masked so the shrunken capsule never launches into the obstacle above.
871
+ const crouchWasActive = this._crouchActive;
872
+ const crouch = this.getCrouchState(
873
+ this.movementState.crouch || false,
874
+ this.movementState.jump || false,
875
+ );
876
+ const jump = this.getJumpState((this.movementState.jump || false) && !crouch);
808
877
  const joystick = this.movementState.joystick;
809
878
  const hasControlInput =
810
879
  forward ||
@@ -812,6 +881,8 @@ export class CharacterController {
812
881
  leftward ||
813
882
  rightward ||
814
883
  jump ||
884
+ this.movementState.crouch ||
885
+ crouch !== crouchWasActive ||
815
886
  Math.abs(joystick.x) > 1e-4 ||
816
887
  Math.abs(joystick.y) > 1e-4;
817
888
 
@@ -1437,6 +1508,85 @@ export class CharacterController {
1437
1508
  return this._runActive;
1438
1509
  }
1439
1510
 
1511
+ /**
1512
+ * Crouch state machine (NEW, no upstream source — AG-775). Intent follows the
1513
+ * input (toggle latch or hold level); the ACTIVE state follows intent except
1514
+ * that standing up is gated on ceiling clearance and retried every step while
1515
+ * the intent stays "stand" (walk out from under the obstacle and you pop up).
1516
+ * Runs before the sleep early-out, so a stuck stand request keeps retrying.
1517
+ */
1518
+ private getCrouchState(crouchPressed: boolean, jumpPressed: boolean): boolean {
1519
+ if (this.crouchMode === "toggle") {
1520
+ // Only toggle crouch intent on the key's rising edge
1521
+ if (crouchPressed && !this.canCrouchAgain) this.crouchIntent = !this.crouchIntent;
1522
+ this.canCrouchAgain = crouchPressed;
1523
+ // Jump doubles as a stand request (the jump itself stays masked while crouched).
1524
+ if (jumpPressed && this.crouchIntent) this.crouchIntent = false;
1525
+ } else {
1526
+ this.crouchIntent = crouchPressed;
1527
+ }
1528
+
1529
+ if (this.crouchIntent && !this._crouchActive) {
1530
+ this._crouchActive = true;
1531
+ this.setCapsuleHalfHeight(this.crouchedHalfHeight);
1532
+ } else if (!this.crouchIntent && this._crouchActive && this.canStandUp()) {
1533
+ this._crouchActive = false;
1534
+ this.setCapsuleHalfHeight(this.standingHalfHeight);
1535
+ }
1536
+ return this._crouchActive;
1537
+ }
1538
+
1539
+ /**
1540
+ * Resize the capsule cylinder, shifting the shrunk shape DOWN inside the body
1541
+ * by the height delta: the capsule BOTTOM keeps its ground clearance (feet
1542
+ * stay planted) and all the change comes off the TOP, while the body ORIGIN —
1543
+ * and with it `root`, the attached avatar, and the float-ray origin — stays at
1544
+ * standing height. (Dropping the origin instead sinks the fixed-offset avatar
1545
+ * into the floor, which foot IK then "fixes" by hyper-bending the knees.)
1546
+ * Rapier re-derives mass properties from the new shape automatically.
1547
+ */
1548
+ private setCapsuleHalfHeight(halfHeight: number): void {
1549
+ this._collider.setHalfHeight(halfHeight);
1550
+ this._collider.setTranslationWrtParent({
1551
+ x: 0,
1552
+ y: -(this.standingHalfHeight - halfHeight),
1553
+ z: 0,
1554
+ });
1555
+ }
1556
+
1557
+ /**
1558
+ * `true` when the STANDING capsule fits — the ceiling check before standing
1559
+ * up. The body origin never moves on crouch (only the collider shape/offset
1560
+ * does), so the standing capsule is tested right at the current translation.
1561
+ * Uses the same collider filter as the ground queries.
1562
+ */
1563
+ private canStandUp(): boolean {
1564
+ const position = this._body.translation();
1565
+ this.standCheckPos.set(position.x, position.y, position.z);
1566
+ let blocked = false;
1567
+ this.world.intersectionsWithShape(
1568
+ this.standCheckPos,
1569
+ this._body.rotation(),
1570
+ this.standCheckShape,
1571
+ () => {
1572
+ blocked = true;
1573
+ return false; // first hit is enough
1574
+ },
1575
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
1576
+ undefined,
1577
+ this._collider,
1578
+ this._body,
1579
+ this.rayFilter,
1580
+ );
1581
+ return !blocked;
1582
+ }
1583
+
1584
+ /** Target ground speed: crouch caps at `crouchSpeedRatio * maxWalkVel` (run ignored). */
1585
+ private targetMoveSpeed(run: boolean): number {
1586
+ if (this._crouchActive) return this.maxWalkVel * this.crouchSpeedRatio;
1587
+ return run ? this.maxRunVel : this.maxWalkVel;
1588
+ }
1589
+
1440
1590
  /** Move impulse (slope climb + rejectVel + above-CoM lean) (upstream l.454-494). */
1441
1591
  private moveCharacter(run: boolean, fpsCorr: number): void {
1442
1592
  // Moving direction: rotate inputDir up/down the slope in front
@@ -1461,7 +1611,7 @@ export class CharacterController {
1461
1611
  (this._actualSlopeAngle > this.slopeMaxAngle ? this.airDragFactor : 1);
1462
1612
  this.baseImpulse
1463
1613
  .copy(this._movingDirection)
1464
- .multiplyScalar(run ? this.maxRunVel : this.maxWalkVel)
1614
+ .multiplyScalar(this.targetMoveSpeed(run))
1465
1615
  .sub(this._relativeVelOnPlane);
1466
1616
  this._moveImpulse.copy(this.baseImpulse).sub(this.rejectVel).multiplyScalar(multiplier);
1467
1617
 
@@ -1629,8 +1779,6 @@ export class CharacterController {
1629
1779
  // Current moving velocity arrow
1630
1780
  d.velocityArrow.position.copy(this.currentPos);
1631
1781
  d.velocityArrow.setDirection(this.currVelDir.copy(this._relativeVel).normalize());
1632
- d.velocityArrow.setLength(
1633
- this._relativeVel.length() / (this._runActive ? this.maxRunVel : this.maxWalkVel)
1634
- );
1782
+ d.velocityArrow.setLength(this._relativeVel.length() / this.targetMoveSpeed(this._runActive));
1635
1783
  }
1636
1784
  }
@@ -19,6 +19,7 @@ export interface CharacterMovementIntent {
19
19
  rightward: boolean;
20
20
  run: boolean;
21
21
  jump: boolean;
22
+ crouch: boolean;
22
23
  }
23
24
 
24
25
  /** Movement intent for the car. Field names match the vehicle controller's `VehicleInput`. */
@@ -58,6 +59,7 @@ type NamedKey =
58
59
  | "space"
59
60
  | "shift"
60
61
  | "f"
62
+ | "c"
61
63
  | "up"
62
64
  | "down"
63
65
  | "left"
@@ -65,6 +67,7 @@ type NamedKey =
65
67
 
66
68
  // Bindings ported from the upstream keyboard map: letters/arrows/space match by
67
69
  // KeyboardEvent.code; Shift matches by event.key so ShiftLeft AND ShiftRight both work.
70
+ // Crouch is KeyC, NOT Ctrl — Ctrl+W (crouch-walking forward) would close the browser tab.
68
71
  const CODE_BINDINGS: Readonly<Partial<Record<string, NamedKey>>> = {
69
72
  KeyW: "w",
70
73
  KeyS: "s",
@@ -72,6 +75,7 @@ const CODE_BINDINGS: Readonly<Partial<Record<string, NamedKey>>> = {
72
75
  KeyD: "d",
73
76
  Space: "space",
74
77
  KeyF: "f",
78
+ KeyC: "c",
75
79
  ArrowUp: "up",
76
80
  ArrowDown: "down",
77
81
  ArrowLeft: "left",
@@ -89,6 +93,7 @@ const ALL_NAMED_KEYS: readonly NamedKey[] = [
89
93
  "space",
90
94
  "shift",
91
95
  "f",
96
+ "c",
92
97
  "up",
93
98
  "down",
94
99
  "left",
@@ -111,6 +116,7 @@ export class KeyboardInput {
111
116
  space: false,
112
117
  shift: false,
113
118
  f: false,
119
+ c: false,
114
120
  up: false,
115
121
  down: false,
116
122
  left: false,
@@ -183,6 +189,9 @@ export class KeyboardInput {
183
189
  get f(): boolean {
184
190
  return this.#keys.f;
185
191
  }
192
+ get c(): boolean {
193
+ return this.#keys.c;
194
+ }
186
195
  get up(): boolean {
187
196
  return this.#keys.up;
188
197
  }
@@ -206,7 +215,8 @@ export class KeyboardInput {
206
215
 
207
216
  // ---- Derived intents (exact upstream wrapper mappings, touch terms merged by the caller) ----
208
217
 
209
- /** WASD or arrows to move, Shift to run, Space to jump. Merge touch input caller-side:
218
+ /** WASD or arrows to move, Shift to run, Space to jump, C to crouch (the controller's
219
+ * crouchMode decides toggle vs hold). Merge touch input caller-side:
210
220
  * `{ ...kb.getCharacterMovement(), run: kb.shift || btnRun.pressed, joystick: {...} }`. */
211
221
  getCharacterMovement(): CharacterMovementIntent {
212
222
  const k = this.#keys;
@@ -217,6 +227,7 @@ export class KeyboardInput {
217
227
  rightward: k.d || k.right,
218
228
  run: k.shift,
219
229
  jump: k.space,
230
+ crouch: k.c,
220
231
  };
221
232
  }
222
233
 
@@ -36,7 +36,9 @@ export interface CharacterPreset {
36
36
  *
37
37
  * Quick tuning map: "slippery" -> lower `slideGripFactor`; "floaty" -> lower
38
38
  * `fallingGravityScale`; "sluggish" -> raise `accDeltaTime`; "jumps too weak"
39
- * -> raise `jumpVel`; "tips over" -> raise `autoBalanceSpringK`.
39
+ * -> raise `jumpVel`; "tips over" -> raise `autoBalanceSpringK`; "sneaks too
40
+ * fast" -> lower `crouchSpeedRatio`; "can't fit under obstacles" -> lower
41
+ * `crouchCapsuleScale`.
40
42
  */
41
43
  export const characterPresets: Readonly<
42
44
  Record<
@@ -57,6 +59,8 @@ export const characterPresets: Readonly<
57
59
  "decisive jump, moderate grip. Tuned for collider density 1.",
58
60
  options: {
59
61
  density: 1,
62
+ crouchSpeedRatio: 0.45,
63
+ crouchCapsuleScale: 0.6,
60
64
  },
61
65
  },
62
66
 
@@ -92,6 +96,9 @@ export const characterPresets: Readonly<
92
96
  autoBalanceDampingC: 3,
93
97
  autoBalanceSpringOnY: 8,
94
98
  autoBalanceDampingOnY: 0.76,
99
+ // Crouch is a Genex extension (no upstream counterpart) — library defaults.
100
+ crouchSpeedRatio: 0.45,
101
+ crouchCapsuleScale: 0.6,
95
102
  },
96
103
  },
97
104
 
@@ -113,6 +120,10 @@ export const characterPresets: Readonly<
113
120
  airDragFactor: 0.3,
114
121
  slideGripFactor: 0.8,
115
122
  enableToggleRun: false,
123
+ // Snappy games sneak a bit faster; hold-to-crouch matches hold-to-run.
124
+ crouchMode: "hold",
125
+ crouchSpeedRatio: 0.5,
126
+ crouchCapsuleScale: 0.6,
116
127
  },
117
128
  },
118
129
 
@@ -132,6 +143,9 @@ export const characterPresets: Readonly<
132
143
  jumpDuration: 0.15,
133
144
  fallingGravityScale: 2.2,
134
145
  moveImpulsePointOffset: 0.6,
146
+ // Weighty crawl-speed sneak, shallower stance change.
147
+ crouchSpeedRatio: 0.4,
148
+ crouchCapsuleScale: 0.65,
135
149
  },
136
150
  },
137
151
 
@@ -152,6 +166,8 @@ export const characterPresets: Readonly<
152
166
  fallingGravityScale: 1,
153
167
  fallingMaxVel: 10,
154
168
  airDragFactor: 0.05,
169
+ crouchSpeedRatio: 0.45,
170
+ crouchCapsuleScale: 0.6,
155
171
  },
156
172
  },
157
173
 
@@ -168,6 +184,8 @@ export const characterPresets: Readonly<
168
184
  accDeltaTime: 0.08,
169
185
  decDeltaTime: 0.03,
170
186
  rejectVelFactor: 0.2,
187
+ crouchSpeedRatio: 0.45,
188
+ crouchCapsuleScale: 0.6,
171
189
  },
172
190
  },
173
191
  };