@navaramap/three-plugins 0.0.0
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/LICENSE +8 -0
- package/README.md +58 -0
- package/dist/index.d.ts +504 -0
- package/dist/index.js +553 -0
- package/dist/index.umd.cjs +1 -0
- package/package.json +43 -0
- package/src/CesiumIonPlugin.test.ts +113 -0
- package/src/CesiumIonPlugin.ts +161 -0
- package/src/OverlayPlugin.ts +202 -0
- package/src/PersonViewPlugin.ts +1044 -0
- package/src/TileJsonPlugin.test.ts +371 -0
- package/src/TileJsonPlugin.ts +301 -0
- package/src/index.ts +35 -0
- package/src/vite-env.d.ts +6 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PersonViewPlugin — Navara Plugin for first/third-person view control.
|
|
3
|
+
*
|
|
4
|
+
* Provides keyboard-driven character movement with a chase (TPV) or
|
|
5
|
+
* first-person (FPV) camera. The character (GLTF model) is optional —
|
|
6
|
+
* when omitted, the plugin still drives a virtual position and the
|
|
7
|
+
* camera follows it, so callers can use it purely as a person-view
|
|
8
|
+
* camera controller.
|
|
9
|
+
*
|
|
10
|
+
* ## Usage
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import ThreeView from "@navaramap/three";
|
|
14
|
+
* import { DefaultPlugin } from "@navaramap/three-default-plugin";
|
|
15
|
+
* import { PersonViewPlugin } from "@navaramap/three-plugins";
|
|
16
|
+
*
|
|
17
|
+
* const view = new ThreeView({ container, animation: true });
|
|
18
|
+
* const defaultPlugin = new DefaultPlugin();
|
|
19
|
+
* const personView = new PersonViewPlugin({
|
|
20
|
+
* character: {
|
|
21
|
+
* modelUrl: "/glTF/my_model/scene.gltf",
|
|
22
|
+
* animation: {
|
|
23
|
+
* idleClip: "Idle",
|
|
24
|
+
* dashClip: "Run",
|
|
25
|
+
* speed: 1.0,
|
|
26
|
+
* crossfadeDuration: 0.3,
|
|
27
|
+
* },
|
|
28
|
+
* modelRotationOffset: { x: 0, y: 0, z: 0 },
|
|
29
|
+
* },
|
|
30
|
+
* allowCameraControl: false,
|
|
31
|
+
* startLat: 35.6812,
|
|
32
|
+
* startLng: 139.7671,
|
|
33
|
+
* startHeight: 500,
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* view.addPlugin(defaultPlugin);
|
|
37
|
+
* view.addPlugin(personView);
|
|
38
|
+
* await view.init();
|
|
39
|
+
*
|
|
40
|
+
* personView.start();
|
|
41
|
+
*
|
|
42
|
+
* const unsub = personView.onStateChange((state) => {
|
|
43
|
+
* console.log(state.lat, state.lng, state.alt, state.mode);
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* personView.teleport({ lng, lat, alt, heading: headingRad });
|
|
47
|
+
* personView.toggleViewMode();
|
|
48
|
+
*
|
|
49
|
+
* unsub();
|
|
50
|
+
* personView.dispose();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
import ThreeView, {
|
|
54
|
+
Plugin,
|
|
55
|
+
MeshHandle,
|
|
56
|
+
geodeticToVector3,
|
|
57
|
+
vector3ToGeodetic,
|
|
58
|
+
eastNorthUpToFixedFrame,
|
|
59
|
+
degreeToRadian,
|
|
60
|
+
radianToDegree,
|
|
61
|
+
type ViewContext,
|
|
62
|
+
} from "@navaramap/three";
|
|
63
|
+
import type { GLTFModelDesc } from "@navaramap/three-default-descs";
|
|
64
|
+
import type { DefaultDescriptions } from "@navaramap/three-default-plugin";
|
|
65
|
+
import { Vector3, Matrix4 } from "three";
|
|
66
|
+
|
|
67
|
+
type View = ThreeView<DefaultDescriptions>;
|
|
68
|
+
|
|
69
|
+
export type ViewMode = "tpv" | "fpv";
|
|
70
|
+
|
|
71
|
+
export type PersonViewState = {
|
|
72
|
+
lng: number;
|
|
73
|
+
lat: number;
|
|
74
|
+
alt: number;
|
|
75
|
+
/** Heading in radians — 0 = north, increasing clockwise. */
|
|
76
|
+
heading: number;
|
|
77
|
+
speed: number;
|
|
78
|
+
/** Current animation clip name, or null when no character is configured. */
|
|
79
|
+
animationState: string | null;
|
|
80
|
+
mode: ViewMode;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Options for {@link PersonViewPlugin.teleport}. */
|
|
84
|
+
export type TeleportOptions = {
|
|
85
|
+
/** Longitude in degrees. */
|
|
86
|
+
lng: number;
|
|
87
|
+
/** Latitude in degrees. */
|
|
88
|
+
lat: number;
|
|
89
|
+
/** Altitude in meters. */
|
|
90
|
+
alt: number;
|
|
91
|
+
/**
|
|
92
|
+
* Heading in radians (0 = north, increasing clockwise). If omitted, the
|
|
93
|
+
* current camera heading is kept. (Use `setHeading` to rotate in place
|
|
94
|
+
* without moving, and `setCameraPitch` / `setFpvPitch` for camera pitch.)
|
|
95
|
+
*/
|
|
96
|
+
heading?: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Model rotation offset applied to the loaded GLTF model.
|
|
101
|
+
* Different models face different directions by default, so this
|
|
102
|
+
* allows you to correct the orientation.
|
|
103
|
+
*/
|
|
104
|
+
export type ModelRotationOffset = {
|
|
105
|
+
x: number;
|
|
106
|
+
y: number;
|
|
107
|
+
z: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Animation configuration for the model.
|
|
112
|
+
* Clip names are model-specific — check your GLTF file for available clips.
|
|
113
|
+
*/
|
|
114
|
+
export type AnimationConfig = {
|
|
115
|
+
/** Clip name played while the model is idle (no movement keys held). */
|
|
116
|
+
idleClip: string;
|
|
117
|
+
/**
|
|
118
|
+
* Clip name played while the model is moving without dashing.
|
|
119
|
+
* Omit to keep `idleClip` playing while walking — useful for models
|
|
120
|
+
* that only ship with idle + dash animations.
|
|
121
|
+
*/
|
|
122
|
+
walkClip?: string;
|
|
123
|
+
/** Clip name played while the model is dashing (dash key held). */
|
|
124
|
+
dashClip: string;
|
|
125
|
+
/** Playback speed multiplier. */
|
|
126
|
+
speed: number;
|
|
127
|
+
/** Duration in seconds for cross-fade transitions between clips. */
|
|
128
|
+
crossfadeDuration: number;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type CharacterConfig = {
|
|
132
|
+
/** URL of the GLTF model to load. */
|
|
133
|
+
modelUrl: string;
|
|
134
|
+
/** Animation clip configuration. */
|
|
135
|
+
animation: AnimationConfig;
|
|
136
|
+
/** Rotation offset to correct the model's default orientation. */
|
|
137
|
+
modelRotationOffset?: ModelRotationOffset;
|
|
138
|
+
modelScale?: number;
|
|
139
|
+
/** Hide the model while the camera is in FPV. Defaults to true. */
|
|
140
|
+
hideModelInFpv?: boolean;
|
|
141
|
+
/** Whether the character casts shadows. Defaults to false. */
|
|
142
|
+
castShadow?: boolean;
|
|
143
|
+
/** Whether the character receives shadows. Defaults to false. */
|
|
144
|
+
receiveShadow?: boolean;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Keyboard bindings. Each entry takes an array of `KeyboardEvent.code`
|
|
149
|
+
* values (e.g. `["KeyW"]`, `["ArrowUp", "ControlLeft"]`) so multiple
|
|
150
|
+
* keys can trigger the same action.
|
|
151
|
+
*/
|
|
152
|
+
export type KeyBindings = {
|
|
153
|
+
forward?: string[];
|
|
154
|
+
backward?: string[];
|
|
155
|
+
turnLeft?: string[];
|
|
156
|
+
turnRight?: string[];
|
|
157
|
+
ascend?: string[];
|
|
158
|
+
descend?: string[];
|
|
159
|
+
dash?: string[];
|
|
160
|
+
/** Hold to enable free camera (orbit) while allowCameraControl is false. */
|
|
161
|
+
orbitCamera?: string[];
|
|
162
|
+
/** Toggle between TPV and FPV. */
|
|
163
|
+
toggleView?: string[];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
type Action =
|
|
167
|
+
| "forward"
|
|
168
|
+
| "backward"
|
|
169
|
+
| "turnLeft"
|
|
170
|
+
| "turnRight"
|
|
171
|
+
| "ascend"
|
|
172
|
+
| "descend"
|
|
173
|
+
| "dash"
|
|
174
|
+
| "orbitCamera"
|
|
175
|
+
| "toggleView";
|
|
176
|
+
|
|
177
|
+
export type PersonViewConfig = {
|
|
178
|
+
character?: CharacterConfig;
|
|
179
|
+
|
|
180
|
+
/** When true, the camera is always free (no Alt-hold required). */
|
|
181
|
+
allowCameraControl?: boolean;
|
|
182
|
+
/** Initial view mode. */
|
|
183
|
+
initialView?: ViewMode;
|
|
184
|
+
|
|
185
|
+
/** m/s */
|
|
186
|
+
moveSpeed?: number;
|
|
187
|
+
/** deg/frame */
|
|
188
|
+
rotationSpeed?: number;
|
|
189
|
+
/** m/s */
|
|
190
|
+
altSpeed?: number;
|
|
191
|
+
minAlt?: number;
|
|
192
|
+
maxAlt?: number;
|
|
193
|
+
cameraDistance?: number;
|
|
194
|
+
/**
|
|
195
|
+
* Downward camera pitch in radians for **TPV**. `0` keeps the camera
|
|
196
|
+
* behind the model at eye level; positive values orbit the camera up and
|
|
197
|
+
* over so it looks down at the model while keeping it centered. Has no
|
|
198
|
+
* effect while the free camera is active (Alt-hold or `allowCameraControl`),
|
|
199
|
+
* where mouse drag controls the pitch instead.
|
|
200
|
+
*/
|
|
201
|
+
cameraPitch?: number;
|
|
202
|
+
cameraLerpSpeed?: number;
|
|
203
|
+
/** Forward offset (meters) applied to the FPV eye position. */
|
|
204
|
+
fpvForwardOffset?: number;
|
|
205
|
+
/**
|
|
206
|
+
* Eye-line height offset (meters) added to the position. Used in FPV for the
|
|
207
|
+
* eye position and in TPV as the shared eye-line height the camera orbits
|
|
208
|
+
* around and aims at.
|
|
209
|
+
*/
|
|
210
|
+
fpvHeightOffset?: number;
|
|
211
|
+
/**
|
|
212
|
+
* Downward camera pitch in radians for **FPV**. `0` looks straight ahead
|
|
213
|
+
* (horizontal); positive values tilt the view down in place without moving
|
|
214
|
+
* the eye. Has no effect while the free camera is active.
|
|
215
|
+
*/
|
|
216
|
+
fpvPitch?: number;
|
|
217
|
+
|
|
218
|
+
startLat?: number;
|
|
219
|
+
startLng?: number;
|
|
220
|
+
startHeight?: number;
|
|
221
|
+
/** radians */
|
|
222
|
+
startHeading?: number;
|
|
223
|
+
|
|
224
|
+
keys?: KeyBindings;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
type StateListener = (s: PersonViewState) => void;
|
|
228
|
+
|
|
229
|
+
const DEFAULT_ROTATION_OFFSET: ModelRotationOffset = {
|
|
230
|
+
x: 0,
|
|
231
|
+
y: 0,
|
|
232
|
+
z: 0,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const DEFAULT_KEYS: Required<KeyBindings> = {
|
|
236
|
+
forward: ["KeyW"],
|
|
237
|
+
backward: ["KeyS"],
|
|
238
|
+
turnLeft: ["KeyA"],
|
|
239
|
+
turnRight: ["KeyD"],
|
|
240
|
+
ascend: ["ArrowUp", "Space"],
|
|
241
|
+
descend: ["ArrowDown", "ControlLeft", "ControlRight"],
|
|
242
|
+
dash: ["ShiftLeft", "ShiftRight"],
|
|
243
|
+
orbitCamera: ["AltLeft", "AltRight"],
|
|
244
|
+
toggleView: ["KeyV"],
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
type PersonViewDefaults = Required<
|
|
248
|
+
Omit<PersonViewConfig, "character" | "keys">
|
|
249
|
+
>;
|
|
250
|
+
|
|
251
|
+
const DEFAULTS: PersonViewDefaults = {
|
|
252
|
+
allowCameraControl: false,
|
|
253
|
+
initialView: "tpv",
|
|
254
|
+
moveSpeed: 50,
|
|
255
|
+
rotationSpeed: 3,
|
|
256
|
+
altSpeed: 30,
|
|
257
|
+
minAlt: 50,
|
|
258
|
+
maxAlt: 5000,
|
|
259
|
+
cameraDistance: 50,
|
|
260
|
+
cameraPitch: 0,
|
|
261
|
+
cameraLerpSpeed: 3,
|
|
262
|
+
fpvForwardOffset: 0,
|
|
263
|
+
fpvHeightOffset: 1,
|
|
264
|
+
fpvPitch: 0,
|
|
265
|
+
startLat: 35.6812,
|
|
266
|
+
startLng: 139.7671,
|
|
267
|
+
startHeight: 500,
|
|
268
|
+
startHeading: Math.PI * 1.3,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const DEFAULT_MODEL_SCALE = 3;
|
|
272
|
+
|
|
273
|
+
/** Distance (meters) from the FPV eye to the look-at target. */
|
|
274
|
+
const FPV_LOOK_AHEAD_DISTANCE = 1000;
|
|
275
|
+
|
|
276
|
+
type ResolvedCharacter = Required<
|
|
277
|
+
Pick<
|
|
278
|
+
CharacterConfig,
|
|
279
|
+
| "modelUrl"
|
|
280
|
+
| "animation"
|
|
281
|
+
| "modelScale"
|
|
282
|
+
| "hideModelInFpv"
|
|
283
|
+
| "castShadow"
|
|
284
|
+
| "receiveShadow"
|
|
285
|
+
>
|
|
286
|
+
> & {
|
|
287
|
+
modelRotationOffset: ModelRotationOffset;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Register via `view.addPlugin(plugin)` **before** `view.init()`,
|
|
292
|
+
* then call `start()` after initialization completes.
|
|
293
|
+
*
|
|
294
|
+
* ### Default controls
|
|
295
|
+
*
|
|
296
|
+
* | Key | Action |
|
|
297
|
+
* |-----|--------|
|
|
298
|
+
* | W / S | Forward / backward |
|
|
299
|
+
* | A / D | Turn left / right |
|
|
300
|
+
* | Arrow Up / Space | Climb |
|
|
301
|
+
* | Arrow Down / Ctrl | Descend |
|
|
302
|
+
* | Shift | Dash (2.5x speed) |
|
|
303
|
+
* | Alt | Free camera (when allowCameraControl is false) |
|
|
304
|
+
* | V | Toggle TPV / FPV |
|
|
305
|
+
*/
|
|
306
|
+
export class PersonViewPlugin extends Plugin<View, ViewContext> {
|
|
307
|
+
private view?: View;
|
|
308
|
+
private config: PersonViewDefaults;
|
|
309
|
+
private character: ResolvedCharacter | null;
|
|
310
|
+
private keys: Required<KeyBindings>;
|
|
311
|
+
private keyToAction: Map<string, Action>;
|
|
312
|
+
|
|
313
|
+
private handle: MeshHandle<GLTFModelDesc> | null = null;
|
|
314
|
+
private modelRef: GLTFModelDesc | null = null;
|
|
315
|
+
private animId: number | null = null;
|
|
316
|
+
private lastTime = 0;
|
|
317
|
+
|
|
318
|
+
private state!: PersonViewState;
|
|
319
|
+
private listeners = new Set<StateListener>();
|
|
320
|
+
|
|
321
|
+
private heldActions = new Set<Action>();
|
|
322
|
+
private dashMultiplier = 1;
|
|
323
|
+
private orbitKeyHeld = false;
|
|
324
|
+
// Persists the free-camera state after Alt release until the user
|
|
325
|
+
// initiates a new movement action. Lets users dwell at an orbited
|
|
326
|
+
// angle instead of snapping back the moment Alt is released.
|
|
327
|
+
private orbitLatched = false;
|
|
328
|
+
private currentAnimState: string | null;
|
|
329
|
+
private modelHeading: number;
|
|
330
|
+
private cameraHeading: number;
|
|
331
|
+
private viewMode: ViewMode;
|
|
332
|
+
|
|
333
|
+
/** Set to `true` to ignore all movement keys (e.g. while a modal is open). */
|
|
334
|
+
movementSuppressed = false;
|
|
335
|
+
|
|
336
|
+
private boundKeyDown: (e: KeyboardEvent) => void;
|
|
337
|
+
private boundKeyUp: (e: KeyboardEvent) => void;
|
|
338
|
+
|
|
339
|
+
// Per-tick scratch buffers reused across frames to keep the hot path
|
|
340
|
+
// allocation-free. Safe because each value is consumed before the next
|
|
341
|
+
// overwrite within a single tick, and `handle.update({matrixWorld})`
|
|
342
|
+
// reads the matrix synchronously (RTE conversion runs inside the
|
|
343
|
+
// update call), so the descriptor never observes a stale reference.
|
|
344
|
+
private _curPos = new Vector3();
|
|
345
|
+
private _east = new Vector3();
|
|
346
|
+
private _north = new Vector3();
|
|
347
|
+
private _worldForward = new Vector3();
|
|
348
|
+
private _offset = new Vector3();
|
|
349
|
+
private _headingMatrix = new Matrix4();
|
|
350
|
+
private _characterFrame = new Matrix4();
|
|
351
|
+
|
|
352
|
+
constructor(config: PersonViewConfig = {}) {
|
|
353
|
+
super();
|
|
354
|
+
const { character, keys, ...rest } = config;
|
|
355
|
+
this.config = { ...DEFAULTS, ...rest };
|
|
356
|
+
this.character = character ? this.resolveCharacter(character) : null;
|
|
357
|
+
this.keys = { ...DEFAULT_KEYS, ...keys };
|
|
358
|
+
this.keyToAction = this.buildKeyMap(this.keys);
|
|
359
|
+
|
|
360
|
+
this.modelHeading = this.config.startHeading;
|
|
361
|
+
this.cameraHeading = this.config.startHeading;
|
|
362
|
+
this.viewMode = this.config.initialView;
|
|
363
|
+
this.currentAnimState = this.character
|
|
364
|
+
? this.character.animation.idleClip
|
|
365
|
+
: null;
|
|
366
|
+
this.boundKeyDown = this.onKeyDown.bind(this);
|
|
367
|
+
this.boundKeyUp = this.onKeyUp.bind(this);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async init(view: View, _ctx: ViewContext): Promise<void> {
|
|
371
|
+
this.view = view;
|
|
372
|
+
this.state = {
|
|
373
|
+
lng: this.config.startLng,
|
|
374
|
+
lat: this.config.startLat,
|
|
375
|
+
alt: this.config.startHeight,
|
|
376
|
+
heading: this.config.startHeading,
|
|
377
|
+
speed: 0,
|
|
378
|
+
animationState: this.currentAnimState,
|
|
379
|
+
mode: this.viewMode,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
start(): void {
|
|
384
|
+
if (!this.view) return;
|
|
385
|
+
if (this.animId != null) return;
|
|
386
|
+
this.animId = -1;
|
|
387
|
+
|
|
388
|
+
const { startLat, startLng, startHeight, startHeading } = this.config;
|
|
389
|
+
const startPos = geodeticToVector3({
|
|
390
|
+
lat: degreeToRadian(startLat),
|
|
391
|
+
lng: degreeToRadian(startLng),
|
|
392
|
+
height: startHeight,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
this.modelHeading = startHeading;
|
|
396
|
+
this.cameraHeading = startHeading;
|
|
397
|
+
|
|
398
|
+
if (this.character) {
|
|
399
|
+
const {
|
|
400
|
+
animation,
|
|
401
|
+
modelRotationOffset,
|
|
402
|
+
modelScale,
|
|
403
|
+
castShadow,
|
|
404
|
+
receiveShadow,
|
|
405
|
+
} = this.character;
|
|
406
|
+
this.handle = this.view.addMesh({
|
|
407
|
+
gltfModel: {
|
|
408
|
+
url: this.character.modelUrl,
|
|
409
|
+
castShadow,
|
|
410
|
+
receiveShadow,
|
|
411
|
+
animationEnabled: true,
|
|
412
|
+
animationAutoPlay: true,
|
|
413
|
+
animationActiveClip: animation.idleClip,
|
|
414
|
+
animationSpeed: animation.speed,
|
|
415
|
+
animationLoop: true,
|
|
416
|
+
animationCrossfadeDuration: animation.crossfadeDuration,
|
|
417
|
+
},
|
|
418
|
+
matrixWorld: this.composeCharacterFrame(startPos, startHeading),
|
|
419
|
+
rotation: {
|
|
420
|
+
x: modelRotationOffset.x,
|
|
421
|
+
y: modelRotationOffset.y,
|
|
422
|
+
z: modelRotationOffset.z,
|
|
423
|
+
},
|
|
424
|
+
scale: { x: modelScale, y: modelScale, z: modelScale },
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
this.modelRef = this.handle.ref;
|
|
428
|
+
this.modelRef.on("load", () => {
|
|
429
|
+
if (!this.view) return;
|
|
430
|
+
this.lastTime = performance.now();
|
|
431
|
+
this.animId = requestAnimationFrame(this.tick);
|
|
432
|
+
});
|
|
433
|
+
} else {
|
|
434
|
+
this.lastTime = performance.now();
|
|
435
|
+
this.animId = requestAnimationFrame(this.tick);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
this.applyInitialCamera();
|
|
439
|
+
this.applyModelVisibility();
|
|
440
|
+
|
|
441
|
+
document.addEventListener("keydown", this.boundKeyDown);
|
|
442
|
+
document.addEventListener("keyup", this.boundKeyUp);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Instantly move to a new geographic position. */
|
|
446
|
+
teleport(options: TeleportOptions): void {
|
|
447
|
+
if (!this.view) return;
|
|
448
|
+
|
|
449
|
+
const { lng, lat, alt } = options;
|
|
450
|
+
const headingRad =
|
|
451
|
+
options.heading != null ? options.heading : this.cameraHeading;
|
|
452
|
+
|
|
453
|
+
if (this.handle && this.character) {
|
|
454
|
+
const pos = geodeticToVector3({
|
|
455
|
+
lat: degreeToRadian(lat),
|
|
456
|
+
lng: degreeToRadian(lng),
|
|
457
|
+
height: alt,
|
|
458
|
+
});
|
|
459
|
+
this.handle.update({
|
|
460
|
+
matrixWorld: this.composeCharacterFrame(pos, headingRad),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
this.modelHeading = headingRad;
|
|
465
|
+
this.cameraHeading = headingRad;
|
|
466
|
+
this.placeChaseCamera(lat, lng, alt, headingRad);
|
|
467
|
+
|
|
468
|
+
this.state = {
|
|
469
|
+
lng,
|
|
470
|
+
lat,
|
|
471
|
+
alt,
|
|
472
|
+
heading: headingRad,
|
|
473
|
+
speed: 0,
|
|
474
|
+
animationState: this.currentAnimState,
|
|
475
|
+
mode: this.viewMode,
|
|
476
|
+
};
|
|
477
|
+
this.notify();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
getState(): PersonViewState {
|
|
481
|
+
return this.state;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
onStateChange(fn: StateListener): () => void {
|
|
485
|
+
this.listeners.add(fn);
|
|
486
|
+
return () => this.listeners.delete(fn);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
setViewMode(mode: ViewMode): void {
|
|
490
|
+
if (this.viewMode === mode) return;
|
|
491
|
+
this.viewMode = mode;
|
|
492
|
+
this.state = { ...this.state, mode };
|
|
493
|
+
this.applyModelVisibility();
|
|
494
|
+
// Snap the camera to the new mode's geometry. Without this, when
|
|
495
|
+
// allowCameraControl is true the per-frame loop calls
|
|
496
|
+
// cameraFollow(target) with no offset and the camera lingers at
|
|
497
|
+
// wherever it was — e.g. switching back from FPV to TPV would keep
|
|
498
|
+
// the FPV eye position instead of restoring the chase distance.
|
|
499
|
+
this.placeChaseCamera(
|
|
500
|
+
this.state.lat,
|
|
501
|
+
this.state.lng,
|
|
502
|
+
this.state.alt,
|
|
503
|
+
this.cameraHeading,
|
|
504
|
+
);
|
|
505
|
+
this.notify();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
toggleViewMode(): void {
|
|
509
|
+
this.setViewMode(this.viewMode === "tpv" ? "fpv" : "tpv");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
setAllowCameraControl(value: boolean): void {
|
|
513
|
+
this.config.allowCameraControl = value;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Rotate the character to the given heading in radians (0 = north,
|
|
518
|
+
* increasing clockwise) without changing position. Snaps the chase camera to
|
|
519
|
+
* match; in free-camera mode only the model rotates.
|
|
520
|
+
*/
|
|
521
|
+
setHeading(radians: number): void {
|
|
522
|
+
if (!this.view) return;
|
|
523
|
+
|
|
524
|
+
const { lat, lng, alt } = this.state;
|
|
525
|
+
this.modelHeading = radians;
|
|
526
|
+
this.cameraHeading = radians;
|
|
527
|
+
|
|
528
|
+
if (this.handle && this.character) {
|
|
529
|
+
const pos = geodeticToVector3({
|
|
530
|
+
lat: degreeToRadian(lat),
|
|
531
|
+
lng: degreeToRadian(lng),
|
|
532
|
+
height: alt,
|
|
533
|
+
});
|
|
534
|
+
this.handle.update({
|
|
535
|
+
matrixWorld: this.composeCharacterFrame(pos, radians),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!this.isFreeCamera()) {
|
|
540
|
+
this.placeChaseCamera(lat, lng, alt, radians);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
this.state = { ...this.state, heading: radians };
|
|
544
|
+
this.notify();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Current character heading in radians. */
|
|
548
|
+
getHeading(): number {
|
|
549
|
+
return this.state.heading;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Set the downward TPV camera pitch in radians (0 = behind at eye level,
|
|
554
|
+
* positive orbits up and over the model). Takes effect immediately for the
|
|
555
|
+
* chase / locked camera.
|
|
556
|
+
*/
|
|
557
|
+
setCameraPitch(radians: number): void {
|
|
558
|
+
this.config.cameraPitch = radians;
|
|
559
|
+
if (!this.isFreeCamera()) {
|
|
560
|
+
this.placeChaseCamera(
|
|
561
|
+
this.state.lat,
|
|
562
|
+
this.state.lng,
|
|
563
|
+
this.state.alt,
|
|
564
|
+
this.cameraHeading,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Current downward TPV camera pitch in radians. */
|
|
570
|
+
getCameraPitch(): number {
|
|
571
|
+
return this.config.cameraPitch;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Set the downward FPV camera pitch in radians (0 = horizontal, positive
|
|
576
|
+
* tilts the view down in place). Takes effect immediately for the chase /
|
|
577
|
+
* locked camera.
|
|
578
|
+
*/
|
|
579
|
+
setFpvPitch(radians: number): void {
|
|
580
|
+
this.config.fpvPitch = radians;
|
|
581
|
+
if (!this.isFreeCamera()) {
|
|
582
|
+
this.placeChaseCamera(
|
|
583
|
+
this.state.lat,
|
|
584
|
+
this.state.lng,
|
|
585
|
+
this.state.alt,
|
|
586
|
+
this.cameraHeading,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Current downward FPV camera pitch in radians. */
|
|
592
|
+
getFpvPitch(): number {
|
|
593
|
+
return this.config.fpvPitch;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Set the FPV eye height offset in meters (added to the position to get the
|
|
598
|
+
* eye height). Also used as the shared eye-line height in TPV. Takes effect
|
|
599
|
+
* immediately for the chase / locked camera.
|
|
600
|
+
*/
|
|
601
|
+
setFpvHeightOffset(meters: number): void {
|
|
602
|
+
this.config.fpvHeightOffset = meters;
|
|
603
|
+
if (!this.isFreeCamera()) {
|
|
604
|
+
this.placeChaseCamera(
|
|
605
|
+
this.state.lat,
|
|
606
|
+
this.state.lng,
|
|
607
|
+
this.state.alt,
|
|
608
|
+
this.cameraHeading,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Current FPV eye height offset in meters. */
|
|
614
|
+
getFpvHeightOffset(): number {
|
|
615
|
+
return this.config.fpvHeightOffset;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
dispose(): void {
|
|
619
|
+
if (this.animId != null) cancelAnimationFrame(this.animId);
|
|
620
|
+
this.animId = null;
|
|
621
|
+
|
|
622
|
+
document.removeEventListener("keydown", this.boundKeyDown);
|
|
623
|
+
document.removeEventListener("keyup", this.boundKeyUp);
|
|
624
|
+
|
|
625
|
+
if (this.handle) {
|
|
626
|
+
this.handle.delete();
|
|
627
|
+
this.handle = null;
|
|
628
|
+
}
|
|
629
|
+
this.modelRef = null;
|
|
630
|
+
this.heldActions.clear();
|
|
631
|
+
this.listeners.clear();
|
|
632
|
+
this.orbitKeyHeld = false;
|
|
633
|
+
this.orbitLatched = false;
|
|
634
|
+
this.dashMultiplier = 1;
|
|
635
|
+
this.view = undefined;
|
|
636
|
+
}
|
|
637
|
+
private resolveCharacter(c: CharacterConfig): ResolvedCharacter {
|
|
638
|
+
return {
|
|
639
|
+
modelUrl: c.modelUrl,
|
|
640
|
+
animation: c.animation,
|
|
641
|
+
modelRotationOffset: c.modelRotationOffset
|
|
642
|
+
? { ...DEFAULT_ROTATION_OFFSET, ...c.modelRotationOffset }
|
|
643
|
+
: DEFAULT_ROTATION_OFFSET,
|
|
644
|
+
modelScale: c.modelScale ?? DEFAULT_MODEL_SCALE,
|
|
645
|
+
hideModelInFpv: c.hideModelInFpv ?? true,
|
|
646
|
+
castShadow: c.castShadow ?? false,
|
|
647
|
+
receiveShadow: c.receiveShadow ?? false,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private buildKeyMap(keys: Required<KeyBindings>): Map<string, Action> {
|
|
652
|
+
const m = new Map<string, Action>();
|
|
653
|
+
const bind = (codes: string[], action: Action) => {
|
|
654
|
+
for (const code of codes) m.set(code, action);
|
|
655
|
+
};
|
|
656
|
+
bind(keys.forward, "forward");
|
|
657
|
+
bind(keys.backward, "backward");
|
|
658
|
+
bind(keys.turnLeft, "turnLeft");
|
|
659
|
+
bind(keys.turnRight, "turnRight");
|
|
660
|
+
bind(keys.ascend, "ascend");
|
|
661
|
+
bind(keys.descend, "descend");
|
|
662
|
+
bind(keys.dash, "dash");
|
|
663
|
+
bind(keys.orbitCamera, "orbitCamera");
|
|
664
|
+
bind(keys.toggleView, "toggleView");
|
|
665
|
+
return m;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private isFreeCamera(): boolean {
|
|
669
|
+
return (
|
|
670
|
+
this.config.allowCameraControl || this.orbitKeyHeld || this.orbitLatched
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private isMovementAction(action: Action): boolean {
|
|
675
|
+
return (
|
|
676
|
+
action === "forward" ||
|
|
677
|
+
action === "backward" ||
|
|
678
|
+
action === "turnLeft" ||
|
|
679
|
+
action === "turnRight" ||
|
|
680
|
+
action === "ascend" ||
|
|
681
|
+
action === "descend"
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private applyModelVisibility(): void {
|
|
686
|
+
if (!this.handle || !this.character) return;
|
|
687
|
+
const shouldHide = this.viewMode === "fpv" && this.character.hideModelInFpv;
|
|
688
|
+
this.handle.visible = !shouldHide;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private applyInitialCamera(): void {
|
|
692
|
+
const { startLat, startLng, startHeight, startHeading } = this.config;
|
|
693
|
+
this.placeChaseCamera(startLat, startLng, startHeight, startHeading);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Chase / locked camera placement. Caller supplies the heading (radians)
|
|
698
|
+
* to use — lerped during the per-frame loop, snapped on teleport / init.
|
|
699
|
+
*
|
|
700
|
+
* Pitch is per-mode: FPV uses `fpvPitch` and tilts the view down in place
|
|
701
|
+
* (the eye stays fixed); TPV uses `cameraPitch` and orbits the camera around
|
|
702
|
+
* the model, staying aimed at it so the model remains centered.
|
|
703
|
+
*/
|
|
704
|
+
private placeChaseCamera(
|
|
705
|
+
lat: number,
|
|
706
|
+
lng: number,
|
|
707
|
+
alt: number,
|
|
708
|
+
heading: number,
|
|
709
|
+
): void {
|
|
710
|
+
if (!this.view) return;
|
|
711
|
+
|
|
712
|
+
const {
|
|
713
|
+
cameraDistance,
|
|
714
|
+
cameraPitch,
|
|
715
|
+
fpvPitch,
|
|
716
|
+
fpvHeightOffset,
|
|
717
|
+
fpvForwardOffset,
|
|
718
|
+
} = this.config;
|
|
719
|
+
const isFpv = this.viewMode === "fpv";
|
|
720
|
+
const eyeHeight = alt + fpvHeightOffset;
|
|
721
|
+
|
|
722
|
+
if (isFpv) {
|
|
723
|
+
// FPV: the eye is locked to the person's position at eye height.
|
|
724
|
+
// `fpvPitch` tilts the view down *in place* by lowering the look-at
|
|
725
|
+
// target while a matching upward offset keeps the eye itself at
|
|
726
|
+
// `eyeHeight` — only the look direction changes, not the position.
|
|
727
|
+
//
|
|
728
|
+
// The target sits far ahead and the camera is offset back by the
|
|
729
|
+
// same distance, so the camera ends up at the eye looking forward.
|
|
730
|
+
const backDistance = FPV_LOOK_AHEAD_DISTANCE;
|
|
731
|
+
const lookAheadDistance = fpvForwardOffset + FPV_LOOK_AHEAD_DISTANCE;
|
|
732
|
+
const targetDrop = Math.tan(fpvPitch) * backDistance;
|
|
733
|
+
const target = this.advanceLatLng(lat, lng, heading, lookAheadDistance);
|
|
734
|
+
|
|
735
|
+
this._offset.set(
|
|
736
|
+
-Math.sin(heading) * backDistance,
|
|
737
|
+
-Math.cos(heading) * backDistance,
|
|
738
|
+
targetDrop,
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
this.view.cameraFollow(
|
|
742
|
+
true,
|
|
743
|
+
{ lat: target.lat, lng: target.lng, height: eyeHeight - targetDrop },
|
|
744
|
+
this._offset,
|
|
745
|
+
);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// TPV: orbit the camera around the model. The look-at target stays on
|
|
750
|
+
// the model at eye height, and `cameraPitch` raises the camera while
|
|
751
|
+
// keeping it aimed at the model, so the model stays centered as you tilt
|
|
752
|
+
// down. The eye orbits at constant `cameraDistance` from the model:
|
|
753
|
+
// pulled back by `cos(pitch)` horizontally and up by `sin(pitch)`.
|
|
754
|
+
const horizontal = cameraDistance * Math.cos(cameraPitch);
|
|
755
|
+
const vertical = cameraDistance * Math.sin(cameraPitch);
|
|
756
|
+
|
|
757
|
+
this._offset.set(
|
|
758
|
+
-Math.sin(heading) * horizontal,
|
|
759
|
+
-Math.cos(heading) * horizontal,
|
|
760
|
+
vertical,
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
this.view.cameraFollow(true, { lat, lng, height: eyeHeight }, this._offset);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Compose the character's world matrix as `ENU * Rz(-heading)`.
|
|
768
|
+
*
|
|
769
|
+
* The ENU frame already places the model at the right spot on the
|
|
770
|
+
* globe with +Z pointing world-up. Multiplying by a Z-rotation of
|
|
771
|
+
* `-heading` (negated because three.js Rz is counter-clockwise when
|
|
772
|
+
* viewed from +Z, while our heading convention is clockwise from
|
|
773
|
+
* north) bakes the heading into the world matrix.
|
|
774
|
+
*
|
|
775
|
+
* The character's `rotation` field is therefore left as the static
|
|
776
|
+
* `modelRotationOffset` only — its job is to position the model in
|
|
777
|
+
* its "facing-north, upright" rest pose. Heading then rotates the
|
|
778
|
+
* already-upright model around world-up, regardless of which axis
|
|
779
|
+
* the GLTF treats as forward.
|
|
780
|
+
*/
|
|
781
|
+
private composeCharacterFrame(pos: Vector3, heading: number): Matrix4 {
|
|
782
|
+
const enuFrame = eastNorthUpToFixedFrame(pos);
|
|
783
|
+
this._headingMatrix.makeRotationZ(-heading);
|
|
784
|
+
return this._characterFrame.multiplyMatrices(enuFrame, this._headingMatrix);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Advance a (lat,lng) point by `meters` along the given ENU heading. */
|
|
788
|
+
private advanceLatLng(
|
|
789
|
+
lat: number,
|
|
790
|
+
lng: number,
|
|
791
|
+
heading: number,
|
|
792
|
+
meters: number,
|
|
793
|
+
): { lat: number; lng: number } {
|
|
794
|
+
const pos = geodeticToVector3({
|
|
795
|
+
lat: degreeToRadian(lat),
|
|
796
|
+
lng: degreeToRadian(lng),
|
|
797
|
+
height: 0,
|
|
798
|
+
});
|
|
799
|
+
const enu: Matrix4 = eastNorthUpToFixedFrame(pos);
|
|
800
|
+
this._east.setFromMatrixColumn(enu, 0).normalize();
|
|
801
|
+
this._north.setFromMatrixColumn(enu, 1).normalize();
|
|
802
|
+
this._worldForward
|
|
803
|
+
.copy(this._east)
|
|
804
|
+
.multiplyScalar(Math.sin(heading))
|
|
805
|
+
.addScaledVector(this._north, Math.cos(heading));
|
|
806
|
+
pos.addScaledVector(this._worldForward, meters);
|
|
807
|
+
const lle = vector3ToGeodetic(pos);
|
|
808
|
+
return { lat: radianToDegree(lle.lat), lng: radianToDegree(lle.lng) };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private notify(): void {
|
|
812
|
+
for (const fn of this.listeners) fn(this.state);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private onKeyDown(e: KeyboardEvent): void {
|
|
816
|
+
const t = e.target as HTMLElement;
|
|
817
|
+
if (
|
|
818
|
+
t.tagName === "INPUT" ||
|
|
819
|
+
t.tagName === "TEXTAREA" ||
|
|
820
|
+
t.isContentEditable
|
|
821
|
+
)
|
|
822
|
+
return;
|
|
823
|
+
|
|
824
|
+
const action = this.keyToAction.get(e.code);
|
|
825
|
+
if (action === undefined) return;
|
|
826
|
+
|
|
827
|
+
if (action === "toggleView") {
|
|
828
|
+
if (e.repeat) return;
|
|
829
|
+
this.toggleViewMode();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (action === "orbitCamera") {
|
|
833
|
+
this.orbitKeyHeld = true;
|
|
834
|
+
this.orbitLatched = true;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (this.movementSuppressed) return;
|
|
839
|
+
|
|
840
|
+
if (action === "dash") {
|
|
841
|
+
this.dashMultiplier = 2.5;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (this.isMovementAction(action)) {
|
|
845
|
+
// A new movement keypress releases the latched orbit so the camera
|
|
846
|
+
// snaps back to chase on the next tick.
|
|
847
|
+
this.orbitLatched = false;
|
|
848
|
+
}
|
|
849
|
+
this.heldActions.add(action);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private onKeyUp(e: KeyboardEvent): void {
|
|
853
|
+
const action = this.keyToAction.get(e.code);
|
|
854
|
+
if (action === undefined) return;
|
|
855
|
+
|
|
856
|
+
if (action === "toggleView") return;
|
|
857
|
+
if (action === "orbitCamera") {
|
|
858
|
+
this.orbitKeyHeld = false;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (action === "dash") {
|
|
862
|
+
this.dashMultiplier = 1;
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
this.heldActions.delete(action);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private tick = (currentTime: number): void => {
|
|
869
|
+
if (!this.view) return;
|
|
870
|
+
this.animId = requestAnimationFrame(this.tick);
|
|
871
|
+
|
|
872
|
+
const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.1);
|
|
873
|
+
this.lastTime = currentTime;
|
|
874
|
+
|
|
875
|
+
let dirX = 0,
|
|
876
|
+
dirY = 0,
|
|
877
|
+
dirZ = 0;
|
|
878
|
+
if (this.heldActions.has("forward")) dirY += 1;
|
|
879
|
+
if (this.heldActions.has("backward")) dirY -= 1;
|
|
880
|
+
if (this.heldActions.has("turnLeft")) dirX -= 1;
|
|
881
|
+
if (this.heldActions.has("turnRight")) dirX += 1;
|
|
882
|
+
if (this.heldActions.has("ascend")) dirZ += 1;
|
|
883
|
+
if (this.heldActions.has("descend")) dirZ -= 1;
|
|
884
|
+
|
|
885
|
+
const {
|
|
886
|
+
moveSpeed,
|
|
887
|
+
rotationSpeed,
|
|
888
|
+
altSpeed,
|
|
889
|
+
minAlt,
|
|
890
|
+
maxAlt,
|
|
891
|
+
cameraLerpSpeed,
|
|
892
|
+
} = this.config;
|
|
893
|
+
|
|
894
|
+
if (dirX !== 0) {
|
|
895
|
+
this.modelHeading += degreeToRadian(rotationSpeed * dirX);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const curPos = this._curPos;
|
|
899
|
+
|
|
900
|
+
if (this.handle && this.modelRef) {
|
|
901
|
+
const refPos: Vector3 | undefined = this.modelRef.getWorldPosition();
|
|
902
|
+
if (!refPos) return;
|
|
903
|
+
curPos.copy(refPos);
|
|
904
|
+
} else {
|
|
905
|
+
curPos.copy(
|
|
906
|
+
geodeticToVector3({
|
|
907
|
+
lat: degreeToRadian(this.state.lat),
|
|
908
|
+
lng: degreeToRadian(this.state.lng),
|
|
909
|
+
height: this.state.alt,
|
|
910
|
+
}),
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const enuMatrix: Matrix4 = eastNorthUpToFixedFrame(curPos);
|
|
915
|
+
this._east.setFromMatrixColumn(enuMatrix, 0).normalize();
|
|
916
|
+
this._north.setFromMatrixColumn(enuMatrix, 1).normalize();
|
|
917
|
+
const currentLLE = vector3ToGeodetic(curPos);
|
|
918
|
+
|
|
919
|
+
this._worldForward
|
|
920
|
+
.copy(this._east)
|
|
921
|
+
.multiplyScalar(Math.sin(this.modelHeading))
|
|
922
|
+
.addScaledVector(this._north, Math.cos(this.modelHeading));
|
|
923
|
+
|
|
924
|
+
if (dirY !== 0) {
|
|
925
|
+
curPos.addScaledVector(
|
|
926
|
+
this._worldForward,
|
|
927
|
+
moveSpeed * this.dashMultiplier * deltaTime * dirY,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const advancedLLE = vector3ToGeodetic(curPos);
|
|
932
|
+
const rawHeight = currentLLE.height + dirZ * altSpeed * deltaTime;
|
|
933
|
+
const height = Math.max(minAlt, Math.min(maxAlt, rawHeight));
|
|
934
|
+
const nextLat = radianToDegree(advancedLLE.lat);
|
|
935
|
+
const nextLng = radianToDegree(advancedLLE.lng);
|
|
936
|
+
const nextAlt = height;
|
|
937
|
+
|
|
938
|
+
if (this.handle && this.character) {
|
|
939
|
+
const finalPos = geodeticToVector3({
|
|
940
|
+
lat: advancedLLE.lat,
|
|
941
|
+
lng: advancedLLE.lng,
|
|
942
|
+
height,
|
|
943
|
+
});
|
|
944
|
+
this.handle.update({
|
|
945
|
+
matrixWorld: this.composeCharacterFrame(finalPos, this.modelHeading),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Lerp camera heading toward the model heading — shared between TPV and FPV
|
|
950
|
+
// so turning feels identical in both modes.
|
|
951
|
+
let headingDiff = this.modelHeading - this.cameraHeading;
|
|
952
|
+
headingDiff =
|
|
953
|
+
headingDiff - Math.round(headingDiff / (Math.PI * 2)) * (Math.PI * 2);
|
|
954
|
+
this.cameraHeading +=
|
|
955
|
+
headingDiff * Math.min(deltaTime * cameraLerpSpeed, 1);
|
|
956
|
+
|
|
957
|
+
if (this.isFreeCamera()) {
|
|
958
|
+
if (this.viewMode === "fpv") {
|
|
959
|
+
// FPV: position-locked free-look at eye height. The Rust side
|
|
960
|
+
// pins the camera to the target and lets mouse drag rotate
|
|
961
|
+
// orientation only — no orbit-around-target motion.
|
|
962
|
+
//
|
|
963
|
+
// Advance the eye by `fpvForwardOffset` so it matches the eye
|
|
964
|
+
// position produced by `placeChaseCamera` (which lands the
|
|
965
|
+
// camera that far ahead of the position). Without this the eye
|
|
966
|
+
// sits exactly on the position, so engaging free-look snaps the
|
|
967
|
+
// camera back by `fpvForwardOffset` from the chase placement.
|
|
968
|
+
const eye = this.advanceLatLng(
|
|
969
|
+
nextLat,
|
|
970
|
+
nextLng,
|
|
971
|
+
this.cameraHeading,
|
|
972
|
+
this.config.fpvForwardOffset,
|
|
973
|
+
);
|
|
974
|
+
this.view.cameraFreeLook(true, {
|
|
975
|
+
lat: eye.lat,
|
|
976
|
+
lng: eye.lng,
|
|
977
|
+
height: nextAlt + this.config.fpvHeightOffset,
|
|
978
|
+
});
|
|
979
|
+
} else {
|
|
980
|
+
// Keep the orbit pivot at the same eye height used everywhere
|
|
981
|
+
// else so switching between free and chase camera is seamless.
|
|
982
|
+
this.view.cameraFollow(true, {
|
|
983
|
+
lat: nextLat,
|
|
984
|
+
lng: nextLng,
|
|
985
|
+
height: nextAlt + this.config.fpvHeightOffset,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
this.placeChaseCamera(nextLat, nextLng, nextAlt, this.cameraHeading);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const isMoving = dirY !== 0 || dirX !== 0 || dirZ !== 0;
|
|
993
|
+
const isDashing = isMoving && this.dashMultiplier > 1;
|
|
994
|
+
|
|
995
|
+
let nextAnimState = this.currentAnimState;
|
|
996
|
+
if (this.character && this.modelRef) {
|
|
997
|
+
const { animation } = this.character;
|
|
998
|
+
const walkClip = animation.walkClip ?? animation.idleClip;
|
|
999
|
+
let targetAnim: string;
|
|
1000
|
+
if (isDashing) {
|
|
1001
|
+
targetAnim = animation.dashClip;
|
|
1002
|
+
} else if (isMoving) {
|
|
1003
|
+
targetAnim = walkClip;
|
|
1004
|
+
} else {
|
|
1005
|
+
targetAnim = animation.idleClip;
|
|
1006
|
+
}
|
|
1007
|
+
if (targetAnim !== this.currentAnimState) {
|
|
1008
|
+
this.modelRef.crossFadeAnimation(
|
|
1009
|
+
this.currentAnimState ?? "",
|
|
1010
|
+
targetAnim,
|
|
1011
|
+
animation.crossfadeDuration,
|
|
1012
|
+
);
|
|
1013
|
+
this.currentAnimState = targetAnim;
|
|
1014
|
+
}
|
|
1015
|
+
nextAnimState = targetAnim;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const nextState: PersonViewState = {
|
|
1019
|
+
lng: nextLng,
|
|
1020
|
+
lat: nextLat,
|
|
1021
|
+
alt: nextAlt,
|
|
1022
|
+
heading: this.modelHeading,
|
|
1023
|
+
speed: moveSpeed * this.dashMultiplier,
|
|
1024
|
+
animationState: nextAnimState,
|
|
1025
|
+
mode: this.viewMode,
|
|
1026
|
+
};
|
|
1027
|
+
if (this.hasStateChanged(this.state, nextState)) {
|
|
1028
|
+
this.state = nextState;
|
|
1029
|
+
this.notify();
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
private hasStateChanged(a: PersonViewState, b: PersonViewState): boolean {
|
|
1034
|
+
return (
|
|
1035
|
+
a.lng !== b.lng ||
|
|
1036
|
+
a.lat !== b.lat ||
|
|
1037
|
+
a.alt !== b.alt ||
|
|
1038
|
+
a.heading !== b.heading ||
|
|
1039
|
+
a.speed !== b.speed ||
|
|
1040
|
+
a.animationState !== b.animationState ||
|
|
1041
|
+
a.mode !== b.mode
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
}
|