@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,162 @@
1
+ # Car: VehicleController + ShapeCastWheel
2
+
3
+ The car is three cooperating pieces:
4
+
5
+ - **`VehicleController`** (`vehicle/vehicle-controller.ts`) — the brain. It
6
+ creates a dynamic Rapier body **without colliders**, owns the drivetrain
7
+ (engine torque curve, gear ratios, RPM-threshold auto shift), routes
8
+ drive/brake/steer demands to the wheels, and applies their suspension +
9
+ friction impulses to the body.
10
+ - **`ShapeCastWheel`** (`vehicle/wheel.ts`) — one per corner, created via
11
+ `car.addWheel()`. Each wheel sweeps a cylinder at the ground, computes a
12
+ suspension spring/damper impulse and slip-curve tire friction, and drives
13
+ its own visual groups (steer, suspension bounce, spin).
14
+ - **`vehiclePresets`** (`vehicle/presets.ts`) — complete car recipes:
15
+ `carConfig`, chassis collider specs, shared wheel options, and four wheel
16
+ slots (order FL, FR, RL, RR).
17
+
18
+ Conventions: **+Z is the car's forward axis**, +X is left, and positive steer
19
+ input turns LEFT. Wheel `position` is the axle mount point in chassis-local
20
+ space.
21
+
22
+ ## Wiring (matches the shipped preset shapes)
23
+
24
+ ```ts
25
+ import * as THREE from "three";
26
+ import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
27
+ import { cuboidCollider } from "./controllers/shared/colliders.ts";
28
+ import { VehicleController } from "./controllers/vehicle/vehicle-controller.ts";
29
+ import { vehiclePresets } from "./controllers/vehicle/presets.ts";
30
+ import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
31
+
32
+ const physics = await PhysicsWorld.create();
33
+ const world = physics.world;
34
+
35
+ // 1. Controller (creates the dynamic body — no colliders yet).
36
+ const preset = vehiclePresets["arcade-kart"];
37
+ const car = new VehicleController({
38
+ world,
39
+ position: new THREE.Vector3(12, 1.4, 4),
40
+ carConfig: preset.carConfig,
41
+ });
42
+
43
+ // 2. Chassis colliders — YOU attach them to car.body from the preset recipe.
44
+ for (const c of preset.chassisColliders) {
45
+ cuboidCollider(world, car.body, [c.halfExtents.x, c.halfExtents.y, c.halfExtents.z], {
46
+ position: [c.offset.x, c.offset.y, c.offset.z],
47
+ density: c.density,
48
+ });
49
+ }
50
+
51
+ // 3. Wheels — shared options + per-slot role flags. Register all four
52
+ // before the first update() so the torque split is stable.
53
+ const wheelGeom = new THREE.CylinderGeometry(0.5, 0.5, 0.3, 20);
54
+ wheelGeom.rotateZ(Math.PI / 2); // the wheel model spins around LOCAL X
55
+ for (const slot of preset.wheelSlots) {
56
+ const wheel = car.addWheel({
57
+ ...preset.wheelShared,
58
+ ...slot,
59
+ position: new THREE.Vector3(slot.position.x, slot.position.y, slot.position.z),
60
+ });
61
+ const mesh = new THREE.Mesh(wheelGeom, wheelMat);
62
+ wheel.modelObject.add(mesh); // wheelGroup steers, suspensionGroup bounces, modelObject spins
63
+ }
64
+
65
+ // 4. Scene graph: chassis mesh under chassisObject, chassisObject in the scene.
66
+ car.chassisObject.add(carBodyMesh); // your visual chassis (a genex model GLB works)
67
+ scene.add(car.chassisObject);
68
+ physics.registerBody(car.body, car.chassisObject); // interpolated render sync
69
+
70
+ // 5. Input + fixed-step update (BEFORE world.step(), via onBeforeStep).
71
+ const kb = new KeyboardInput(); // WASD/arrows drive+steer, Space = brake
72
+ physics.onBeforeStep(() => {
73
+ car.setMovement(kb.getCarMovement());
74
+ car.update();
75
+ });
76
+ ```
77
+
78
+ `setMovement` merges field-wise — send the complete `CarMovementIntent`
79
+ every frame (a stale partial leaves old `true`s behind), which
80
+ `kb.getCarMovement()` already does. Touch input: pass
81
+ `{ joystickL: { x, y } }` from `TouchJoystick` (pushing right steers right).
82
+
83
+ If an on-foot character shares the world, create its body with
84
+ `userData: { controller: { excludeVehicleRay: true } }` so the wheels never
85
+ treat the player as drivable ground.
86
+
87
+ ## Presets and provenance
88
+
89
+ | Preset | Drivetrain | Suspension | Notes |
90
+ | --- | --- | --- | --- |
91
+ | `arcade-kart` | 600 HP, AWD, front steer, single gear | stiff (springK 38000) | upstream demo "Vehicle 1", verbatim. The safe default. |
92
+ | `muscle-drift` | 600 HP, AWD with rear `driveTorqueWeight: 2` | soft (springK 25000) | upstream demo "Vehicle 2", verbatim. Power-oversteers into drifts under throttle. |
93
+ | `offroad-bouncy` | 450 HP, AWD, wider steering | soft, long travel (rayLength 0.8), bigger wheels | Genex-authored starting point derived from arcade-kart. |
94
+ | `race-grip` | 800 HP, RWD, 4-speed gearbox | very stiff (springK 42000) | Genex-authored, tuned in the testbed (see below). |
95
+
96
+ **Suspension scales with mass.** `springK`/`dampingC` in `wheelShared` were
97
+ tuned against each preset's `assumedChassisDensity` (200 for all four). If
98
+ your chassis is materially heavier or lighter, rescale `springK`
99
+ proportionally and keep `dampingC` below `2*sqrt(springK * massPerWheel)` —
100
+ too little spring bottoms out, too much damping locks the suspension solid.
101
+
102
+ ## Gearbox and RPM
103
+
104
+ - Peak engine torque derives as `engineHorsepower * 7022 / engineMaxRPM`
105
+ (N·m); "car feels slow" → raise `engineHorsepower`.
106
+ - A single-entry `gearRatios` (arcade-kart, muscle-drift) disables shifting.
107
+ Multiple entries enable the auto shift: up above `shiftUpRPM`, down below
108
+ `shiftDownRPM`, with `shiftCooldown` seconds between shifts.
109
+ `transmissionMode: "manual"` shifts only via `car.setGear(index)`.
110
+ - Live telemetry for HUDs: `car.engineRPM` (drive-weighted average wheel RPM
111
+ × drive ratio), `car.gearIndex` (0-based — display `gearIndex + 1`),
112
+ `car.currLinVel.length() * 3.6` for km/h.
113
+ - **Why race-grip's gearing works** (and what to check if you author your
114
+ own gearbox): rolling resistance grows with wheel speed, so each gear has
115
+ a drag-limited equilibrium RPM it can never exceed on flat ground
116
+ (measured in the testbed: ~5500 / ~4900 / ~4100 for race-grip's gears
117
+ 1–3). `shiftUpRPM` must sit BELOW the current gear's equilibrium or the
118
+ shift point is never reached and the car sits at redline forever.
119
+ race-grip uses `shiftUpRPM: 4300`, giving clean upshifts with post-shift
120
+ RPM ~2800–3000 — safely above `shiftDownRPM: 2400`, so no gear hunting.
121
+ Lower `rollingResistanceCoef` (race-grip: 0.004 vs default 0.007) raises
122
+ every gear's ceiling and buys the shifter headroom.
123
+
124
+ ## Drift tuning: tire grip vs rollover
125
+
126
+ The knobs, in the order to reach for them:
127
+
128
+ - `tireGripFactor` (wheel option, default 1.5) — averaged with the ground
129
+ collider's friction: effective grip = `(surfaceFriction + tireGripFactor) / 2`.
130
+ Lower = more slide everywhere.
131
+ - `latFrictionEllipseScale` — scales ONLY the cornering half of the friction
132
+ ellipse. The direct drift knob: below 1 the car slides sideways sooner
133
+ while braking/accelerating stay strong.
134
+ - `driveTorqueWeight` on the rear slots (muscle-drift uses 2) — rear-biased
135
+ torque makes throttle break the rear loose.
136
+ - Brake-and-turn (Space is the brake) initiates a slide with any preset.
137
+
138
+ **The rollover trap** — measured in the testbed while retuning `race-grip`:
139
+ the lateral slip curve keeps ~90% grip even in a full slide, so peak lateral
140
+ acceleration is about `(surfaceFriction + tireGripFactor) / 2 *
141
+ latFrictionEllipseScale` in g. If that exceeds the chassis's static rollover
142
+ threshold (`halfTrack / comHeight` — ~1.0 g for the race-grip chassis), a
143
+ hard slide TRIPS THE CAR OVER instead of drifting. The race-grip retune
144
+ fixed exactly this, two-sided:
145
+
146
+ 1. capped lateral grip with `latFrictionEllipseScale: 0.8` (and
147
+ `lngFrictionEllipseScale: 1.15` so braking/launch stay strong), and
148
+ 2. lowered the center of mass — a light cabin collider (density 60) over a
149
+ low-slung main mass (offset y −0.15) keeps the CoM near axle height so
150
+ hard cornering leans instead of tipping.
151
+
152
+ If your car flips in corners, do the same: lower the grip-side product or
153
+ lower the CoM. Raising `springK` alone does not fix rollover.
154
+
155
+ ## Telemetry for effects
156
+
157
+ Per wheel (from `car.wheels`, a `ReadonlyMap<string, ShapeCastWheel>`):
158
+ `wheel.slipStrength` (0..1, max of longitudinal/lateral slip — the skid
159
+ smoke/screech trigger), `wheel.rayHitPos` + `wheel.rayHitNormal` (where to
160
+ spawn marks), `wheel.wheelLinVel` (surface speed), `wheel.rayHit` (null when
161
+ airborne). Vector getters are live internal instances — `.copy()` them,
162
+ never mutate. Value getters are one-frame-stale snapshots by design.
@@ -0,0 +1,150 @@
1
+ # Drone: DroneController + propellers
2
+
3
+ `DroneController` (`drone/drone-controller.ts`) is a PD flight brain over a
4
+ **caller-created** dynamic body: each registered propeller contributes thrust
5
+ along its mount's local +Y plus a reaction torque; every step the brain
6
+ computes a hover throttle (weight / total upward thrust potential) and mixes
7
+ per-propeller attitude corrections on top, clamped so attitude control never
8
+ costs altitude. Unlike the car, the controller never creates or frees the
9
+ body — you build body, colliders, and chassis visuals, then hand them over.
10
+
11
+ `dronePresets` (`drone/presets.ts`) are complete recipes: `config` (merged
12
+ over `DEFAULT_DRONE_CONFIG`), four propeller slots in a quad-X layout, and a
13
+ collider recipe (`body`) with the density the gains were tuned against.
14
+
15
+ ## Wiring (from the preset recipe)
16
+
17
+ ```ts
18
+ import * as THREE from "three";
19
+ import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
20
+ import { cuboidCollider, cylinderCollider } from "./controllers/shared/colliders.ts";
21
+ import { DroneController, type PropellerOptions } from "./controllers/drone/drone-controller.ts";
22
+ import { dronePresets } from "./controllers/drone/presets.ts";
23
+ import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
24
+
25
+ const physics = await PhysicsWorld.create();
26
+ const preset = dronePresets["camera-drone"];
27
+
28
+ // 1. Chassis visual root + one mount Object3D per propeller (descendants of
29
+ // the chassis; local +Y = thrust axis). spinModel is optional blade visual.
30
+ const chassis = new THREE.Group();
31
+ chassis.add(coreMesh); // your body visual
32
+ const propellers: PropellerOptions[] = [];
33
+ for (const p of preset.propellers) {
34
+ const mount = new THREE.Object3D();
35
+ mount.position.set(p.position.x, p.position.y, p.position.z);
36
+ chassis.add(mount);
37
+ const blade = new THREE.Mesh(bladeGeom, bladeMat);
38
+ mount.add(blade);
39
+ propellers.push({
40
+ object: mount,
41
+ spinModel: blade,
42
+ maxThrust: p.maxThrust,
43
+ torqueRatio: p.torqueRatio,
44
+ invertTorque: p.invertTorque,
45
+ });
46
+ }
47
+ scene.add(chassis);
48
+
49
+ // 2. Dynamic body + colliders from the preset recipe (density sets the mass).
50
+ const body = physics.createBody({ type: "dynamic", position: [-6, 1.6, 14] }, chassis);
51
+ const b = preset.body;
52
+ cuboidCollider(physics.world, body,
53
+ [b.cuboidHalfExtents.x, b.cuboidHalfExtents.y, b.cuboidHalfExtents.z],
54
+ { density: b.density });
55
+ for (const pos of b.armCylinders.positions) {
56
+ cylinderCollider(physics.world, body, b.armCylinders.halfHeight, b.armCylinders.radius,
57
+ { position: [pos.x, pos.y, pos.z], density: b.density });
58
+ }
59
+
60
+ // 3. Controller + fixed-step update (BEFORE world.step()).
61
+ const drone = new DroneController({
62
+ world: physics.world,
63
+ body,
64
+ chassis,
65
+ propellers,
66
+ config: preset.config,
67
+ });
68
+ const kb = new KeyboardInput(); // W/S throttle, A/D yaw, arrows pitch/roll
69
+ physics.onBeforeStep(() => {
70
+ drone.setMovement(kb.getDroneMovement());
71
+ drone.update();
72
+ });
73
+ ```
74
+
75
+ The keyboard scheme is deliberately asymmetric (WASD = throttle/yaw, arrows
76
+ = pitch/roll — they are NOT aliases; do not "unify" them). Touch sticks map
77
+ as `joystickL` = climb/yaw, `joystickR` = pitch/roll.
78
+
79
+ ## Control modes: VELOCITY vs POSITION
80
+
81
+ `DroneControlMode` — switch at runtime with `drone.setControlMode(mode)`:
82
+
83
+ - **`"VELOCITY"`** — stick flying: inputs command velocities (up to
84
+ `maxHorizSpeed` / `maxVertSpeed` / `maxYawRate`) and the PD loop converts
85
+ them to tilt + throttle. Use while a player is on board.
86
+ - **`"POSITION"`** — autopilot: the drone holds `targetPos` and faces
87
+ `targetFwd`. Use for parked, idle, or scripted drones. The parking recipe
88
+ (also what the enter/exit hook does on dismount):
89
+
90
+ ```ts
91
+ drone.setTarget(drone.currPos, drone.bodyZAxis); // hold HERE, facing THIS way
92
+ drone.setControlMode("POSITION");
93
+ ```
94
+
95
+ Set the target BEFORE anything else moves — `currPos`/`bodyZAxis` are live
96
+ vectors; `setTarget` copies them.
97
+
98
+ ## The PD gain mass-scaling rule
99
+
100
+ `DroneConfig` gains fall into two families — this is the single most common
101
+ tuning mistake:
102
+
103
+ - **`VERT_POS_P/D`, `HORIZ_POS_P/D`** (POSITION-mode hold gains) are in
104
+ **absolute force units** — they MUST scale with the drone's mass. The
105
+ library defaults fit a ~2 kg drone; the `heavy-lifter` preset (~298 kg)
106
+ ships them pre-scaled ×100 (e.g. `VERT_POS_P: 900` vs default 9). A drone
107
+ that sags away from its hold point or oscillates around it after a mass
108
+ change has stale POSITION gains.
109
+ - **`HORIZ_VEL_P`, `VERT_VEL_P`** (VELOCITY-mode gains) are in
110
+ **acceleration units** — mass-independent, usually fine as-is.
111
+ - `airDragFactor` is also an absolute force per (m/s): meaningful on a 2 kg
112
+ drone, cosmetic on a 300 kg one.
113
+ - Attitude feel: sluggish to tilt → raise `TILT_P`; wobbles/overshoots after
114
+ a maneuver → raise `TILT_D`. `maxTiltAngle` caps how far it leans (and
115
+ therefore horizontal acceleration).
116
+
117
+ Tune at runtime with `drone.updateConfig({ TILT_P: 18 })` — it recomputes
118
+ the cached tilt limit when `maxTiltAngle` changes.
119
+
120
+ ## Propeller sizing
121
+
122
+ - Size `maxThrust` so total thrust ≈ **2× the drone's weight**: hover
123
+ throttle then sits near 0.5, which maximizes attitude authority (the mixer
124
+ clamps corrections to `min(1 - hover, hover)`). Check `drone.hoverThrottle`
125
+ at runtime — near 1 means underpowered (climbs barely, steers worse).
126
+ - Diagonal pairs must share the same `invertTorque` value (quad-X
127
+ counter-rotation) or reaction torques won't cancel and the drone yaws
128
+ constantly.
129
+ - `debug: true` on a propeller attaches upstream thrust/torque arrows +
130
+ axis markers under the mount — great while building a custom frame.
131
+
132
+ ## Presets and provenance
133
+
134
+ | Preset | Mass | Feel | Provenance |
135
+ | --- | --- | --- | --- |
136
+ | `camera-drone` | ~2 kg (density 335) | gentle 20° tilt limit, strong drag, floaty on purpose | Genex-authored |
137
+ | `racing-drone` | ~5 kg (density 240) | full 45° tilt, fast yaw, snappy `TILT_P: 18` | Genex-authored |
138
+ | `heavy-lifter` | ~298 kg (density 200) | huge climb reserve, hover ~0.146, POSITION gains ×100 | upstream demo, verbatim |
139
+
140
+ Each preset's `notes` field carries its own plain-language tuning hints; the
141
+ `approxMassKg`/`approxHoverThrottle` fields are documentation values (Rapier
142
+ derives the real mass from the colliders and density).
143
+
144
+ ## Telemetry for effects
145
+
146
+ `drone.propellersInfo` (a `ReadonlyMap<string, PropellerState>`) exposes per
147
+ propeller: `finalThrottle` (0..1 mixer output — rotor-wash/audio intensity),
148
+ `worldThrustPos`/`worldThrustDir` (where and which way to emit), and
149
+ `thrustImpulse`. `drone.hoverThrottle` reads the last computed hover value.
150
+ All vectors are reused internal instances — copy, never mutate.
@@ -0,0 +1,199 @@
1
+ # Enter/exit: character ↔ vehicle handoff
2
+
3
+ `EnterExitManager` (`interact/enter-exit.ts`) owns the whole flow: it puts a
4
+ proximity sensor on every registered vehicle, surfaces a "Press F" prompt
5
+ when the on-foot character walks into one, and on interact **parks** the
6
+ character (hidden + physics disabled) and hands control — input routing and
7
+ camera target — to the vehicle; interacting again places the character
8
+ beside the vehicle and hands control back.
9
+
10
+ It needs the on-foot character from `npx genex controller character`
11
+ (`CharacterController` implements the `park()`/`unpark()` contract the
12
+ manager calls; see `$genex-threejs-character-controller`). `CHARACTER_ID`
13
+ (`"character"`) is the reserved id for the on-foot unit.
14
+
15
+ ## Wiring
16
+
17
+ ```ts
18
+ import { EnterExitManager, CHARACTER_ID } from "./controllers/interact/enter-exit.ts";
19
+
20
+ const mgr = new EnterExitManager({
21
+ world: physics.world,
22
+ character, // the CharacterController instance
23
+ applyCharacterInput: () => {
24
+ character.setMovement(kb.getCharacterMovement()); // routed only while on foot
25
+ },
26
+ onPromptChange: (target) => {
27
+ // Drive your DOM prompt from here; fires on change only (incl. -> null).
28
+ promptEl.textContent = target ? `Press F to enter ${target.label}` : "";
29
+ promptEl.style.display = target ? "block" : "none";
30
+ },
31
+ onHandoff: (fromId, toId) => {
32
+ // Fires after control switches — seat visuals + input hygiene (below).
33
+ },
34
+ });
35
+
36
+ mgr.registerVehicle({
37
+ id: "car",
38
+ label: "Car",
39
+ vehicle: car, // VehicleController satisfies this structurally
40
+ exitAxis: "bodyX", // cars step out sideways
41
+ exitLength: 2.2,
42
+ // radius ≥ the chassis half-length (2.4 here) — see "Sensor and exit tuning".
43
+ sensor: { kind: "cylinder", halfHeight: 0.5, radius: 2.5, offset: { x: 0, y: 0.1, z: 0 } },
44
+ applyInput: () => car.setMovement(kb.getCarMovement()), // routed only while driving
45
+ });
46
+
47
+ mgr.registerVehicle({
48
+ id: "drone",
49
+ label: "Drone",
50
+ vehicle: drone,
51
+ exitAxis: "up", // the drone drops the pilot off below it
52
+ exitLength: 1.6,
53
+ sensor: { kind: "ball", radius: 2.4 },
54
+ applyInput: () => drone.setMovement(kb.getDroneMovement()),
55
+ onOccupantEnter: () => {
56
+ drone.setControlMode("VELOCITY"); // stick flying while boarded
57
+ },
58
+ onOccupantExit: () => {
59
+ // Fires BEFORE the character is unparked — capture the hold pose first.
60
+ drone.setTarget(drone.currPos, drone.bodyZAxis);
61
+ drone.setControlMode("POSITION"); // autopilot-hover where it was left
62
+ },
63
+ });
64
+
65
+ // Sensor events: feed the raw collision tap into the manager.
66
+ physics.onCollisionEvent((h1, h2, started) =>
67
+ mgr.handleIntersectionEvent(h1, h2, started)
68
+ );
69
+
70
+ // Interact: EDGE-triggered (key transition, never a held-key poll — that
71
+ // would enter and exit every frame). kb.onInteract is the F key, pre-guarded.
72
+ kb.onInteract(() => mgr.requestInteract());
73
+ ```
74
+
75
+ Loop order per fixed substep (inside `physics.onBeforeStep`): **`mgr.update(dt)`
76
+ FIRST** (consumes the interact request, refreshes the camera feed, routes
77
+ input to the active unit), then the controller `update()`s, then the world
78
+ steps. Skip `character.update()` while `character.isParked`. Sensor events
79
+ drain after the step, so proximity lags input by at most one frame — as
80
+ designed.
81
+
82
+ Registration order = prompt priority: when two sensors overlap, the first
83
+ registered vehicle wins the prompt.
84
+
85
+ ## Sensor and exit tuning
86
+
87
+ - Default sensor: cylinder `halfHeight 0.4, radius 1.5, offset (0, 0.1, 0)`.
88
+ `radius` is the knob that matters for prompt range. Sensors are mass-0 —
89
+ they never shift the chassis center of mass.
90
+ - **Long-chassis gotcha (measured in the testbed):** the sensor is centered
91
+ on the body, so if the chassis half-length exceeds the sensor radius
92
+ (testbed car: half-length 2.4 vs radius 2.2), walking up from dead astern
93
+ touches the bumper before the capsule enters the sensor — the prompt only
94
+ appears after sliding along the side, while a side approach prompts
95
+ immediately. Fix: sensor radius ≥ chassis half-length, or an offset
96
+ sensor per end.
97
+ - `exitLength` (default 1.5) is how far along `exitAxis` the character
98
+ reappears. Raise it if the character spawns inside a wide chassis. The
99
+ default equals the default sensor radius so immediate re-entry stays
100
+ possible — intended.
101
+ - Exiting a flipped vehicle whose forward axis is parallel to up produces a
102
+ degenerate exit basis (faithful upstream non-guard). If vehicles in your
103
+ game can end up nose-down, right them before allowing exit.
104
+
105
+ ## Seat visuals: Sitting_Enter / Driving_Loop / Sitting_Exit
106
+
107
+ `park()` hides the character body and visuals — the manager does NOT animate
108
+ a seated pilot; that is game glue on the `onHandoff` seam. The pattern: keep
109
+ a reference to the character's visual model, re-parent it into a seat anchor
110
+ under the vehicle's chassis object, and play the seat clips through a
111
+ dedicated `THREE.AnimationMixer` (the locomotion state machine is idle while
112
+ parked). The clips ship in `animation-library.glb` (installed by
113
+ `npx genex controller character`).
114
+
115
+ ```ts
116
+ const seatMixer = new THREE.AnimationMixer(characterModel);
117
+ const seatAnchor = new THREE.Group();
118
+
119
+ function enterSeat(host: THREE.Object3D, seatPos: THREE.Vector3) {
120
+ seatAnchor.position.copy(seatPos); // e.g. (0, 0.55, -0.6) for a car cabin
121
+ host.add(seatAnchor);
122
+ characterModel.position.set(0, 0, 0); // remember the old pose to restore on exit
123
+ characterModel.quaternion.identity();
124
+ seatAnchor.add(characterModel);
125
+ const enter = seatMixer.clipAction(sittingEnterClip); // "Sitting_Enter"
126
+ enter.reset();
127
+ enter.setLoop(THREE.LoopOnce, 1);
128
+ enter.clampWhenFinished = true;
129
+ enter.play();
130
+ // On the mixer's "finished" event, crossFade to the "Driving_Loop" action.
131
+ }
132
+
133
+ function exitSeat() {
134
+ // Optionally play "Sitting_Exit" (LoopOnce) before restoring; then:
135
+ seatMixer.stopAllAction();
136
+ seatAnchor.removeFromParent();
137
+ character.root.add(characterModel); // restore the remembered pose
138
+ }
139
+
140
+ // onHandoff wiring: enter when control moves TO a vehicle, exit when it
141
+ // returns to the character.
142
+ onHandoff: (fromId, toId) => {
143
+ if (toId !== CHARACTER_ID) enterSeat(hostFor(toId), seatPosFor(toId));
144
+ else exitSeat();
145
+ // Input hygiene: zero the released unit's held inputs, or an exited car
146
+ // keeps driving on its last merged input state.
147
+ if (fromId === "car") car.setMovement({ forward: false, backward: false,
148
+ steerLeft: false, steerRight: false, brake: false });
149
+ }
150
+ ```
151
+
152
+ While parked, tick `seatMixer.update(renderDelta)` in the render loop
153
+ instead of the locomotion `CharacterAnimations.update()`. With a rig-less
154
+ model (no matching clips) skip the mixer and just re-seat the visual — the
155
+ handoff still works.
156
+
157
+ ## Camera handoff via FollowCamera
158
+
159
+ The manager exposes a camera feed; the bundled `FollowCamera` consumes it.
160
+ Per RENDER frame (render delta — never inside the fixed step):
161
+
162
+ ```ts
163
+ const t = mgr.cameraTarget; // activeUnit.currPos + bodyYAxis * 0.5
164
+ followCam.moveTo(t.x, t.y, t.z, true);
165
+ followCam.setUp(mgr.cameraUp); // activeUnit.upAxis
166
+ if (mgr.activeControllerId === CHARACTER_ID) {
167
+ if (physics.stepsLastFrame > 0 && character.isOnPlatform) {
168
+ followCam.applyPlatformTurn(character.turnOnYQuat); // per-physics-step delta
169
+ }
170
+ } else if (mgr.activeVehicle) {
171
+ followCam.alignHeading(mgr.activeVehicle.bodyZAxis, delta); // ease behind the vehicle
172
+ }
173
+ followCam.update(delta);
174
+ ```
175
+
176
+ `alignHeading` eases the orbit behind the vehicle at `headingAlignGain`
177
+ (default 5; 0 disables) and always yields to an active user drag-orbit.
178
+ `cameraTarget`/`cameraUp` are reused internal vectors — copy, never mutate.
179
+
180
+ **Follow camera v1 limits (by design):** no pointer-lock look mode, no
181
+ truck/pan (the pivot is always the followed unit), and the orbit space
182
+ assumes up ≈ +Y — `camera.up` lerps toward the fed up-axis, but a
183
+ far-from-Y gravity direction will misbehave. For rigs beyond this, see
184
+ `$genex-threejs-camera-direction`.
185
+
186
+ ## Multiplayer
187
+
188
+ Occupancy is shared state — remote players must see who is in what. With
189
+ `$genex-threejs-multiplayer` (load it before writing any networking code):
190
+
191
+ - Put `driving: "car" | "drone" | null` in each player's synced state; on
192
+ remote change, show/hide that player's on-foot avatar and seat their
193
+ visual in the vehicle (the seat-visual pattern above, minus the physics).
194
+ - The occupant simulates the vehicle and publishes its pose; everyone else
195
+ interpolates a plain mesh. Never run the vehicle controllers for a remote
196
+ player's vehicle.
197
+ - Gate entry on a shared occupancy key so two players can't board the same
198
+ seat: claim it (e.g. `room.shared.set("occupant:car", room.id)`) and treat
199
+ a losing race as "prompt stays up".