@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.
- package/README.md +1 -0
- package/dist/index.js +344 -20
- package/package.json +1 -1
- package/templates/controllers/NOTICE.md +8 -5
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/anims-manifest.json +1306 -0
- package/templates/controllers/character/animation-packs.ts +70 -0
- package/templates/controllers/character/character-animations.ts +51 -8
- package/templates/controllers/character/character-controller.ts +153 -5
- package/templates/controllers/character/keyboard-input.ts +12 -1
- package/templates/controllers/character/presets.ts +19 -1
- package/templates/controllers/character/vrm/foot-ik.ts +71 -15
- package/templates/controllers/character/vrm/vrm-retarget.ts +72 -12
- package/templates/skills/genex-threejs-character-controller/SKILL.md +55 -22
- package/templates/skills/genex-threejs-character-controller/references/animations.md +91 -46
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +3 -0
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +7 -6
- package/templates/skills/genex-threejs-skill-router/SKILL.md +1 -1
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +3 -2
- package/templates/controllers/assets/character.glb +0 -0
|
@@ -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
|
|
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 (
|
|
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 } =
|
|
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
|
|
294
|
-
// locomotion states (
|
|
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
|
|
497
|
-
* Pistol_Shoot, Interact, Hit_Chest,
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
};
|