@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,615 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TypeScript port of the ecctrl vehicle controller (React/R3F removed).
|
|
4
|
+
// This file is the car brain: chassis rigid body, drivetrain (engine torque
|
|
5
|
+
// curve, gear ratios, RPM-threshold auto shift with cooldown), speed-sensitive
|
|
6
|
+
// steering config, per-wheel demand routing, and final suspension/friction
|
|
7
|
+
// impulse application. The drone half of the upstream component lives in
|
|
8
|
+
// `drone/drone-controller.ts`; the upstream dead `carConfig.controlMode` key
|
|
9
|
+
// was removed.
|
|
10
|
+
|
|
11
|
+
import * as THREE from "three";
|
|
12
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
13
|
+
import type { World, RigidBody } from "@dimforge/rapier3d-compat";
|
|
14
|
+
import {
|
|
15
|
+
bakeCurveLUT,
|
|
16
|
+
createSlerpVec3,
|
|
17
|
+
type CurveData,
|
|
18
|
+
type CurveLUT,
|
|
19
|
+
} from "../shared/math.ts";
|
|
20
|
+
import { ShapeCastWheel, type DriveWheelConfig, type SteerWheelConfig, type WheelOptions } from "./wheel.ts";
|
|
21
|
+
|
|
22
|
+
const clamp = THREE.MathUtils.clamp;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Car input. Booleans are momentary state (hold to act); `setMovement` merges
|
|
26
|
+
* field-wise, so send only the keys you own. `+steer = LEFT turn` and
|
|
27
|
+
* `joystickL.x` is subtracted (push right = turn right).
|
|
28
|
+
*/
|
|
29
|
+
export type VehicleInput = {
|
|
30
|
+
forward?: boolean;
|
|
31
|
+
backward?: boolean;
|
|
32
|
+
steerLeft?: boolean;
|
|
33
|
+
steerRight?: boolean;
|
|
34
|
+
brake?: boolean;
|
|
35
|
+
joystickL?: { x: number; y: number };
|
|
36
|
+
};
|
|
37
|
+
export type ReadonlyVehicleInput = Readonly<Omit<VehicleInput, "joystickL">> & {
|
|
38
|
+
readonly joystickL?: Readonly<{ x: number; y: number }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type TransmissionMode = "auto" | "manual";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Drivetrain + steering config.
|
|
45
|
+
*
|
|
46
|
+
* Tuning hints:
|
|
47
|
+
* - "car feels slow" -> raise `engineHorsepower` (peak torque = HP*7022/maxRPM).
|
|
48
|
+
* - "reverse too fast" -> lower `reverseRPMScale` (0.3 = reverse tops out at 30%).
|
|
49
|
+
* - "twitchy at speed" -> steeper falloff in `steerAngleCurveData` (default
|
|
50
|
+
* already gives full angle below 20% of top speed, 0.4x at top).
|
|
51
|
+
* - single-entry `gearRatios` disables shifting; multiple entries enable the
|
|
52
|
+
* RPM-threshold auto shift (`shiftUpRPM`/`shiftDownRPM` with `shiftCooldown`).
|
|
53
|
+
*/
|
|
54
|
+
export type CarConfig = {
|
|
55
|
+
/** Engine power (HP). Peak torque derives as HP * 7022 / engineMaxRPM. */
|
|
56
|
+
engineHorsepower: number;
|
|
57
|
+
/** Engine redline (RPM). */
|
|
58
|
+
engineMaxRPM: number;
|
|
59
|
+
/** Gear ratios, low to high gear. Empty array falls back to [10]. */
|
|
60
|
+
gearRatios: number[];
|
|
61
|
+
/** Multiplied into every gear ratio. */
|
|
62
|
+
finalDriveRatio: number;
|
|
63
|
+
/** "auto" shifts by RPM thresholds; "manual" only via setGear(). */
|
|
64
|
+
transmissionMode: TransmissionMode;
|
|
65
|
+
/** Auto-upshift above this engine RPM. */
|
|
66
|
+
shiftUpRPM: number;
|
|
67
|
+
/** Auto-downshift below this engine RPM. */
|
|
68
|
+
shiftDownRPM: number;
|
|
69
|
+
/** Seconds between automatic shifts. */
|
|
70
|
+
shiftCooldown: number;
|
|
71
|
+
/** Steering slew rate (rad/s). */
|
|
72
|
+
steerRate: number;
|
|
73
|
+
/** Max steer angle at standstill (rad). */
|
|
74
|
+
maxSteerAngle: number;
|
|
75
|
+
/** Torque multiplier while reversing. */
|
|
76
|
+
reverseTorqueScale: number;
|
|
77
|
+
/** Scales the reverse speed cap. */
|
|
78
|
+
reverseRPMScale: number;
|
|
79
|
+
/** Engine torque over normalized wheel speed (1 at idle -> 0 at redline). */
|
|
80
|
+
engineTorqueCurveData: CurveData;
|
|
81
|
+
/** Steer-angle falloff over forward-speed ratio (speed-sensitive steering). */
|
|
82
|
+
steerAngleCurveData: CurveData;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Library defaults (upstream values; HP 6 is deliberately tiny — presets tune it). */
|
|
86
|
+
export const DEFAULT_CAR_CONFIG: CarConfig = {
|
|
87
|
+
// Engine and drive train
|
|
88
|
+
engineHorsepower: 6,
|
|
89
|
+
engineMaxRPM: 6000,
|
|
90
|
+
gearRatios: [10],
|
|
91
|
+
finalDriveRatio: 1,
|
|
92
|
+
transmissionMode: "auto",
|
|
93
|
+
shiftUpRPM: 5200,
|
|
94
|
+
shiftDownRPM: 2200,
|
|
95
|
+
shiftCooldown: 0.35,
|
|
96
|
+
// Steering
|
|
97
|
+
steerRate: Math.PI * 2,
|
|
98
|
+
maxSteerAngle: Math.PI / 6, // 30 degrees in radians
|
|
99
|
+
// Reverse
|
|
100
|
+
reverseTorqueScale: 1,
|
|
101
|
+
reverseRPMScale: 0.3,
|
|
102
|
+
// Curves
|
|
103
|
+
engineTorqueCurveData: {
|
|
104
|
+
points: [
|
|
105
|
+
{ x: 0, y: 1, r_out: 0 },
|
|
106
|
+
{ x: 1, y: 0, r_in: 0 },
|
|
107
|
+
],
|
|
108
|
+
samples: 50,
|
|
109
|
+
},
|
|
110
|
+
steerAngleCurveData: {
|
|
111
|
+
points: [
|
|
112
|
+
{ x: 0, y: 1, r_out: 0 },
|
|
113
|
+
{ x: 0.2, y: 1, r_in: 0, r_out: 0 },
|
|
114
|
+
{ x: 1, y: 0.4, r_in: 0 },
|
|
115
|
+
],
|
|
116
|
+
samples: 50,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type VehicleControllerOptions = {
|
|
121
|
+
/** The Rapier world (from `PhysicsWorld.create()`'s `.world`). */
|
|
122
|
+
world: World;
|
|
123
|
+
/** Initial body translation. */
|
|
124
|
+
position?: THREE.Vector3;
|
|
125
|
+
/** Initial body rotation. */
|
|
126
|
+
rotation?: THREE.Quaternion;
|
|
127
|
+
/** Allow the body to sleep when at rest. Default true. */
|
|
128
|
+
canSleep?: boolean;
|
|
129
|
+
/** Start enabled. Default true. */
|
|
130
|
+
enable?: boolean;
|
|
131
|
+
/** Merged over DEFAULT_CAR_CONFIG. */
|
|
132
|
+
carConfig?: Partial<CarConfig>;
|
|
133
|
+
/** Gravity-direction smoothing (factor 1 - exp(-k*dt)). Default 6. */
|
|
134
|
+
gravityDirLerpSpeed?: number;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const getDriveRatio = (
|
|
138
|
+
gearRatios: number[],
|
|
139
|
+
gearIndex: number,
|
|
140
|
+
finalDriveRatio: number
|
|
141
|
+
) => (gearRatios[gearIndex] ?? gearRatios[0] ?? 0) * finalDriveRatio;
|
|
142
|
+
|
|
143
|
+
const getMaxWheelAngVel = (engineMaxRPM: number, driveRatio: number) =>
|
|
144
|
+
driveRatio !== 0 ? (engineMaxRPM / driveRatio) * ((2 * Math.PI) / 60) : 0;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Drivable car controller over a dynamic Rapier body plus shapecast wheels.
|
|
148
|
+
*
|
|
149
|
+
* The controller creates the rigid body WITHOUT colliders — attach chassis
|
|
150
|
+
* colliders to `vehicle.body` yourself (see `vehicle/presets.ts` for shapes
|
|
151
|
+
* and densities), then add wheels via `addWheel()`. Parent your chassis mesh
|
|
152
|
+
* under `chassisObject` and add that group to the scene; wheels auto-parent
|
|
153
|
+
* under it (the scene graph is the wheels' pose source).
|
|
154
|
+
*
|
|
155
|
+
* Call `update()` exactly once per fixed physics step, BEFORE `world.step()`.
|
|
156
|
+
* `+Z` is the vehicle's FORWARD axis.
|
|
157
|
+
*/
|
|
158
|
+
export class VehicleController {
|
|
159
|
+
/** Exposed so wheels (WheelVehicleContext) can query the world. */
|
|
160
|
+
readonly world: World;
|
|
161
|
+
/** Dynamic body, created WITHOUT colliders — the caller attaches them. */
|
|
162
|
+
readonly body: RigidBody;
|
|
163
|
+
/** Scene-graph root: add to scene, parent the chassis mesh under it. */
|
|
164
|
+
readonly chassisObject: THREE.Group;
|
|
165
|
+
/** Master enable; `update()` early-outs when false. */
|
|
166
|
+
enabled: boolean;
|
|
167
|
+
|
|
168
|
+
// --- config ---
|
|
169
|
+
private readonly carConfig: CarConfig;
|
|
170
|
+
private readonly gravityDirLerpSpeed: number;
|
|
171
|
+
private readonly gearRatiosList: number[];
|
|
172
|
+
private readonly engineMaxTorque: number;
|
|
173
|
+
private readonly engineTorqueCurve: CurveLUT;
|
|
174
|
+
private readonly steerAngleCurve: CurveLUT;
|
|
175
|
+
|
|
176
|
+
// --- drivetrain state ---
|
|
177
|
+
private _gearIndex = 0;
|
|
178
|
+
private _driveRatio: number;
|
|
179
|
+
private _engineRPM = 0;
|
|
180
|
+
private shiftCooldownTimer = 0;
|
|
181
|
+
private maxWheelAngVel: number;
|
|
182
|
+
private readonly driveWheelConfig: DriveWheelConfig;
|
|
183
|
+
private readonly steerWheelConfig: SteerWheelConfig;
|
|
184
|
+
|
|
185
|
+
// --- wheels ---
|
|
186
|
+
private readonly wheelsMap = new Map<string, ShapeCastWheel>();
|
|
187
|
+
|
|
188
|
+
// --- input state ---
|
|
189
|
+
private readonly movementState = {
|
|
190
|
+
forward: false,
|
|
191
|
+
backward: false,
|
|
192
|
+
steerLeft: false,
|
|
193
|
+
steerRight: false,
|
|
194
|
+
brake: false,
|
|
195
|
+
joystickL: { x: 0, y: 0 },
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// --- vehicle info scratch ---
|
|
199
|
+
private readonly vehiclePos = new THREE.Vector3();
|
|
200
|
+
private readonly vehicleQuat = new THREE.Quaternion();
|
|
201
|
+
private readonly vehicleInvertQuat = new THREE.Quaternion();
|
|
202
|
+
private readonly vehicleLinVel = new THREE.Vector3();
|
|
203
|
+
private readonly vehicleAngVel = new THREE.Vector3();
|
|
204
|
+
private readonly vehicleXAxis = new THREE.Vector3();
|
|
205
|
+
private readonly vehicleYAxis = new THREE.Vector3();
|
|
206
|
+
private readonly vehicleZAxis = new THREE.Vector3();
|
|
207
|
+
|
|
208
|
+
// --- gravity state ---
|
|
209
|
+
private readonly _upAxis = new THREE.Vector3();
|
|
210
|
+
private readonly referenceGravity = new THREE.Vector3();
|
|
211
|
+
private referenceGravityMag = 0;
|
|
212
|
+
private readonly referenceGravityDir = new THREE.Vector3();
|
|
213
|
+
private readonly _gravityDir = new THREE.Vector3();
|
|
214
|
+
private readonly slerpVec3 = createSlerpVec3();
|
|
215
|
+
|
|
216
|
+
constructor(options: VehicleControllerOptions) {
|
|
217
|
+
this.world = options.world;
|
|
218
|
+
this.enabled = options.enable ?? true;
|
|
219
|
+
this.gravityDirLerpSpeed = options.gravityDirLerpSpeed ?? 6;
|
|
220
|
+
|
|
221
|
+
// Merge config; empty gearRatios falls back to the library default [10].
|
|
222
|
+
this.carConfig = { ...DEFAULT_CAR_CONFIG, ...options.carConfig };
|
|
223
|
+
this.gearRatiosList =
|
|
224
|
+
Array.isArray(this.carConfig.gearRatios) && this.carConfig.gearRatios.length > 0
|
|
225
|
+
? this.carConfig.gearRatios
|
|
226
|
+
: DEFAULT_CAR_CONFIG.gearRatios;
|
|
227
|
+
|
|
228
|
+
// Peak engine torque: HP * 7022 / maxRPM (7022 ~= 5252 lb·ft·RPM/HP in N·m).
|
|
229
|
+
this.engineMaxTorque =
|
|
230
|
+
this.carConfig.engineMaxRPM !== 0
|
|
231
|
+
? (this.carConfig.engineHorsepower * 7022) / this.carConfig.engineMaxRPM
|
|
232
|
+
: 0;
|
|
233
|
+
this._driveRatio = getDriveRatio(
|
|
234
|
+
this.gearRatiosList,
|
|
235
|
+
this._gearIndex,
|
|
236
|
+
this.carConfig.finalDriveRatio
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Bake curve LUTs.
|
|
240
|
+
this.engineTorqueCurve = bakeCurveLUT(
|
|
241
|
+
this.carConfig.engineTorqueCurveData.points,
|
|
242
|
+
this.carConfig.engineTorqueCurveData.samples ?? 50
|
|
243
|
+
);
|
|
244
|
+
this.steerAngleCurve = bakeCurveLUT(
|
|
245
|
+
this.carConfig.steerAngleCurveData.points,
|
|
246
|
+
this.carConfig.steerAngleCurveData.samples ?? 50
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
this.maxWheelAngVel = getMaxWheelAngVel(
|
|
250
|
+
this.carConfig.engineMaxRPM,
|
|
251
|
+
this._driveRatio
|
|
252
|
+
);
|
|
253
|
+
this.driveWheelConfig = {
|
|
254
|
+
maxDriveTorque: 0,
|
|
255
|
+
maxWheelAngVel: this.maxWheelAngVel,
|
|
256
|
+
engineTorqueCurve: this.engineTorqueCurve,
|
|
257
|
+
reverseTorqueScale: this.carConfig.reverseTorqueScale,
|
|
258
|
+
reverseRPMScale: this.carConfig.reverseRPMScale,
|
|
259
|
+
driveRatio: this._driveRatio,
|
|
260
|
+
};
|
|
261
|
+
this.steerWheelConfig = {
|
|
262
|
+
steerAngleCurve: this.steerAngleCurve,
|
|
263
|
+
steerRate: this.carConfig.steerRate,
|
|
264
|
+
maxSteerAngle: this.carConfig.maxSteerAngle,
|
|
265
|
+
maxWheelAngVel: this.maxWheelAngVel,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Create the dynamic body (no colliders — caller attaches them).
|
|
269
|
+
const desc = RAPIER.RigidBodyDesc.dynamic();
|
|
270
|
+
if (options.position)
|
|
271
|
+
desc.setTranslation(options.position.x, options.position.y, options.position.z);
|
|
272
|
+
if (options.rotation) desc.setRotation(options.rotation);
|
|
273
|
+
desc.setCanSleep(options.canSleep ?? true);
|
|
274
|
+
this.body = this.world.createRigidBody(desc);
|
|
275
|
+
|
|
276
|
+
// Scene-graph root, aligned with the body from the start.
|
|
277
|
+
this.chassisObject = new THREE.Group();
|
|
278
|
+
this.chassisObject.position.copy(this.body.translation());
|
|
279
|
+
this.chassisObject.quaternion.copy(this.body.rotation());
|
|
280
|
+
|
|
281
|
+
// Prime vehicle info so getters are sensible before the first update().
|
|
282
|
+
this.vehiclePos.copy(this.body.translation());
|
|
283
|
+
this.vehicleQuat.copy(this.body.rotation());
|
|
284
|
+
this.vehicleXAxis.set(1, 0, 0).applyQuaternion(this.vehicleQuat);
|
|
285
|
+
this.vehicleYAxis.set(0, 1, 0).applyQuaternion(this.vehicleQuat);
|
|
286
|
+
this.vehicleZAxis.set(0, 0, 1).applyQuaternion(this.vehicleQuat);
|
|
287
|
+
|
|
288
|
+
// Prime gravity state so the first slerp doesn't swing from a zero
|
|
289
|
+
// vector and wheels never see gravityMag 0 on frame 1.
|
|
290
|
+
const g = this.world.gravity;
|
|
291
|
+
this.referenceGravity.set(g.x, g.y, g.z);
|
|
292
|
+
this.referenceGravityMag = this.referenceGravity.length();
|
|
293
|
+
this.referenceGravityDir.copy(this.referenceGravity).normalize();
|
|
294
|
+
if (this.referenceGravityDir.lengthSq() === 0)
|
|
295
|
+
this.referenceGravityDir.copy(this.vehicleYAxis).negate();
|
|
296
|
+
this._gravityDir.copy(this.referenceGravityDir);
|
|
297
|
+
this._upAxis.copy(this._gravityDir).negate();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- readonly state getters (live internal instances; copy, never mutate) ---
|
|
301
|
+
get upAxis(): THREE.Vector3 {
|
|
302
|
+
return this._upAxis;
|
|
303
|
+
}
|
|
304
|
+
get gravityDir(): THREE.Vector3 {
|
|
305
|
+
return this._gravityDir;
|
|
306
|
+
}
|
|
307
|
+
get gravityMag(): number {
|
|
308
|
+
return this.referenceGravityMag;
|
|
309
|
+
}
|
|
310
|
+
get currPos(): THREE.Vector3 {
|
|
311
|
+
return this.vehiclePos;
|
|
312
|
+
}
|
|
313
|
+
get currQuat(): THREE.Quaternion {
|
|
314
|
+
return this.vehicleQuat;
|
|
315
|
+
}
|
|
316
|
+
get currLinVel(): THREE.Vector3 {
|
|
317
|
+
return this.vehicleLinVel;
|
|
318
|
+
}
|
|
319
|
+
get currAngVel(): THREE.Vector3 {
|
|
320
|
+
return this.vehicleAngVel;
|
|
321
|
+
}
|
|
322
|
+
get bodyXAxis(): THREE.Vector3 {
|
|
323
|
+
return this.vehicleXAxis;
|
|
324
|
+
}
|
|
325
|
+
get bodyYAxis(): THREE.Vector3 {
|
|
326
|
+
return this.vehicleYAxis;
|
|
327
|
+
}
|
|
328
|
+
/** +Z is the vehicle's FORWARD axis. */
|
|
329
|
+
get bodyZAxis(): THREE.Vector3 {
|
|
330
|
+
return this.vehicleZAxis;
|
|
331
|
+
}
|
|
332
|
+
get input(): ReadonlyVehicleInput {
|
|
333
|
+
return this.movementState;
|
|
334
|
+
}
|
|
335
|
+
get wheels(): ReadonlyMap<string, ShapeCastWheel> {
|
|
336
|
+
return this.wheelsMap;
|
|
337
|
+
}
|
|
338
|
+
get gearIndex(): number {
|
|
339
|
+
return this._gearIndex;
|
|
340
|
+
}
|
|
341
|
+
/** Current gear ratio x final drive ratio. */
|
|
342
|
+
get driveRatio(): number {
|
|
343
|
+
return this._driveRatio;
|
|
344
|
+
}
|
|
345
|
+
/** Live engine RPM readout (drive-weighted average wheel RPM x |driveRatio|). */
|
|
346
|
+
get engineRPM(): number {
|
|
347
|
+
return this._engineRPM;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Field-wise input merge: only keys present in `input` are written, so
|
|
352
|
+
* multiple sources (keyboard + joystick) can push independently.
|
|
353
|
+
*/
|
|
354
|
+
setMovement(input: VehicleInput): void {
|
|
355
|
+
const state = this.movementState;
|
|
356
|
+
if (input.forward !== undefined) state.forward = input.forward;
|
|
357
|
+
if (input.backward !== undefined) state.backward = input.backward;
|
|
358
|
+
if (input.steerLeft !== undefined) state.steerLeft = input.steerLeft;
|
|
359
|
+
if (input.steerRight !== undefined) state.steerRight = input.steerRight;
|
|
360
|
+
if (input.brake !== undefined) state.brake = input.brake;
|
|
361
|
+
if (input.joystickL) {
|
|
362
|
+
state.joystickL.x = input.joystickL.x;
|
|
363
|
+
state.joystickL.y = input.joystickL.y;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Manually select a gear (index into `gearRatios`, clamped). Starts the
|
|
369
|
+
* shift cooldown, so it also pauses auto-shifting for `shiftCooldown` s.
|
|
370
|
+
*/
|
|
371
|
+
setGear(index: number): void {
|
|
372
|
+
const nextGearIndex = clamp(Math.floor(index), 0, this.gearRatiosList.length - 1);
|
|
373
|
+
if (this._gearIndex === nextGearIndex) return;
|
|
374
|
+
this._gearIndex = nextGearIndex;
|
|
375
|
+
this.shiftCooldownTimer = this.carConfig.shiftCooldown;
|
|
376
|
+
this.syncTransmissionConfig();
|
|
377
|
+
this.syncWheelConfig();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Create a wheel and parent its group under `chassisObject`. Register all
|
|
382
|
+
* wheels before the first `update()` for a stable torque split (adding
|
|
383
|
+
* later works — the split just re-balances).
|
|
384
|
+
*/
|
|
385
|
+
addWheel(options: WheelOptions): ShapeCastWheel {
|
|
386
|
+
const wheel = new ShapeCastWheel(this, options);
|
|
387
|
+
if (!this.wheelsMap.has(wheel.id)) {
|
|
388
|
+
this.chassisObject.add(wheel.wheelGroup);
|
|
389
|
+
this.wheelsMap.set(wheel.id, wheel);
|
|
390
|
+
this.syncWheelConfig();
|
|
391
|
+
}
|
|
392
|
+
return wheel;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Remove a wheel by id (detaches its groups, re-splits drive torque). */
|
|
396
|
+
removeWheel(id: string): void {
|
|
397
|
+
const wheel = this.wheelsMap.get(id);
|
|
398
|
+
if (!wheel) return;
|
|
399
|
+
this.wheelsMap.delete(id);
|
|
400
|
+
wheel.wheelGroup.removeFromParent();
|
|
401
|
+
this.syncWheelConfig();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Per-fixed-step update; call exactly once per physics substep BEFORE
|
|
406
|
+
* `world.step()`. The `dt` parameter is accepted for a uniform controller
|
|
407
|
+
* call shape and IGNORED — all timing uses the fixed `world.timestep`.
|
|
408
|
+
*
|
|
409
|
+
* Order (replicates upstream's children-before-parent frame order):
|
|
410
|
+
* A. sync `chassisObject` from the rigid body (wheels read world poses),
|
|
411
|
+
* B. update every wheel, C. refresh vehicle/gravity info (unless sleeping),
|
|
412
|
+
* D. transmission -> demands -> impulses. Demands written in D are consumed
|
|
413
|
+
* by the wheels NEXT step — an intentional upstream one-frame delay.
|
|
414
|
+
*/
|
|
415
|
+
update(_dt?: number): void {
|
|
416
|
+
if (!this.enabled) return;
|
|
417
|
+
|
|
418
|
+
// A. Scene graph <- rigid body (must precede any wheel world-pose read).
|
|
419
|
+
this.chassisObject.position.copy(this.body.translation());
|
|
420
|
+
this.chassisObject.quaternion.copy(this.body.rotation());
|
|
421
|
+
this.chassisObject.updateMatrixWorld(true);
|
|
422
|
+
|
|
423
|
+
// B. Wheels (children-first useFrame order upstream).
|
|
424
|
+
for (const wheel of this.wheelsMap.values()) wheel.update();
|
|
425
|
+
|
|
426
|
+
// C. Vehicle + gravity info while awake.
|
|
427
|
+
if (!this.body.isSleeping()) {
|
|
428
|
+
this.updateVehicleInfo();
|
|
429
|
+
this.updateGravityInfo();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// D. Car control whenever there is a wheel.
|
|
433
|
+
if (this.wheelsMap.size > 0) this.applyCarControl();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Remove the body from the world and detach the scene-graph objects. */
|
|
437
|
+
dispose(): void {
|
|
438
|
+
for (const wheel of this.wheelsMap.values()) wheel.dispose();
|
|
439
|
+
this.wheelsMap.clear();
|
|
440
|
+
this.world.removeRigidBody(this.body);
|
|
441
|
+
this.chassisObject.removeFromParent();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ------------------------------------------------------------------
|
|
445
|
+
// Internals (formulas and ordering mirror upstream exactly)
|
|
446
|
+
// ------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
private syncTransmissionConfig(): void {
|
|
449
|
+
this._driveRatio = getDriveRatio(
|
|
450
|
+
this.gearRatiosList,
|
|
451
|
+
this._gearIndex,
|
|
452
|
+
this.carConfig.finalDriveRatio
|
|
453
|
+
);
|
|
454
|
+
this.maxWheelAngVel = getMaxWheelAngVel(
|
|
455
|
+
this.carConfig.engineMaxRPM,
|
|
456
|
+
this._driveRatio
|
|
457
|
+
);
|
|
458
|
+
this.driveWheelConfig.driveRatio = this._driveRatio;
|
|
459
|
+
this.driveWheelConfig.maxWheelAngVel = this.maxWheelAngVel;
|
|
460
|
+
this.steerWheelConfig.maxWheelAngVel = this.maxWheelAngVel;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private syncWheelConfig(): void {
|
|
464
|
+
let totalDriveTorqueWeight = 0;
|
|
465
|
+
for (const wheel of this.wheelsMap.values()) {
|
|
466
|
+
if (!wheel.driveWheel) continue;
|
|
467
|
+
totalDriveTorqueWeight += Math.max(0, wheel.driveTorqueWeight);
|
|
468
|
+
}
|
|
469
|
+
for (const wheel of this.wheelsMap.values()) {
|
|
470
|
+
if (wheel.driveWheel) {
|
|
471
|
+
const driveTorqueWeight = Math.max(0, wheel.driveTorqueWeight);
|
|
472
|
+
// Shallow copy for drive config (upstream spreads)...
|
|
473
|
+
wheel.setDriveWheelConfig({
|
|
474
|
+
...this.driveWheelConfig,
|
|
475
|
+
maxDriveTorque:
|
|
476
|
+
totalDriveTorqueWeight > 0
|
|
477
|
+
? (this.engineMaxTorque * driveTorqueWeight) / totalDriveTorqueWeight
|
|
478
|
+
: 0,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
// ...but the SHARED object for steer config (upstream passes the ref),
|
|
482
|
+
// so steer wheels see later maxWheelAngVel updates without a re-push.
|
|
483
|
+
if (wheel.steerWheel) wheel.setSteerWheelConfig(this.steerWheelConfig);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private updateVehicleInfo(): void {
|
|
488
|
+
this.vehiclePos.copy(this.body.translation());
|
|
489
|
+
this.vehicleQuat.copy(this.body.rotation());
|
|
490
|
+
this.vehicleInvertQuat.copy(this.vehicleQuat).invert();
|
|
491
|
+
this.vehicleLinVel.copy(this.body.linvel());
|
|
492
|
+
this.vehicleAngVel.copy(this.body.angvel());
|
|
493
|
+
this.vehicleYAxis.set(0, 1, 0).applyQuaternion(this.vehicleQuat);
|
|
494
|
+
this.vehicleXAxis.set(1, 0, 0).applyQuaternion(this.vehicleQuat);
|
|
495
|
+
this.vehicleZAxis.set(0, 0, 1).applyQuaternion(this.vehicleQuat);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private updateGravityInfo(): void {
|
|
499
|
+
// Constant world gravity only (upstream custom-gravity fields are out of
|
|
500
|
+
// scope for v1); the slerp is retained faithfully — it converges
|
|
501
|
+
// instantly under constant gravity and keeps the formula intact.
|
|
502
|
+
const g = this.world.gravity;
|
|
503
|
+
this.referenceGravity.set(g.x, g.y, g.z);
|
|
504
|
+
this.referenceGravityMag = this.referenceGravity.length();
|
|
505
|
+
this.referenceGravityDir.copy(this.referenceGravity).normalize();
|
|
506
|
+
if (this.referenceGravityDir.lengthSq() === 0)
|
|
507
|
+
this.referenceGravityDir.copy(this.vehicleYAxis).negate();
|
|
508
|
+
this._gravityDir.copy(
|
|
509
|
+
this.slerpVec3(
|
|
510
|
+
this._gravityDir,
|
|
511
|
+
this.referenceGravityDir,
|
|
512
|
+
1 - Math.exp(-this.gravityDirLerpSpeed * this.world.timestep),
|
|
513
|
+
this.vehicleZAxis
|
|
514
|
+
)
|
|
515
|
+
);
|
|
516
|
+
this._upAxis.copy(this._gravityDir).negate();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private updateTransmission(): void {
|
|
520
|
+
// Drive-weighted average wheel RPM -> engine RPM (updated even while the
|
|
521
|
+
// shift cooldown runs; only the shift decision is skipped).
|
|
522
|
+
let totalWheelRPM = 0;
|
|
523
|
+
let totalDriveTorqueWeight = 0;
|
|
524
|
+
for (const wheel of this.wheelsMap.values()) {
|
|
525
|
+
if (!wheel.driveWheel) continue;
|
|
526
|
+
const driveTorqueWeight = Math.max(0, wheel.driveTorqueWeight);
|
|
527
|
+
totalWheelRPM +=
|
|
528
|
+
((Math.abs(wheel.wheelAngVel) * 60) / (Math.PI * 2)) * driveTorqueWeight;
|
|
529
|
+
totalDriveTorqueWeight += driveTorqueWeight;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const averageWheelRPM =
|
|
533
|
+
totalDriveTorqueWeight > 0 ? totalWheelRPM / totalDriveTorqueWeight : 0;
|
|
534
|
+
this._engineRPM = averageWheelRPM * Math.abs(this._driveRatio);
|
|
535
|
+
if (this.carConfig.transmissionMode !== "auto" || this.gearRatiosList.length <= 1)
|
|
536
|
+
return;
|
|
537
|
+
|
|
538
|
+
if (this.shiftCooldownTimer > 0) {
|
|
539
|
+
this.shiftCooldownTimer = Math.max(
|
|
540
|
+
0,
|
|
541
|
+
this.shiftCooldownTimer - this.world.timestep
|
|
542
|
+
);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (
|
|
547
|
+
this._engineRPM > this.carConfig.shiftUpRPM &&
|
|
548
|
+
this._gearIndex < this.gearRatiosList.length - 1
|
|
549
|
+
) {
|
|
550
|
+
this.setGear(this._gearIndex + 1);
|
|
551
|
+
} else if (this._engineRPM < this.carConfig.shiftDownRPM && this._gearIndex > 0) {
|
|
552
|
+
this.setGear(this._gearIndex - 1);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private velocityBasedCarControl(): void {
|
|
557
|
+
const input = this.movementState;
|
|
558
|
+
// Convert user input to drive/brake/steer demand (+steer = LEFT turn).
|
|
559
|
+
const driveIn = clamp((input.forward ? 1 : 0) - (input.backward ? 1 : 0), -1, 1);
|
|
560
|
+
const steerIn = clamp(
|
|
561
|
+
(input.steerLeft ? 1 : 0) - (input.steerRight ? 1 : 0) - input.joystickL.x,
|
|
562
|
+
-1,
|
|
563
|
+
1
|
|
564
|
+
);
|
|
565
|
+
const brakeIn = input.brake ? 1 : 0;
|
|
566
|
+
|
|
567
|
+
// Wheels consume these demands NEXT step (intentional one-frame delay).
|
|
568
|
+
for (const wheel of this.wheelsMap.values()) {
|
|
569
|
+
if (wheel.driveWheel) wheel.setDriveDemand(driveIn);
|
|
570
|
+
if (wheel.brakeWheel) wheel.setBrakeDemand(brakeIn);
|
|
571
|
+
if (wheel.steerWheel) wheel.setSteerDemand(steerIn);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private applyWheelImpulse(): void {
|
|
576
|
+
const body = this.body;
|
|
577
|
+
|
|
578
|
+
// Wake-up check: only wake when a wheel has contact and a moving surface
|
|
579
|
+
// or non-zero wheel surface speed.
|
|
580
|
+
if (body.isSleeping()) {
|
|
581
|
+
let shouldWake = false;
|
|
582
|
+
for (const wheel of this.wheelsMap.values()) {
|
|
583
|
+
if (!wheel.rayHit) continue;
|
|
584
|
+
if (wheel.isOnPlatform || Math.abs(wheel.wheelLinVel) > 1e-4) {
|
|
585
|
+
shouldWake = true;
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!shouldWake) return;
|
|
590
|
+
body.wakeUp();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
for (const wheel of this.wheelsMap.values()) {
|
|
594
|
+
// `rayHit` is the wheel's ONE-FRAME-STALE published snapshot (upstream
|
|
595
|
+
// wheelInfo): on contact loss the stale friction impulses fire once
|
|
596
|
+
// more; on contact gain this step's suspension impulse is skipped.
|
|
597
|
+
if (!wheel.rayHit) continue;
|
|
598
|
+
// Suspension at the SUPPORT point (avoids contact-patch jacking while
|
|
599
|
+
// steering); friction impulses at the actual hit point. Wake flag
|
|
600
|
+
// `false` for the vehicle's own impulses.
|
|
601
|
+
body.applyImpulseAtPoint(wheel.floatImp, wheel.supPos, false);
|
|
602
|
+
body.applyImpulseAtPoint(wheel.lngFricImp, wheel.rayHitPos, false);
|
|
603
|
+
body.applyImpulseAtPoint(wheel.latFricImp, wheel.rayHitPos, false);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private applyCarControl(): void {
|
|
608
|
+
// Engine RPM + automatic gear changes before sending demands to wheels.
|
|
609
|
+
this.updateTransmission();
|
|
610
|
+
// Route drive/brake/steer demands to the wheels.
|
|
611
|
+
this.velocityBasedCarControl();
|
|
612
|
+
// Apply suspension + friction impulses from the shapecast wheels.
|
|
613
|
+
this.applyWheelImpulse();
|
|
614
|
+
}
|
|
615
|
+
}
|