@navaramap/three-plugins 0.0.5 → 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.
- package/README.md +1 -1
- package/dist/index.d.ts +224 -3
- package/dist/index.js +279 -96
- package/dist/index.umd.cjs +1 -1
- package/package.json +5 -5
- package/src/PersonViewPlugin.test.ts +784 -0
- package/src/PersonViewPlugin.ts +709 -76
- package/src/index.ts +3 -0
package/src/PersonViewPlugin.ts
CHANGED
|
@@ -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
|
|
126
|
-
speed
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
431
|
-
this.animId = requestAnimationFrame(this.tick);
|
|
622
|
+
if (!this.view || this.animId == null) return;
|
|
623
|
+
this.resumeLoop();
|
|
432
624
|
});
|
|
433
625
|
} else {
|
|
434
|
-
this.
|
|
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
|
-
|
|
442
|
-
|
|
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
|
|
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:
|
|
456
|
-
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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.
|
|
939
|
+
this.placed = false;
|
|
631
940
|
this.listeners.clear();
|
|
632
|
-
this.
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 *
|
|
1549
|
+
moveSpeed * dashMultiplier * deltaTime * dirY,
|
|
928
1550
|
);
|
|
929
1551
|
}
|
|
930
1552
|
|
|
931
1553
|
const advancedLLE = vector3ToGeodetic(curPos);
|
|
932
|
-
|
|
933
|
-
|
|
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:
|
|
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:
|
|
1623
|
+
height: this.eyeHeightAt(nextAlt),
|
|
986
1624
|
});
|
|
987
1625
|
}
|
|
988
1626
|
} else {
|
|
989
|
-
this.placeChaseCamera(
|
|
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.
|
|
1637
|
+
const isDashing = isMoving && this.dashHeld;
|
|
994
1638
|
|
|
995
1639
|
let nextAnimState = this.currentAnimState;
|
|
996
|
-
if (this.character
|
|
1640
|
+
if (this.character) {
|
|
997
1641
|
const { animation } = this.character;
|
|
998
1642
|
const walkClip = animation.walkClip ?? animation.idleClip;
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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 *
|
|
1656
|
+
speed: moveSpeed * dashMultiplier,
|
|
1024
1657
|
animationState: nextAnimState,
|
|
1025
1658
|
mode: this.viewMode,
|
|
1026
1659
|
};
|