@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.
- package/README.md +1 -0
- package/dist/index.js +203 -4
- package/package.json +7 -2
- package/templates/controllers/NOTICE.md +65 -0
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/character.glb +0 -0
- package/templates/controllers/assets/default-avatar.vrm +0 -0
- package/templates/controllers/character/character-animations.ts +682 -0
- package/templates/controllers/character/character-controller.ts +1636 -0
- package/templates/controllers/character/follow-camera.ts +644 -0
- package/templates/controllers/character/keyboard-input.ts +277 -0
- package/templates/controllers/character/presets.ts +176 -0
- package/templates/controllers/character/touch-joystick.ts +387 -0
- package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
- package/templates/controllers/character/vrm/foot-ik.ts +341 -0
- package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
- package/templates/controllers/drone/drone-controller.ts +1073 -0
- package/templates/controllers/drone/presets.ts +225 -0
- package/templates/controllers/interact/enter-exit.ts +502 -0
- package/templates/controllers/shared/colliders.ts +456 -0
- package/templates/controllers/shared/math.ts +230 -0
- package/templates/controllers/shared/physics-world.ts +622 -0
- package/templates/controllers/vehicle/presets.ts +297 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
- package/templates/controllers/vehicle/wheel.ts +1200 -0
- package/templates/skills/genex-getting-started/SKILL.md +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
- package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
- package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
- 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
|
+
}
|