@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,502 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TS port of the ecctrl controller's vehicle enter/exit example
|
|
4
|
+
// pattern (EcctrlWrapper): proximity sensors, key prompt, control + camera
|
|
5
|
+
// handoff between the on-foot character and registered vehicles, and exit
|
|
6
|
+
// placement beside the vehicle. Upstream keeps this logic as React demo glue
|
|
7
|
+
// (zustand store + useCallbacks + JSX sensor colliders); this port folds it
|
|
8
|
+
// into one reusable manager class. Deliberate renames from upstream:
|
|
9
|
+
// collider-name match "character-capsule-collider" -> collider HANDLE
|
|
10
|
+
// equality; store value "ecctrl" -> CHARACTER_ID = "character"; the store's
|
|
11
|
+
// typo'd `ContorlType` union is replaced by plain string ids.
|
|
12
|
+
|
|
13
|
+
import * as THREE from "three";
|
|
14
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
15
|
+
|
|
16
|
+
/** Well-known id for the on-foot character unit. */
|
|
17
|
+
export const CHARACTER_ID = "character";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What the follow camera needs from whichever unit is in control. Mirrors
|
|
21
|
+
* the subset of the upstream imperative handles read by the demo wrapper.
|
|
22
|
+
* All getters return live, internally-reused THREE objects — treat them as
|
|
23
|
+
* read-only and `.copy()` if you need to keep a value across frames.
|
|
24
|
+
*/
|
|
25
|
+
export interface FollowTargetLike {
|
|
26
|
+
/** Body world position. */
|
|
27
|
+
readonly currPos: THREE.Vector3;
|
|
28
|
+
/** Body-frame up axis (world space). */
|
|
29
|
+
readonly bodyYAxis: THREE.Vector3;
|
|
30
|
+
/** Gravity-aligned up axis (world space). */
|
|
31
|
+
readonly upAxis: THREE.Vector3;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The character controller as seen by this module. Implemented by
|
|
36
|
+
* `character/character-controller.ts`; enter-exit only knows this interface.
|
|
37
|
+
*/
|
|
38
|
+
export interface CharacterUnitLike extends FollowTargetLike {
|
|
39
|
+
/**
|
|
40
|
+
* The capsule collider — used to recognize the character in sensor events.
|
|
41
|
+
* Upstream matched by collider NAME ("character-capsule-collider"); the
|
|
42
|
+
* port matches by collider handle instead (raw Rapier colliders are
|
|
43
|
+
* nameless).
|
|
44
|
+
*/
|
|
45
|
+
readonly collider: RAPIER.Collider;
|
|
46
|
+
/**
|
|
47
|
+
* Hide + disable physics. Port equivalent of the upstream React unmount of
|
|
48
|
+
* the character component while driving. Must disable the rigid body AND
|
|
49
|
+
* collider, hide the visual group, and zero input.
|
|
50
|
+
*/
|
|
51
|
+
park(): void;
|
|
52
|
+
/**
|
|
53
|
+
* Teleport to (position, rotation) — the Euler uses order "YXZ" — zero
|
|
54
|
+
* linear/angular velocity, re-enable body and collider, show visuals.
|
|
55
|
+
* Port equivalent of remounting the character at a respawn transform.
|
|
56
|
+
*/
|
|
57
|
+
unpark(position: THREE.Vector3, rotation: THREE.Euler): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A drivable/flyable unit. Satisfied structurally by both
|
|
62
|
+
* `vehicle/vehicle-controller.ts` and `drone/drone-controller.ts`.
|
|
63
|
+
*/
|
|
64
|
+
export interface VehicleUnitLike extends FollowTargetLike {
|
|
65
|
+
/** Chassis rigid body — the proximity sensor collider attaches to it. */
|
|
66
|
+
readonly body: RAPIER.RigidBody;
|
|
67
|
+
/** World-space body X axis (live vector). */
|
|
68
|
+
readonly bodyXAxis: THREE.Vector3;
|
|
69
|
+
/** World-space body Z axis (live vector). */
|
|
70
|
+
readonly bodyZAxis: THREE.Vector3;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which live vehicle axis the character is placed along on exit.
|
|
75
|
+
* Cars step out sideways ("bodyX"); a drone drops the pilot off along its
|
|
76
|
+
* up axis ("up"). Evaluated at the moment of exit, never cached.
|
|
77
|
+
*/
|
|
78
|
+
export type ExitAxis = "bodyX" | "bodyZ" | "up";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Proximity sensor shape, created by the manager and attached to
|
|
82
|
+
* `vehicle.body`. Offsets are body-local. Bigger radius = the "Press F"
|
|
83
|
+
* prompt appears from farther away.
|
|
84
|
+
*/
|
|
85
|
+
export type VehicleSensorShape =
|
|
86
|
+
| {
|
|
87
|
+
kind: "cylinder";
|
|
88
|
+
halfHeight: number;
|
|
89
|
+
radius: number;
|
|
90
|
+
offset?: { x: number; y: number; z: number };
|
|
91
|
+
}
|
|
92
|
+
| { kind: "ball"; radius: number; offset?: { x: number; y: number; z: number } };
|
|
93
|
+
|
|
94
|
+
/** One vehicle the character can enter. */
|
|
95
|
+
export interface VehicleRegistration {
|
|
96
|
+
/** Unique id, e.g. "car", "drone". Must not equal CHARACTER_ID. */
|
|
97
|
+
id: string;
|
|
98
|
+
/** Prompt text shown to the player, e.g. "Car", "Drone". */
|
|
99
|
+
label: string;
|
|
100
|
+
vehicle: VehicleUnitLike;
|
|
101
|
+
/**
|
|
102
|
+
* Default: cylinder halfHeight 0.4, radius 1.5, offset (0, 0.1, 0) — the
|
|
103
|
+
* upstream car sensor. Use `{ kind: "ball", radius: 1 }` for a drone.
|
|
104
|
+
* Tuning hint: the upstream demo's second car uses cylinder 0.3/1.5 with
|
|
105
|
+
* no offset; radius is the knob that matters for prompt range.
|
|
106
|
+
*/
|
|
107
|
+
sensor?: VehicleSensorShape;
|
|
108
|
+
/**
|
|
109
|
+
* Direction the character is placed on exit, from LIVE vehicle axes at
|
|
110
|
+
* exit time. Cars: "bodyX" (default); drone: "up".
|
|
111
|
+
*/
|
|
112
|
+
exitAxis?: ExitAxis;
|
|
113
|
+
/**
|
|
114
|
+
* Distance along `exitAxis` from the vehicle center. Default 1.5 (equals
|
|
115
|
+
* the default sensor radius, so immediate re-entry stays possible —
|
|
116
|
+
* intended, matches upstream). Raise it if the character spawns inside
|
|
117
|
+
* wide chassis geometry.
|
|
118
|
+
*/
|
|
119
|
+
exitLength?: number;
|
|
120
|
+
/**
|
|
121
|
+
* Per-frame input routing while this vehicle is active. The game builds
|
|
122
|
+
* the concrete input object (keyboard/joystick modules) inside this
|
|
123
|
+
* callback, e.g. `() => car.setMovement(kb.getCarMovement())`.
|
|
124
|
+
*/
|
|
125
|
+
applyInput?: (dt: number) => void;
|
|
126
|
+
/**
|
|
127
|
+
* Handoff hooks. The drone registers `onOccupantEnter` to switch to
|
|
128
|
+
* VELOCITY control mode, and `onOccupantExit` to capture a hold target
|
|
129
|
+
* (`setTarget(currPos, bodyZAxis)`) then switch to POSITION mode.
|
|
130
|
+
* `onOccupantExit` fires BEFORE the character is unparked. Also the seam
|
|
131
|
+
* where game code plays Sitting_Enter / Driving_Loop / Sitting_Exit clips
|
|
132
|
+
* (new design — upstream unmounts the character instead of animating it).
|
|
133
|
+
*/
|
|
134
|
+
onOccupantEnter?: () => void;
|
|
135
|
+
onOccupantExit?: () => void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Current key-prompt target (which vehicle "Press F" would enter). */
|
|
139
|
+
export interface PromptTarget {
|
|
140
|
+
id: string;
|
|
141
|
+
label: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface EnterExitOptions {
|
|
145
|
+
world: RAPIER.World;
|
|
146
|
+
character: CharacterUnitLike;
|
|
147
|
+
/**
|
|
148
|
+
* Per-frame input routing while on foot, e.g.
|
|
149
|
+
* `() => character.setMovement(kb.getCharacterMovement())`.
|
|
150
|
+
*/
|
|
151
|
+
applyCharacterInput?: (dt: number) => void;
|
|
152
|
+
/**
|
|
153
|
+
* Fires whenever the key-prompt target changes (including -> null).
|
|
154
|
+
* Drive your DOM prompt element from here.
|
|
155
|
+
*/
|
|
156
|
+
onPromptChange?: (target: PromptTarget | null) => void;
|
|
157
|
+
/** Fires after control switches. fromId/toId are CHARACTER_ID or vehicle ids. */
|
|
158
|
+
onHandoff?: (fromId: string, toId: string) => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Named constant table (upstream demo values) -------------------------
|
|
162
|
+
// Default proximity sensor: the upstream car sensor (cylinder, mass 0 so it
|
|
163
|
+
// never shifts the chassis center of mass).
|
|
164
|
+
const DEFAULT_SENSOR: VehicleSensorShape = {
|
|
165
|
+
kind: "cylinder",
|
|
166
|
+
halfHeight: 0.4,
|
|
167
|
+
radius: 1.5,
|
|
168
|
+
offset: { x: 0, y: 0.1, z: 0 },
|
|
169
|
+
};
|
|
170
|
+
// Distance the character is placed from the vehicle center on exit.
|
|
171
|
+
const DEFAULT_EXIT_LENGTH = 1.5;
|
|
172
|
+
// Default exit direction (cars step out along body X).
|
|
173
|
+
const DEFAULT_EXIT_AXIS: ExitAxis = "bodyX";
|
|
174
|
+
// Camera aim point sits this far above the body center, along bodyYAxis.
|
|
175
|
+
const CAMERA_TARGET_LIFT = 0.5;
|
|
176
|
+
|
|
177
|
+
interface VehicleRecord {
|
|
178
|
+
reg: VehicleRegistration;
|
|
179
|
+
sensorCollider: RAPIER.Collider;
|
|
180
|
+
/** Character is inside this vehicle's proximity sensor. */
|
|
181
|
+
inRange: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Manages the character <-> vehicle enter/exit flow:
|
|
186
|
+
*
|
|
187
|
+
* - Creates one sensor collider per registered vehicle and tracks whether
|
|
188
|
+
* the character capsule is inside it.
|
|
189
|
+
* - Surfaces a key prompt (`onPromptChange`) for the nearest eligible
|
|
190
|
+
* vehicle (registration order = prompt priority, first match wins).
|
|
191
|
+
* - On `requestInteract()`: parks the character and hands control to the
|
|
192
|
+
* vehicle, or places the character beside the vehicle and hands control
|
|
193
|
+
* back.
|
|
194
|
+
* - Exposes `cameraTarget` / `cameraUp` / `activeVehicle` for the follow
|
|
195
|
+
* camera, and routes device input to whichever unit is in control.
|
|
196
|
+
*
|
|
197
|
+
* Loop contract (per fixed physics substep): call `update(dt)` FIRST, then
|
|
198
|
+
* the character/vehicle controllers' own `update()`, then `world.step()`.
|
|
199
|
+
* After stepping, feed drained collision events into
|
|
200
|
+
* `handleIntersectionEvent` — proximity therefore lags input by at most one
|
|
201
|
+
* frame, exactly as upstream.
|
|
202
|
+
*/
|
|
203
|
+
export class EnterExitManager {
|
|
204
|
+
private world: RAPIER.World;
|
|
205
|
+
private character: CharacterUnitLike;
|
|
206
|
+
private applyCharacterInput?: (dt: number) => void;
|
|
207
|
+
private onPromptChange?: (target: PromptTarget | null) => void;
|
|
208
|
+
private onHandoff?: (fromId: string, toId: string) => void;
|
|
209
|
+
|
|
210
|
+
/** Registration order = prompt priority. */
|
|
211
|
+
private records: VehicleRecord[] = [];
|
|
212
|
+
private activeId: string = CHARACTER_ID;
|
|
213
|
+
private prompt: PromptTarget | null = null;
|
|
214
|
+
/** One-shot interact latch, consumed at the start of the next update(). */
|
|
215
|
+
private interactRequested = false;
|
|
216
|
+
|
|
217
|
+
// Camera feed (reused instances — consumers copy, never mutate).
|
|
218
|
+
// Upstream initializes both to (0,0,0); the port starts cameraUp at
|
|
219
|
+
// (0,1,0) so the up vector is never degenerate before the first update
|
|
220
|
+
// (deliberate, documented deviation).
|
|
221
|
+
private cameraTargetV = new THREE.Vector3();
|
|
222
|
+
private cameraUpV = new THREE.Vector3(0, 1, 0);
|
|
223
|
+
|
|
224
|
+
// Preallocated exit-transform scratch (upstream: five refs).
|
|
225
|
+
private exitPos = new THREE.Vector3();
|
|
226
|
+
private exitRot = new THREE.Euler();
|
|
227
|
+
private exitZAxis = new THREE.Vector3();
|
|
228
|
+
private exitXAxis = new THREE.Vector3();
|
|
229
|
+
private exitMatrix = new THREE.Matrix4();
|
|
230
|
+
|
|
231
|
+
constructor(opts: EnterExitOptions) {
|
|
232
|
+
this.world = opts.world;
|
|
233
|
+
this.character = opts.character;
|
|
234
|
+
this.applyCharacterInput = opts.applyCharacterInput;
|
|
235
|
+
this.onPromptChange = opts.onPromptChange;
|
|
236
|
+
this.onHandoff = opts.onHandoff;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Creates the sensor collider on `reg.vehicle.body` and starts tracking.
|
|
241
|
+
* Call after the vehicle's rigid body exists. Registration ORDER = prompt
|
|
242
|
+
* priority (first registered wins when sensors overlap).
|
|
243
|
+
*/
|
|
244
|
+
registerVehicle(reg: VehicleRegistration): void {
|
|
245
|
+
if (reg.id === CHARACTER_ID) {
|
|
246
|
+
throw new Error(`EnterExitManager: vehicle id "${CHARACTER_ID}" is reserved`);
|
|
247
|
+
}
|
|
248
|
+
if (this.records.some((r) => r.reg.id === reg.id)) {
|
|
249
|
+
throw new Error(`EnterExitManager: duplicate vehicle id "${reg.id}"`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const shape = reg.sensor ?? DEFAULT_SENSOR;
|
|
253
|
+
const desc =
|
|
254
|
+
shape.kind === "cylinder"
|
|
255
|
+
? RAPIER.ColliderDesc.cylinder(shape.halfHeight, shape.radius)
|
|
256
|
+
: RAPIER.ColliderDesc.ball(shape.radius);
|
|
257
|
+
desc.setSensor(true);
|
|
258
|
+
// mass 0: the sensor must never shift the chassis mass properties —
|
|
259
|
+
// that would break car/drone tuning parity.
|
|
260
|
+
desc.setMass(0);
|
|
261
|
+
if (shape.offset) desc.setTranslation(shape.offset.x, shape.offset.y, shape.offset.z);
|
|
262
|
+
// Raw Rapier needs the explicit opt-in (@react-three/rapier set this
|
|
263
|
+
// implicitly when intersection handlers existed). Without it, sensor
|
|
264
|
+
// events silently never fire.
|
|
265
|
+
desc.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS);
|
|
266
|
+
|
|
267
|
+
const sensorCollider = this.world.createCollider(desc, reg.vehicle.body);
|
|
268
|
+
this.records.push({ reg, sensorCollider, inRange: false });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Removes the sensor collider and forgets the vehicle. */
|
|
272
|
+
unregisterVehicle(id: string): void {
|
|
273
|
+
const index = this.records.findIndex((r) => r.reg.id === id);
|
|
274
|
+
if (index < 0) return;
|
|
275
|
+
const [record] = this.records.splice(index, 1);
|
|
276
|
+
this.world.removeCollider(record.sensorCollider, false);
|
|
277
|
+
this.refreshPrompt();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Edge-triggered interact request (F key rising edge / virtual button
|
|
282
|
+
* press). Latched and consumed at the START of the next `update(dt)`;
|
|
283
|
+
* multiple calls within one frame collapse into one request. Wire this to
|
|
284
|
+
* a key TRANSITION, not level — polling a held key here would enter and
|
|
285
|
+
* exit every frame.
|
|
286
|
+
*/
|
|
287
|
+
requestInteract(): void {
|
|
288
|
+
this.interactRequested = true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Feed Rapier collision events here, e.g. from the shared physics-world
|
|
293
|
+
* drain: `eventQueue.drainCollisionEvents((h1, h2, started) =>
|
|
294
|
+
* mgr.handleIntersectionEvent(h1, h2, started))`. Runs AFTER
|
|
295
|
+
* `world.step()`; effects are consumed by the NEXT frame's `update()`.
|
|
296
|
+
*/
|
|
297
|
+
handleIntersectionEvent(handle1: number, handle2: number, started: boolean): void {
|
|
298
|
+
const charHandle = this.character.collider.handle;
|
|
299
|
+
for (const record of this.records) {
|
|
300
|
+
const sensorHandle = record.sensorCollider.handle;
|
|
301
|
+
if (
|
|
302
|
+
(handle1 === sensorHandle && handle2 === charHandle) ||
|
|
303
|
+
(handle2 === sensorHandle && handle1 === charHandle)
|
|
304
|
+
) {
|
|
305
|
+
record.inRange = started;
|
|
306
|
+
this.refreshPrompt();
|
|
307
|
+
}
|
|
308
|
+
// Any event not matching a (sensor, character-capsule) pair is
|
|
309
|
+
// ignored — this replaces the upstream collider-name check.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Per-frame logic. MUST run each physics frame BEFORE the active
|
|
315
|
+
* controller's `update(dt)`, which itself runs before `world.step()`.
|
|
316
|
+
* Consumes a pending interact request, refreshes the camera target/up,
|
|
317
|
+
* and routes input to the active unit. This module does no integration —
|
|
318
|
+
* `dt` is only forwarded to the `applyInput` callbacks.
|
|
319
|
+
*/
|
|
320
|
+
update(dt: number): void {
|
|
321
|
+
// 1. Consume interact request.
|
|
322
|
+
if (this.interactRequested) {
|
|
323
|
+
this.interactRequested = false;
|
|
324
|
+
this.performInteract();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 2. Camera target/up.
|
|
328
|
+
if (this.activeId === CHARACTER_ID) {
|
|
329
|
+
// Guard: skip the copy while the character position is still all-zero
|
|
330
|
+
// (pre-physics); keep previous values, matching upstream.
|
|
331
|
+
if (this.character.currPos.lengthSq() > 0) {
|
|
332
|
+
this.cameraTargetV
|
|
333
|
+
.copy(this.character.currPos)
|
|
334
|
+
.addScaledVector(this.character.bodyYAxis, CAMERA_TARGET_LIFT);
|
|
335
|
+
this.cameraUpV.copy(this.character.upAxis);
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
const record = this.activeRecord();
|
|
339
|
+
if (record) {
|
|
340
|
+
const v = record.reg.vehicle;
|
|
341
|
+
this.cameraTargetV.copy(v.currPos).addScaledVector(v.bodyYAxis, CAMERA_TARGET_LIFT);
|
|
342
|
+
this.cameraUpV.copy(v.upAxis);
|
|
343
|
+
} else {
|
|
344
|
+
// Defensive branch (unreachable in practice): no active unit.
|
|
345
|
+
this.cameraTargetV.set(0, 0, 0);
|
|
346
|
+
this.cameraUpV.set(0, 1, 0);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 3. Route input to whichever unit is in control.
|
|
351
|
+
if (this.activeId === CHARACTER_ID) {
|
|
352
|
+
this.applyCharacterInput?.(dt);
|
|
353
|
+
} else {
|
|
354
|
+
this.activeRecord()?.reg.applyInput?.(dt);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Which unit has control: CHARACTER_ID or a registered vehicle id. */
|
|
359
|
+
get activeControllerId(): string {
|
|
360
|
+
return this.activeId;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The unit in control (character or vehicle) as a camera target. */
|
|
364
|
+
get activeUnit(): FollowTargetLike {
|
|
365
|
+
const record = this.activeRecord();
|
|
366
|
+
return record ? record.reg.vehicle : this.character;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The active vehicle, or null when on foot — lets camera glue do the
|
|
371
|
+
* vehicle-heading alignment via `activeVehicle.bodyZAxis`.
|
|
372
|
+
*/
|
|
373
|
+
get activeVehicle(): VehicleUnitLike | null {
|
|
374
|
+
return this.activeRecord()?.reg.vehicle ?? null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `activeUnit.currPos + activeUnit.bodyYAxis * 0.5`, recomputed in
|
|
379
|
+
* `update()`. Reused internal vector — copy, do not mutate.
|
|
380
|
+
*/
|
|
381
|
+
get cameraTarget(): THREE.Vector3 {
|
|
382
|
+
return this.cameraTargetV;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Copy of `activeUnit.upAxis`. The follow camera lerps `camera.up`
|
|
387
|
+
* toward this each frame (that lerp lives in follow-camera, not here).
|
|
388
|
+
* Reused internal vector — copy, do not mutate.
|
|
389
|
+
*/
|
|
390
|
+
get cameraUp(): THREE.Vector3 {
|
|
391
|
+
return this.cameraUpV;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Current prompt target, or null when nothing is in range. */
|
|
395
|
+
get promptTarget(): PromptTarget | null {
|
|
396
|
+
return this.prompt;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Removes all sensor colliders; the manager becomes inert. */
|
|
400
|
+
dispose(): void {
|
|
401
|
+
for (const record of this.records) {
|
|
402
|
+
this.world.removeCollider(record.sensorCollider, false);
|
|
403
|
+
}
|
|
404
|
+
this.records = [];
|
|
405
|
+
this.setPrompt(null);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- private -----------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
private activeRecord(): VehicleRecord | null {
|
|
411
|
+
if (this.activeId === CHARACTER_ID) return null;
|
|
412
|
+
return this.records.find((r) => r.reg.id === this.activeId) ?? null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Fires onPromptChange only when the target actually changes. */
|
|
416
|
+
private setPrompt(next: PromptTarget | null): void {
|
|
417
|
+
const prev = this.prompt;
|
|
418
|
+
const changed =
|
|
419
|
+
(prev === null) !== (next === null) ||
|
|
420
|
+
(prev !== null && next !== null && prev.id !== next.id);
|
|
421
|
+
this.prompt = next;
|
|
422
|
+
if (changed) this.onPromptChange?.(next);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Port of the upstream `updateVehicleAccessTarget` callback. */
|
|
426
|
+
private refreshPrompt(): void {
|
|
427
|
+
if (this.activeId !== CHARACTER_ID) {
|
|
428
|
+
this.setPrompt(null);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const first = this.records.find((r) => r.inRange);
|
|
432
|
+
this.setPrompt(first ? { id: first.reg.id, label: first.reg.label } : null);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Port of the upstream `handleVehicleAccess` callback. */
|
|
436
|
+
private performInteract(): void {
|
|
437
|
+
// ENTERING a vehicle.
|
|
438
|
+
if (this.activeId === CHARACTER_ID) {
|
|
439
|
+
const record = this.records.find((r) => r.inRange);
|
|
440
|
+
if (!record) return;
|
|
441
|
+
record.inRange = false;
|
|
442
|
+
this.character.park();
|
|
443
|
+
// Port adaptation: upstream unmounted the character, which REMOVED
|
|
444
|
+
// the capsule collider and made Rapier emit intersection-exit events
|
|
445
|
+
// for every sensor. A disable-based park does not guarantee those
|
|
446
|
+
// `stopped` events across Rapier versions, so clear every in-range
|
|
447
|
+
// flag locally. Do NOT remove this even if events appear to fire.
|
|
448
|
+
for (const r of this.records) r.inRange = false;
|
|
449
|
+
this.activeId = record.reg.id;
|
|
450
|
+
this.setPrompt(null);
|
|
451
|
+
record.reg.onOccupantEnter?.();
|
|
452
|
+
this.onHandoff?.(CHARACTER_ID, record.reg.id);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// EXITING the active vehicle.
|
|
457
|
+
const record = this.activeRecord();
|
|
458
|
+
if (!record) return;
|
|
459
|
+
const vehicle = record.reg.vehicle;
|
|
460
|
+
const exitAxis = record.reg.exitAxis ?? DEFAULT_EXIT_AXIS;
|
|
461
|
+
// Live axes, read at the moment of exit — never cached at registration.
|
|
462
|
+
const exitDir =
|
|
463
|
+
exitAxis === "bodyX"
|
|
464
|
+
? vehicle.bodyXAxis
|
|
465
|
+
: exitAxis === "up"
|
|
466
|
+
? vehicle.upAxis
|
|
467
|
+
: vehicle.bodyZAxis;
|
|
468
|
+
this.computeExitTransform(vehicle, exitDir, record.reg.exitLength ?? DEFAULT_EXIT_LENGTH);
|
|
469
|
+
// onOccupantExit BEFORE unparking: the drone must capture its hold
|
|
470
|
+
// target (currPos/bodyZAxis) before anything else moves.
|
|
471
|
+
record.reg.onOccupantExit?.();
|
|
472
|
+
this.character.unpark(this.exitPos, this.exitRot);
|
|
473
|
+
this.activeId = CHARACTER_ID;
|
|
474
|
+
this.setPrompt(null);
|
|
475
|
+
this.onHandoff?.(record.reg.id, CHARACTER_ID);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Port of the upstream `computeExitTransform` — exact math and order.
|
|
480
|
+
* Writes `this.exitPos` / `this.exitRot`.
|
|
481
|
+
*
|
|
482
|
+
* Faithful non-guard: if the vehicle is flipped so bodyZAxis is parallel
|
|
483
|
+
* to upAxis, projectOnPlane yields a near-zero vector and normalize()
|
|
484
|
+
* produces NaN — upstream does not guard this either (realistic trigger:
|
|
485
|
+
* exiting a nose-down drone along its up axis).
|
|
486
|
+
*/
|
|
487
|
+
private computeExitTransform(
|
|
488
|
+
vehicle: VehicleUnitLike,
|
|
489
|
+
exitDirection: THREE.Vector3,
|
|
490
|
+
exitLength: number,
|
|
491
|
+
): void {
|
|
492
|
+
this.exitPos.copy(vehicle.currPos).addScaledVector(exitDirection, exitLength);
|
|
493
|
+
const up = vehicle.upAxis;
|
|
494
|
+
this.exitZAxis.copy(vehicle.bodyZAxis).projectOnPlane(up).normalize();
|
|
495
|
+
// Cross order matters: X = up x Z. Swapping mirrors the spawn basis.
|
|
496
|
+
this.exitXAxis.crossVectors(up, this.exitZAxis);
|
|
497
|
+
// makeBasis takes COLUMN vectors X, Y, Z.
|
|
498
|
+
this.exitMatrix.makeBasis(this.exitXAxis, up, this.exitZAxis);
|
|
499
|
+
// Euler order "YXZ" — the character's unpark() interprets it the same way.
|
|
500
|
+
this.exitRot.setFromRotationMatrix(this.exitMatrix, "YXZ");
|
|
501
|
+
}
|
|
502
|
+
}
|