@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,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".
|