@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.
@@ -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
+ }