@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.
- 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-embed-auth/SKILL.md +126 -54
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
- 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,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".
|