@genex-ai/cli-demo 0.12.1 → 0.14.2

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.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +203 -4
  3. package/package.json +7 -2
  4. package/templates/controllers/NOTICE.md +65 -0
  5. package/templates/controllers/assets/animation-library.glb +0 -0
  6. package/templates/controllers/assets/character.glb +0 -0
  7. package/templates/controllers/assets/default-avatar.vrm +0 -0
  8. package/templates/controllers/character/character-animations.ts +682 -0
  9. package/templates/controllers/character/character-controller.ts +1636 -0
  10. package/templates/controllers/character/follow-camera.ts +644 -0
  11. package/templates/controllers/character/keyboard-input.ts +277 -0
  12. package/templates/controllers/character/presets.ts +176 -0
  13. package/templates/controllers/character/touch-joystick.ts +387 -0
  14. package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
  15. package/templates/controllers/character/vrm/foot-ik.ts +341 -0
  16. package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
  17. package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
  18. package/templates/controllers/drone/drone-controller.ts +1073 -0
  19. package/templates/controllers/drone/presets.ts +225 -0
  20. package/templates/controllers/interact/enter-exit.ts +502 -0
  21. package/templates/controllers/shared/colliders.ts +456 -0
  22. package/templates/controllers/shared/math.ts +230 -0
  23. package/templates/controllers/shared/physics-world.ts +622 -0
  24. package/templates/controllers/vehicle/presets.ts +297 -0
  25. package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
  26. package/templates/controllers/vehicle/wheel.ts +1200 -0
  27. package/templates/skills/genex-getting-started/SKILL.md +5 -0
  28. package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
  29. package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
  30. package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
  31. package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
  32. package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
  33. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  34. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  35. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  36. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  37. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  38. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  39. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
@@ -0,0 +1,1636 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TypeScript port of the ecctrl character controller (React/R3F removed).
4
+ // Renames vs upstream: option `rayOriginOffest` -> `rayOriginOffset` (typo fix);
5
+ // rigid-body userData key `ecctrl` -> `controller` (de-branding rename).
6
+
7
+ import * as THREE from "three";
8
+ import RAPIER from "@dimforge/rapier3d-compat";
9
+ import {
10
+ bakeCurveLUT,
11
+ evaluateCurveLUT,
12
+ createSlerpVec3,
13
+ type CurveData,
14
+ type CurveLUT,
15
+ } from "../shared/math.ts";
16
+ import type { ControllerUserData } from "../shared/physics-world.ts";
17
+
18
+ export type { ControllerUserData };
19
+
20
+ const clamp = THREE.MathUtils.clamp;
21
+
22
+ /** Default platform mass-ratio falloff curve: flat 0 until half the character's
23
+ * mass, then rising to full inheritance at equal-or-heavier platforms. */
24
+ const DEFAULT_CURVE_DATA: CurveData = {
25
+ points: [
26
+ { x: 0, y: 0, r_out: 0 },
27
+ { x: 0.5, y: 0, r_in: 0, r_out: 0 },
28
+ { x: 1, y: 1, r_in: 0 },
29
+ ],
30
+ };
31
+
32
+ /**
33
+ * Movement intents pushed into the controller via {@link CharacterController.setMovement}.
34
+ * Booleans are digital WASD-style input; `joystick` is an analog stick in
35
+ * [-1, 1] on each axis (overrides the booleans while non-zero).
36
+ */
37
+ export type MovementInput = {
38
+ forward?: boolean;
39
+ backward?: boolean;
40
+ leftward?: boolean;
41
+ rightward?: boolean;
42
+ joystick?: { x: number; y: number };
43
+ run?: boolean;
44
+ jump?: boolean;
45
+ };
46
+
47
+ /** Read-only view of the merged movement state (returned by the `input` getter). */
48
+ export type ReadonlyMovementInput = Readonly<Omit<MovementInput, "joystick">> & {
49
+ readonly joystick?: Readonly<{ x: number; y: number }>;
50
+ };
51
+
52
+ /**
53
+ * Ground-detection strategy: `"shapeCast"` (default) sweeps a small ball down
54
+ * (forgiving on ledges/stairs), `"rayCast"` casts a single ray (cheaper,
55
+ * stricter). Switch at runtime with {@link CharacterController.setGroundDetection}.
56
+ */
57
+ export type GroundDetectionMode = "shapeCast" | "rayCast";
58
+
59
+ /** Fully-populated internal movement state (all fields always present). */
60
+ type ResolvedMovementInput = {
61
+ forward: boolean;
62
+ backward: boolean;
63
+ leftward: boolean;
64
+ rightward: boolean;
65
+ joystick: { x: number; y: number };
66
+ run: boolean;
67
+ jump: boolean;
68
+ };
69
+
70
+ /**
71
+ * Options for {@link CharacterController}. Every value has a tuned default —
72
+ * start from a preset in `./presets.ts` and only override what feels wrong.
73
+ *
74
+ * IMPORTANT: the float/auto-balance spring constants scale roughly linearly
75
+ * with body mass, so they are tuned for a specific `density`. If you change
76
+ * `density` (or capsule size), re-tune `springK`/`dampingC` and the four
77
+ * `autoBalance*` values in the same proportion.
78
+ */
79
+ export interface CharacterControllerOptions {
80
+ /** Spawn position of the rigid body. Default `{ x: 0, y: 1, z: 0 }`. */
81
+ position?: { x: number; y: number; z: number };
82
+ /** Spawn rotation of the rigid body. Default identity. */
83
+ rotation?: THREE.Quaternion;
84
+ /**
85
+ * Capsule collider friction. Default `-0.5` — NEGATIVE on purpose: traction
86
+ * is synthesized by the move-impulse model, and negative friction (averaged
87
+ * with the ground's) keeps the capsule itself from grabbing walls/ground.
88
+ * Do not "fix" this to a positive value.
89
+ */
90
+ friction?: number;
91
+ /** Collider density (mass = density x capsule volume). Default `1`. Presets state the density they were tuned for. */
92
+ density?: number;
93
+ /** Allow the body to sleep when at rest. Default `true`. */
94
+ canSleep?: boolean;
95
+ /** Initial gravity scale while airborne and not falling. Default `1`. */
96
+ gravityScale?: number;
97
+ /**
98
+ * Stored on `body.userData` — the ray-filter contract other controllers
99
+ * read. Recommended: `{ controller: { excludeVehicleRay: true } }` so car
100
+ * wheels never treat the on-foot character as drivable ground.
101
+ */
102
+ userData?: ControllerUserData;
103
+ /** Build debug indicators (needs the `debugScene` constructor arg). Default `false`. */
104
+ debug?: boolean;
105
+ /** Initial value of the `enabled` switch. Default `true`. */
106
+ enable?: boolean;
107
+
108
+ // ── character capsule ──
109
+ /** Capsule cylinder half-height (total height = 2*(halfHeight+radius)). Default `0.3`. */
110
+ capsuleHalfHeight?: number;
111
+ /** Capsule radius. Default `0.3`. */
112
+ capsuleRadius?: number;
113
+
114
+ // ── forward direction ──
115
+ /** `true` = always face the camera/custom forward (strafe mode). Default `false`. */
116
+ lockForward?: boolean;
117
+ /** `true` = use the vector given via `setForwardDir` instead of the camera's forward. Default `false`. */
118
+ useCustomForward?: boolean;
119
+ /** `true` = project movement on the character's own up axis instead of the gravity up axis. Default `false`. */
120
+ useCharacterUpAxis?: boolean;
121
+
122
+ // ── gravity-direction smoothing ──
123
+ /** How fast the smoothed gravity direction chases world gravity (`1 - exp(-k*dt)`). Higher = snappier. Default `6`. */
124
+ gravityDirLerpSpeed?: number;
125
+
126
+ // ── base control ──
127
+ /** Target walk speed in m/s. Default `2`. */
128
+ maxWalkVel?: number;
129
+ /** Target run speed in m/s. Default `5`. */
130
+ maxRunVel?: number;
131
+ /** Acceleration responsiveness in (0,1]: higher = reaches target speed faster. Default `0.2`. */
132
+ accDeltaTime?: number;
133
+ /** Braking responsiveness in (0,1]: higher = stops faster when input is released. Default `0.2`. */
134
+ decDeltaTime?: number;
135
+ /** How strongly off-axis (sideways) velocity is cancelled while grounded. 0 = keep drifting, 1 = full cancel. Default `1`. */
136
+ rejectVelFactor?: number;
137
+ /** Height above the body center where the move impulse is applied — creates the run lean. 0 = no lean. Default `0.5`. */
138
+ moveImpulsePointOffset?: number;
139
+ /** Jump takeoff speed in m/s (applied via setLinvel, i.e. velocity replace). Default `5`. */
140
+ jumpVel?: number;
141
+ /** Seconds the jump keeps re-asserting takeoff velocity (also suppresses the downward float spring). Default `0.1`. */
142
+ jumpDuration?: number;
143
+ /** Blends the ground normal into the jump direction (0 = straight up, 1 = off the slope). Default `0`. */
144
+ slopeJumpFactor?: number;
145
+ /** Air control strength (replaces ground grip while airborne). Lower = floatier air control. Default `0.1`. */
146
+ airDragFactor?: number;
147
+ /** Added to the ground's friction before the 0..1 grip blend. Lower = slippery ice feel. Default `0.5`. */
148
+ slideGripFactor?: number;
149
+ /** Gravity multiplier while falling — higher = heavier, snappier falls. Default `3`. */
150
+ fallingGravityScale?: number;
151
+ /** Terminal fall speed in m/s (gravity cuts to 0 past it). Default `20`. */
152
+ fallingMaxVel?: number;
153
+ /** `true` = the run key toggles run on/off; `false` = hold to run. Default `true`. */
154
+ enableToggleRun?: boolean;
155
+
156
+ // ── floating ray ──
157
+ /** Ground-detection strategy. Default `"shapeCast"`. */
158
+ groundDetection?: GroundDetectionMode;
159
+ /** Max walkable slope in radians; steeper ground makes the character slide. Default `Math.PI / 2.5` (72 deg). */
160
+ slopeMaxAngle?: number;
161
+ /** How high the capsule floats above the ground. Default `0.2`. */
162
+ floatHeight?: number;
163
+ /**
164
+ * Ground-query origin offset along the character's own up axis (usually
165
+ * negative: start under the hips). Default `-capsuleHalfHeight`.
166
+ * RENAMED from upstream's typo'd `rayOriginOffest`.
167
+ */
168
+ rayOriginOffset?: number;
169
+ /** Extra grounded tolerance beyond the float distance — raise it if stairs/ledges flicker the grounded state. Default `0.28`. */
170
+ rayHitForgiveness?: number;
171
+ /** Max ground-query distance. Default `capsuleRadius + 1`. */
172
+ rayLength?: number;
173
+ /** Radius of the shapecast ball. Default `capsuleRadius / 2`. */
174
+ rayRadius?: number;
175
+ /** Float spring stiffness. Scales with mass — tuned per density (see presets). Default `80`. */
176
+ springK?: number;
177
+ /** Float spring damping. Too low = pogo bounce, too high = sticky landings. Default `6`. */
178
+ dampingC?: number;
179
+
180
+ // ── auto balance ──
181
+ /** Keep the capsule upright with spring torques. Default `true`. */
182
+ autoBalance?: boolean;
183
+ /** Upright spring stiffness. Scales with mass. Default `0.5`. */
184
+ autoBalanceSpringK?: number;
185
+ /** Upright spring damping. Default `0.03`. */
186
+ autoBalanceDampingC?: number;
187
+ /** Turning (yaw) spring stiffness — higher = faster facing changes. Default `0.08`. */
188
+ autoBalanceSpringOnY?: number;
189
+ /** Turning (yaw) spring damping. Default `0.006`. */
190
+ autoBalanceDampingOnY?: number;
191
+
192
+ // ── moving platform ──
193
+ /** Inherit velocity/rotation from the platform under the character. Default `true`. */
194
+ followPlatform?: boolean;
195
+ /**
196
+ * Falloff of platform-rotation inheritance by platform/character mass ratio
197
+ * (light dynamic props don't drag the character around). Default: 0 below
198
+ * 0.5x character mass, ramping to 1 at equal mass.
199
+ */
200
+ massRatioFallOffCurveData?: CurveData;
201
+ /** Push the character's weight down into dynamic ground each step. Default `true`. */
202
+ applyCounterMass?: boolean;
203
+ /** Kick dynamic ground downward when jumping off it. Default `true`. */
204
+ applyCounterJumpImp?: boolean;
205
+ /** Scale of the counter-jump kick. Default `1`. */
206
+ counterJumpImpFactor?: number;
207
+ /** Push dynamic ground backward when running on it. Default `true`. */
208
+ applyCounterMoveImp?: boolean;
209
+ /** Scale of the counter-move push. Default `1`. */
210
+ counterMoveImpFactor?: number;
211
+ }
212
+
213
+ /** Bundle of debug indicator objects (parity-non-critical helper). */
214
+ type DebugAssets = {
215
+ group: THREE.Group;
216
+ forwardIndicator: THREE.Group;
217
+ moveIndicator: THREE.Group;
218
+ rayStart: THREE.Mesh;
219
+ rayEnd: THREE.Mesh;
220
+ rayTrigger: THREE.Mesh;
221
+ rayStable: THREE.Mesh;
222
+ standingPoint: THREE.Mesh;
223
+ velocityArrow: THREE.ArrowHelper;
224
+ disposables: Array<{ dispose(): void }>;
225
+ };
226
+
227
+ /**
228
+ * Dynamic floating-capsule character controller.
229
+ *
230
+ * The character is a real dynamic rigid body kept floating above the ground by
231
+ * a spring (downward shapecast), moved by impulses, turned by yaw torques and
232
+ * kept upright by balance torques. It pushes and is pushed by other dynamic
233
+ * bodies, rides moving/rotating platforms, climbs walkable slopes and slides
234
+ * on steep ones.
235
+ *
236
+ * Loop contract: call `update()` once per FIXED physics substep BEFORE
237
+ * `world.step()`; all internal time terms use `world.timestep`. Sync the
238
+ * visual root after stepping (register `(body, root)` with the PhysicsWorld
239
+ * registry, or call `syncRoot()` yourself).
240
+ */
241
+ export class CharacterController {
242
+ /** Master enable switch — `false` skips the whole per-step brain. */
243
+ enabled: boolean;
244
+
245
+ /** Visual anchor: parent your character model under this group. */
246
+ readonly root: THREE.Group;
247
+
248
+ private readonly world: RAPIER.World;
249
+ private readonly camera: THREE.Camera;
250
+ private readonly _body: RAPIER.RigidBody;
251
+ private readonly _collider: RAPIER.Collider;
252
+
253
+ // ── resolved options ──
254
+ private readonly debugEnabled: boolean;
255
+ private readonly capsuleRadius: number;
256
+ private readonly useCustomForward: boolean;
257
+ private readonly gravityDirLerpSpeed: number;
258
+ private readonly maxWalkVel: number;
259
+ private readonly maxRunVel: number;
260
+ private readonly accDeltaTime: number;
261
+ private readonly decDeltaTime: number;
262
+ private readonly rejectVelFactor: number;
263
+ private readonly moveImpulsePointOffset: number;
264
+ private readonly jumpVel: number;
265
+ private readonly jumpDuration: number;
266
+ private readonly slopeJumpFactor: number;
267
+ private readonly airDragFactor: number;
268
+ private readonly slideGripFactor: number;
269
+ private readonly fallingGravityScale: number;
270
+ private readonly fallingMaxVel: number;
271
+ private readonly enableToggleRun: boolean;
272
+ private groundDetectionMode: GroundDetectionMode;
273
+ private readonly slopeMaxAngle: number;
274
+ private readonly floatHeight: number;
275
+ private readonly rayOriginOffset: number;
276
+ private readonly rayHitForgiveness: number;
277
+ private readonly rayLength: number;
278
+ private readonly rayRadius: number;
279
+ private readonly springK: number;
280
+ private readonly dampingC: number;
281
+ private readonly autoBalance: boolean;
282
+ private readonly autoBalanceSpringK: number;
283
+ private readonly autoBalanceDampingC: number;
284
+ private readonly autoBalanceSpringOnY: number;
285
+ private readonly autoBalanceDampingOnY: number;
286
+ private readonly followPlatform: boolean;
287
+ private readonly applyCounterMass: boolean;
288
+ private readonly applyCounterJumpImp: boolean;
289
+ private readonly counterJumpImpFactor: number;
290
+ private readonly applyCounterMoveImp: boolean;
291
+ private readonly counterMoveImpFactor: number;
292
+ private readonly initialGravityScale: number;
293
+ private readonly massRatioFallOffCurve: CurveLUT;
294
+
295
+ // ── input state ──
296
+ private readonly movementState: ResolvedMovementInput = {
297
+ forward: false,
298
+ backward: false,
299
+ leftward: false,
300
+ rightward: false,
301
+ joystick: { x: 0, y: 0 },
302
+ run: false,
303
+ jump: false,
304
+ };
305
+ private jumpElapsedTime = 0;
306
+ private _jumpActive = false;
307
+ private canJumpAgain = true;
308
+ private _runActive = false;
309
+ private canRunAgain = false;
310
+
311
+ // ── fixed axes ──
312
+ private readonly fixedZero = new THREE.Vector3(0, 0, 0);
313
+ private readonly fixedOrigin = new THREE.Vector3(0, 0, 0);
314
+ private readonly fixedZAxis = new THREE.Vector3(0, 0, 1);
315
+
316
+ // ── body axes ──
317
+ private readonly characterYAxis = new THREE.Vector3(0, 1, 0);
318
+ private readonly characterXAxis = new THREE.Vector3(1, 0, 0);
319
+ private readonly characterZAxis = new THREE.Vector3(0, 0, 1);
320
+
321
+ // ── gravity ──
322
+ private isZeroGravity = false;
323
+ private readonly upAxisVec = new THREE.Vector3();
324
+ /** Alias of a LIVE vector: `characterYAxis` when `useCharacterUpAxis`, else `upAxisVec`. */
325
+ private readonly referenceUpAxis: THREE.Vector3;
326
+ private readonly referenceGravity = new THREE.Vector3();
327
+ private referenceGravityMag = 0;
328
+ private readonly referenceGravityDir = new THREE.Vector3();
329
+ private readonly gravityDirVec = new THREE.Vector3();
330
+ private readonly slerpVec3 = createSlerpVec3();
331
+
332
+ // ── kinematic state ──
333
+ private readonly _relativeVel = new THREE.Vector3();
334
+ private readonly _relativeVelOnPlane = new THREE.Vector3();
335
+ private readonly _relativeVelOnUp = new THREE.Vector3();
336
+ private readonly currentPos = new THREE.Vector3();
337
+ private readonly currentVel = new THREE.Vector3();
338
+ private readonly currentVelOnPlane = new THREE.Vector3();
339
+ private readonly currentVelOnUp = new THREE.Vector3();
340
+ private readonly currentAngVel = new THREE.Vector3();
341
+ private readonly currentAngVelOnPlane = new THREE.Vector3();
342
+ private readonly currentAngVelOnUp = new THREE.Vector3();
343
+ private readonly currentQuat = new THREE.Quaternion();
344
+
345
+ // ── balance / turning ──
346
+ private readonly balanceCrossAxis = new THREE.Vector3();
347
+ private readonly turnCrossAxis = new THREE.Vector3();
348
+ private readonly turnOnYAxis = new THREE.Vector3();
349
+ private readonly _turnOnYQuat = new THREE.Quaternion();
350
+
351
+ // ── movement ──
352
+ private isLockForward: boolean;
353
+ private readonly forwardDirection = new THREE.Vector3();
354
+ private readonly camRightDirection = new THREE.Vector3();
355
+ private readonly rightwardDirection = new THREE.Vector3();
356
+ private readonly _inputDir = new THREE.Vector3();
357
+ private readonly lastInputDir = new THREE.Vector3();
358
+ private readonly baseImpulse = new THREE.Vector3();
359
+ private readonly _moveImpulse = new THREE.Vector3();
360
+ private readonly moveImpulsePoint = new THREE.Vector3();
361
+ private readonly moveImpulseToGround = new THREE.Vector3();
362
+ private readonly _movingDirection = new THREE.Vector3();
363
+ private readonly movingDirCrossAxis = new THREE.Vector3();
364
+ private readonly wantToMoveVel = new THREE.Vector3();
365
+ private readonly rejectVel = new THREE.Vector3();
366
+
367
+ // ── jump ──
368
+ private _isOnGround = false;
369
+ private readonly jumpDirection = new THREE.Vector3();
370
+ private readonly jumpVelocityVec = new THREE.Vector3();
371
+ private readonly jumpImpulseToGround = new THREE.Vector3();
372
+
373
+ // ── fall / friction ──
374
+ private _isFalling = false;
375
+ private readonly _dragFrictionImpulse = new THREE.Vector3();
376
+
377
+ // ── floating ray ──
378
+ private readonly springDistVec = new THREE.Vector3();
379
+ private readonly dampingVelVec = new THREE.Vector3();
380
+ private readonly floatingForce = new THREE.Vector3();
381
+ private readonly _floatingImpulse = new THREE.Vector3();
382
+ private readonly rayOrigin = new THREE.Vector3();
383
+ private readonly groundHitOrigin = new THREE.Vector3();
384
+ private readonly rayDirection = new THREE.Vector3();
385
+ private readonly rayShape: RAPIER.Ball;
386
+ private readonly ray: RAPIER.Ray;
387
+ private shapeRayHit: RAPIER.ColliderShapeCastHit | null = null;
388
+ private rayHit: RAPIER.RayColliderIntersection | null = null;
389
+ private castRayHit: RAPIER.RayColliderIntersection | null = null;
390
+ private castShapeHit: RAPIER.ColliderShapeCastHit | null = null;
391
+ private groundHitDistance = 0;
392
+ private groundFloatingDistance = 0;
393
+ private rayHitBody: RAPIER.RigidBody | null = null;
394
+
395
+ // ── slope ──
396
+ private slopeAngleInFront = 0;
397
+ private _actualSlopeAngle = 0;
398
+ private readonly actualSlopeNormalVec = new THREE.Vector3();
399
+
400
+ // ── standing platform ──
401
+ private massRatio = 1;
402
+ private isOnMovingObject = false;
403
+ private slideFrictionCoef = 0;
404
+ private standingPointFriction = 0;
405
+ private readonly standingPoint = new THREE.Vector3();
406
+ private readonly characterMassImpulse = new THREE.Vector3();
407
+ private readonly movingObjectPosition = new THREE.Vector3();
408
+ private readonly movingObjectVelocity = new THREE.Vector3();
409
+ private readonly movingObjectVelocityOnPlane = new THREE.Vector3();
410
+ private readonly movingObjectVelocityOnUp = new THREE.Vector3();
411
+ private readonly movingObjectLinearVelocity = new THREE.Vector3();
412
+ private readonly movingObjectAngularVelocity = new THREE.Vector3();
413
+ private movingObjectAngularVelocityValue = 0;
414
+ private readonly movingObjectAngularVelocityAxis = new THREE.Vector3();
415
+ private readonly distanceFromCharacterToObjectPoint = new THREE.Vector3();
416
+ private readonly movingObjectAngvelToLinvel = new THREE.Vector3();
417
+
418
+ // ── park state (enter/exit vehicle support) ──
419
+ private parked = false;
420
+ private readonly unparkEuler = new THREE.Euler();
421
+ private readonly unparkQuat = new THREE.Quaternion();
422
+
423
+ // ── debug ──
424
+ private debugAssets: DebugAssets | null = null;
425
+
426
+ /**
427
+ * Creates the dynamic rigid body + capsule collider immediately (the Rapier
428
+ * world must be initialized before constructing).
429
+ *
430
+ * Also creates `root` (positioned at the body): parent your character model
431
+ * under it — the upstream demo placed its model with a -0.6 Y offset.
432
+ *
433
+ * @param world Raw Rapier world (the controller does NOT self-register any
434
+ * step callbacks — your game loop calls `update()`).
435
+ * @param camera Camera used to derive the movement forward direction.
436
+ * @param options Tuning options; see {@link CharacterControllerOptions}.
437
+ * @param debugScene If `options.debug` is set, debug indicator meshes are
438
+ * added to this scene.
439
+ */
440
+ constructor(
441
+ world: RAPIER.World,
442
+ camera: THREE.Camera,
443
+ options: CharacterControllerOptions = {},
444
+ debugScene?: THREE.Scene
445
+ ) {
446
+ this.world = world;
447
+ this.camera = camera;
448
+
449
+ // ── resolve options (defaults mirror upstream Ecctrl.tsx l.26-88) ──
450
+ this.debugEnabled = options.debug ?? false;
451
+ this.enabled = options.enable ?? true;
452
+ const capsuleHalfHeight = options.capsuleHalfHeight ?? 0.3;
453
+ this.capsuleRadius = options.capsuleRadius ?? 0.3;
454
+ this.isLockForward = options.lockForward ?? false;
455
+ this.useCustomForward = options.useCustomForward ?? false;
456
+ const useCharacterUpAxis = options.useCharacterUpAxis ?? false;
457
+ this.gravityDirLerpSpeed = options.gravityDirLerpSpeed ?? 6;
458
+ this.maxWalkVel = options.maxWalkVel ?? 2;
459
+ this.maxRunVel = options.maxRunVel ?? 5;
460
+ this.accDeltaTime = options.accDeltaTime ?? 0.2;
461
+ this.decDeltaTime = options.decDeltaTime ?? 0.2;
462
+ this.rejectVelFactor = options.rejectVelFactor ?? 1;
463
+ this.moveImpulsePointOffset = options.moveImpulsePointOffset ?? 0.5;
464
+ this.jumpVel = options.jumpVel ?? 5;
465
+ this.jumpDuration = options.jumpDuration ?? 0.1;
466
+ this.slopeJumpFactor = options.slopeJumpFactor ?? 0;
467
+ this.airDragFactor = options.airDragFactor ?? 0.1;
468
+ this.slideGripFactor = options.slideGripFactor ?? 0.5;
469
+ this.fallingGravityScale = options.fallingGravityScale ?? 3;
470
+ this.fallingMaxVel = options.fallingMaxVel ?? 20;
471
+ this.enableToggleRun = options.enableToggleRun ?? true;
472
+ this.groundDetectionMode = options.groundDetection ?? "shapeCast";
473
+ this.slopeMaxAngle = options.slopeMaxAngle ?? Math.PI / 2.5;
474
+ this.floatHeight = options.floatHeight ?? 0.2;
475
+ this.rayOriginOffset = options.rayOriginOffset ?? -capsuleHalfHeight;
476
+ this.rayHitForgiveness = options.rayHitForgiveness ?? 0.28;
477
+ this.rayLength = options.rayLength ?? this.capsuleRadius + 1;
478
+ this.rayRadius = options.rayRadius ?? this.capsuleRadius / 2;
479
+ this.springK = options.springK ?? 80;
480
+ this.dampingC = options.dampingC ?? 6;
481
+ this.autoBalance = options.autoBalance ?? true;
482
+ this.autoBalanceSpringK = options.autoBalanceSpringK ?? 0.5;
483
+ this.autoBalanceDampingC = options.autoBalanceDampingC ?? 0.03;
484
+ this.autoBalanceSpringOnY = options.autoBalanceSpringOnY ?? 0.08;
485
+ this.autoBalanceDampingOnY = options.autoBalanceDampingOnY ?? 0.006;
486
+ this.followPlatform = options.followPlatform ?? true;
487
+ this.applyCounterMass = options.applyCounterMass ?? true;
488
+ this.applyCounterJumpImp = options.applyCounterJumpImp ?? true;
489
+ this.counterJumpImpFactor = options.counterJumpImpFactor ?? 1;
490
+ this.applyCounterMoveImp = options.applyCounterMoveImp ?? true;
491
+ this.counterMoveImpFactor = options.counterMoveImpFactor ?? 1;
492
+ this.initialGravityScale = options.gravityScale ?? 1;
493
+
494
+ const curveData = options.massRatioFallOffCurveData ?? DEFAULT_CURVE_DATA;
495
+ this.massRatioFallOffCurve = bakeCurveLUT(curveData.points, curveData.samples ?? 50);
496
+
497
+ // `referenceUpAxis` ALIASES a live vector (never copied) — upstream l.190.
498
+ this.referenceUpAxis = useCharacterUpAxis ? this.characterYAxis : this.upAxisVec;
499
+
500
+ // ── rigid body + capsule collider (JSX <RigidBody>/<CapsuleCollider> replacement) ──
501
+ const position = options.position ?? { x: 0, y: 1, z: 0 };
502
+ const rotation = options.rotation ?? new THREE.Quaternion();
503
+ const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
504
+ .setTranslation(position.x, position.y, position.z)
505
+ .setRotation(rotation)
506
+ .setCanSleep(options.canSleep ?? true)
507
+ .setGravityScale(this.initialGravityScale);
508
+ this._body = world.createRigidBody(bodyDesc);
509
+ this._body.userData = options.userData ?? {};
510
+
511
+ // Capsule args order matches the JSX args: (halfHeight, radius).
512
+ const colliderDesc = RAPIER.ColliderDesc.capsule(capsuleHalfHeight, this.capsuleRadius)
513
+ .setFriction(options.friction ?? -0.5)
514
+ .setDensity(options.density ?? 1);
515
+ this._collider = world.createCollider(colliderDesc, this._body);
516
+
517
+ // Ground-query scratch shapes (reused every step).
518
+ this.rayShape = new RAPIER.Ball(this.rayRadius);
519
+ this.ray = new RAPIER.Ray(this.rayOrigin, this.rayDirection);
520
+
521
+ // Visual root (the JSX children slot).
522
+ this.root = new THREE.Group();
523
+ this.root.position.copy(position);
524
+ this.root.quaternion.copy(rotation);
525
+
526
+ if (this.debugEnabled && debugScene) this.buildDebugAssets(debugScene);
527
+ }
528
+
529
+ // ────────────────────────────────────────────────────────────────────────
530
+ // Imperative handle (mirror of upstream EcctrlHandle)
531
+ // All vector/quaternion getters return LIVE internal instances: read-only,
532
+ // `.clone()`/`.copy()` if you keep them.
533
+ // ────────────────────────────────────────────────────────────────────────
534
+
535
+ /** The character's dynamic rigid body. */
536
+ get body(): RAPIER.RigidBody {
537
+ return this._body;
538
+ }
539
+ /** The capsule collider. */
540
+ get collider(): RAPIER.Collider {
541
+ return this._collider;
542
+ }
543
+ /** Smoothed up axis (opposite of the smoothed gravity direction). Live vector. */
544
+ get upAxis(): THREE.Vector3 {
545
+ return this.upAxisVec;
546
+ }
547
+ /** Smoothed gravity direction (unit). Live vector. */
548
+ get gravityDir(): THREE.Vector3 {
549
+ return this.gravityDirVec;
550
+ }
551
+ /** Magnitude of world gravity. */
552
+ get gravityMag(): number {
553
+ return this.referenceGravityMag;
554
+ }
555
+ /** Body position (this step). Live vector. */
556
+ get currPos(): THREE.Vector3 {
557
+ return this.currentPos;
558
+ }
559
+ /** Body rotation (this step). Live quaternion. */
560
+ get currQuat(): THREE.Quaternion {
561
+ return this.currentQuat;
562
+ }
563
+ /** Body linear velocity. Live vector. */
564
+ get currLinVel(): THREE.Vector3 {
565
+ return this.currentVel;
566
+ }
567
+ /** Body angular velocity. Live vector. */
568
+ get currAngVel(): THREE.Vector3 {
569
+ return this.currentAngVel;
570
+ }
571
+ /** Current merged movement input. */
572
+ get input(): ReadonlyMovementInput {
573
+ return this.movementState;
574
+ }
575
+ /** World-space input direction (unit, camera-relative). Live vector. */
576
+ get inputDir(): THREE.Vector3 {
577
+ return this._inputDir;
578
+ }
579
+ /** Actual moving direction (input dir rotated up walkable slopes). Live vector. */
580
+ get movingDirection(): THREE.Vector3 {
581
+ return this._movingDirection;
582
+ }
583
+ /** Velocity relative to the ground/platform under the character. Live vector. */
584
+ get relativeVel(): THREE.Vector3 {
585
+ return this._relativeVel;
586
+ }
587
+ /** Relative velocity projected on the ground plane. Live vector. */
588
+ get relativeVelOnPlane(): THREE.Vector3 {
589
+ return this._relativeVelOnPlane;
590
+ }
591
+ /** Relative velocity projected on the up axis. Live vector. */
592
+ get relativeVelOnUp(): THREE.Vector3 {
593
+ return this._relativeVelOnUp;
594
+ }
595
+ /** Last applied move impulse. NOTE: already scaled by frameRateCorrection. Live vector. */
596
+ get moveImpulse(): THREE.Vector3 {
597
+ return this._moveImpulse;
598
+ }
599
+ /** Last applied float-spring impulse (NOT frameRateCorrection-scaled). Live vector. */
600
+ get floatingImpulse(): THREE.Vector3 {
601
+ return this._floatingImpulse;
602
+ }
603
+ /** Last applied idle drag impulse. NOTE: already scaled by frameRateCorrection. Live vector. */
604
+ get dragFrictionImpulse(): THREE.Vector3 {
605
+ return this._dragFrictionImpulse;
606
+ }
607
+ /** Body local +X axis in world space. Live vector. */
608
+ get bodyXAxis(): THREE.Vector3 {
609
+ return this.characterXAxis;
610
+ }
611
+ /** Body local +Y axis in world space. Live vector. */
612
+ get bodyYAxis(): THREE.Vector3 {
613
+ return this.characterYAxis;
614
+ }
615
+ /** Body local +Z axis (facing) in world space. Live vector. */
616
+ get bodyZAxis(): THREE.Vector3 {
617
+ return this.characterZAxis;
618
+ }
619
+ /**
620
+ * The RIGID BODY the character stands on (or `null`). Upstream misnomer
621
+ * kept on purpose — it returns the body, not a collider.
622
+ */
623
+ get standCollider(): RAPIER.RigidBody | null {
624
+ return this.rayHitBody;
625
+ }
626
+ /** World-space standing point on the ground. Live vector. */
627
+ get standPoint(): THREE.Vector3 {
628
+ return this.standingPoint;
629
+ }
630
+ /** Ground normal at the standing point. Live vector. */
631
+ get standNormal(): THREE.Vector3 {
632
+ return this.actualSlopeNormalVec;
633
+ }
634
+ /** `true` while the float query holds the character up. */
635
+ get isOnGround(): boolean {
636
+ return this._isOnGround;
637
+ }
638
+ /** `true` while airborne and moving downward. */
639
+ get isFalling(): boolean {
640
+ return this._isFalling;
641
+ }
642
+ /** `true` while standing on a dynamic or kinematic (position-based) body. */
643
+ get isOnPlatform(): boolean {
644
+ return this.isOnMovingObject;
645
+ }
646
+ /** Signed slope angle in front of the moving direction (radians). */
647
+ get slopeAngle(): number {
648
+ return this.slopeAngleInFront;
649
+ }
650
+ /** Absolute slope angle of the ground under the character (radians). */
651
+ get actualSlopeAngle(): number {
652
+ return this._actualSlopeAngle;
653
+ }
654
+ /** Friction of the collider under the character. */
655
+ get standFriction(): number {
656
+ return this.standingPointFriction;
657
+ }
658
+ /** Blended grip coefficient in [0, 1] (refreshed only while idling on ground). */
659
+ get slideFriction(): number {
660
+ return this.slideFrictionCoef;
661
+ }
662
+ /** `true` while there is movement input. */
663
+ get isMoving(): boolean {
664
+ return this._inputDir.lengthSq() > 1e-6;
665
+ }
666
+ /** Ground-plane speed relative to the platform (m/s). */
667
+ get moveSpeed(): number {
668
+ return this._relativeVelOnPlane.length();
669
+ }
670
+ /** Signed vertical speed along the up axis (m/s). */
671
+ get verticalSpeed(): number {
672
+ return this._relativeVelOnUp.dot(this.referenceUpAxis);
673
+ }
674
+ /** `true` while the run toggle/hold is active. */
675
+ get runActive(): boolean {
676
+ return this._runActive;
677
+ }
678
+ /** `true` during the `jumpDuration` takeoff window. */
679
+ get jumpActive(): boolean {
680
+ return this._jumpActive;
681
+ }
682
+ /** `true` while the character always faces the forward direction (strafe mode). */
683
+ get lockForward(): boolean {
684
+ return this.isLockForward;
685
+ }
686
+ /** Per-step rotation of the platform under the character (identity when off-platform). Live quaternion. */
687
+ get turnOnYQuat(): THREE.Quaternion {
688
+ return this._turnOnYQuat;
689
+ }
690
+ /** `true` while parked (hidden + physics disabled, e.g. inside a vehicle). */
691
+ get isParked(): boolean {
692
+ return this.parked;
693
+ }
694
+
695
+ // ────────────────────────────────────────────────────────────────────────
696
+ // Public methods
697
+ // ────────────────────────────────────────────────────────────────────────
698
+
699
+ /**
700
+ * Merge movement intents into the input state. Only fields you pass are
701
+ * changed, so different input sources (keyboard, joystick, buttons) can each
702
+ * push their own subset.
703
+ */
704
+ setMovement(movement: MovementInput): void {
705
+ if (movement.forward !== undefined) this.movementState.forward = movement.forward;
706
+ if (movement.backward !== undefined) this.movementState.backward = movement.backward;
707
+ if (movement.leftward !== undefined) this.movementState.leftward = movement.leftward;
708
+ if (movement.rightward !== undefined) this.movementState.rightward = movement.rightward;
709
+ if (movement.joystick) {
710
+ this.movementState.joystick.x = movement.joystick.x;
711
+ this.movementState.joystick.y = movement.joystick.y;
712
+ }
713
+ if (movement.run !== undefined) this.movementState.run = movement.run;
714
+ if (movement.jump !== undefined) this.movementState.jump = movement.jump;
715
+ }
716
+
717
+ /** Toggle strafe mode (always face the camera/custom forward direction). */
718
+ setLockForward(lock: boolean): void {
719
+ this.isLockForward = lock;
720
+ }
721
+
722
+ /** Set the custom forward direction (only used with `useCustomForward: true`). */
723
+ setForwardDir(dir: THREE.Vector3): void {
724
+ this.forwardDirection.copy(dir);
725
+ }
726
+
727
+ /** Switch ground-detection strategy at runtime (clears the stale hit of the other mode). */
728
+ setGroundDetection(mode: GroundDetectionMode): void {
729
+ this.groundDetectionMode = mode;
730
+ if (mode === "rayCast") this.shapeRayHit = null;
731
+ else this.rayHit = null;
732
+ }
733
+
734
+ /**
735
+ * Park the character (used when entering a vehicle): disables the body and
736
+ * collider, hides the root, zeroes velocities and clears movement input.
737
+ * `update()` early-outs while parked.
738
+ */
739
+ park(): void {
740
+ if (this.parked) return;
741
+ this.parked = true;
742
+ this._body.setLinvel(this.fixedZero, false);
743
+ this._body.setAngvel(this.fixedZero, false);
744
+ this._body.setEnabled(false);
745
+ this._collider.setEnabled(false);
746
+ this.root.visible = false;
747
+ this.movementState.forward = false;
748
+ this.movementState.backward = false;
749
+ this.movementState.leftward = false;
750
+ this.movementState.rightward = false;
751
+ this.movementState.joystick.x = 0;
752
+ this.movementState.joystick.y = 0;
753
+ this.movementState.run = false;
754
+ this.movementState.jump = false;
755
+ }
756
+
757
+ /**
758
+ * Un-park the character at a new pose (used when exiting a vehicle).
759
+ * The Euler is interpreted with rotation order "YXZ". Re-enables the body
760
+ * and collider, zeroes velocities and wakes the body. The facing memory
761
+ * (`lastInputDir`) resets to the new forward so the character doesn't snap
762
+ * back to its pre-park heading.
763
+ */
764
+ unpark(position: THREE.Vector3, rotation: THREE.Euler): void {
765
+ this.unparkEuler.set(rotation.x, rotation.y, rotation.z, "YXZ");
766
+ this.unparkQuat.setFromEuler(this.unparkEuler);
767
+ this._body.setTranslation(position, false);
768
+ this._body.setRotation(this.unparkQuat, false);
769
+ this._body.setLinvel(this.fixedZero, false);
770
+ this._body.setAngvel(this.fixedZero, false);
771
+ this._body.setEnabled(true);
772
+ this._collider.setEnabled(true);
773
+ this.root.visible = true;
774
+ this.root.position.copy(position);
775
+ this.root.quaternion.copy(this.unparkQuat);
776
+ // Reset facing memory to the new character forward (+Z).
777
+ this.lastInputDir.set(0, 0, 1).applyQuaternion(this.unparkQuat);
778
+ this.parked = false;
779
+ this._body.wakeUp();
780
+ }
781
+
782
+ /**
783
+ * Per-physics-step brain. Call once per fixed substep BEFORE `world.step()`.
784
+ * The optional `dt` exists only for a uniform controller call shape and is
785
+ * IGNORED — all time terms use the fixed `world.timestep`.
786
+ */
787
+ update(dt?: number): void {
788
+ void dt; // uniform signature; internal dt is world.timestep (fixed)
789
+
790
+ // Skip the whole controller loop when disabled or parked
791
+ if (!this.enabled || this.parked) return;
792
+ const characterBody = this._body;
793
+ let isSleeping = characterBody.isSleeping();
794
+
795
+ // Correct frame rate difference
796
+ const frameRateCorrection = 60 * this.world.timestep;
797
+
798
+ /**
799
+ * Getting all the user input states
800
+ * (run/jump edge state machines run every step, BEFORE the sleep early-out)
801
+ */
802
+ const forward = this.movementState.forward;
803
+ const backward = this.movementState.backward;
804
+ const leftward = this.movementState.leftward;
805
+ const rightward = this.movementState.rightward;
806
+ const run = this.getRunState(this.movementState.run || false);
807
+ const jump = this.getJumpState(this.movementState.jump || false);
808
+ const joystick = this.movementState.joystick;
809
+ const hasControlInput =
810
+ forward ||
811
+ backward ||
812
+ leftward ||
813
+ rightward ||
814
+ jump ||
815
+ Math.abs(joystick.x) > 1e-4 ||
816
+ Math.abs(joystick.y) > 1e-4;
817
+
818
+ // Wake on moving platforms or player input so the controller can refresh
819
+ // contact state before applying impulses.
820
+ if (isSleeping && (this.isOnMovingObject || hasControlInput)) {
821
+ characterBody.wakeUp();
822
+ isSleeping = false;
823
+ }
824
+
825
+ // If character is sleeping, skip the update to save performance.
826
+ if (isSleeping) return;
827
+
828
+ // Update character collider pos/vel/quat/axis
829
+ this.updateCharacterInfo();
830
+
831
+ // Update gravity value & direction
832
+ this.updateGravityInfo();
833
+
834
+ // Update input direction after gravity/up-axis refresh so slope and
835
+ // movement use current-frame input.
836
+ this.updateForwardDirection();
837
+ this.setInputDirection({ forward, backward, rightward, leftward, joystick });
838
+ const hasMoveInput = this._inputDir.lengthSq() > 0;
839
+
840
+ // Update character auto balance
841
+ // (NOTE: consumes LAST step's isZeroGravity — refreshed below; upstream parity)
842
+ if (this.autoBalance && !this.isZeroGravity) this.autoBalanceCharacter(frameRateCorrection);
843
+
844
+ // Update ground contact info
845
+ this.floatCharacter();
846
+
847
+ // Detect if character is on a moving object
848
+ this.isOnMovingObjectDetect();
849
+
850
+ // Compute relative velocity
851
+ this.computeRelativeVelocity();
852
+
853
+ // Float character up
854
+ this.applyFloatingForce();
855
+
856
+ // Apply character mass to standing object
857
+ this.applyMassOnStandCollider();
858
+
859
+ // Detect slope angle below character
860
+ this.slopeDetect();
861
+
862
+ // Detect if character is under zero gravity condition
863
+ this.zeroGravityDetect();
864
+
865
+ // Detect if character is falling
866
+ this.fallDetect();
867
+
868
+ // Apply drag force if character is not moving
869
+ if (!hasMoveInput) this.applyFriction(frameRateCorrection);
870
+
871
+ // Apply dynamic gravity scale: grounded / jump-up / fall / exceed-fall-max-vel
872
+ this.applyDynamicGravity();
873
+
874
+ // Apply jump impulse to character
875
+ if (jump && this._isOnGround) this.applyJumpImpulse();
876
+
877
+ /**
878
+ * Move character model to correct direction and speed
879
+ * (camera-based movement vs character-based movement)
880
+ */
881
+ if (this.isLockForward) {
882
+ // Camera based movement always turns character to camera forward direction
883
+ if (!this.isZeroGravity) this.turnCharacter(this.forwardDirection, frameRateCorrection);
884
+ if (hasMoveInput) this.moveCharacter(run, frameRateCorrection);
885
+ // Keep last input direction same as forward direction
886
+ this.lastInputDir.copy(this.forwardDirection);
887
+ } else {
888
+ // Character based movement
889
+ if (hasMoveInput) {
890
+ if (!this.isZeroGravity) this.turnCharacter(this._inputDir, frameRateCorrection);
891
+ this.moveCharacter(run, frameRateCorrection);
892
+ this.lastInputDir.copy(this._inputDir);
893
+ } else {
894
+ // If no last input, keep character facing forward direction
895
+ if (this.lastInputDir.lengthSq() === 0) this.lastInputDir.copy(this.characterZAxis);
896
+ // Keep character at last input direction, spinning with the platform when idle
897
+ // (applyQuaternion mutates lastInputDir in place — intentional accumulation)
898
+ if (!this.isZeroGravity)
899
+ this.turnCharacter(
900
+ this.isOnMovingObject && this.followPlatform
901
+ ? this.lastInputDir.applyQuaternion(this._turnOnYQuat)
902
+ : this.lastInputDir,
903
+ frameRateCorrection
904
+ );
905
+ // Keep moving direction same as last input direction
906
+ this._movingDirection.copy(this.lastInputDir);
907
+ }
908
+ }
909
+
910
+ // Update debug indicators
911
+ if (this.debugEnabled) this.updateDebugger();
912
+ }
913
+
914
+ /**
915
+ * Copies the body pose onto `root`. Call AFTER `world.step()` — not needed
916
+ * when `(body, root)` is registered with the PhysicsWorld sync registry.
917
+ */
918
+ syncRoot(): void {
919
+ this.root.position.copy(this._body.translation());
920
+ this.root.quaternion.copy(this._body.rotation());
921
+ }
922
+
923
+ /** Removes the body (and its collider) from the world and tears down visuals. */
924
+ dispose(): void {
925
+ this.world.removeRigidBody(this._body);
926
+ this.root.removeFromParent();
927
+ if (this.debugAssets) {
928
+ this.debugAssets.group.removeFromParent();
929
+ for (const item of this.debugAssets.disposables) item.dispose();
930
+ this.debugAssets.velocityArrow.dispose();
931
+ this.debugAssets = null;
932
+ }
933
+ }
934
+
935
+ // ────────────────────────────────────────────────────────────────────────
936
+ // Private per-step helpers (bodies are verbatim ports of the upstream
937
+ // useCallback helpers; comments cite Ecctrl.tsx line numbers)
938
+ // ────────────────────────────────────────────────────────────────────────
939
+
940
+ /** Update character collider pos/vel/quat/axis (upstream l.397-412). */
941
+ private updateCharacterInfo(): void {
942
+ const body = this._body;
943
+ this.currentPos.copy(body.translation());
944
+ this.currentQuat.copy(body.rotation());
945
+
946
+ this.characterYAxis.set(0, 1, 0).applyQuaternion(this.currentQuat);
947
+ this.characterXAxis.set(1, 0, 0).applyQuaternion(this.currentQuat);
948
+ this.characterZAxis.set(0, 0, 1).applyQuaternion(this.currentQuat);
949
+
950
+ // Linear projections use referenceUpAxis; angular use the body's own Y axis.
951
+ this.currentVel.copy(body.linvel());
952
+ this.currentVelOnPlane.copy(this.currentVel).projectOnPlane(this.referenceUpAxis);
953
+ this.currentVelOnUp.copy(this.currentVel).projectOnVector(this.referenceUpAxis);
954
+
955
+ this.currentAngVel.copy(body.angvel());
956
+ this.currentAngVelOnPlane.copy(this.currentAngVel).projectOnPlane(this.characterYAxis);
957
+ this.currentAngVelOnUp.copy(this.currentAngVel).projectOnVector(this.characterYAxis);
958
+ }
959
+
960
+ /**
961
+ * Update gravity/upAxis direction and value (upstream l.499-513; the custom
962
+ * gravity-field branch is dropped per the v1 port scope — world gravity may
963
+ * still be nonstandard or changed at runtime).
964
+ */
965
+ private updateGravityInfo(): void {
966
+ this.referenceGravity.copy(this.world.gravity);
967
+
968
+ this.referenceGravityMag = this.referenceGravity.length();
969
+ this.referenceGravityDir.copy(this.referenceGravity).normalize();
970
+ // Prevent NaN when gravity is zero: fall back to opposite of character up axis.
971
+ if (this.referenceGravityDir.lengthSq() === 0)
972
+ this.referenceGravityDir.copy(this.characterYAxis).negate();
973
+ // slerpVec3 returns its own scratch vector — copy immediately.
974
+ this.gravityDirVec.copy(
975
+ this.slerpVec3(
976
+ this.gravityDirVec,
977
+ this.referenceGravityDir,
978
+ 1 - Math.exp(-this.gravityDirLerpSpeed * this.world.timestep),
979
+ this.characterXAxis
980
+ )
981
+ );
982
+ this.upAxisVec.copy(this.gravityDirVec).negate();
983
+ }
984
+
985
+ /** Camera-projected forward/rightward directions (upstream l.417-427). */
986
+ private updateForwardDirection(): void {
987
+ if (!this.useCustomForward) {
988
+ this.camera.getWorldDirection(this.forwardDirection);
989
+ this.camRightDirection.crossVectors(this.forwardDirection, this.camera.up).normalize();
990
+ this.forwardDirection.crossVectors(this.referenceUpAxis, this.camRightDirection);
991
+ this.rightwardDirection.crossVectors(this.forwardDirection, this.referenceUpAxis).normalize();
992
+ } else {
993
+ this.forwardDirection.projectOnPlane(this.referenceUpAxis).normalize();
994
+ this.rightwardDirection.crossVectors(this.forwardDirection, this.referenceUpAxis).normalize();
995
+ }
996
+ }
997
+
998
+ /** Build the world-space input direction from intents (upstream l.432-449). */
999
+ private setInputDirection(dir: MovementInput): void {
1000
+ this._inputDir.set(0, 0, 0);
1001
+ // Handle joystick analog input (if available)
1002
+ if (dir.joystick && (dir.joystick.x !== 0 || dir.joystick.y !== 0)) {
1003
+ this._inputDir
1004
+ .addScaledVector(this.forwardDirection, dir.joystick.y)
1005
+ .addScaledVector(this.rightwardDirection, dir.joystick.x);
1006
+ } else {
1007
+ if (dir.forward) this._inputDir.add(this.forwardDirection);
1008
+ if (dir.backward) this._inputDir.sub(this.forwardDirection);
1009
+ if (dir.leftward) this._inputDir.sub(this.rightwardDirection);
1010
+ if (dir.rightward) this._inputDir.add(this.rightwardDirection);
1011
+ }
1012
+ this._inputDir.normalize();
1013
+ }
1014
+
1015
+ /** Upright balance spring torque (upstream l.518-522). */
1016
+ private autoBalanceCharacter(fpsCorr: number): void {
1017
+ this.balanceCrossAxis.crossVectors(this.characterYAxis, this.upAxisVec);
1018
+ const torque = this.balanceCrossAxis
1019
+ .multiplyScalar(this.autoBalanceSpringK)
1020
+ .sub(this.currentAngVelOnPlane.multiplyScalar(this.autoBalanceDampingC));
1021
+ this._body.applyTorqueImpulse(torque.multiplyScalar(fpsCorr), false);
1022
+ }
1023
+
1024
+ /** Yaw turn spring torque toward `direction` (upstream l.527-534). */
1025
+ private turnCharacter(direction: THREE.Vector3, fpsCorr: number): void {
1026
+ this.turnCrossAxis.crossVectors(this.characterZAxis, direction);
1027
+ let dot = clamp(this.characterZAxis.dot(direction), -1, 1);
1028
+ if (Math.abs(dot) < 1e-10) dot = 0; // prevent dot = -0 flipping atan2
1029
+ const angle = Math.atan2(this.turnCrossAxis.dot(this.characterYAxis), dot);
1030
+ const torque = this.turnOnYAxis
1031
+ .copy(this.characterYAxis)
1032
+ .multiplyScalar(angle * this.autoBalanceSpringOnY)
1033
+ .sub(this.currentAngVelOnUp.multiplyScalar(this.autoBalanceDampingOnY));
1034
+ this._body.applyTorqueImpulse(torque.multiplyScalar(fpsCorr), false);
1035
+ }
1036
+
1037
+ /**
1038
+ * Ground-query collider filter (upstream l.539-542; userData key renamed
1039
+ * `ecctrl` -> `controller`).
1040
+ */
1041
+ private readonly rayFilter = (collider: RAPIER.Collider): boolean => {
1042
+ const userData = collider.parent()?.userData as ControllerUserData | undefined;
1043
+ return !(userData?.controller?.excludeRay || userData?.controller?.excludeCharacterRay);
1044
+ };
1045
+
1046
+ /**
1047
+ * Steep-hit fallback: scan the center ray for the nearest walkable hit
1048
+ * (upstream l.544-580).
1049
+ */
1050
+ private findWalkableCenterRayHit(maxDistance: number): boolean {
1051
+ this.castRayHit = null;
1052
+ this.ray.origin = this.rayOrigin;
1053
+ this.ray.dir = this.rayDirection;
1054
+
1055
+ // Scan center ray for walkable hit
1056
+ this.world.intersectionsWithRay(
1057
+ this.ray,
1058
+ maxDistance,
1059
+ false,
1060
+ (hit) => {
1061
+ const slopeAngle = this.actualSlopeNormalVec.copy(hit.normal).angleTo(this.referenceUpAxis);
1062
+ if (
1063
+ slopeAngle < this.slopeMaxAngle &&
1064
+ (!this.castRayHit || hit.timeOfImpact < this.castRayHit.timeOfImpact)
1065
+ ) {
1066
+ this.castRayHit = hit;
1067
+ }
1068
+ return true;
1069
+ },
1070
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
1071
+ undefined,
1072
+ undefined,
1073
+ this._body,
1074
+ this.rayFilter
1075
+ );
1076
+
1077
+ const selectedRayHit = this.castRayHit as RAPIER.RayColliderIntersection | null;
1078
+ if (!selectedRayHit) return false;
1079
+
1080
+ this.rayHit = selectedRayHit;
1081
+ this.rayHitBody = selectedRayHit.collider.parent();
1082
+ this.actualSlopeNormalVec.copy(selectedRayHit.normal);
1083
+ this._actualSlopeAngle = this.actualSlopeNormalVec.angleTo(this.referenceUpAxis);
1084
+ this.groundHitDistance = selectedRayHit.timeOfImpact;
1085
+ // rayCast-style float distance even when called from shapeCast mode (upstream parity).
1086
+ this.groundFloatingDistance = this.rayRadius * 2 + this.floatHeight;
1087
+ this.groundHitOrigin.copy(this.rayOrigin);
1088
+ this.standingPointFriction = selectedRayHit.collider.friction() ?? 0;
1089
+ return true;
1090
+ }
1091
+
1092
+ /** Ground detection + grounded-state update (upstream l.582-675). */
1093
+ private floatCharacter(): void {
1094
+ // Ray origin uses the body's OWN Y axis; direction is gravity-down.
1095
+ this.rayOrigin.copy(this.currentPos).addScaledVector(this.characterYAxis, this.rayOriginOffset);
1096
+ this.rayDirection.copy(this.referenceUpAxis).negate();
1097
+ // Reset previous hit state
1098
+ this.rayHit = null;
1099
+ this.shapeRayHit = null;
1100
+ this.rayHitBody = null;
1101
+
1102
+ // RayCast ground detection
1103
+ if (this.groundDetectionMode === "rayCast") {
1104
+ this.ray.origin = this.rayOrigin;
1105
+ this.ray.dir = this.rayDirection;
1106
+ this.castRayHit = this.world.castRayAndGetNormal(
1107
+ this.ray,
1108
+ this.rayLength,
1109
+ false,
1110
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
1111
+ undefined,
1112
+ undefined,
1113
+ this._body,
1114
+ this.rayFilter
1115
+ );
1116
+
1117
+ if (this.castRayHit) {
1118
+ this.actualSlopeNormalVec.copy(this.castRayHit.normal);
1119
+ this._actualSlopeAngle = this.actualSlopeNormalVec.angleTo(this.referenceUpAxis);
1120
+ // Use first walkable ray hit
1121
+ if (this._actualSlopeAngle < this.slopeMaxAngle) {
1122
+ this.rayHit = this.castRayHit;
1123
+ this.rayHitBody = this.castRayHit.collider.parent();
1124
+ this.groundHitOrigin.copy(this.rayOrigin);
1125
+ this.groundHitDistance = this.castRayHit.timeOfImpact;
1126
+ this.groundFloatingDistance = this.rayRadius * 2 + this.floatHeight;
1127
+ this.standingPointFriction = this.castRayHit.collider.friction() ?? 0;
1128
+ }
1129
+ // Ignore steep hit and scan center ray below
1130
+ else this.findWalkableCenterRayHit(this.rayLength);
1131
+ }
1132
+ }
1133
+ // ShapeCast ground detection
1134
+ else if (this.groundDetectionMode === "shapeCast") {
1135
+ this.castShapeHit = this.world.castShape(
1136
+ this.rayOrigin,
1137
+ this._body.rotation(),
1138
+ this.rayDirection,
1139
+ this.rayShape,
1140
+ 0,
1141
+ this.rayLength,
1142
+ false,
1143
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
1144
+ undefined,
1145
+ undefined,
1146
+ this._body,
1147
+ this.rayFilter
1148
+ );
1149
+
1150
+ if (this.castShapeHit) {
1151
+ this.actualSlopeNormalVec.copy(this.castShapeHit.normal1);
1152
+ this._actualSlopeAngle = this.actualSlopeNormalVec.angleTo(this.referenceUpAxis);
1153
+ // Use first walkable shape hit
1154
+ if (this._actualSlopeAngle < this.slopeMaxAngle) {
1155
+ this.shapeRayHit = this.castShapeHit;
1156
+ this.groundHitOrigin.copy(this.rayOrigin);
1157
+ this.rayHitBody = this.castShapeHit.collider.parent();
1158
+ // NOTE: shapecast hits expose snake_case `time_of_impact` (rapier3d-compat).
1159
+ this.groundHitDistance = this.castShapeHit.time_of_impact;
1160
+ this.groundFloatingDistance = this.rayRadius + this.floatHeight;
1161
+ this.standingPointFriction = this.castShapeHit.collider.friction() ?? 0;
1162
+ } else {
1163
+ // Ignore steep hit and scan center ray below
1164
+ this.findWalkableCenterRayHit(this.rayLength + this.rayRadius);
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ // Update ground contact state
1170
+ if (this.rayHitBody) {
1171
+ this._isOnGround =
1172
+ this.groundHitDistance < this.groundFloatingDistance + this.rayHitForgiveness;
1173
+
1174
+ if (this._isOnGround) {
1175
+ // Retrieve actual standing point
1176
+ if (this.rayHit)
1177
+ this.standingPoint
1178
+ .copy(this.groundHitOrigin)
1179
+ .addScaledVector(this.rayDirection, this.groundHitDistance);
1180
+ else if (this.shapeRayHit) this.standingPoint.copy(this.shapeRayHit.witness1);
1181
+ } else {
1182
+ this.standingPointFriction = 0;
1183
+ }
1184
+ } else {
1185
+ this.rayHitBody = null;
1186
+ this._isOnGround = false;
1187
+ this._actualSlopeAngle = 0;
1188
+ this.slopeAngleInFront = 0;
1189
+ this.standingPointFriction = 0;
1190
+ }
1191
+ }
1192
+
1193
+ /** Float spring: I = F * dt (NOT frameRateCorrection-scaled) (upstream l.677-691). */
1194
+ private applyFloatingForce(): void {
1195
+ const hasGroundHit = this.rayHit || this.shapeRayHit;
1196
+ if (!hasGroundHit || !this._isOnGround) {
1197
+ this._floatingImpulse.set(0, 0, 0);
1198
+ return;
1199
+ }
1200
+
1201
+ this.springDistVec
1202
+ .copy(this.referenceUpAxis)
1203
+ .multiplyScalar(this.groundFloatingDistance - this.groundHitDistance);
1204
+ this.dampingVelVec.copy(this._relativeVel).projectOnVector(this.referenceUpAxis);
1205
+ this.floatingForce.subVectors(
1206
+ this.springDistVec.multiplyScalar(this.springK),
1207
+ this.dampingVelVec.multiplyScalar(this.dampingC)
1208
+ );
1209
+ // Convert force to impulse: I = F * dt (already multiplied by timestep, no fpsCorr)
1210
+ this._floatingImpulse.copy(this.floatingForce).multiplyScalar(this.world.timestep);
1211
+ // During jump startup, keep support force but skip downward adhesion that
1212
+ // can cancel slow-motion jumps.
1213
+ if (this._jumpActive && this._floatingImpulse.dot(this.referenceUpAxis) < 0)
1214
+ this._floatingImpulse.set(0, 0, 0);
1215
+ if (!this._body.isSleeping()) this._body.applyImpulse(this._floatingImpulse, false);
1216
+ }
1217
+
1218
+ /** Push the character's weight down into dynamic ground (upstream l.696-705). */
1219
+ private applyMassOnStandCollider(): void {
1220
+ if (
1221
+ !this.rayHitBody ||
1222
+ this.rayHitBody.bodyType() !== RAPIER.RigidBodyType.Dynamic ||
1223
+ !this._isOnGround
1224
+ )
1225
+ return;
1226
+ // Apply opposite force to standing object
1227
+ const impulseMag = Math.max(-this._floatingImpulse.dot(this.upAxisVec), 0);
1228
+ const weightMag = this._body.mass() * this.referenceGravityMag * this.world.timestep; // I = F * dt = m * g * dt
1229
+ // Gravity is not applied when on ground, so impulseMag is 0 at stable
1230
+ // condition — apply a constant weightMag instead.
1231
+ this.characterMassImpulse
1232
+ .copy(this.gravityDirVec)
1233
+ .multiplyScalar(Math.max(impulseMag, weightMag) * this.massRatio);
1234
+ if (this.applyCounterMass)
1235
+ this.rayHitBody.applyImpulseAtPoint(this.characterMassImpulse, this.standingPoint, true);
1236
+ }
1237
+
1238
+ /** Idle drag friction (only called when there is no move input) (upstream l.710-717). */
1239
+ private applyFriction(fpsCorr: number): void {
1240
+ if (!this.rayHitBody || !this._isOnGround) return;
1241
+ // Calculate friction coefficient — the ONLY place slideFrictionCoef refreshes.
1242
+ this.slideFrictionCoef = clamp((this.standingPointFriction + this.slideGripFactor) * 0.5, 0, 1);
1243
+ // Apply friction impulse, I = m * dv * frictionCoef
1244
+ this._dragFrictionImpulse
1245
+ .copy(this._relativeVelOnPlane)
1246
+ .negate()
1247
+ .multiplyScalar(this._body.mass() * this.slideFrictionCoef * clamp(this.decDeltaTime, 0, 1));
1248
+ this._body.applyImpulse(this._dragFrictionImpulse.multiplyScalar(fpsCorr), false);
1249
+ }
1250
+
1251
+ /** Slope angles under/in front of the character (upstream l.722-737). */
1252
+ private slopeDetect(): void {
1253
+ const hasGroundHit = this.rayHit || this.shapeRayHit;
1254
+ if (hasGroundHit) {
1255
+ // Actual slope angle from upAxis
1256
+ this._actualSlopeAngle = this.actualSlopeNormalVec.angleTo(this.referenceUpAxis);
1257
+ if (this._isOnGround) {
1258
+ // Slope angle in front of character moving direction (no clamp on the dot — upstream parity)
1259
+ this.slopeAngleInFront = -Math.asin(this.actualSlopeNormalVec.dot(this._inputDir));
1260
+ } else {
1261
+ this.slopeAngleInFront = 0;
1262
+ }
1263
+ } else {
1264
+ this._actualSlopeAngle = 0;
1265
+ this.slopeAngleInFront = 0;
1266
+ }
1267
+ }
1268
+
1269
+ /** Falling detect (upstream l.742-744). */
1270
+ private fallDetect(): void {
1271
+ this._isFalling = this.currentVelOnUp.dot(this.upAxisVec) < 0 && !this._isOnGround;
1272
+ }
1273
+
1274
+ /** Zero gravity detect (upstream l.749-751). */
1275
+ private zeroGravityDetect(): void {
1276
+ this.isZeroGravity = this.referenceGravityMag === 0;
1277
+ }
1278
+
1279
+ /**
1280
+ * Moving/rotating platform detection + inherited velocity (upstream
1281
+ * l.756-794). Matches dynamic (0) and kinematic-position (2) bodies only —
1282
+ * velocity-based kinematic bodies are NOT matched (upstream parity).
1283
+ */
1284
+ private isOnMovingObjectDetect(): void {
1285
+ if (
1286
+ this.followPlatform &&
1287
+ this.rayHitBody &&
1288
+ this._isOnGround &&
1289
+ (this.rayHitBody.bodyType() === RAPIER.RigidBodyType.Dynamic ||
1290
+ this.rayHitBody.bodyType() === RAPIER.RigidBodyType.KinematicPositionBased)
1291
+ ) {
1292
+ this.isOnMovingObject = true;
1293
+
1294
+ // Find the proper rigid body mass ratio
1295
+ if (this.rayHitBody.bodyType() === RAPIER.RigidBodyType.Dynamic) {
1296
+ const ratio = clamp(this.rayHitBody.mass() / Math.max(this._body.mass(), 1e-6), 0, 1);
1297
+ this.massRatio = evaluateCurveLUT(ratio, this.massRatioFallOffCurve);
1298
+ } else {
1299
+ this.massRatio = 1;
1300
+ }
1301
+
1302
+ // Distance from character to the platform's center of mass
1303
+ this.movingObjectPosition.copy(this.rayHitBody.worldCom());
1304
+ this.distanceFromCharacterToObjectPoint.copy(this.currentPos).sub(this.movingObjectPosition);
1305
+ // Moving object linear velocity
1306
+ this.movingObjectLinearVelocity.copy(this.rayHitBody.linvel());
1307
+ // Moving object angular velocity
1308
+ this.movingObjectAngularVelocity.copy(this.rayHitBody.angvel());
1309
+ // Combine linear velocity and angular velocity into movingObjectVelocity
1310
+ // (only the rotational part is scaled by the mass-ratio falloff)
1311
+ this.movingObjectAngvelToLinvel.crossVectors(
1312
+ this.movingObjectAngularVelocity,
1313
+ this.distanceFromCharacterToObjectPoint
1314
+ );
1315
+ this.movingObjectVelocity
1316
+ .copy(this.movingObjectLinearVelocity)
1317
+ .addScaledVector(this.movingObjectAngvelToLinvel, this.massRatio);
1318
+ this.movingObjectVelocityOnPlane
1319
+ .copy(this.movingObjectVelocity)
1320
+ .projectOnPlane(this.referenceUpAxis);
1321
+ this.movingObjectVelocityOnUp
1322
+ .copy(this.movingObjectVelocity)
1323
+ .projectOnVector(this.referenceUpAxis);
1324
+
1325
+ // Compute moving object angular velocity turn quaternion
1326
+ this.movingObjectAngularVelocityValue = this.movingObjectAngularVelocity.length();
1327
+ this.movingObjectAngularVelocityAxis.copy(this.movingObjectAngularVelocity).normalize();
1328
+ this._turnOnYQuat.setFromAxisAngle(
1329
+ this.movingObjectAngularVelocityAxis,
1330
+ this.movingObjectAngularVelocityValue * this.world.timestep
1331
+ );
1332
+ } else {
1333
+ this.isOnMovingObject = false;
1334
+ this.movingObjectVelocity.set(0, 0, 0);
1335
+ this.movingObjectVelocityOnPlane.set(0, 0, 0);
1336
+ this.movingObjectVelocityOnUp.set(0, 0, 0);
1337
+ this._turnOnYQuat.identity();
1338
+ this.massRatio = 1;
1339
+ }
1340
+ }
1341
+
1342
+ /** Velocity relative to the platform under the character (upstream l.799-808). */
1343
+ private computeRelativeVelocity(): void {
1344
+ this._relativeVel.copy(this.currentVel);
1345
+ this._relativeVelOnPlane.copy(this.currentVelOnPlane);
1346
+ this._relativeVelOnUp.copy(this.currentVelOnUp);
1347
+ if (this.isOnMovingObject && this.followPlatform) {
1348
+ this._relativeVel.sub(this.movingObjectVelocity);
1349
+ this._relativeVelOnPlane.sub(this.movingObjectVelocityOnPlane);
1350
+ this._relativeVelOnUp.sub(this.movingObjectVelocityOnUp);
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * Jump: velocity REPLACE via setLinvel, re-fired every step while
1356
+ * `jumpActive && isOnGround` (upstream l.813-823 — do not add a fired-once latch).
1357
+ */
1358
+ private applyJumpImpulse(): void {
1359
+ this.jumpDirection
1360
+ .copy(this.referenceUpAxis)
1361
+ .addScaledVector(this.actualSlopeNormalVec, this.slopeJumpFactor)
1362
+ .normalize();
1363
+ this.jumpVelocityVec
1364
+ .copy(this._relativeVelOnPlane)
1365
+ .add(this.movingObjectVelocity)
1366
+ .addScaledVector(this.jumpDirection, this.jumpVel);
1367
+ this._body.setLinvel(this.jumpVelocityVec, true);
1368
+ // Apply opposite impulse to dynamic ground (not fpsCorr-scaled)
1369
+ if (
1370
+ this.applyCounterJumpImp &&
1371
+ this.rayHitBody &&
1372
+ this.rayHitBody.bodyType() === RAPIER.RigidBodyType.Dynamic
1373
+ ) {
1374
+ this.jumpImpulseToGround
1375
+ .copy(this.jumpDirection)
1376
+ .multiplyScalar(-this._body.mass() * this.jumpVel * this.massRatio * this.counterJumpImpFactor);
1377
+ this.rayHitBody.applyImpulseAtPoint(this.jumpImpulseToGround, this.standingPoint, true);
1378
+ }
1379
+ }
1380
+
1381
+ /**
1382
+ * Gravity scale control: zero on ground, `fallingGravityScale` while falling,
1383
+ * zero past terminal velocity, initial scale otherwise (upstream l.829-848).
1384
+ */
1385
+ private applyDynamicGravity(): void {
1386
+ const body = this._body;
1387
+ // Falling condition
1388
+ if (this._isFalling) {
1389
+ // Past fallingMaxVel: cut gravity to 0 (terminal velocity), else apply fallingGravityScale
1390
+ if (this.currentVelOnUp.lengthSq() > this.fallingMaxVel * this.fallingMaxVel) {
1391
+ if (body.gravityScale() !== 0) body.setGravityScale(0, false);
1392
+ } else {
1393
+ if (body.gravityScale() !== this.fallingGravityScale)
1394
+ body.setGravityScale(this.fallingGravityScale, false);
1395
+ }
1396
+ }
1397
+ // Jump up and ground condition
1398
+ else {
1399
+ if (this._isOnGround) {
1400
+ if (body.gravityScale() !== 0) body.setGravityScale(0, false);
1401
+ } else {
1402
+ if (body.gravityScale() !== this.initialGravityScale)
1403
+ body.setGravityScale(this.initialGravityScale, false);
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ /** Jump edge/timer state machine (upstream l.853-871). */
1409
+ private getJumpState(jumpPressed: boolean): boolean {
1410
+ if (this._jumpActive) {
1411
+ this.jumpElapsedTime += this.world.timestep;
1412
+ // Once jump duration is exceeded, set jump to inactive
1413
+ if (this.jumpElapsedTime >= this.jumpDuration) this._jumpActive = false;
1414
+ } else {
1415
+ // If jump key is pressed and can jump again, activate the jump and block
1416
+ // continuous jumping until the key is released.
1417
+ if (jumpPressed && this.canJumpAgain) {
1418
+ this._jumpActive = true;
1419
+ this.jumpElapsedTime = 0;
1420
+ this.canJumpAgain = false;
1421
+ }
1422
+ // Once jump key is released, allow jumping again
1423
+ if (!jumpPressed) this.canJumpAgain = true;
1424
+ }
1425
+ return this._jumpActive;
1426
+ }
1427
+
1428
+ /** Run toggle/hold state machine (upstream l.876-886). */
1429
+ private getRunState(runPressed: boolean): boolean {
1430
+ if (this.enableToggleRun) {
1431
+ // Only toggle run state on the key's rising edge
1432
+ if (runPressed && !this.canRunAgain) this._runActive = !this._runActive;
1433
+ this.canRunAgain = runPressed;
1434
+ } else {
1435
+ this._runActive = runPressed;
1436
+ }
1437
+ return this._runActive;
1438
+ }
1439
+
1440
+ /** Move impulse (slope climb + rejectVel + above-CoM lean) (upstream l.454-494). */
1441
+ private moveCharacter(run: boolean, fpsCorr: number): void {
1442
+ // Moving direction: rotate inputDir up/down the slope in front
1443
+ this.movingDirCrossAxis.crossVectors(this._inputDir, this.referenceUpAxis);
1444
+ this._movingDirection
1445
+ .copy(this._inputDir)
1446
+ .applyAxisAngle(this.movingDirCrossAxis, this.slopeAngleInFront);
1447
+
1448
+ // Rejection velocity: cancel off-axis drift (zeroed while airborne)
1449
+ this.wantToMoveVel.copy(this._relativeVelOnPlane).projectOnVector(this._inputDir);
1450
+ this.rejectVel
1451
+ .copy(this._relativeVelOnPlane)
1452
+ .sub(this.wantToMoveVel)
1453
+ .multiplyScalar(this._isOnGround ? this.rejectVelFactor : 0);
1454
+
1455
+ // Required moving impulse: I = m * dv
1456
+ // (slideFrictionCoef may be stale here — it refreshes only in applyFriction; upstream parity)
1457
+ const multiplier =
1458
+ this._body.mass() *
1459
+ clamp(this.accDeltaTime, 0, 1) *
1460
+ (this._isOnGround ? this.slideFrictionCoef : this.airDragFactor) *
1461
+ (this._actualSlopeAngle > this.slopeMaxAngle ? this.airDragFactor : 1);
1462
+ this.baseImpulse
1463
+ .copy(this._movingDirection)
1464
+ .multiplyScalar(run ? this.maxRunVel : this.maxWalkVel)
1465
+ .sub(this._relativeVelOnPlane);
1466
+ this._moveImpulse.copy(this.baseImpulse).sub(this.rejectVel).multiplyScalar(multiplier);
1467
+
1468
+ // Apply the impulse above the center of mass -> run lean
1469
+ this.moveImpulsePoint
1470
+ .copy(this.currentPos)
1471
+ .addScaledVector(this.characterYAxis, this.moveImpulsePointOffset);
1472
+ this._body.applyImpulseAtPoint(
1473
+ this._moveImpulse.multiplyScalar(fpsCorr),
1474
+ this.moveImpulsePoint,
1475
+ true
1476
+ );
1477
+
1478
+ // Apply opposite moving impulse to the standing point (dynamic ground only)
1479
+ if (
1480
+ this.applyCounterMoveImp &&
1481
+ this.rayHitBody &&
1482
+ this._isOnGround &&
1483
+ this.rayHitBody.bodyType() === RAPIER.RigidBodyType.Dynamic
1484
+ ) {
1485
+ this.moveImpulseToGround
1486
+ .copy(this.baseImpulse)
1487
+ .multiplyScalar(multiplier * this.massRatio * this.counterMoveImpFactor)
1488
+ .negate();
1489
+ this.rayHitBody.applyImpulseAtPoint(
1490
+ this.moveImpulseToGround.multiplyScalar(fpsCorr),
1491
+ this.standingPoint,
1492
+ true
1493
+ );
1494
+ } else {
1495
+ this.moveImpulseToGround.set(0, 0, 0);
1496
+ }
1497
+ }
1498
+
1499
+ // ────────────────────────────────────────────────────────────────────────
1500
+ // Debug indicators (parity-non-critical helper; simplified from upstream
1501
+ // l.299-360 / 891-931 / 1112-1159)
1502
+ // ────────────────────────────────────────────────────────────────────────
1503
+
1504
+ private buildDebugAssets(scene: THREE.Scene): void {
1505
+ const r = this.capsuleRadius;
1506
+ const rayCastGeo = new THREE.CircleGeometry(
1507
+ this.groundDetectionMode === "rayCast" ? this.rayRadius / 2 : this.rayRadius,
1508
+ 12
1509
+ );
1510
+ const rayCastMat = new THREE.MeshBasicMaterial({
1511
+ color: 0x9370db,
1512
+ side: THREE.DoubleSide,
1513
+ transparent: true,
1514
+ opacity: 0.5,
1515
+ });
1516
+ const standingMat = new THREE.MeshBasicMaterial({
1517
+ color: 0x800080,
1518
+ transparent: true,
1519
+ opacity: 0.5,
1520
+ });
1521
+ const standingGeo = new THREE.OctahedronGeometry(this.rayRadius / 2, 3);
1522
+ const forwardRingGeo = new THREE.RingGeometry(r * 2, r * 2.1, 32);
1523
+ const forwardPointerGeo = new THREE.PlaneGeometry(r / 2, r / 2);
1524
+ const forwardIndicatorMat = new THREE.MeshBasicMaterial({
1525
+ color: 0x007fff,
1526
+ side: THREE.DoubleSide,
1527
+ });
1528
+ const movePointerGeo = new THREE.OctahedronGeometry(r / 3, 0);
1529
+ const moveRingGeo = new THREE.RingGeometry(r * 1.5, r * 2, 32);
1530
+ const moveIndicatorMat = new THREE.MeshBasicMaterial({
1531
+ color: 0x4169e1,
1532
+ side: THREE.DoubleSide,
1533
+ transparent: true,
1534
+ opacity: 0.5,
1535
+ });
1536
+
1537
+ const group = new THREE.Group();
1538
+
1539
+ const forwardIndicator = new THREE.Group();
1540
+ const forwardRing = new THREE.Mesh(forwardRingGeo, forwardIndicatorMat);
1541
+ forwardRing.rotation.set(-Math.PI / 2, 0, 0);
1542
+ const forwardPointer = new THREE.Mesh(forwardPointerGeo, forwardIndicatorMat);
1543
+ forwardPointer.rotation.set(-Math.PI / 2, 0, Math.PI / 4);
1544
+ forwardPointer.position.set(0, 0, -r * 2);
1545
+ forwardIndicator.add(forwardRing, forwardPointer);
1546
+
1547
+ const rayStart = new THREE.Mesh(rayCastGeo, rayCastMat);
1548
+ const rayEnd = new THREE.Mesh(rayCastGeo, rayCastMat);
1549
+ const rayTrigger = new THREE.Mesh(rayCastGeo, standingMat);
1550
+ const rayStable = new THREE.Mesh(rayCastGeo, standingMat);
1551
+ const standingPoint = new THREE.Mesh(standingGeo, rayCastMat);
1552
+
1553
+ const moveIndicator = new THREE.Group();
1554
+ const movePointer = new THREE.Mesh(movePointerGeo, moveIndicatorMat);
1555
+ movePointer.scale.set(0.5, 0.5, 2);
1556
+ movePointer.position.set(0, 0, -r * 2);
1557
+ const moveRing = new THREE.Mesh(moveRingGeo, moveIndicatorMat);
1558
+ moveRing.rotation.set(-Math.PI / 2, 0, 0);
1559
+ moveIndicator.add(movePointer, moveRing);
1560
+
1561
+ const velocityArrow = new THREE.ArrowHelper(undefined, undefined, undefined, 0xff0000);
1562
+
1563
+ group.add(forwardIndicator, rayStart, rayEnd, rayTrigger, rayStable, standingPoint, moveIndicator, velocityArrow);
1564
+ scene.add(group);
1565
+
1566
+ this.debugAssets = {
1567
+ group,
1568
+ forwardIndicator,
1569
+ moveIndicator,
1570
+ rayStart,
1571
+ rayEnd,
1572
+ rayTrigger,
1573
+ rayStable,
1574
+ standingPoint,
1575
+ velocityArrow,
1576
+ disposables: [
1577
+ rayCastGeo,
1578
+ rayCastMat,
1579
+ standingGeo,
1580
+ standingMat,
1581
+ forwardRingGeo,
1582
+ forwardPointerGeo,
1583
+ forwardIndicatorMat,
1584
+ movePointerGeo,
1585
+ moveRingGeo,
1586
+ moveIndicatorMat,
1587
+ ],
1588
+ };
1589
+ }
1590
+
1591
+ private readonly forwardIndicatorMatrix = new THREE.Matrix4();
1592
+ private readonly moveIndicatorMatrix = new THREE.Matrix4();
1593
+ private readonly currVelDir = new THREE.Vector3();
1594
+
1595
+ /** Debug indicator poses (upstream l.891-931). */
1596
+ private updateDebugger(): void {
1597
+ const d = this.debugAssets;
1598
+ if (!d) return;
1599
+
1600
+ // Look-forward direction indicator
1601
+ d.forwardIndicator.position.copy(this.rayOrigin);
1602
+ this.forwardIndicatorMatrix.lookAt(this.fixedOrigin, this.forwardDirection, this.referenceUpAxis);
1603
+ d.forwardIndicator.quaternion.setFromRotationMatrix(this.forwardIndicatorMatrix);
1604
+
1605
+ // Floating shape cast indicator
1606
+ const debugStableDistance =
1607
+ this.groundDetectionMode === "rayCast"
1608
+ ? this.rayRadius * 2 + this.floatHeight
1609
+ : this.rayRadius + this.groundFloatingDistance;
1610
+ d.rayStart.position.copy(this.rayOrigin);
1611
+ d.rayStart.quaternion.setFromUnitVectors(this.fixedZAxis, this.referenceUpAxis);
1612
+ d.rayEnd.position.copy(this.rayOrigin).addScaledVector(this.referenceUpAxis, -this.rayLength);
1613
+ d.rayEnd.quaternion.setFromUnitVectors(this.fixedZAxis, this.referenceUpAxis);
1614
+ d.rayTrigger.position
1615
+ .copy(this.rayOrigin)
1616
+ .addScaledVector(this.referenceUpAxis, -debugStableDistance - this.rayHitForgiveness);
1617
+ d.rayTrigger.quaternion.setFromUnitVectors(this.fixedZAxis, this.referenceUpAxis);
1618
+ d.rayStable.position
1619
+ .copy(this.rayOrigin)
1620
+ .addScaledVector(this.referenceUpAxis, -debugStableDistance);
1621
+ d.rayStable.quaternion.setFromUnitVectors(this.fixedZAxis, this.referenceUpAxis);
1622
+ d.standingPoint.position.copy(this.standingPoint);
1623
+
1624
+ // Want-to-move direction indicator
1625
+ d.moveIndicator.position.copy(this.rayOrigin);
1626
+ this.moveIndicatorMatrix.lookAt(this.fixedOrigin, this._movingDirection, this.referenceUpAxis);
1627
+ d.moveIndicator.quaternion.setFromRotationMatrix(this.moveIndicatorMatrix);
1628
+
1629
+ // Current moving velocity arrow
1630
+ d.velocityArrow.position.copy(this.currentPos);
1631
+ d.velocityArrow.setDirection(this.currVelDir.copy(this._relativeVel).normalize());
1632
+ d.velocityArrow.setLength(
1633
+ this._relativeVel.length() / (this._runActive ? this.maxRunVel : this.maxWalkVel)
1634
+ );
1635
+ }
1636
+ }