@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,644 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TS port of the ecctrl controller's follow camera (standalone; no camera-controls dependency).
|
|
4
|
+
|
|
5
|
+
import * as THREE from "three";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for {@link FollowCamera}.
|
|
9
|
+
*
|
|
10
|
+
* Tuning cheat-sheet:
|
|
11
|
+
* - Camera feels laggy / rubber-bandy -> lower `smoothTime` (0.05 is snappy, 0.25 is cinematic).
|
|
12
|
+
* - Camera clips into walls -> add your static environment meshes to `colliderMeshes`.
|
|
13
|
+
* - Zoom range wrong for your scale -> `minDistance` / `maxDistance` (0.02 min enables first-person).
|
|
14
|
+
* - Vehicle camera swings behind the car too slowly/quickly -> `headingAlignGain` (default 5).
|
|
15
|
+
*/
|
|
16
|
+
export type FollowCameraOptions = {
|
|
17
|
+
/** Input surface for pointer/wheel events (usually `renderer.domElement`). */
|
|
18
|
+
domElement: HTMLElement;
|
|
19
|
+
/**
|
|
20
|
+
* SmoothDamp time (seconds) for target + orbit angles + distance. Roughly "time to cover ~63%
|
|
21
|
+
* of the remaining gap"; the camera settles in ~2-3x this. Default 0.1 (demo-tuned).
|
|
22
|
+
*/
|
|
23
|
+
smoothTime?: number;
|
|
24
|
+
/**
|
|
25
|
+
* SmoothDamp time while the user is controlling an axis, applied PER AXIS (upstream parity):
|
|
26
|
+
* orbit angles use it while a drag-rotate is in effect, distance while a wheel/pinch zoom is
|
|
27
|
+
* in effect (each flag persists until the next programmatic rotate()/dollyTo() call); the
|
|
28
|
+
* follow target always uses `smoothTime`. Default 0.125.
|
|
29
|
+
*/
|
|
30
|
+
draggingSmoothTime?: number;
|
|
31
|
+
/** Per-frame lerp factor for `camera.up` toward the fed up-axis. Default 0.1. */
|
|
32
|
+
upLerpFactor?: number;
|
|
33
|
+
/** Polar (vertical orbit) clamp, radians. Defaults 0.1 and PI - 0.1 — never exactly 0/PI (lookAt degenerates). */
|
|
34
|
+
minPolarAngle?: number;
|
|
35
|
+
maxPolarAngle?: number;
|
|
36
|
+
/** Distance (zoom) clamp. Defaults: 0.02 (first-person capable) and 12. */
|
|
37
|
+
minDistance?: number;
|
|
38
|
+
maxDistance?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Initial orbit pose. Defaults: distance 4, azimuth PI, polar PI/2 — camera starts at
|
|
41
|
+
* target + (0, 0, -4), level with the target and looking +Z (matches the upstream demo).
|
|
42
|
+
*/
|
|
43
|
+
initialDistance?: number;
|
|
44
|
+
initialAzimuthAngle?: number;
|
|
45
|
+
initialPolarAngle?: number;
|
|
46
|
+
/** Drag speed multipliers; a full drag across the element HEIGHT = one full turn (2*PI rad). Defaults 1. */
|
|
47
|
+
azimuthRotateSpeed?: number;
|
|
48
|
+
polarRotateSpeed?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Wheel zoom speed multiplier. Multiplicative zoom with upstream normalization:
|
|
51
|
+
* `0.95^(-deltaY/30)` per pixel-mode wheel event (~18.7% distance change per classic 100-unit
|
|
52
|
+
* notch; deltaY/10 on Mac, deltaY/3 for line-mode/ctrlKey trackpad events). Default 1.
|
|
53
|
+
*/
|
|
54
|
+
dollySpeed?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Gain for vehicle heading auto-alignment: azimuth rotates by `angle * headingAlignGain * dt`
|
|
57
|
+
* toward the vehicle's forward axis each frame. Default 5. Higher = camera snaps behind the
|
|
58
|
+
* vehicle faster; 0 disables auto-align.
|
|
59
|
+
*/
|
|
60
|
+
headingAlignGain?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Static environment meshes for collision pullback (ray tests from target toward camera).
|
|
63
|
+
* Only static LEAF meshes (intersection is non-recursive); NEVER include the character or
|
|
64
|
+
* vehicle meshes — the rays start at the character's head and would hit them every frame.
|
|
65
|
+
* Default []. Mutable after construction via the public `colliderMeshes` field.
|
|
66
|
+
*/
|
|
67
|
+
colliderMeshes?: THREE.Mesh[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Mutable scalar velocity slot for SmoothDamp (Unity-style ref param). */
|
|
71
|
+
type ScalarRef = { value: number };
|
|
72
|
+
|
|
73
|
+
/** Upstream approxEquals epsilon (used for the "currently collided" test in dollyTo). */
|
|
74
|
+
const EPSILON = 1e-5;
|
|
75
|
+
|
|
76
|
+
/** Upstream wheel normalization: Mac reports finer-grained deltaY, so it divides less. */
|
|
77
|
+
const IS_MAC = typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Unity-style critically damped spring (exact port of the smoothing the upstream camera uses).
|
|
81
|
+
* `smoothTime` = time to reach ~63% of the gap; settles in ~2-3x smoothTime. Module-private
|
|
82
|
+
* on purpose (not part of shared/math).
|
|
83
|
+
*/
|
|
84
|
+
function smoothDamp(
|
|
85
|
+
current: number,
|
|
86
|
+
target: number,
|
|
87
|
+
velRef: ScalarRef,
|
|
88
|
+
smoothTime: number,
|
|
89
|
+
maxSpeed: number,
|
|
90
|
+
dt: number
|
|
91
|
+
): number {
|
|
92
|
+
if (dt <= 0) return current; // numerical guard: dt=0 with current===target would NaN the velocity
|
|
93
|
+
smoothTime = Math.max(0.0001, smoothTime);
|
|
94
|
+
const omega = 2 / smoothTime;
|
|
95
|
+
const x = omega * dt;
|
|
96
|
+
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
|
|
97
|
+
let change = current - target;
|
|
98
|
+
const originalTo = target;
|
|
99
|
+
const maxChange = maxSpeed * smoothTime;
|
|
100
|
+
change = THREE.MathUtils.clamp(change, -maxChange, maxChange);
|
|
101
|
+
target = current - change;
|
|
102
|
+
const temp = (velRef.value + omega * change) * dt;
|
|
103
|
+
velRef.value = (velRef.value - omega * temp) * exp;
|
|
104
|
+
let output = target + (change + temp) * exp;
|
|
105
|
+
// overshoot clamp
|
|
106
|
+
if (originalTo - current > 0 === output > originalTo) {
|
|
107
|
+
output = originalTo;
|
|
108
|
+
velRef.value = (output - originalTo) / dt;
|
|
109
|
+
}
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Spring-damped third-person follow camera (vanilla-TS stand-in for the camera-controls-based
|
|
115
|
+
* rig the upstream demo uses; same imperative surface: `moveTo`, `setUp`, `rotate`, `dolly`,
|
|
116
|
+
* `distance`, `colliderMeshes`, `smoothTime`).
|
|
117
|
+
*
|
|
118
|
+
* Features: SmoothDamp'd target follow + orbit + zoom, pointer-drag orbit, wheel/pinch zoom,
|
|
119
|
+
* collision-aware pullback (4 near-plane-corner rays from the target toward the camera; the
|
|
120
|
+
* damped distance is clamped to the hit while the END distance is preserved, so the camera
|
|
121
|
+
* smoothly re-extends via SmoothDamp when the obstruction clears — upstream semantics),
|
|
122
|
+
* platform/vehicle heading compensation.
|
|
123
|
+
*
|
|
124
|
+
* Call order per RENDER frame (render delta, never the fixed physics step):
|
|
125
|
+
* 1. `moveTo(t.x, t.y, t.z, true)` with the pivot (controller `currPos + bodyYAxis * 0.5`)
|
|
126
|
+
* 2. `setUp(controller.upAxis)`
|
|
127
|
+
* 3. character on platform -> `applyPlatformTurn(controller.turnOnYQuat)` — ONLY on frames where
|
|
128
|
+
* at least one physics step ran (`turnOnYQuat` is a per-physics-step yaw delta; re-applying a
|
|
129
|
+
* stale delta on zero-step high-refresh frames over-rotates the camera)
|
|
130
|
+
* 4. vehicle active -> `alignHeading(vehicle.bodyZAxis, dt)`
|
|
131
|
+
* 5. `update(dt)`
|
|
132
|
+
*
|
|
133
|
+
* v1 assumes upAxis ~= +Y (no custom gravity): the orbit sphere is built in world-Y space;
|
|
134
|
+
* `camera.up` lerps toward the fed up-axis and the heading math projects onto it, but a far-from-Y
|
|
135
|
+
* up-axis will misbehave.
|
|
136
|
+
*
|
|
137
|
+
* Wiring tip: give the canvas `touch-action: none` so pointer drags aren't hijacked by scrolling.
|
|
138
|
+
*/
|
|
139
|
+
export class FollowCamera {
|
|
140
|
+
/** Master switch: false freezes the camera entirely (no damping, input ignored). */
|
|
141
|
+
enabled: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Gates target-follow + up-lerp + heading compensation (the demo's "followPlayer" toggle).
|
|
144
|
+
* Manual orbit/zoom and damping keep working while false.
|
|
145
|
+
*/
|
|
146
|
+
followEnabled: boolean;
|
|
147
|
+
/** SmoothDamp time for target/orbit/zoom; live-tunable. */
|
|
148
|
+
smoothTime: number;
|
|
149
|
+
/** Static environment meshes for collision pullback; mutate freely (e.g. after level load). */
|
|
150
|
+
colliderMeshes: THREE.Mesh[];
|
|
151
|
+
|
|
152
|
+
private _camera: THREE.PerspectiveCamera;
|
|
153
|
+
private _domElement: HTMLElement;
|
|
154
|
+
|
|
155
|
+
private _draggingSmoothTime: number;
|
|
156
|
+
private _upLerpFactor: number;
|
|
157
|
+
private _minPolarAngle: number;
|
|
158
|
+
private _maxPolarAngle: number;
|
|
159
|
+
private _minDistance: number;
|
|
160
|
+
private _maxDistance: number;
|
|
161
|
+
private _azimuthRotateSpeed: number;
|
|
162
|
+
private _polarRotateSpeed: number;
|
|
163
|
+
private _dollySpeed: number;
|
|
164
|
+
private _headingAlignGain: number;
|
|
165
|
+
|
|
166
|
+
// Damped state (current) + targets (end) + SmoothDamp velocities.
|
|
167
|
+
private _target: THREE.Vector3;
|
|
168
|
+
private _targetEnd: THREE.Vector3;
|
|
169
|
+
private _targetVelocity: THREE.Vector3;
|
|
170
|
+
private _azimuth: number;
|
|
171
|
+
private _azimuthEnd: number;
|
|
172
|
+
private _azimuthVel: ScalarRef;
|
|
173
|
+
private _polar: number;
|
|
174
|
+
private _polarEnd: number;
|
|
175
|
+
private _polarVel: ScalarRef;
|
|
176
|
+
private _distance: number;
|
|
177
|
+
private _distanceEnd: number;
|
|
178
|
+
private _distanceVel: ScalarRef;
|
|
179
|
+
|
|
180
|
+
private _upAxis: THREE.Vector3;
|
|
181
|
+
|
|
182
|
+
// Pointer state.
|
|
183
|
+
private _orbiting: boolean;
|
|
184
|
+
private _pinching: boolean;
|
|
185
|
+
private _pinchDistance: number;
|
|
186
|
+
private _pointers: Map<number, { x: number; y: number }>;
|
|
187
|
+
|
|
188
|
+
// Per-axis "user is controlling" flags (upstream _isUserControllingRotate/_isUserControllingDolly):
|
|
189
|
+
// set by drag-rotate / wheel+pinch respectively, and cleared ONLY by the next programmatic
|
|
190
|
+
// rotate()/dollyTo() call — they intentionally persist past pointerup so the in-flight damping
|
|
191
|
+
// keeps the dragging constant, exactly like upstream.
|
|
192
|
+
private _userDragRotate: boolean;
|
|
193
|
+
private _userDolly: boolean;
|
|
194
|
+
|
|
195
|
+
// Preallocated scratch (no allocations in the per-frame path).
|
|
196
|
+
private _sdRef: ScalarRef;
|
|
197
|
+
private _sphericalDir: THREE.Vector3;
|
|
198
|
+
private _camDir: THREE.Vector3;
|
|
199
|
+
private _finalDir: THREE.Vector3;
|
|
200
|
+
private _crossAxis: THREE.Vector3;
|
|
201
|
+
private _rayDir: THREE.Vector3;
|
|
202
|
+
private _corner: THREE.Vector3;
|
|
203
|
+
private _rayOrigin: THREE.Vector3;
|
|
204
|
+
private _zero: THREE.Vector3;
|
|
205
|
+
private _lookMatrix: THREE.Matrix4;
|
|
206
|
+
private _raycaster: THREE.Raycaster;
|
|
207
|
+
|
|
208
|
+
// Bound handlers (stored so dispose() removes the same references).
|
|
209
|
+
private _onPointerDown: (e: PointerEvent) => void;
|
|
210
|
+
private _onPointerMove: (e: PointerEvent) => void;
|
|
211
|
+
private _onPointerUp: (e: PointerEvent) => void;
|
|
212
|
+
private _onWheel: (e: WheelEvent) => void;
|
|
213
|
+
private _onContextMenu: (e: MouseEvent) => void;
|
|
214
|
+
|
|
215
|
+
constructor(camera: THREE.PerspectiveCamera, options: FollowCameraOptions) {
|
|
216
|
+
this._camera = camera;
|
|
217
|
+
this._domElement = options.domElement;
|
|
218
|
+
|
|
219
|
+
this.enabled = true;
|
|
220
|
+
this.followEnabled = true;
|
|
221
|
+
this.smoothTime = options.smoothTime ?? 0.1;
|
|
222
|
+
this.colliderMeshes = options.colliderMeshes ?? [];
|
|
223
|
+
|
|
224
|
+
this._draggingSmoothTime = options.draggingSmoothTime ?? 0.125;
|
|
225
|
+
this._upLerpFactor = options.upLerpFactor ?? 0.1;
|
|
226
|
+
this._minPolarAngle = options.minPolarAngle ?? 0.1;
|
|
227
|
+
this._maxPolarAngle = options.maxPolarAngle ?? Math.PI - 0.1;
|
|
228
|
+
this._minDistance = options.minDistance ?? 0.02;
|
|
229
|
+
this._maxDistance = options.maxDistance ?? 12;
|
|
230
|
+
this._azimuthRotateSpeed = options.azimuthRotateSpeed ?? 1;
|
|
231
|
+
this._polarRotateSpeed = options.polarRotateSpeed ?? 1;
|
|
232
|
+
this._dollySpeed = options.dollySpeed ?? 1;
|
|
233
|
+
this._headingAlignGain = options.headingAlignGain ?? 5;
|
|
234
|
+
|
|
235
|
+
this._target = new THREE.Vector3();
|
|
236
|
+
this._targetEnd = new THREE.Vector3();
|
|
237
|
+
this._targetVelocity = new THREE.Vector3();
|
|
238
|
+
this._azimuth = options.initialAzimuthAngle ?? Math.PI;
|
|
239
|
+
this._azimuthEnd = this._azimuth;
|
|
240
|
+
this._azimuthVel = { value: 0 };
|
|
241
|
+
this._polar = THREE.MathUtils.clamp(
|
|
242
|
+
options.initialPolarAngle ?? Math.PI / 2,
|
|
243
|
+
this._minPolarAngle,
|
|
244
|
+
this._maxPolarAngle
|
|
245
|
+
);
|
|
246
|
+
this._polarEnd = this._polar;
|
|
247
|
+
this._polarVel = { value: 0 };
|
|
248
|
+
this._distance = THREE.MathUtils.clamp(
|
|
249
|
+
options.initialDistance ?? 4,
|
|
250
|
+
this._minDistance,
|
|
251
|
+
this._maxDistance
|
|
252
|
+
);
|
|
253
|
+
this._distanceEnd = this._distance;
|
|
254
|
+
this._distanceVel = { value: 0 };
|
|
255
|
+
|
|
256
|
+
this._upAxis = new THREE.Vector3(0, 1, 0);
|
|
257
|
+
|
|
258
|
+
this._orbiting = false;
|
|
259
|
+
this._pinching = false;
|
|
260
|
+
this._pinchDistance = 0;
|
|
261
|
+
this._pointers = new Map();
|
|
262
|
+
this._userDragRotate = false;
|
|
263
|
+
this._userDolly = false;
|
|
264
|
+
|
|
265
|
+
this._sdRef = { value: 0 };
|
|
266
|
+
this._sphericalDir = new THREE.Vector3();
|
|
267
|
+
this._camDir = new THREE.Vector3();
|
|
268
|
+
this._finalDir = new THREE.Vector3();
|
|
269
|
+
this._crossAxis = new THREE.Vector3();
|
|
270
|
+
this._rayDir = new THREE.Vector3();
|
|
271
|
+
this._corner = new THREE.Vector3();
|
|
272
|
+
this._rayOrigin = new THREE.Vector3();
|
|
273
|
+
this._zero = new THREE.Vector3();
|
|
274
|
+
this._lookMatrix = new THREE.Matrix4();
|
|
275
|
+
this._raycaster = new THREE.Raycaster();
|
|
276
|
+
|
|
277
|
+
this._onPointerDown = (e: PointerEvent) => {
|
|
278
|
+
if (!this.enabled) return;
|
|
279
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
280
|
+
this._domElement.setPointerCapture(e.pointerId);
|
|
281
|
+
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
282
|
+
if (this._pointers.size === 1) {
|
|
283
|
+
this._orbiting = true;
|
|
284
|
+
this._pinching = false;
|
|
285
|
+
} else if (this._pointers.size === 2) {
|
|
286
|
+
this._orbiting = false;
|
|
287
|
+
this._pinching = true;
|
|
288
|
+
this._pinchDistance = this._currentPinchDistance();
|
|
289
|
+
} else {
|
|
290
|
+
this._orbiting = false;
|
|
291
|
+
this._pinching = false;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
this._onPointerMove = (e: PointerEvent) => {
|
|
296
|
+
if (!this.enabled) return;
|
|
297
|
+
const p = this._pointers.get(e.pointerId);
|
|
298
|
+
if (!p) return;
|
|
299
|
+
const dx = e.clientX - p.x;
|
|
300
|
+
const dy = e.clientY - p.y;
|
|
301
|
+
p.x = e.clientX;
|
|
302
|
+
p.y = e.clientY;
|
|
303
|
+
if (this._orbiting) {
|
|
304
|
+
// Full drag across the element HEIGHT (both axes) = one full turn. Drag right pans the
|
|
305
|
+
// view right; drag up looks up. Sign convention is the classic port bug — verified in
|
|
306
|
+
// the testbed; flip here (not in rotate()) if a scene ever disagrees.
|
|
307
|
+
const h = this._domElement.clientHeight || 1;
|
|
308
|
+
this.rotate(
|
|
309
|
+
-2 * Math.PI * this._azimuthRotateSpeed * (dx / h),
|
|
310
|
+
-2 * Math.PI * this._polarRotateSpeed * (dy / h),
|
|
311
|
+
true
|
|
312
|
+
);
|
|
313
|
+
this._userDragRotate = true; // AFTER rotate() — rotate() clears it (upstream lifecycle)
|
|
314
|
+
} else if (this._pinching && this._pointers.size >= 2) {
|
|
315
|
+
const d = this._currentPinchDistance();
|
|
316
|
+
if (this._pinchDistance > 1e-6 && d > 1e-6) {
|
|
317
|
+
// Fingers spread (d grows) -> distance shrinks (zoom in), multiplicatively.
|
|
318
|
+
this.dollyTo(this._distanceEnd * (this._pinchDistance / d), true);
|
|
319
|
+
this._userDolly = true; // AFTER dollyTo() — dollyTo() clears it (upstream lifecycle)
|
|
320
|
+
}
|
|
321
|
+
this._pinchDistance = d;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
this._onPointerUp = (e: PointerEvent) => {
|
|
326
|
+
if (this._domElement.hasPointerCapture(e.pointerId)) {
|
|
327
|
+
this._domElement.releasePointerCapture(e.pointerId);
|
|
328
|
+
}
|
|
329
|
+
this._pointers.delete(e.pointerId);
|
|
330
|
+
if (this._pointers.size === 1) {
|
|
331
|
+
this._orbiting = true;
|
|
332
|
+
this._pinching = false;
|
|
333
|
+
} else if (this._pointers.size === 0) {
|
|
334
|
+
this._orbiting = false;
|
|
335
|
+
this._pinching = false;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
this._onWheel = (e: WheelEvent) => {
|
|
340
|
+
if (!this.enabled) return;
|
|
341
|
+
e.preventDefault();
|
|
342
|
+
// Upstream wheel normalization (unclamped, proportional): pixel mode divides deltaY by
|
|
343
|
+
// deltaYFactor*10 (-30, or -10 on Mac); line mode (Firefox) or ctrlKey (trackpad pinch
|
|
344
|
+
// gesture) divides by deltaYFactor only. Then multiplicative zoom, dollyScale =
|
|
345
|
+
// 0.95^(-delta * dollySpeed) — ~18.7% per classic 100-unit notch. deltaY > 0 zooms out.
|
|
346
|
+
const deltaYFactor = IS_MAC ? -1 : -3;
|
|
347
|
+
const delta =
|
|
348
|
+
e.deltaMode === 1 || e.ctrlKey ? e.deltaY / deltaYFactor : e.deltaY / (deltaYFactor * 10);
|
|
349
|
+
this.dollyTo(this._distanceEnd * Math.pow(0.95, delta * this._dollySpeed), true);
|
|
350
|
+
this._userDolly = true; // AFTER dollyTo() — dollyTo() clears it (upstream lifecycle)
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
this._onContextMenu = (e: MouseEvent) => {
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
this._domElement.addEventListener("pointerdown", this._onPointerDown);
|
|
358
|
+
this._domElement.addEventListener("pointermove", this._onPointerMove);
|
|
359
|
+
this._domElement.addEventListener("pointerup", this._onPointerUp);
|
|
360
|
+
this._domElement.addEventListener("pointercancel", this._onPointerUp);
|
|
361
|
+
this._domElement.addEventListener("wheel", this._onWheel, { passive: false });
|
|
362
|
+
this._domElement.addEventListener("contextmenu", this._onContextMenu);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- follow feed (call before update(), every frame the controller is active) ----
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Set the orbit pivot (world space). Feed the controller's head point every frame:
|
|
369
|
+
* `currPos + bodyYAxis * 0.5`. `transition=true` (default) damps toward it; `false` snaps.
|
|
370
|
+
* No-op while `followEnabled` is false.
|
|
371
|
+
*/
|
|
372
|
+
moveTo(x: number, y: number, z: number, transition: boolean = true): void {
|
|
373
|
+
if (!this.followEnabled) return;
|
|
374
|
+
this._targetEnd.set(x, y, z);
|
|
375
|
+
if (!transition) {
|
|
376
|
+
this._target.copy(this._targetEnd);
|
|
377
|
+
this._targetVelocity.set(0, 0, 0);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Feed the controller's up-axis. v1: stored + normalized; `camera.up` lerps toward it inside
|
|
383
|
+
* `update()`, but the orbit space stays world +Y (custom-gravity orbit reorientation is a
|
|
384
|
+
* documented v1 cut). No-op while `followEnabled` is false.
|
|
385
|
+
*/
|
|
386
|
+
setUp(up: THREE.Vector3): void {
|
|
387
|
+
if (!this.followEnabled) return;
|
|
388
|
+
this._upAxis.copy(up).normalize();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Character-on-platform compensation: rotates the orbit by the FULL signed yaw angle of
|
|
393
|
+
* `turnOnYQuat` (no gain, no dt — the quat is already a per-physics-step delta). Call only
|
|
394
|
+
* when the character controller reports `isOnPlatform`, and only on frames where at least one
|
|
395
|
+
* physics step ran. No-op while `followEnabled` is false.
|
|
396
|
+
*/
|
|
397
|
+
applyPlatformTurn(turnOnYQuat: THREE.Quaternion): void {
|
|
398
|
+
if (!this.followEnabled) return;
|
|
399
|
+
this._camera.getWorldDirection(this._camDir).projectOnPlane(this._upAxis).normalize();
|
|
400
|
+
this._finalDir.copy(this._camDir).applyQuaternion(turnOnYQuat);
|
|
401
|
+
this.rotate(this._signedHeadingAngle(), 0, true);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Vehicle heading auto-align: eases the azimuth toward the vehicle's forward axis by
|
|
406
|
+
* `angle * headingAlignGain * dt` per frame (dt = RENDER delta). Skipped while the user is
|
|
407
|
+
* orbiting so manual look-around always wins. No-op while `followEnabled` is false.
|
|
408
|
+
*/
|
|
409
|
+
alignHeading(bodyZAxis: THREE.Vector3, dt: number): void {
|
|
410
|
+
if (!this.followEnabled) return;
|
|
411
|
+
if (this.isUserOrbiting) return;
|
|
412
|
+
this._camera.getWorldDirection(this._camDir).projectOnPlane(this._upAxis).normalize();
|
|
413
|
+
this._finalDir.copy(bodyZAxis).projectOnPlane(this._upAxis).normalize();
|
|
414
|
+
this.rotate(this._signedHeadingAngle() * this._headingAlignGain * dt, 0, true);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---- imperative controls ----
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Add deltas to the orbit angles (radians). Polar is clamped on the END value so damping never
|
|
421
|
+
* fights the clamp. `transition=false` snaps. Clears the drag-rotate damping flag (upstream
|
|
422
|
+
* rotateTo lifecycle) — platform/vehicle heading compensation goes through here, so it also
|
|
423
|
+
* restores the base `smoothTime` for the angle axes.
|
|
424
|
+
*/
|
|
425
|
+
rotate(azimuthDelta: number, polarDelta: number, transition: boolean = true): void {
|
|
426
|
+
this._userDragRotate = false;
|
|
427
|
+
this._azimuthEnd += azimuthDelta;
|
|
428
|
+
this._polarEnd = THREE.MathUtils.clamp(
|
|
429
|
+
this._polarEnd + polarDelta,
|
|
430
|
+
this._minPolarAngle,
|
|
431
|
+
this._maxPolarAngle
|
|
432
|
+
);
|
|
433
|
+
if (!transition) {
|
|
434
|
+
this._azimuth = this._azimuthEnd;
|
|
435
|
+
this._polar = this._polarEnd;
|
|
436
|
+
this._azimuthVel.value = 0;
|
|
437
|
+
this._polarVel.value = 0;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Move the zoom distance by `-delta` (positive delta dollies the camera toward the target).
|
|
443
|
+
* First-person recipe: `cam.dolly(cam.distance - 0.02, true)`.
|
|
444
|
+
*/
|
|
445
|
+
dolly(delta: number, transition: boolean = true): void {
|
|
446
|
+
this.dollyTo(this._distanceEnd - delta, transition);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Set the absolute zoom distance, clamped to [minDistance, maxDistance]. Collision-aware
|
|
451
|
+
* (upstream _dollyToNoClamp): while the camera is pinned against geometry, dolly-OUT requests
|
|
452
|
+
* are refused (so zoom-out can't silently accumulate behind a wall), and the END distance is
|
|
453
|
+
* additionally clamped by the collision test. Clears the wheel/pinch damping flag (upstream
|
|
454
|
+
* dollyTo lifecycle).
|
|
455
|
+
*/
|
|
456
|
+
dollyTo(distance: number, transition: boolean = true): void {
|
|
457
|
+
this._userDolly = false;
|
|
458
|
+
const clamped = THREE.MathUtils.clamp(distance, this._minDistance, this._maxDistance);
|
|
459
|
+
if (this.colliderMeshes.length > 0) {
|
|
460
|
+
const hit = this._collisionTest();
|
|
461
|
+
const isCollided = Math.abs(hit - this._distance) < EPSILON;
|
|
462
|
+
const isDollyIn = this._distanceEnd > clamped;
|
|
463
|
+
if (!isDollyIn && isCollided) return;
|
|
464
|
+
this._distanceEnd = Math.min(clamped, hit);
|
|
465
|
+
} else {
|
|
466
|
+
this._distanceEnd = clamped;
|
|
467
|
+
}
|
|
468
|
+
if (!transition) {
|
|
469
|
+
this._distance = this._distanceEnd;
|
|
470
|
+
this._distanceVel.value = 0;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---- readonly state ----
|
|
475
|
+
|
|
476
|
+
/** Damped (current) zoom distance. */
|
|
477
|
+
get distance(): number {
|
|
478
|
+
return this._distance;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Damped azimuth angle (radians, world-Y orbit space). */
|
|
482
|
+
get azimuthAngle(): number {
|
|
483
|
+
return this._azimuth;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Damped polar angle (radians). */
|
|
487
|
+
get polarAngle(): number {
|
|
488
|
+
return this._polar;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Damped pivot position. Returns the internal vector — read-only, do not mutate. */
|
|
492
|
+
get target(): THREE.Vector3 {
|
|
493
|
+
return this._target;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** True while a pointer-drag orbit (or pinch zoom) is in progress. */
|
|
497
|
+
get isUserOrbiting(): boolean {
|
|
498
|
+
return this._orbiting || this._pinching;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---- per-frame ----
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Render-phase update. Call ONCE per render frame, AFTER physics stepping + body->mesh sync,
|
|
505
|
+
* with the RENDER delta (never the fixed physics timestep).
|
|
506
|
+
*/
|
|
507
|
+
update(dt: number): void {
|
|
508
|
+
if (!this.enabled) return;
|
|
509
|
+
|
|
510
|
+
// Up-lerp: naive per-frame lerp with factor 0.1 — intentionally frame-rate dependent for
|
|
511
|
+
// upstream parity (do NOT convert to 1 - exp(-k*dt)).
|
|
512
|
+
if (this.followEnabled) {
|
|
513
|
+
this._camera.up.lerp(this._upAxis, this._upLerpFactor).normalize();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Damping: per-axis constants (upstream parity) — angles use the dragging constant while a
|
|
517
|
+
// drag-rotate is in effect, distance while a wheel/pinch zoom is in effect; the follow
|
|
518
|
+
// target ALWAYS uses the base smoothTime (upstream's truck flag is never set by orbit/zoom).
|
|
519
|
+
const rotSt = this._userDragRotate ? this._draggingSmoothTime : this.smoothTime;
|
|
520
|
+
const dollySt = this._userDolly ? this._draggingSmoothTime : this.smoothTime;
|
|
521
|
+
this._azimuth = smoothDamp(this._azimuth, this._azimuthEnd, this._azimuthVel, rotSt, Infinity, dt);
|
|
522
|
+
this._polar = smoothDamp(this._polar, this._polarEnd, this._polarVel, rotSt, Infinity, dt);
|
|
523
|
+
this._distance = smoothDamp(this._distance, this._distanceEnd, this._distanceVel, dollySt, Infinity, dt);
|
|
524
|
+
this._smoothDampVec3(this._target, this._targetEnd, this._targetVelocity, this.smoothTime, dt);
|
|
525
|
+
|
|
526
|
+
// Collision pullback (upstream: `_spherical.radius = min(_spherical.radius, collisionTest)`):
|
|
527
|
+
// the DAMPED distance itself is clamped to the hit; `_distanceEnd` (and the velocity) stay
|
|
528
|
+
// untouched, so when the obstruction clears, SmoothDamp eases the camera back out to the
|
|
529
|
+
// user's zoom instead of snapping.
|
|
530
|
+
if (this.colliderMeshes.length > 0) {
|
|
531
|
+
this._distance = Math.min(this._distance, this._collisionTest());
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Unit offset direction from spherical, world-Y orbit space (THREE.Spherical convention:
|
|
535
|
+
// azimuth 0 = +Z, increasing azimuth = right-hand rotation about +Y).
|
|
536
|
+
const dir = this._sphericalDir.set(
|
|
537
|
+
Math.sin(this._polar) * Math.sin(this._azimuth),
|
|
538
|
+
Math.cos(this._polar),
|
|
539
|
+
Math.sin(this._polar) * Math.cos(this._azimuth)
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
this._camera.position.copy(this._target).addScaledVector(dir, this._distance);
|
|
543
|
+
this._camera.lookAt(this._target);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---- lifecycle ----
|
|
547
|
+
|
|
548
|
+
/** Remove all DOM listeners. The camera object itself is left wherever it was. */
|
|
549
|
+
dispose(): void {
|
|
550
|
+
this._domElement.removeEventListener("pointerdown", this._onPointerDown);
|
|
551
|
+
this._domElement.removeEventListener("pointermove", this._onPointerMove);
|
|
552
|
+
this._domElement.removeEventListener("pointerup", this._onPointerUp);
|
|
553
|
+
this._domElement.removeEventListener("pointercancel", this._onPointerUp);
|
|
554
|
+
this._domElement.removeEventListener("wheel", this._onWheel);
|
|
555
|
+
this._domElement.removeEventListener("contextmenu", this._onContextMenu);
|
|
556
|
+
this._pointers.clear();
|
|
557
|
+
this._orbiting = false;
|
|
558
|
+
this._pinching = false;
|
|
559
|
+
this._userDragRotate = false;
|
|
560
|
+
this._userDolly = false;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---- private ----
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Signed angle taking `_camDir` to `_finalDir` about `_upAxis` (both scratch vectors must be
|
|
567
|
+
* populated by the caller): `atan2(cross(cur, final) . up, clamp(cur . final, -1, 1))`, with
|
|
568
|
+
* the upstream `dot = -0` guard (atan2(y, -0) flips the branch and produces +-PI spikes).
|
|
569
|
+
*/
|
|
570
|
+
private _signedHeadingAngle(): number {
|
|
571
|
+
this._crossAxis.crossVectors(this._camDir, this._finalDir);
|
|
572
|
+
let dot = THREE.MathUtils.clamp(this._camDir.dot(this._finalDir), -1, 1);
|
|
573
|
+
if (Math.abs(dot) < 1e-10) dot = 0; // prevent dot=-0
|
|
574
|
+
return Math.atan2(this._crossAxis.dot(this._upAxis), dot);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Collision test (upstream _collisionTest): 4 rays from the target's near-plane corners toward
|
|
579
|
+
* the camera along the DAMPED orbit direction (a single center ray would let the near plane
|
|
580
|
+
* clip through wall edges); ray reach is `_distance + 1`. Returns the nearest hit distance, or
|
|
581
|
+
* Infinity when nothing is hit / no colliders. fov/aspect/near are read fresh each call so
|
|
582
|
+
* window resizes keep working.
|
|
583
|
+
*/
|
|
584
|
+
private _collisionTest(): number {
|
|
585
|
+
let distance = Infinity;
|
|
586
|
+
if (this.colliderMeshes.length === 0) return distance;
|
|
587
|
+
const dir = this._rayDir.set(
|
|
588
|
+
Math.sin(this._polar) * Math.sin(this._azimuth),
|
|
589
|
+
Math.cos(this._polar),
|
|
590
|
+
Math.sin(this._polar) * Math.cos(this._azimuth)
|
|
591
|
+
);
|
|
592
|
+
const nearH =
|
|
593
|
+
Math.tan(THREE.MathUtils.degToRad(this._camera.getEffectiveFOV()) / 2) * this._camera.near;
|
|
594
|
+
const nearW = nearH * this._camera.aspect;
|
|
595
|
+
this._lookMatrix.lookAt(this._zero, dir, this._camera.up);
|
|
596
|
+
for (let i = 0; i < 4; i++) {
|
|
597
|
+
const sx = (i & 1) === 0 ? 1 : -1;
|
|
598
|
+
const sy = (i & 2) === 0 ? 1 : -1;
|
|
599
|
+
this._corner.set(sx * nearW, sy * nearH, 0).applyMatrix4(this._lookMatrix);
|
|
600
|
+
this._rayOrigin.copy(this._target).add(this._corner);
|
|
601
|
+
this._raycaster.set(this._rayOrigin, dir);
|
|
602
|
+
this._raycaster.far = this._distance + 1;
|
|
603
|
+
const hits = this._raycaster.intersectObjects(this.colliderMeshes, false);
|
|
604
|
+
const first = hits[0];
|
|
605
|
+
if (first && first.distance < distance) distance = first.distance;
|
|
606
|
+
}
|
|
607
|
+
return distance;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Distance in CSS pixels between the first two tracked pointers (0 if fewer than two). */
|
|
611
|
+
private _currentPinchDistance(): number {
|
|
612
|
+
let first: { x: number; y: number } | null = null;
|
|
613
|
+
for (const p of this._pointers.values()) {
|
|
614
|
+
if (first === null) {
|
|
615
|
+
first = p;
|
|
616
|
+
} else {
|
|
617
|
+
return Math.hypot(p.x - first.x, p.y - first.y);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Component-wise scalar SmoothDamp for the target vector (visually identical to a coupled
|
|
625
|
+
* vec3 SmoothDamp at smoothTime ~0.1; faithful simplification, noted per the port spec).
|
|
626
|
+
*/
|
|
627
|
+
private _smoothDampVec3(
|
|
628
|
+
current: THREE.Vector3,
|
|
629
|
+
target: THREE.Vector3,
|
|
630
|
+
velocity: THREE.Vector3,
|
|
631
|
+
smoothTime: number,
|
|
632
|
+
dt: number
|
|
633
|
+
): void {
|
|
634
|
+
this._sdRef.value = velocity.x;
|
|
635
|
+
current.x = smoothDamp(current.x, target.x, this._sdRef, smoothTime, Infinity, dt);
|
|
636
|
+
velocity.x = this._sdRef.value;
|
|
637
|
+
this._sdRef.value = velocity.y;
|
|
638
|
+
current.y = smoothDamp(current.y, target.y, this._sdRef, smoothTime, Infinity, dt);
|
|
639
|
+
velocity.y = this._sdRef.value;
|
|
640
|
+
this._sdRef.value = velocity.z;
|
|
641
|
+
current.z = smoothDamp(current.z, target.z, this._sdRef, smoothTime, Infinity, dt);
|
|
642
|
+
velocity.z = this._sdRef.value;
|
|
643
|
+
}
|
|
644
|
+
}
|