@genex-ai/cli-demo 0.11.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +203 -4
  3. package/package.json +7 -2
  4. package/templates/controllers/NOTICE.md +65 -0
  5. package/templates/controllers/assets/animation-library.glb +0 -0
  6. package/templates/controllers/assets/character.glb +0 -0
  7. package/templates/controllers/assets/default-avatar.vrm +0 -0
  8. package/templates/controllers/character/character-animations.ts +682 -0
  9. package/templates/controllers/character/character-controller.ts +1636 -0
  10. package/templates/controllers/character/follow-camera.ts +644 -0
  11. package/templates/controllers/character/keyboard-input.ts +277 -0
  12. package/templates/controllers/character/presets.ts +176 -0
  13. package/templates/controllers/character/touch-joystick.ts +387 -0
  14. package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
  15. package/templates/controllers/character/vrm/foot-ik.ts +341 -0
  16. package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
  17. package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
  18. package/templates/controllers/drone/drone-controller.ts +1073 -0
  19. package/templates/controllers/drone/presets.ts +225 -0
  20. package/templates/controllers/interact/enter-exit.ts +502 -0
  21. package/templates/controllers/shared/colliders.ts +456 -0
  22. package/templates/controllers/shared/math.ts +230 -0
  23. package/templates/controllers/shared/physics-world.ts +622 -0
  24. package/templates/controllers/vehicle/presets.ts +297 -0
  25. package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
  26. package/templates/controllers/vehicle/wheel.ts +1200 -0
  27. package/templates/skills/genex-getting-started/SKILL.md +5 -0
  28. package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
  29. package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
  30. package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
  31. package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
  32. package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
  33. package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
  34. package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
  35. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  36. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  37. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  38. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  39. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  41. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  42. 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
+ }