@navaramap/three-plugins 0.0.4 → 0.0.6

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.
@@ -62,7 +62,7 @@ import ThreeView, {
62
62
  } from "@navaramap/three";
63
63
  import type { GLTFModelDesc } from "@navaramap/three-default-descs";
64
64
  import type { DefaultDescriptions } from "@navaramap/three-default-plugin";
65
- import { Vector3, Matrix4 } from "three";
65
+ import { Vector3, Matrix4, MathUtils } from "three";
66
66
 
67
67
  type View = ThreeView<DefaultDescriptions>;
68
68
 
@@ -122,12 +122,69 @@ export type AnimationConfig = {
122
122
  walkClip?: string;
123
123
  /** Clip name played while the model is dashing (dash key held). */
124
124
  dashClip: string;
125
- /** Playback speed multiplier. */
126
- speed: number;
125
+ /** Playback speed for any clip without a per-clip override below. */
126
+ speed?: number;
127
+ /** Playback speed for the idle clip (falls back to {@link speed}). */
128
+ idleSpeed?: number;
129
+ /** Playback speed for the walk clip (falls back to {@link speed}). */
130
+ walkSpeed?: number;
131
+ /** Playback speed for the dash clip (falls back to {@link speed}). */
132
+ dashSpeed?: number;
127
133
  /** Duration in seconds for cross-fade transitions between clips. */
128
134
  crossfadeDuration: number;
129
135
  };
130
136
 
137
+ /**
138
+ * How the character's altitude reacts to the terrain surface.
139
+ *
140
+ * - `"off"` — altitude is driven purely by the ascend / descend keys.
141
+ * - `"clamp"` — free flight, but the terrain acts as a floor: the character is
142
+ * pushed up whenever it would end up below the surface.
143
+ * - `"ground"` — the character is glued to the terrain surface, so it walks up
144
+ * and down slopes. The ascend / descend keys have no effect in this mode.
145
+ */
146
+ export type CollisionMode = "off" | "clamp" | "ground";
147
+
148
+ /** Terrain collision configuration. See {@link CollisionMode}. */
149
+ export type CollisionConfig = {
150
+ /** @defaultValue `"off"` */
151
+ mode?: CollisionMode;
152
+ /**
153
+ * Height (meters) kept above the sampled terrain surface — the character's
154
+ * feet in `"ground"` mode, the floor it cannot sink below in `"clamp"`.
155
+ * @defaultValue `0`
156
+ */
157
+ groundOffset?: number;
158
+ /**
159
+ * Tilt the character to match the slope it stands on. Costs several extra
160
+ * terrain lookups per frame, and fades out as it leaves the ground in
161
+ * `"clamp"` mode.
162
+ * @defaultValue `true`
163
+ */
164
+ alignToSlope?: boolean;
165
+ /**
166
+ * Footprint (meters) the slope under the character is averaged over. The
167
+ * default suits the triangle spacing terrain meshes arrive at; below that
168
+ * there is nothing left to average and the tilt starts to step.
169
+ * @defaultValue `4`
170
+ */
171
+ slopeSampleDistance?: number;
172
+ /**
173
+ * Largest tilt (radians) {@link alignToSlope} may apply, so ground far
174
+ * steeper than anything walkable does not lay the character flat against it.
175
+ * @defaultValue `Math.PI / 4` (45°)
176
+ */
177
+ maxSlopeTilt?: number;
178
+ /**
179
+ * How much of the terrain's slope the camera pitch follows, from `0` (fixed
180
+ * pitch, so a steep hillside fills the frame) to `1` (the view runs parallel
181
+ * to the slope, looking up a climb and down a descent). Applies to the TPV
182
+ * chase camera and the FPV eye line alike.
183
+ * @defaultValue `1`
184
+ */
185
+ cameraSlopeFollow?: number;
186
+ };
187
+
131
188
  export type CharacterConfig = {
132
189
  /** URL of the GLTF model to load. */
133
190
  modelUrl: string;
@@ -163,7 +220,12 @@ export type KeyBindings = {
163
220
  toggleView?: string[];
164
221
  };
165
222
 
166
- type Action =
223
+ /**
224
+ * A recognized control input. Delivered to {@link PersonViewPlugin.onAction}
225
+ * listeners on each keypress — e.g. to dismiss an on-screen controls hint once
226
+ * the user starts driving the character.
227
+ */
228
+ export type PersonViewAction =
167
229
  | "forward"
168
230
  | "backward"
169
231
  | "turnLeft"
@@ -174,8 +236,15 @@ type Action =
174
236
  | "orbitCamera"
175
237
  | "toggleView";
176
238
 
239
+ type Action = PersonViewAction;
240
+
177
241
  export type PersonViewConfig = {
178
242
  character?: CharacterConfig;
243
+ /**
244
+ * Terrain collision. Off by default — the character flies freely and ignores
245
+ * the terrain surface.
246
+ */
247
+ collision?: CollisionConfig;
179
248
 
180
249
  /** When true, the camera is always free (no Alt-hold required). */
181
250
  allowCameraControl?: boolean;
@@ -188,6 +257,11 @@ export type PersonViewConfig = {
188
257
  rotationSpeed?: number;
189
258
  /** m/s */
190
259
  altSpeed?: number;
260
+ /**
261
+ * Factor applied to {@link moveSpeed} while the dash key is held.
262
+ * @defaultValue `2.5`
263
+ */
264
+ dashSpeedMultiplier?: number;
191
265
  minAlt?: number;
192
266
  maxAlt?: number;
193
267
  cameraDistance?: number;
@@ -225,6 +299,7 @@ export type PersonViewConfig = {
225
299
  };
226
300
 
227
301
  type StateListener = (s: PersonViewState) => void;
302
+ type ActionListener = (action: PersonViewAction) => void;
228
303
 
229
304
  const DEFAULT_ROTATION_OFFSET: ModelRotationOffset = {
230
305
  x: 0,
@@ -245,15 +320,80 @@ const DEFAULT_KEYS: Required<KeyBindings> = {
245
320
  };
246
321
 
247
322
  type PersonViewDefaults = Required<
248
- Omit<PersonViewConfig, "character" | "keys">
323
+ Omit<PersonViewConfig, "character" | "keys" | "collision">
249
324
  >;
250
325
 
326
+ type ResolvedCollision = Required<CollisionConfig>;
327
+
328
+ /** A geodetic position in radians, as the geodetic helpers return it. */
329
+ type LatLngRadians = { lat: number; lng: number };
330
+
331
+ // Tuned so that `collision: { mode: "ground" }` alone walks terrain well: the
332
+ // slope footprint is wide enough for the triangle spacing real terrain meshes
333
+ // arrive at.
334
+ const DEFAULT_COLLISION: ResolvedCollision = {
335
+ mode: "off",
336
+ groundOffset: 0,
337
+ alignToSlope: true,
338
+ slopeSampleDistance: 4,
339
+ maxSlopeTilt: Math.PI / 4,
340
+ cameraSlopeFollow: 1,
341
+ };
342
+
343
+ /**
344
+ * Terrain tiles keep being replaced by finer ones, which moves the surface and
345
+ * its gradient under a character that has not gone anywhere — by hundreds of
346
+ * meters right after load. These bound how fast that is allowed to reach the
347
+ * character: without them it is thrown up the mountainside and back, and a
348
+ * single frame swings the slope tilt by 13°.
349
+ *
350
+ * The height settles under an acceleration (m/s²) rather than at a fixed speed,
351
+ * because the two cases need opposite things from it. A second of tile churn
352
+ * must barely move the character, which a speed low enough to do that then
353
+ * takes half a minute to walk back a surface that turned out to be 250m out.
354
+ * Falling covers the first in a few meters and the second in a few seconds.
355
+ *
356
+ * They are constants rather than options because there is nothing about an
357
+ * application that makes another value right: ground the character *walks*
358
+ * onto is never bounded by them, so they only ever act on data catching up.
359
+ */
360
+ const GROUND_SETTLE_ACCELERATION = 10;
361
+ const SLOPE_TILT_SMOOTHING = 6;
362
+
363
+ /**
364
+ * Largest pitch change {@link CollisionConfig.cameraSlopeFollow} may apply, so
365
+ * a near-vertical face does not swing the view to straight up or down. Framing
366
+ * is tuned with `cameraSlopeFollow`; this is only the guard rail behind it.
367
+ */
368
+ const MAX_CAMERA_SLOPE_PITCH = Math.PI / 4;
369
+
370
+ /**
371
+ * Bearings (radians from north) of the probe ring that measures the terrain
372
+ * gradient for `alignToSlope`. Fitting a ring is steadier than a forward /
373
+ * sideways pair: a probe crossing a mesh edge steps the slope it reports, and
374
+ * the ring keeps any single step from tilting the whole character.
375
+ */
376
+ const SLOPE_PROBE_BEARINGS = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => {
377
+ const bearing = (i * Math.PI) / 4;
378
+ return { sin: Math.sin(bearing), cos: Math.cos(bearing) };
379
+ });
380
+
381
+ /**
382
+ * Fraction of the remaining distance to cover this frame when easing toward a
383
+ * target at `rate` (1/s). Exponential, so it is frame-rate independent and
384
+ * never overshoots. A rate of `0` — or a frame of no length, as when
385
+ * teleporting — snaps, which is why this is not `MathUtils.damp`.
386
+ */
387
+ const easeFactor = (rate: number, deltaTime: number): number =>
388
+ rate > 0 && deltaTime > 0 ? 1 - Math.exp(-rate * deltaTime) : 1;
389
+
251
390
  const DEFAULTS: PersonViewDefaults = {
252
391
  allowCameraControl: false,
253
392
  initialView: "tpv",
254
393
  moveSpeed: 50,
255
394
  rotationSpeed: 3,
256
395
  altSpeed: 30,
396
+ dashSpeedMultiplier: 2.5,
257
397
  minAlt: 50,
258
398
  maxAlt: 5000,
259
399
  cameraDistance: 50,
@@ -306,6 +446,7 @@ type ResolvedCharacter = Required<
306
446
  export class PersonViewPlugin extends Plugin<View, ViewContext> {
307
447
  private view?: View;
308
448
  private config: PersonViewDefaults;
449
+ private collision: ResolvedCollision;
309
450
  private character: ResolvedCharacter | null;
310
451
  private keys: Required<KeyBindings>;
311
452
  private keyToAction: Map<string, Action>;
@@ -313,13 +454,18 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
313
454
  private handle: MeshHandle<GLTFModelDesc> | null = null;
314
455
  private modelRef: GLTFModelDesc | null = null;
315
456
  private animId: number | null = null;
457
+ /** Whether the character has been placed, so `start()` resumes from here. */
458
+ private placed = false;
316
459
  private lastTime = 0;
317
460
 
318
461
  private state!: PersonViewState;
319
462
  private listeners = new Set<StateListener>();
463
+ private actionListeners = new Set<ActionListener>();
320
464
 
321
465
  private heldActions = new Set<Action>();
322
- private dashMultiplier = 1;
466
+ /** Whether the dash key is held. Kept separate from the numeric speed factor
467
+ * so the dash animation still plays when `dashSpeedMultiplier` is 1 (or <1). */
468
+ private dashHeld = false;
323
469
  private orbitKeyHeld = false;
324
470
  // Persists the free-camera state after Alt release until the user
325
471
  // initiates a new movement action. Lets users dwell at an orbited
@@ -346,13 +492,38 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
346
492
  private _north = new Vector3();
347
493
  private _worldForward = new Vector3();
348
494
  private _offset = new Vector3();
495
+ private _probePos = new Vector3();
349
496
  private _headingMatrix = new Matrix4();
497
+ private _tiltMatrix = new Matrix4();
350
498
  private _characterFrame = new Matrix4();
351
499
 
500
+ /** Smoothed pitch (radians) the chase camera gives up to follow the slope. */
501
+ private cameraSlopePitch = 0;
502
+
503
+ // Smoothed terrain gradient (meters of rise per meter travelled) under the
504
+ // character, held in ENU so it does not have to be re-smoothed when the
505
+ // character turns. Zero unless `alignToSlope` is on.
506
+ private slopeGradEast = 0;
507
+ private slopeGradNorth = 0;
508
+
509
+ /**
510
+ * Terrain height (meters) sampled under the character last frame, with the
511
+ * position it was taken at, so the next frame can re-sample the same spot and
512
+ * tell a change in the terrain data apart from the character having moved.
513
+ */
514
+ private lastGroundSample?: { lat: number; lng: number; height: number };
515
+ /** Meters of terrain data shift not yet passed on to the character. */
516
+ private unabsorbedGroundShift = 0;
517
+ /** Speed (m/s) that shift is being worked off at, positive when rising. */
518
+ private settleVelocity = 0;
519
+ /** Surface height (meters) the character stands on, once settling is applied. */
520
+ private groundSurfaceHeight?: number;
521
+
352
522
  constructor(config: PersonViewConfig = {}) {
353
523
  super();
354
- const { character, keys, ...rest } = config;
524
+ const { character, keys, collision, ...rest } = config;
355
525
  this.config = { ...DEFAULTS, ...rest };
526
+ this.collision = { ...DEFAULT_COLLISION, ...collision };
356
527
  this.character = character ? this.resolveCharacter(character) : null;
357
528
  this.keys = { ...DEFAULT_KEYS, ...keys };
358
529
  this.keyToAction = this.buildKeyMap(this.keys);
@@ -380,11 +551,32 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
380
551
  };
381
552
  }
382
553
 
554
+ /**
555
+ * Take over the camera and start reading the movement keys. Calling it again
556
+ * after {@link stop} resumes from wherever the character was left, rather
557
+ * than starting over at the configured position.
558
+ */
383
559
  start(): void {
384
560
  if (!this.view) return;
385
561
  if (this.animId != null) return;
386
562
  this.animId = -1;
387
563
 
564
+ document.addEventListener("keydown", this.boundKeyDown);
565
+ document.addEventListener("keyup", this.boundKeyUp);
566
+
567
+ if (this.placed) {
568
+ this.resumeLoop();
569
+ this.placeChaseCamera(
570
+ this.state.lat,
571
+ this.state.lng,
572
+ this.state.alt,
573
+ this.cameraHeading,
574
+ );
575
+ this.applyModelVisibility();
576
+ return;
577
+ }
578
+ this.placed = true;
579
+
388
580
  const { startLat, startLng, startHeight, startHeading } = this.config;
389
581
  const startPos = geodeticToVector3({
390
582
  lat: degreeToRadian(startLat),
@@ -411,7 +603,8 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
411
603
  animationEnabled: true,
412
604
  animationAutoPlay: true,
413
605
  animationActiveClip: animation.idleClip,
414
- animationSpeed: animation.speed,
606
+ // The initial clip is idle, so start it at the idle clip's speed.
607
+ animationSpeed: this.clipSpeed(animation.idleClip),
415
608
  animationLoop: true,
416
609
  animationCrossfadeDuration: animation.crossfadeDuration,
417
610
  },
@@ -426,34 +619,92 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
426
619
 
427
620
  this.modelRef = this.handle.ref;
428
621
  this.modelRef.on("load", () => {
429
- if (!this.view) return;
430
- this.lastTime = performance.now();
431
- this.animId = requestAnimationFrame(this.tick);
622
+ if (!this.view || this.animId == null) return;
623
+ this.resumeLoop();
432
624
  });
433
625
  } else {
434
- this.lastTime = performance.now();
435
- this.animId = requestAnimationFrame(this.tick);
626
+ this.resumeLoop();
436
627
  }
437
628
 
438
629
  this.applyInitialCamera();
439
630
  this.applyModelVisibility();
631
+ }
440
632
 
441
- document.addEventListener("keydown", this.boundKeyDown);
442
- document.addEventListener("keyup", this.boundKeyUp);
633
+ /**
634
+ * Hand the camera back to the view's own controls and stop reading the
635
+ * movement keys, leaving the character where it stands. {@link start}
636
+ * resumes from there — use the two to step out of person view and back
637
+ * without losing the position.
638
+ */
639
+ stop(): void {
640
+ if (this.animId == null) return;
641
+ this.releaseControl();
642
+
643
+ // Both camera modes are released: whichever was driving, the view's own
644
+ // controls take over from where it left the camera.
645
+ this.view?.cameraFollow(false);
646
+ this.view?.cameraFreeLook(false);
647
+
648
+ // Nothing is driving the character any more, so it stands idle.
649
+ this.playAnimation(this.character?.animation.idleClip);
650
+ const stopped: PersonViewState = {
651
+ ...this.state,
652
+ speed: 0,
653
+ animationState: this.currentAnimState,
654
+ };
655
+ if (this.hasStateChanged(this.state, stopped)) {
656
+ this.state = stopped;
657
+ this.notify();
658
+ }
659
+ }
660
+
661
+ /** Stop the per-frame loop and the key handling, keeping everything else. */
662
+ private releaseControl(): void {
663
+ if (this.animId != null && this.animId >= 0)
664
+ cancelAnimationFrame(this.animId);
665
+ this.animId = null;
666
+
667
+ document.removeEventListener("keydown", this.boundKeyDown);
668
+ document.removeEventListener("keyup", this.boundKeyUp);
669
+
670
+ // Keys held at the moment control is released would otherwise still read as
671
+ // held when it is taken back.
672
+ this.heldActions.clear();
673
+ this.orbitKeyHeld = false;
674
+ this.orbitLatched = false;
675
+ this.dashHeld = false;
676
+ }
677
+
678
+ /**
679
+ * Run the per-frame loop from now. The clock restarts with it, so a pause
680
+ * between `stop()` and `start()` is not delivered as one enormous frame.
681
+ */
682
+ private resumeLoop(): void {
683
+ this.lastTime = performance.now();
684
+ this.animId = requestAnimationFrame(this.tick);
443
685
  }
444
686
 
445
687
  /** Instantly move to a new geographic position. */
446
688
  teleport(options: TeleportOptions): void {
447
689
  if (!this.view) return;
448
690
 
449
- const { lng, lat, alt } = options;
691
+ const { lng, lat } = options;
450
692
  const headingRad =
451
693
  options.heading != null ? options.heading : this.cameraHeading;
694
+ // A teleport lands on the terrain outright rather than settling onto it.
695
+ const destination = { lat: degreeToRadian(lat), lng: degreeToRadian(lng) };
696
+ const alt = this.resolveGroundHeight(
697
+ destination,
698
+ destination,
699
+ options.alt,
700
+ 0,
701
+ );
702
+ this.updateSlopeTilt(destination.lat, destination.lng, alt, 0);
452
703
 
453
704
  if (this.handle && this.character) {
454
705
  const pos = geodeticToVector3({
455
- lat: degreeToRadian(lat),
456
- lng: degreeToRadian(lng),
706
+ lat: destination.lat,
707
+ lng: destination.lng,
457
708
  height: alt,
458
709
  });
459
710
  this.handle.update({
@@ -486,6 +737,17 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
486
737
  return () => this.listeners.delete(fn);
487
738
  }
488
739
 
740
+ /**
741
+ * Subscribe to control-input events. The callback fires once per keypress of
742
+ * any bound action (movement, dash, view toggle, orbit) — for example, to
743
+ * hide an on-screen controls hint once the user starts driving the
744
+ * character. Returns an unsubscribe function.
745
+ */
746
+ onAction(fn: ActionListener): () => void {
747
+ this.actionListeners.add(fn);
748
+ return () => this.actionListeners.delete(fn);
749
+ }
750
+
489
751
  setViewMode(mode: ViewMode): void {
490
752
  if (this.viewMode === mode) return;
491
753
  this.viewMode = mode;
@@ -513,6 +775,19 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
513
775
  this.config.allowCameraControl = value;
514
776
  }
515
777
 
778
+ /**
779
+ * Update the terrain collision settings at runtime. Only the given fields
780
+ * change; the rest keep their current values.
781
+ */
782
+ setCollision(collision: CollisionConfig): void {
783
+ this.collision = { ...this.collision, ...collision };
784
+ }
785
+
786
+ /** Current terrain collision settings. */
787
+ getCollision(): Readonly<ResolvedCollision> {
788
+ return this.collision;
789
+ }
790
+
516
791
  /**
517
792
  * Rotate the character to the given heading in radians (0 = north,
518
793
  * increasing clockwise) without changing position. Snaps the chase camera to
@@ -615,25 +890,71 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
615
890
  return this.config.fpvHeightOffset;
616
891
  }
617
892
 
618
- dispose(): void {
619
- if (this.animId != null) cancelAnimationFrame(this.animId);
620
- this.animId = null;
893
+ /**
894
+ * Set the base animation playback speed — the fallback used by any clip
895
+ * without a per-clip override (`idleSpeed` / `walkSpeed` / `dashSpeed`).
896
+ * Re-applies to the clip currently playing, so it takes effect immediately.
897
+ */
898
+ setAnimationSpeed(speed: number): void {
899
+ if (this.character) this.character.animation.speed = speed;
900
+ this.modelRef?.setAnimationSpeed(this.clipSpeed(this.currentAnimState));
901
+ }
621
902
 
622
- document.removeEventListener("keydown", this.boundKeyDown);
623
- document.removeEventListener("keyup", this.boundKeyUp);
903
+ /** Current base animation playback speed (per-clip overrides aside). */
904
+ getAnimationSpeed(): number {
905
+ return this.character?.animation.speed ?? 1;
906
+ }
907
+
908
+ /**
909
+ * The loaded character model's mesh handle, or `null` before {@link start}
910
+ * has loaded it (or when no character is configured). Its `ref` is the
911
+ * GLTFModelDesc — reach the model itself through it, e.g. `model.ref.raw`
912
+ * for the underlying three.js object or `model.ref.getWorldPosition()`.
913
+ */
914
+ get model(): MeshHandle<GLTFModelDesc> | null {
915
+ return this.handle;
916
+ }
917
+
918
+ /**
919
+ * Resolve the effective playback speed for a clip: its per-clip override
920
+ * (`idleSpeed` / `walkSpeed` / `dashSpeed`) if set, else the base `speed`.
921
+ */
922
+ private clipSpeed(clip: string | null): number {
923
+ const a = this.character?.animation;
924
+ if (!a) return 1;
925
+ if (clip === a.dashClip) return a.dashSpeed ?? a.speed ?? 1;
926
+ if (a.walkClip != null && clip === a.walkClip)
927
+ return a.walkSpeed ?? a.speed ?? 1;
928
+ return a.idleSpeed ?? a.speed ?? 1;
929
+ }
930
+
931
+ dispose(): void {
932
+ this.releaseControl();
624
933
 
625
934
  if (this.handle) {
626
935
  this.handle.delete();
627
936
  this.handle = null;
628
937
  }
629
938
  this.modelRef = null;
630
- this.heldActions.clear();
939
+ this.placed = false;
631
940
  this.listeners.clear();
632
- this.orbitKeyHeld = false;
633
- this.orbitLatched = false;
634
- this.dashMultiplier = 1;
941
+ this.actionListeners.clear();
635
942
  this.view = undefined;
636
943
  }
944
+
945
+ /** Cross-fade the model onto `clip`, unless it is already the one playing. */
946
+ private playAnimation(clip: string | undefined): void {
947
+ if (!clip || !this.character || !this.modelRef) return;
948
+ if (clip === this.currentAnimState) return;
949
+ this.modelRef.crossFadeAnimation(
950
+ this.currentAnimState ?? "",
951
+ clip,
952
+ this.character.animation.crossfadeDuration,
953
+ );
954
+ // Apply the incoming clip's own playback speed.
955
+ this.modelRef.setAnimationSpeed(this.clipSpeed(clip));
956
+ this.currentAnimState = clip;
957
+ }
637
958
  private resolveCharacter(c: CharacterConfig): ResolvedCharacter {
638
959
  return {
639
960
  modelUrl: c.modelUrl,
@@ -706,22 +1027,17 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
706
1027
  lng: number,
707
1028
  alt: number,
708
1029
  heading: number,
1030
+ deltaTime = 0,
709
1031
  ): void {
710
1032
  if (!this.view) return;
711
1033
 
712
- const {
713
- cameraDistance,
714
- cameraPitch,
715
- fpvPitch,
716
- fpvHeightOffset,
717
- fpvForwardOffset,
718
- } = this.config;
1034
+ const { cameraDistance, fpvPitch, fpvForwardOffset } = this.config;
719
1035
  const isFpv = this.viewMode === "fpv";
720
- const eyeHeight = alt + fpvHeightOffset;
1036
+ const eyeHeight = this.eyeHeightAt(alt);
721
1037
 
722
1038
  if (isFpv) {
723
1039
  // 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
1040
+ // The pitch tilts the view down *in place* by lowering the look-at
725
1041
  // target while a matching upward offset keeps the eye itself at
726
1042
  // `eyeHeight` — only the look direction changes, not the position.
727
1043
  //
@@ -729,7 +1045,15 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
729
1045
  // same distance, so the camera ends up at the eye looking forward.
730
1046
  const backDistance = FPV_LOOK_AHEAD_DISTANCE;
731
1047
  const lookAheadDistance = fpvForwardOffset + FPV_LOOK_AHEAD_DISTANCE;
732
- const targetDrop = Math.tan(fpvPitch) * backDistance;
1048
+ const pitch = this.followSlopeWithPitch(
1049
+ fpvPitch,
1050
+ lat,
1051
+ lng,
1052
+ alt,
1053
+ heading,
1054
+ deltaTime,
1055
+ );
1056
+ const targetDrop = Math.tan(pitch) * backDistance;
733
1057
  const target = this.advanceLatLng(lat, lng, heading, lookAheadDistance);
734
1058
 
735
1059
  this._offset.set(
@@ -746,14 +1070,22 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
746
1070
  return;
747
1071
  }
748
1072
 
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);
1073
+ // TPV: orbit the camera around the model, staying aimed at it so it keeps
1074
+ // its place in frame. The eye sits at `cameraDistance`, pulled back by
1075
+ // `cos(pitch)` horizontally and up by `sin(pitch)`.
1076
+ const pitch = this.followSlopeWithPitch(
1077
+ this.config.cameraPitch,
1078
+ lat,
1079
+ lng,
1080
+ alt,
1081
+ heading,
1082
+ deltaTime,
1083
+ );
1084
+ const horizontal = cameraDistance * Math.cos(pitch);
1085
+ const vertical = cameraDistance * Math.sin(pitch);
756
1086
 
1087
+ // The eye rides the character's own height: the terrain settling already
1088
+ // lives in that altitude, so filtering it again would only add lag.
757
1089
  this._offset.set(
758
1090
  -Math.sin(heading) * horizontal,
759
1091
  -Math.cos(heading) * horizontal,
@@ -763,6 +1095,76 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
763
1095
  this.view.cameraFollow(true, { lat, lng, height: eyeHeight }, this._offset);
764
1096
  }
765
1097
 
1098
+ /**
1099
+ * Tilt `configured` (radians) with the slope the character is walking along,
1100
+ * eased in over time. Position in degrees, `alt` in meters.
1101
+ *
1102
+ * A fixed pitch hugs a horizontal plane while the ground does not: on a steep
1103
+ * climb the view runs into the hillside and the frame fills with ground. The
1104
+ * slope is taken over the chase distance either side of the character, and
1105
+ * fades out as it rises off the surface so a flying character in `"clamp"`
1106
+ * mode keeps the pitch it was configured with.
1107
+ */
1108
+ private followSlopeWithPitch(
1109
+ configured: number,
1110
+ lat: number,
1111
+ lng: number,
1112
+ alt: number,
1113
+ heading: number,
1114
+ deltaTime: number,
1115
+ ): number {
1116
+ const { cameraDistance, cameraLerpSpeed } = this.config;
1117
+ const { mode, cameraSlopeFollow } = this.collision;
1118
+
1119
+ let target = 0;
1120
+ if (mode !== "off" && cameraSlopeFollow > 0) {
1121
+ const ahead = this.terrainAlong(lat, lng, heading, cameraDistance);
1122
+ const behind = this.terrainAlong(
1123
+ lat,
1124
+ lng,
1125
+ heading + Math.PI,
1126
+ cameraDistance,
1127
+ );
1128
+ if (ahead !== undefined && behind !== undefined) {
1129
+ target = MathUtils.clamp(
1130
+ Math.atan2(ahead - behind, 2 * cameraDistance) *
1131
+ cameraSlopeFollow *
1132
+ this.groundedFactor(alt, cameraDistance),
1133
+ -MAX_CAMERA_SLOPE_PITCH,
1134
+ MAX_CAMERA_SLOPE_PITCH,
1135
+ );
1136
+ }
1137
+ }
1138
+ // Eased: the slope is read a chase distance away, where the ground can
1139
+ // change far faster than it does under the character.
1140
+ this.cameraSlopePitch +=
1141
+ (target - this.cameraSlopePitch) * easeFactor(cameraLerpSpeed, deltaTime);
1142
+ return configured - this.cameraSlopePitch;
1143
+ }
1144
+
1145
+ /**
1146
+ * How much the ground still applies to a character at `alt`: `1` standing on
1147
+ * the surface, fading to `0` once it is `scale` meters above it. Nothing the
1148
+ * terrain does should tilt a character that is flying over it in `"clamp"`
1149
+ * mode — neither the model nor the view follows a slope it has left behind.
1150
+ */
1151
+ private groundedFactor(alt: number, scale: number): number {
1152
+ const surface = this.groundSurfaceHeight;
1153
+ if (surface === undefined) return 0;
1154
+ return MathUtils.clamp(1 - (alt - surface) / scale, 0, 1);
1155
+ }
1156
+
1157
+ /** Terrain height `meters` along `heading` from a position in degrees. */
1158
+ private terrainAlong(
1159
+ lat: number,
1160
+ lng: number,
1161
+ heading: number,
1162
+ meters: number,
1163
+ ): number | undefined {
1164
+ const at = this.advanceLatLng(lat, lng, heading, meters);
1165
+ return this.sampleTerrain(degreeToRadian(at.lat), degreeToRadian(at.lng));
1166
+ }
1167
+
766
1168
  /**
767
1169
  * Compose the character's world matrix as `ENU * Rz(-heading)`.
768
1170
  *
@@ -777,11 +1179,217 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
777
1179
  * its "facing-north, upright" rest pose. Heading then rotates the
778
1180
  * already-upright model around world-up, regardless of which axis
779
1181
  * the GLTF treats as forward.
1182
+ *
1183
+ * After the heading the frame's axes are X = right, Y = forward,
1184
+ * Z = up, so `alignToSlope` tilts around X (nose up when climbing)
1185
+ * then around Y (roll onto the side of the slope).
780
1186
  */
781
1187
  private composeCharacterFrame(pos: Vector3, heading: number): Matrix4 {
782
1188
  const enuFrame = eastNorthUpToFixedFrame(pos);
783
1189
  this._headingMatrix.makeRotationZ(-heading);
784
- return this._characterFrame.multiplyMatrices(enuFrame, this._headingMatrix);
1190
+ this._characterFrame.multiplyMatrices(enuFrame, this._headingMatrix);
1191
+
1192
+ const gradEast = this.slopeGradEast;
1193
+ const gradNorth = this.slopeGradNorth;
1194
+ if (gradEast !== 0 || gradNorth !== 0) {
1195
+ const sin = Math.sin(heading);
1196
+ const cos = Math.cos(heading);
1197
+ const limit = this.collision.maxSlopeTilt;
1198
+ // Rise per meter along the character's own axes: forward is
1199
+ // (sin, cos) in ENU and right is (cos, -sin).
1200
+ const pitch = MathUtils.clamp(
1201
+ Math.atan(gradEast * sin + gradNorth * cos),
1202
+ -limit,
1203
+ limit,
1204
+ );
1205
+ const roll = MathUtils.clamp(
1206
+ -Math.atan(gradEast * cos - gradNorth * sin),
1207
+ -limit,
1208
+ limit,
1209
+ );
1210
+
1211
+ this._tiltMatrix.makeRotationX(pitch);
1212
+ this._characterFrame.multiply(this._tiltMatrix);
1213
+ this._tiltMatrix.makeRotationY(roll);
1214
+ this._characterFrame.multiply(this._tiltMatrix);
1215
+ }
1216
+
1217
+ return this._characterFrame;
1218
+ }
1219
+
1220
+ /** Eye-line height (meters) for a character standing at `alt`. */
1221
+ private eyeHeightAt(alt: number): number {
1222
+ return alt + this.config.fpvHeightOffset;
1223
+ }
1224
+
1225
+ /** Terrain height at a position in radians, or `undefined` if not loaded. */
1226
+ private sampleTerrain(lat: number, lng: number): number | undefined {
1227
+ return this.view?.sampleTerrainHeight({ lat, lng, height: 0 });
1228
+ }
1229
+
1230
+ /**
1231
+ * Apply terrain collision to a candidate altitude (meters). Positions are in
1232
+ * radians. `deltaTime` drives the absorption of terrain data changes — pass
1233
+ * `0` to land on the terrain the sampler reports right now.
1234
+ */
1235
+ private resolveGroundHeight(
1236
+ at: LatLngRadians,
1237
+ from: LatLngRadians,
1238
+ height: number,
1239
+ deltaTime: number,
1240
+ ): number {
1241
+ const { mode, groundOffset } = this.collision;
1242
+ if (mode === "off") {
1243
+ this.forgetGroundSamples();
1244
+ return height;
1245
+ }
1246
+
1247
+ const terrain = this.sampleTerrain(at.lat, at.lng);
1248
+ // Terrain tiles for this spot are not loaded yet. Keep the caller's
1249
+ // altitude — snapping to a fallback would drop the character to sea level.
1250
+ if (terrain === undefined) {
1251
+ this.forgetGroundSamples();
1252
+ return height;
1253
+ }
1254
+ this.groundSurfaceHeight = terrain + groundOffset;
1255
+ const floor =
1256
+ this.groundSurfaceHeight -
1257
+ this.absorbGroundDataShift(at, from, terrain, height, deltaTime);
1258
+ // The floor already tracks the surface exactly as the character walks over
1259
+ // it, so a climb can never bury it and the ground never has to shove it
1260
+ // upward out of turn.
1261
+ return mode === "ground" ? floor : Math.max(height, floor);
1262
+ }
1263
+
1264
+ /**
1265
+ * Absorb terrain that moved because its *data* changed rather than because
1266
+ * the character did, and return how much of that shift is still being held
1267
+ * back (meters, positive when the data rose).
1268
+ *
1269
+ * Only one of the two is real ground: walking onto higher ground has to move
1270
+ * the character at once or a climb buries it, while a tile re-measured at a
1271
+ * finer LOD moving the surface hundreds of meters must not. Re-sampling the
1272
+ * *previous* frame's position separates them — whatever the height there has
1273
+ * changed by is data, and the rest is the character having moved.
1274
+ */
1275
+ private absorbGroundDataShift(
1276
+ at: LatLngRadians,
1277
+ from: LatLngRadians,
1278
+ terrain: number,
1279
+ height: number,
1280
+ deltaTime: number,
1281
+ ): number {
1282
+ const previous = this.lastGroundSample;
1283
+ let shift: number;
1284
+ if (previous === undefined) {
1285
+ // First contact with the terrain, at load or once tiles reach a spot the
1286
+ // character was standing over unloaded ground on: the gap to the surface
1287
+ // is the shift, so it glides down instead of dropping out of the sky.
1288
+ shift =
1289
+ (this.sampleTerrain(from.lat, from.lng) ?? terrain) +
1290
+ this.collision.groundOffset -
1291
+ height;
1292
+ } else {
1293
+ const before = this.sampleTerrain(previous.lat, previous.lng);
1294
+ // The spot just left has gone unloaded: nothing can be attributed.
1295
+ shift = before === undefined ? 0 : before - previous.height;
1296
+ }
1297
+
1298
+ const carried = this.unabsorbedGroundShift + shift;
1299
+ const toward = Math.sign(carried);
1300
+ if (deltaTime > 0) {
1301
+ // Speed builds up while the gap stays on the same side, and starts over
1302
+ // whenever the surface crosses the character — it has landed by then.
1303
+ if (toward !== Math.sign(this.settleVelocity)) this.settleVelocity = 0;
1304
+ this.settleVelocity += toward * GROUND_SETTLE_ACCELERATION * deltaTime;
1305
+ }
1306
+ // A frame of no length is a teleport or a first placement: land outright.
1307
+ const step =
1308
+ deltaTime > 0
1309
+ ? Math.abs(this.settleVelocity) * deltaTime
1310
+ : Math.abs(carried);
1311
+ this.unabsorbedGroundShift = toward * Math.max(0, Math.abs(carried) - step);
1312
+ if (this.unabsorbedGroundShift === 0) this.settleVelocity = 0;
1313
+ this.lastGroundSample = { lat: at.lat, lng: at.lng, height: terrain };
1314
+ return this.unabsorbedGroundShift;
1315
+ }
1316
+
1317
+ /** Drop the terrain history, so the next sample starts a fresh landing. */
1318
+ private forgetGroundSamples(): void {
1319
+ this.lastGroundSample = undefined;
1320
+ this.unabsorbedGroundShift = 0;
1321
+ this.settleVelocity = 0;
1322
+ this.groundSurfaceHeight = undefined;
1323
+ }
1324
+
1325
+ /**
1326
+ * Refresh the terrain gradient the slope tilt is built from, by probing a
1327
+ * ring of points around the character (position in radians, `alt` in meters).
1328
+ * `deltaTime` drives the smoothing — pass `0` to snap.
1329
+ */
1330
+ private updateSlopeTilt(
1331
+ lat: number,
1332
+ lng: number,
1333
+ alt: number,
1334
+ deltaTime: number,
1335
+ ): void {
1336
+ const { mode, alignToSlope, slopeSampleDistance } = this.collision;
1337
+
1338
+ let gradEast = 0;
1339
+ let gradNorth = 0;
1340
+ if (mode !== "off" && alignToSlope) {
1341
+ const gradient = this.measureSlopeGradient(lat, lng);
1342
+ // A missing probe would fake a cliff, so hold the current tilt until the
1343
+ // terrain around the character is fully loaded.
1344
+ if (!gradient) return;
1345
+ // Its own footprint is the scale at which it is still standing on the
1346
+ // ground rather than flying over it.
1347
+ const grounded = this.groundedFactor(alt, slopeSampleDistance);
1348
+ gradEast = gradient.east * grounded;
1349
+ gradNorth = gradient.north * grounded;
1350
+ } else if (this.slopeGradEast === 0 && this.slopeGradNorth === 0) {
1351
+ return;
1352
+ }
1353
+
1354
+ const factor = easeFactor(SLOPE_TILT_SMOOTHING, deltaTime);
1355
+ this.slopeGradEast += (gradEast - this.slopeGradEast) * factor;
1356
+ this.slopeGradNorth += (gradNorth - this.slopeGradNorth) * factor;
1357
+ }
1358
+
1359
+ /**
1360
+ * Terrain gradient (rise per meter, east and north) under a point given in
1361
+ * radians, fitted to a ring of probes, or `undefined` while any of them is
1362
+ * over terrain that has not loaded. The probes sit a few meters apart, so
1363
+ * they share one ENU frame rather than each re-deriving its own.
1364
+ */
1365
+ private measureSlopeGradient(
1366
+ lat: number,
1367
+ lng: number,
1368
+ ): { east: number; north: number } | undefined {
1369
+ const distance = this.collision.slopeSampleDistance;
1370
+ const center = geodeticToVector3({ lat, lng, height: 0 });
1371
+ const enu: Matrix4 = eastNorthUpToFixedFrame(center);
1372
+ this._east.setFromMatrixColumn(enu, 0).normalize();
1373
+ this._north.setFromMatrixColumn(enu, 1).normalize();
1374
+
1375
+ let sumEast = 0;
1376
+ let sumNorth = 0;
1377
+ for (const { sin, cos } of SLOPE_PROBE_BEARINGS) {
1378
+ this._probePos
1379
+ .copy(center)
1380
+ .addScaledVector(this._east, sin * distance)
1381
+ .addScaledVector(this._north, cos * distance);
1382
+ const probe = vector3ToGeodetic(this._probePos);
1383
+ const terrain = this.sampleTerrain(probe.lat, probe.lng);
1384
+ if (terrain === undefined) return undefined;
1385
+ sumEast += terrain * sin;
1386
+ sumNorth += terrain * cos;
1387
+ }
1388
+
1389
+ // Least-squares gradient of the plane through the ring, which for evenly
1390
+ // spaced bearings reduces to this weighted mean.
1391
+ const scale = 2 / (SLOPE_PROBE_BEARINGS.length * distance);
1392
+ return { east: sumEast * scale, north: sumNorth * scale };
785
1393
  }
786
1394
 
787
1395
  /** Advance a (lat,lng) point by `meters` along the given ENU heading. */
@@ -812,6 +1420,10 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
812
1420
  for (const fn of this.listeners) fn(this.state);
813
1421
  }
814
1422
 
1423
+ private emitAction(action: Action): void {
1424
+ for (const fn of this.actionListeners) fn(action);
1425
+ }
1426
+
815
1427
  private onKeyDown(e: KeyboardEvent): void {
816
1428
  const t = e.target as HTMLElement;
817
1429
  if (
@@ -824,6 +1436,10 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
824
1436
  const action = this.keyToAction.get(e.code);
825
1437
  if (action === undefined) return;
826
1438
 
1439
+ // Notify listeners once per physical press (ignore auto-repeat), before
1440
+ // the action-specific handling below so every control input is reported.
1441
+ if (!e.repeat) this.emitAction(action);
1442
+
827
1443
  if (action === "toggleView") {
828
1444
  if (e.repeat) return;
829
1445
  this.toggleViewMode();
@@ -838,7 +1454,7 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
838
1454
  if (this.movementSuppressed) return;
839
1455
 
840
1456
  if (action === "dash") {
841
- this.dashMultiplier = 2.5;
1457
+ this.dashHeld = true;
842
1458
  return;
843
1459
  }
844
1460
  if (this.isMovementAction(action)) {
@@ -859,14 +1475,16 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
859
1475
  return;
860
1476
  }
861
1477
  if (action === "dash") {
862
- this.dashMultiplier = 1;
1478
+ this.dashHeld = false;
863
1479
  return;
864
1480
  }
865
1481
  this.heldActions.delete(action);
866
1482
  }
867
1483
 
868
1484
  private tick = (currentTime: number): void => {
869
- if (!this.view) return;
1485
+ // A frame already scheduled when control was released must not drive the
1486
+ // character, nor schedule another one behind `stop()`.
1487
+ if (!this.view || this.animId == null) return;
870
1488
  this.animId = requestAnimationFrame(this.tick);
871
1489
 
872
1490
  const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.1);
@@ -891,6 +1509,10 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
891
1509
  cameraLerpSpeed,
892
1510
  } = this.config;
893
1511
 
1512
+ // Derive the speed factor from the held state, so animation choice
1513
+ // (dashHeld) stays independent of the numeric multiplier.
1514
+ const dashMultiplier = this.dashHeld ? this.config.dashSpeedMultiplier : 1;
1515
+
894
1516
  if (dirX !== 0) {
895
1517
  this.modelHeading += degreeToRadian(rotationSpeed * dirX);
896
1518
  }
@@ -924,17 +1546,34 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
924
1546
  if (dirY !== 0) {
925
1547
  curPos.addScaledVector(
926
1548
  this._worldForward,
927
- moveSpeed * this.dashMultiplier * deltaTime * dirY,
1549
+ moveSpeed * dashMultiplier * deltaTime * dirY,
928
1550
  );
929
1551
  }
930
1552
 
931
1553
  const advancedLLE = vector3ToGeodetic(curPos);
932
- const rawHeight = currentLLE.height + dirZ * altSpeed * deltaTime;
933
- const height = Math.max(minAlt, Math.min(maxAlt, rawHeight));
1554
+ // Ground mode lets the terrain set the altitude, so the ascend / descend
1555
+ // keys and the limits that bound them play no part. Collision runs last
1556
+ // either way, or `maxAlt` could push the character underground.
1557
+ const flownHeight =
1558
+ this.collision.mode === "ground"
1559
+ ? currentLLE.height
1560
+ : MathUtils.clamp(
1561
+ currentLLE.height + dirZ * altSpeed * deltaTime,
1562
+ minAlt,
1563
+ maxAlt,
1564
+ );
1565
+ const height = this.resolveGroundHeight(
1566
+ advancedLLE,
1567
+ currentLLE,
1568
+ flownHeight,
1569
+ deltaTime,
1570
+ );
934
1571
  const nextLat = radianToDegree(advancedLLE.lat);
935
1572
  const nextLng = radianToDegree(advancedLLE.lng);
936
1573
  const nextAlt = height;
937
1574
 
1575
+ this.updateSlopeTilt(advancedLLE.lat, advancedLLE.lng, height, deltaTime);
1576
+
938
1577
  if (this.handle && this.character) {
939
1578
  const finalPos = geodeticToVector3({
940
1579
  lat: advancedLLE.lat,
@@ -951,8 +1590,7 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
951
1590
  let headingDiff = this.modelHeading - this.cameraHeading;
952
1591
  headingDiff =
953
1592
  headingDiff - Math.round(headingDiff / (Math.PI * 2)) * (Math.PI * 2);
954
- this.cameraHeading +=
955
- headingDiff * Math.min(deltaTime * cameraLerpSpeed, 1);
1593
+ this.cameraHeading += headingDiff * easeFactor(cameraLerpSpeed, deltaTime);
956
1594
 
957
1595
  if (this.isFreeCamera()) {
958
1596
  if (this.viewMode === "fpv") {
@@ -974,7 +1612,7 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
974
1612
  this.view.cameraFreeLook(true, {
975
1613
  lat: eye.lat,
976
1614
  lng: eye.lng,
977
- height: nextAlt + this.config.fpvHeightOffset,
1615
+ height: this.eyeHeightAt(nextAlt),
978
1616
  });
979
1617
  } else {
980
1618
  // Keep the orbit pivot at the same eye height used everywhere
@@ -982,37 +1620,32 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
982
1620
  this.view.cameraFollow(true, {
983
1621
  lat: nextLat,
984
1622
  lng: nextLng,
985
- height: nextAlt + this.config.fpvHeightOffset,
1623
+ height: this.eyeHeightAt(nextAlt),
986
1624
  });
987
1625
  }
988
1626
  } else {
989
- this.placeChaseCamera(nextLat, nextLng, nextAlt, this.cameraHeading);
1627
+ this.placeChaseCamera(
1628
+ nextLat,
1629
+ nextLng,
1630
+ nextAlt,
1631
+ this.cameraHeading,
1632
+ deltaTime,
1633
+ );
990
1634
  }
991
1635
 
992
1636
  const isMoving = dirY !== 0 || dirX !== 0 || dirZ !== 0;
993
- const isDashing = isMoving && this.dashMultiplier > 1;
1637
+ const isDashing = isMoving && this.dashHeld;
994
1638
 
995
1639
  let nextAnimState = this.currentAnimState;
996
- if (this.character && this.modelRef) {
1640
+ if (this.character) {
997
1641
  const { animation } = this.character;
998
1642
  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;
1643
+ nextAnimState = isDashing
1644
+ ? animation.dashClip
1645
+ : isMoving
1646
+ ? walkClip
1647
+ : animation.idleClip;
1648
+ this.playAnimation(nextAnimState);
1016
1649
  }
1017
1650
 
1018
1651
  const nextState: PersonViewState = {
@@ -1020,7 +1653,7 @@ export class PersonViewPlugin extends Plugin<View, ViewContext> {
1020
1653
  lat: nextLat,
1021
1654
  alt: nextAlt,
1022
1655
  heading: this.modelHeading,
1023
- speed: moveSpeed * this.dashMultiplier,
1656
+ speed: moveSpeed * dashMultiplier,
1024
1657
  animationState: nextAnimState,
1025
1658
  mode: this.viewMode,
1026
1659
  };