@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.
- package/README.md +1 -1
- package/dist/index.d.ts +289 -12
- package/dist/index.js +356 -159
- 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/TileJsonPlugin.test.ts +119 -3
- package/src/TileJsonPlugin.ts +145 -32
- package/src/index.ts +7 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { Matrix4, Vector3 } from "three";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { PersonViewPlugin, type CollisionConfig } from "./PersonViewPlugin";
|
|
5
|
+
|
|
6
|
+
// Importing the real @navaramap/three touches WASM/os at module load, which
|
|
7
|
+
// fails in the test environment. The geodetic helpers the plugin calls are
|
|
8
|
+
// stubbed with a spherical-earth model: it shares the conventions the plugin
|
|
9
|
+
// relies on (ENU columns are east / north / up, heading 0 = north) which is
|
|
10
|
+
// what these tests exercise, without pulling in the ellipsoid math.
|
|
11
|
+
const R = 6378137;
|
|
12
|
+
|
|
13
|
+
/* eslint-disable @typescript-eslint/no-extraneous-class */
|
|
14
|
+
vi.mock("@navaramap/three", () => ({
|
|
15
|
+
default: class ThreeView {},
|
|
16
|
+
Plugin: class Plugin {},
|
|
17
|
+
MeshHandle: class MeshHandle {},
|
|
18
|
+
degreeToRadian: (deg: number) => (deg * Math.PI) / 180,
|
|
19
|
+
radianToDegree: (rad: number) => (rad * 180) / Math.PI,
|
|
20
|
+
geodeticToVector3: ({
|
|
21
|
+
lat,
|
|
22
|
+
lng,
|
|
23
|
+
height,
|
|
24
|
+
}: {
|
|
25
|
+
lat: number;
|
|
26
|
+
lng: number;
|
|
27
|
+
height: number;
|
|
28
|
+
}) =>
|
|
29
|
+
new Vector3(
|
|
30
|
+
(R + height) * Math.cos(lat) * Math.cos(lng),
|
|
31
|
+
(R + height) * Math.cos(lat) * Math.sin(lng),
|
|
32
|
+
(R + height) * Math.sin(lat),
|
|
33
|
+
),
|
|
34
|
+
vector3ToGeodetic: (v: Vector3) => {
|
|
35
|
+
const radius = v.length();
|
|
36
|
+
return {
|
|
37
|
+
lat: Math.asin(v.z / radius),
|
|
38
|
+
lng: Math.atan2(v.y, v.x),
|
|
39
|
+
height: radius - R,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
eastNorthUpToFixedFrame: (pos: Vector3) => {
|
|
43
|
+
const radius = pos.length();
|
|
44
|
+
const lat = Math.asin(pos.z / radius);
|
|
45
|
+
const lng = Math.atan2(pos.y, pos.x);
|
|
46
|
+
const east = new Vector3(-Math.sin(lng), Math.cos(lng), 0);
|
|
47
|
+
const north = new Vector3(
|
|
48
|
+
-Math.sin(lat) * Math.cos(lng),
|
|
49
|
+
-Math.sin(lat) * Math.sin(lng),
|
|
50
|
+
Math.cos(lat),
|
|
51
|
+
);
|
|
52
|
+
const up = new Vector3(
|
|
53
|
+
Math.cos(lat) * Math.cos(lng),
|
|
54
|
+
Math.cos(lat) * Math.sin(lng),
|
|
55
|
+
Math.sin(lat),
|
|
56
|
+
);
|
|
57
|
+
return new Matrix4().makeBasis(east, north, up).setPosition(pos);
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
/* eslint-enable @typescript-eslint/no-extraneous-class */
|
|
61
|
+
|
|
62
|
+
const START = { lat: 36.25, lng: 137.64 };
|
|
63
|
+
const START_LAT_RAD = (START.lat * Math.PI) / 180;
|
|
64
|
+
|
|
65
|
+
/** Terrain rising toward the north at the given grade. */
|
|
66
|
+
const northSlope =
|
|
67
|
+
(grade: number): TerrainFn =>
|
|
68
|
+
(lat) =>
|
|
69
|
+
(lat - START_LAT_RAD) * R * grade;
|
|
70
|
+
|
|
71
|
+
/** Terrain height in meters, from a position in radians. */
|
|
72
|
+
type TerrainFn = (lat: number, lng: number) => number | undefined;
|
|
73
|
+
|
|
74
|
+
const makeFakeView = (terrain: TerrainFn) => {
|
|
75
|
+
const loadListeners: (() => void)[] = [];
|
|
76
|
+
const placements: {
|
|
77
|
+
targetHeight: number;
|
|
78
|
+
offsetZ: number;
|
|
79
|
+
offsetHorizontal: number;
|
|
80
|
+
}[] = [];
|
|
81
|
+
const position = new Vector3();
|
|
82
|
+
const ref = {
|
|
83
|
+
on: (event: string, cb: () => void) => {
|
|
84
|
+
if (event === "load") loadListeners.push(cb);
|
|
85
|
+
},
|
|
86
|
+
getWorldPosition: () => position.clone(),
|
|
87
|
+
setAnimationSpeed: vi.fn(),
|
|
88
|
+
crossFadeAnimation: vi.fn(),
|
|
89
|
+
};
|
|
90
|
+
const handle = {
|
|
91
|
+
ref,
|
|
92
|
+
visible: true,
|
|
93
|
+
update: vi.fn((desc: { matrixWorld?: Matrix4 }) => {
|
|
94
|
+
if (desc.matrixWorld) position.setFromMatrixPosition(desc.matrixWorld);
|
|
95
|
+
}),
|
|
96
|
+
delete: vi.fn(),
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
addMesh: vi.fn((desc: { matrixWorld?: Matrix4 }) => {
|
|
100
|
+
if (desc.matrixWorld) position.setFromMatrixPosition(desc.matrixWorld);
|
|
101
|
+
return handle;
|
|
102
|
+
}),
|
|
103
|
+
sampleTerrainHeight: vi.fn(({ lat, lng }: { lat: number; lng: number }) =>
|
|
104
|
+
terrain(lat, lng),
|
|
105
|
+
),
|
|
106
|
+
// The plugin passes a reused scratch Vector3 as the offset, so snapshot
|
|
107
|
+
// each placement instead of holding on to the object.
|
|
108
|
+
placements,
|
|
109
|
+
cameraFollow: vi.fn(
|
|
110
|
+
(
|
|
111
|
+
_enabled: boolean,
|
|
112
|
+
target?: { height: number },
|
|
113
|
+
offset?: { x: number; y: number; z: number },
|
|
114
|
+
) => {
|
|
115
|
+
if (target && offset)
|
|
116
|
+
placements.push({
|
|
117
|
+
targetHeight: target.height,
|
|
118
|
+
offsetZ: offset.z,
|
|
119
|
+
offsetHorizontal: Math.hypot(offset.x, offset.y),
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
cameraFreeLook: vi.fn(),
|
|
124
|
+
handle,
|
|
125
|
+
/** Fire the model's "load" event, which starts the per-frame loop. */
|
|
126
|
+
finishModelLoad: () => loadListeners.forEach((cb) => cb()),
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
type FakeView = ReturnType<typeof makeFakeView>;
|
|
131
|
+
|
|
132
|
+
/** Plugins to dispose after the running test, however it ends. */
|
|
133
|
+
const active: PersonViewPlugin[] = [];
|
|
134
|
+
|
|
135
|
+
const initPlugin = async (plugin: PersonViewPlugin, view: FakeView) => {
|
|
136
|
+
// The fake view only implements the members the plugin touches.
|
|
137
|
+
await plugin.init(view as never, {} as never);
|
|
138
|
+
active.push(plugin);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The character world matrix written by the most recent frame. */
|
|
142
|
+
const lastCharacterFrame = (view: FakeView): Matrix4 => {
|
|
143
|
+
const matrix = view.handle.update.mock.lastCall?.[0].matrixWorld;
|
|
144
|
+
if (!matrix) throw new Error("no character frame was written");
|
|
145
|
+
return matrix;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
describe("PersonViewPlugin terrain collision", () => {
|
|
149
|
+
const frames: FrameRequestCallback[] = [];
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
frames.length = 0;
|
|
153
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
154
|
+
frames.push(cb);
|
|
155
|
+
return frames.length;
|
|
156
|
+
});
|
|
157
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
for (const plugin of active) plugin.dispose();
|
|
162
|
+
active.length = 0;
|
|
163
|
+
vi.unstubAllGlobals();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/** Run the pending frame callback at `time` ms. */
|
|
167
|
+
const advance = (time: number) => {
|
|
168
|
+
const cb = frames.pop();
|
|
169
|
+
if (!cb) throw new Error("no frame scheduled");
|
|
170
|
+
cb(time);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
describe("teleport", () => {
|
|
174
|
+
it("keeps the requested altitude when collision is off", async () => {
|
|
175
|
+
const view = makeFakeView(() => 1500);
|
|
176
|
+
const plugin = new PersonViewPlugin();
|
|
177
|
+
await initPlugin(plugin, view);
|
|
178
|
+
|
|
179
|
+
plugin.teleport({ ...START, alt: 800 });
|
|
180
|
+
|
|
181
|
+
expect(plugin.getState().alt).toBe(800);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("snaps onto the terrain in ground mode", async () => {
|
|
185
|
+
const view = makeFakeView(() => 1500);
|
|
186
|
+
const plugin = new PersonViewPlugin({
|
|
187
|
+
// Settling must not delay a teleport — it is meant to be instant.
|
|
188
|
+
collision: { mode: "ground", groundOffset: 2 },
|
|
189
|
+
});
|
|
190
|
+
await initPlugin(plugin, view);
|
|
191
|
+
|
|
192
|
+
plugin.teleport({ ...START, alt: 800 });
|
|
193
|
+
|
|
194
|
+
expect(plugin.getState().alt).toBe(1502);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("only lifts up to the terrain floor in clamp mode", async () => {
|
|
198
|
+
const view = makeFakeView(() => 1500);
|
|
199
|
+
const plugin = new PersonViewPlugin({ collision: { mode: "clamp" } });
|
|
200
|
+
await initPlugin(plugin, view);
|
|
201
|
+
|
|
202
|
+
plugin.teleport({ ...START, alt: 800 });
|
|
203
|
+
expect(plugin.getState().alt).toBe(1500);
|
|
204
|
+
|
|
205
|
+
plugin.teleport({ ...START, alt: 2000 });
|
|
206
|
+
expect(plugin.getState().alt).toBe(2000);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("keeps the requested altitude while terrain is unloaded", async () => {
|
|
210
|
+
const view = makeFakeView(() => undefined);
|
|
211
|
+
const plugin = new PersonViewPlugin({ collision: { mode: "ground" } });
|
|
212
|
+
await initPlugin(plugin, view);
|
|
213
|
+
|
|
214
|
+
plugin.teleport({ ...START, alt: 800 });
|
|
215
|
+
|
|
216
|
+
expect(plugin.getState().alt).toBe(800);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("per-frame follow", () => {
|
|
221
|
+
it("glues the altitude to the terrain in ground mode", async () => {
|
|
222
|
+
const view = makeFakeView(() => 1500);
|
|
223
|
+
const plugin = new PersonViewPlugin({
|
|
224
|
+
collision: { mode: "ground" },
|
|
225
|
+
startLat: START.lat,
|
|
226
|
+
startLng: START.lng,
|
|
227
|
+
startHeight: 1495,
|
|
228
|
+
});
|
|
229
|
+
await initPlugin(plugin, view);
|
|
230
|
+
|
|
231
|
+
plugin.start();
|
|
232
|
+
// Long enough for the 5m the character starts off the surface by to be
|
|
233
|
+
// settled through at the plugin's own settle speed.
|
|
234
|
+
let time = performance.now();
|
|
235
|
+
for (let i = 0; i < 100; i++) advance((time += 16));
|
|
236
|
+
|
|
237
|
+
expect(plugin.getState().alt).toBeCloseTo(1500, 6);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("settles down onto the terrain at a bounded speed", async () => {
|
|
241
|
+
const view = makeFakeView(() => 0);
|
|
242
|
+
const plugin = new PersonViewPlugin({
|
|
243
|
+
collision: { mode: "ground" },
|
|
244
|
+
startLat: START.lat,
|
|
245
|
+
startLng: START.lng,
|
|
246
|
+
startHeight: 1000,
|
|
247
|
+
});
|
|
248
|
+
await initPlugin(plugin, view);
|
|
249
|
+
|
|
250
|
+
plugin.start();
|
|
251
|
+
// 0.1s is the plugin's delta clamp, so the drop is covered at the settle
|
|
252
|
+
// speed — a fraction of a meter — however far off the surface turned out
|
|
253
|
+
// to be.
|
|
254
|
+
advance(performance.now() + 100);
|
|
255
|
+
|
|
256
|
+
const covered = 1000 - plugin.getState().alt;
|
|
257
|
+
expect(covered).toBeGreaterThan(0);
|
|
258
|
+
expect(covered).toBeLessThan(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("tracks a slope it walks onto without settling", async () => {
|
|
262
|
+
// Settling is for the terrain data changing, never for ground the
|
|
263
|
+
// character walks onto: lagging behind a climb would leave it — and the
|
|
264
|
+
// chase camera behind it — inside the hillside.
|
|
265
|
+
const grade = 0.5;
|
|
266
|
+
const view = makeFakeView(northSlope(grade));
|
|
267
|
+
const plugin = new PersonViewPlugin({
|
|
268
|
+
collision: { mode: "ground" },
|
|
269
|
+
startLat: START.lat,
|
|
270
|
+
startLng: START.lng,
|
|
271
|
+
startHeight: 0,
|
|
272
|
+
startHeading: 0, // north, straight up the slope
|
|
273
|
+
moveSpeed: 50,
|
|
274
|
+
});
|
|
275
|
+
await initPlugin(plugin, view);
|
|
276
|
+
|
|
277
|
+
plugin.start();
|
|
278
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
|
|
279
|
+
const start = performance.now();
|
|
280
|
+
for (let i = 1; i <= 10; i++) advance(start + i * 16);
|
|
281
|
+
|
|
282
|
+
const state = plugin.getState();
|
|
283
|
+
const climbed = ((state.lat - START.lat) * Math.PI) / 180;
|
|
284
|
+
expect(climbed).toBeGreaterThan(0);
|
|
285
|
+
expect(state.alt).toBeCloseTo(climbed * R * grade, 3);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("works off a terrain data shift at a pace of its own", async () => {
|
|
289
|
+
// A finer LOD tile replacing a coarse one moves the surface without the
|
|
290
|
+
// character having moved at all — often by hundreds of meters right after
|
|
291
|
+
// load. Following that in one frame is a teleport, so it is worked off at
|
|
292
|
+
// a pace that does not depend on how far off the data was.
|
|
293
|
+
const run = async (shiftTo: number, frames: number) => {
|
|
294
|
+
let terrain = 1000;
|
|
295
|
+
const view = makeFakeView(() => terrain);
|
|
296
|
+
const plugin = new PersonViewPlugin({
|
|
297
|
+
collision: { mode: "ground" },
|
|
298
|
+
startLat: START.lat,
|
|
299
|
+
startLng: START.lng,
|
|
300
|
+
startHeight: 1000,
|
|
301
|
+
});
|
|
302
|
+
await initPlugin(plugin, view);
|
|
303
|
+
|
|
304
|
+
plugin.start();
|
|
305
|
+
let time = performance.now();
|
|
306
|
+
advance((time += 16));
|
|
307
|
+
expect(plugin.getState().alt).toBeCloseTo(1000, 6);
|
|
308
|
+
|
|
309
|
+
terrain = shiftTo;
|
|
310
|
+
for (let i = 0; i < frames; i++) advance((time += 16));
|
|
311
|
+
return plugin.getState().alt - 1000;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// 50m out and 4000m out are covered identically — the character is not
|
|
315
|
+
// thrown across the difference.
|
|
316
|
+
expect(await run(1050, 10)).toBeCloseTo(await run(5000, 10), 6);
|
|
317
|
+
|
|
318
|
+
// The pace builds up, so a correction that stays finishes in seconds
|
|
319
|
+
// rather than crawling in at a fixed speed for half a minute.
|
|
320
|
+
expect(await run(1200, 500)).toBeCloseTo(200, 6);
|
|
321
|
+
|
|
322
|
+
// Ground dropping away is worked off exactly the same way.
|
|
323
|
+
expect(await run(800, 500)).toBeCloseTo(-200, 6);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("ignores the ascend key in ground mode", async () => {
|
|
327
|
+
const view = makeFakeView(() => 1500);
|
|
328
|
+
const plugin = new PersonViewPlugin({
|
|
329
|
+
collision: { mode: "ground" },
|
|
330
|
+
startHeight: 1500,
|
|
331
|
+
altSpeed: 100,
|
|
332
|
+
});
|
|
333
|
+
await initPlugin(plugin, view);
|
|
334
|
+
|
|
335
|
+
plugin.start();
|
|
336
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { code: "Space" }));
|
|
337
|
+
advance(performance.now() + 100);
|
|
338
|
+
|
|
339
|
+
expect(plugin.getState().alt).toBeCloseTo(1500, 6);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("alignToSlope", () => {
|
|
344
|
+
const character = {
|
|
345
|
+
modelUrl: "/model.glb",
|
|
346
|
+
animation: { idleClip: "Idle", dashClip: "Run", crossfadeDuration: 0.3 },
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* How far the character's own axes are lifted off the horizontal:
|
|
351
|
+
* `sin(pitch)` forward and `-sin(roll)` sideways, so a slope of `grade`
|
|
352
|
+
* settles at `sin(atan(grade))` on whichever axis faces uphill.
|
|
353
|
+
*/
|
|
354
|
+
const tilt = (view: FakeView): { forward: number; right: number } => {
|
|
355
|
+
const frame = lastCharacterFrame(view);
|
|
356
|
+
const up = new Vector3().setFromMatrixPosition(frame).normalize();
|
|
357
|
+
return {
|
|
358
|
+
forward: new Vector3().setFromMatrixColumn(frame, 1).dot(up),
|
|
359
|
+
right: new Vector3().setFromMatrixColumn(frame, 0).dot(up),
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** A started plugin with its model loaded, standing on `terrain`. */
|
|
364
|
+
const walkOn = async (
|
|
365
|
+
terrain: TerrainFn,
|
|
366
|
+
collision: CollisionConfig,
|
|
367
|
+
startHeading = 0,
|
|
368
|
+
) => {
|
|
369
|
+
const view = makeFakeView(terrain);
|
|
370
|
+
const plugin = new PersonViewPlugin({
|
|
371
|
+
character,
|
|
372
|
+
collision: { mode: "ground", alignToSlope: true, ...collision },
|
|
373
|
+
startLat: START.lat,
|
|
374
|
+
startLng: START.lng,
|
|
375
|
+
startHeight: 0,
|
|
376
|
+
startHeading,
|
|
377
|
+
});
|
|
378
|
+
await initPlugin(plugin, view);
|
|
379
|
+
plugin.start();
|
|
380
|
+
view.finishModelLoad();
|
|
381
|
+
return { view, plugin };
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** Run frames until the eased tilt has settled on the terrain. */
|
|
385
|
+
const settleTilt = () => {
|
|
386
|
+
let time = performance.now();
|
|
387
|
+
for (let i = 0; i < 200; i++) advance((time += 16));
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
it("pitches the character nose-up when it faces uphill", async () => {
|
|
391
|
+
const grade = 0.5;
|
|
392
|
+
const { view } = await walkOn(northSlope(grade), {}, 0);
|
|
393
|
+
|
|
394
|
+
settleTilt();
|
|
395
|
+
|
|
396
|
+
// Forward is lifted off the horizontal by the slope angle...
|
|
397
|
+
expect(tilt(view).forward).toBeCloseTo(Math.sin(Math.atan(grade)), 3);
|
|
398
|
+
// ...while the sideways axis stays level: the slope has no cross fall.
|
|
399
|
+
expect(tilt(view).right).toBeCloseTo(0, 6);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("rolls the character onto a slope that falls away sideways", async () => {
|
|
403
|
+
const grade = 0.5;
|
|
404
|
+
// Facing east, so the slope rises to the left.
|
|
405
|
+
const { view } = await walkOn(northSlope(grade), {}, Math.PI / 2);
|
|
406
|
+
|
|
407
|
+
settleTilt();
|
|
408
|
+
|
|
409
|
+
expect(tilt(view).forward).toBeCloseTo(0, 6);
|
|
410
|
+
// Uphill is to the left, so the right-hand side dips below the horizontal.
|
|
411
|
+
expect(tilt(view).right).toBeCloseTo(-Math.sin(Math.atan(grade)), 3);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("eases onto a slope that steps under the character", async () => {
|
|
415
|
+
// Terrain that turns from flat to steep between two frames, the way a
|
|
416
|
+
// finer LOD tile replacing a coarse one does.
|
|
417
|
+
let grade = 0;
|
|
418
|
+
const { view } = await walkOn((lat) => northSlope(grade)(lat, 0), {});
|
|
419
|
+
|
|
420
|
+
let time = performance.now();
|
|
421
|
+
advance((time += 16));
|
|
422
|
+
expect(tilt(view).forward).toBeCloseTo(0, 6);
|
|
423
|
+
|
|
424
|
+
grade = 0.5;
|
|
425
|
+
advance((time += 16));
|
|
426
|
+
const settled = Math.sin(Math.atan(grade));
|
|
427
|
+
// One frame covers a fraction of the step instead of snapping to it.
|
|
428
|
+
const afterStep = tilt(view).forward;
|
|
429
|
+
expect(afterStep).toBeGreaterThan(0);
|
|
430
|
+
expect(afterStep).toBeLessThan(settled * 0.2);
|
|
431
|
+
|
|
432
|
+
for (let i = 0; i < 100; i++) advance((time += 16));
|
|
433
|
+
expect(tilt(view).forward).toBeCloseTo(settled, 3);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("re-aims the tilt at the new heading the moment it turns", async () => {
|
|
437
|
+
// The smoothed gradient is held relative to north, so turning resolves
|
|
438
|
+
// against it immediately — only the terrain changing is ever eased.
|
|
439
|
+
const grade = 0.5;
|
|
440
|
+
const { view, plugin } = await walkOn(northSlope(grade), {});
|
|
441
|
+
|
|
442
|
+
let time = performance.now();
|
|
443
|
+
for (let i = 0; i < 100; i++) advance((time += 16));
|
|
444
|
+
expect(tilt(view).forward).toBeCloseTo(Math.sin(Math.atan(grade)), 3);
|
|
445
|
+
|
|
446
|
+
// Face east without advancing a frame: uphill is now to the left.
|
|
447
|
+
plugin.setHeading(Math.PI / 2);
|
|
448
|
+
|
|
449
|
+
expect(tilt(view).forward).toBeCloseTo(0, 3);
|
|
450
|
+
expect(tilt(view).right).toBeCloseTo(-Math.sin(Math.atan(grade)), 3);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("caps the tilt on near-vertical ground", async () => {
|
|
454
|
+
// A 5:1 face is 79°, well past what the character should lie down to.
|
|
455
|
+
const { view } = await walkOn(northSlope(5), {
|
|
456
|
+
maxSlopeTilt: Math.PI / 3,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
settleTilt();
|
|
460
|
+
|
|
461
|
+
expect(tilt(view).forward).toBeCloseTo(Math.sin(Math.PI / 3), 3);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("keeps the character upright when disabled", async () => {
|
|
465
|
+
const { view } = await walkOn(northSlope(0.5), { alignToSlope: false });
|
|
466
|
+
|
|
467
|
+
settleTilt();
|
|
468
|
+
|
|
469
|
+
expect(tilt(view).forward).toBeCloseTo(0, 6);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("keeps a character flying over the slope upright", async () => {
|
|
473
|
+
// Clamp mode well clear of the ground: there is no slope being stood on,
|
|
474
|
+
// so the model must not lie back with terrain it has left behind.
|
|
475
|
+
const view = makeFakeView(northSlope(0.5));
|
|
476
|
+
const plugin = new PersonViewPlugin({
|
|
477
|
+
character,
|
|
478
|
+
collision: { mode: "clamp", slopeSampleDistance: 4 },
|
|
479
|
+
startLat: START.lat,
|
|
480
|
+
startLng: START.lng,
|
|
481
|
+
startHeight: 100,
|
|
482
|
+
});
|
|
483
|
+
await initPlugin(plugin, view);
|
|
484
|
+
plugin.start();
|
|
485
|
+
view.finishModelLoad();
|
|
486
|
+
|
|
487
|
+
settleTilt();
|
|
488
|
+
|
|
489
|
+
expect(tilt(view).forward).toBeCloseTo(0, 6);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe("chase camera height", () => {
|
|
494
|
+
/** Where the camera eye sits, in meters of altitude. */
|
|
495
|
+
const eyeHeight = (view: FakeView): number => {
|
|
496
|
+
const last = view.placements[view.placements.length - 1];
|
|
497
|
+
if (!last) throw new Error("the camera was never placed");
|
|
498
|
+
return last.targetHeight + last.offsetZ;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const makePlugin = (terrainFn: TerrainFn, startHeight: number) => {
|
|
502
|
+
const view = makeFakeView(terrainFn);
|
|
503
|
+
const plugin = new PersonViewPlugin({
|
|
504
|
+
collision: { mode: "ground" },
|
|
505
|
+
startLat: START.lat,
|
|
506
|
+
startLng: START.lng,
|
|
507
|
+
startHeight,
|
|
508
|
+
cameraDistance: 20,
|
|
509
|
+
cameraLerpSpeed: 3,
|
|
510
|
+
});
|
|
511
|
+
return { view, plugin };
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
it("keeps up with the character while a data step is settled", async () => {
|
|
515
|
+
// A finer LOD tile lands 10m higher under the character: it works that
|
|
516
|
+
// off at the settle speed, and the camera has to ride along with it
|
|
517
|
+
// rather than chase it down over the next second.
|
|
518
|
+
let terrain = 1000;
|
|
519
|
+
const { view, plugin } = makePlugin(() => terrain, 1000);
|
|
520
|
+
await initPlugin(plugin, view);
|
|
521
|
+
|
|
522
|
+
plugin.start();
|
|
523
|
+
let time = performance.now();
|
|
524
|
+
for (let i = 0; i < 10; i++) advance((time += 16));
|
|
525
|
+
// How far the eye sits above the character once settled — on level
|
|
526
|
+
// ground, purely the chase pitch.
|
|
527
|
+
const settledLead = eyeHeight(view) - plugin.getState().alt;
|
|
528
|
+
|
|
529
|
+
terrain = 1010;
|
|
530
|
+
let worstGap = 0;
|
|
531
|
+
for (let i = 0; i < 200; i++) {
|
|
532
|
+
advance((time += 16));
|
|
533
|
+
const lead = eyeHeight(view) - plugin.getState().alt;
|
|
534
|
+
worstGap = Math.max(worstGap, Math.abs(lead - settledLead));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The character glides onto the new surface and the camera glides with
|
|
538
|
+
// it — the settling belongs to the ground, not to the camera.
|
|
539
|
+
expect(plugin.getState().alt).toBeCloseTo(1010, 6);
|
|
540
|
+
expect(worstGap).toBeLessThan(0.1);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("keeps the character exactly on the look-at target", async () => {
|
|
544
|
+
let terrain = 1000;
|
|
545
|
+
const { view, plugin } = makePlugin(() => terrain, 1000);
|
|
546
|
+
await initPlugin(plugin, view);
|
|
547
|
+
|
|
548
|
+
plugin.start();
|
|
549
|
+
terrain = 1025;
|
|
550
|
+
advance(performance.now() + 16);
|
|
551
|
+
|
|
552
|
+
// Smoothing moves the eye, never the aim: the character stays framed.
|
|
553
|
+
const last = view.placements[view.placements.length - 1];
|
|
554
|
+
expect(last.targetHeight).toBe(
|
|
555
|
+
plugin.getState().alt + plugin.getFpvHeightOffset(),
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("follows a sustained descent without falling behind", async () => {
|
|
560
|
+
// Ground dropping 0.2m per frame — about 12 m/s. Easing the height alone
|
|
561
|
+
// would trail that ramp by speed / cameraLerpSpeed = 4m indefinitely.
|
|
562
|
+
let terrain = 1000;
|
|
563
|
+
const { view, plugin } = makePlugin(() => terrain, 1000);
|
|
564
|
+
await initPlugin(plugin, view);
|
|
565
|
+
|
|
566
|
+
plugin.start();
|
|
567
|
+
let time = performance.now();
|
|
568
|
+
for (let i = 0; i < 300; i++) {
|
|
569
|
+
terrain -= 0.2;
|
|
570
|
+
advance((time += 16));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const trueEye = plugin.getState().alt + plugin.getFpvHeightOffset();
|
|
574
|
+
expect(Math.abs(eyeHeight(view) - trueEye)).toBeLessThan(1);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe("camera slope pitch", () => {
|
|
579
|
+
const CAMERA_PITCH = 0.1;
|
|
580
|
+
const CAMERA_DISTANCE = 20;
|
|
581
|
+
|
|
582
|
+
/** Where the chase camera ended up, as a pitch in radians. */
|
|
583
|
+
const cameraPitch = (view: FakeView): number => {
|
|
584
|
+
const last = view.placements[view.placements.length - 1];
|
|
585
|
+
if (!last) throw new Error("the camera was never placed");
|
|
586
|
+
return Math.atan2(last.offsetZ, last.offsetHorizontal);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
/** A plugin standing on a north-facing slope, settled for a few seconds. */
|
|
590
|
+
const standOn = async (
|
|
591
|
+
grade: number,
|
|
592
|
+
heading: number,
|
|
593
|
+
collision: CollisionConfig = {},
|
|
594
|
+
initialView: "tpv" | "fpv" = "tpv",
|
|
595
|
+
) => {
|
|
596
|
+
const view = makeFakeView(northSlope(grade));
|
|
597
|
+
const plugin = new PersonViewPlugin({
|
|
598
|
+
collision: { mode: "ground", ...collision },
|
|
599
|
+
startLat: START.lat,
|
|
600
|
+
startLng: START.lng,
|
|
601
|
+
startHeight: 0,
|
|
602
|
+
startHeading: heading,
|
|
603
|
+
initialView,
|
|
604
|
+
cameraDistance: CAMERA_DISTANCE,
|
|
605
|
+
cameraPitch: CAMERA_PITCH,
|
|
606
|
+
fpvPitch: CAMERA_PITCH,
|
|
607
|
+
cameraLerpSpeed: 10,
|
|
608
|
+
});
|
|
609
|
+
await initPlugin(plugin, view);
|
|
610
|
+
plugin.start();
|
|
611
|
+
let time = performance.now();
|
|
612
|
+
for (let i = 0; i < 120; i++) advance((time += 16));
|
|
613
|
+
return { view, plugin };
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
it("drops down-slope to look up a climb", async () => {
|
|
617
|
+
const grade = 0.7;
|
|
618
|
+
const { view } = await standOn(grade, 0); // facing north, uphill
|
|
619
|
+
|
|
620
|
+
// The whole slope is given up, so the camera lies along it: below the
|
|
621
|
+
// character, looking up the hill rather than into it.
|
|
622
|
+
expect(cameraPitch(view)).toBeCloseTo(CAMERA_PITCH - Math.atan(grade), 2);
|
|
623
|
+
expect(cameraPitch(view)).toBeLessThan(0);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("rises above the slope on a descent", async () => {
|
|
627
|
+
const grade = 0.7;
|
|
628
|
+
const { view } = await standOn(grade, Math.PI); // facing south, downhill
|
|
629
|
+
|
|
630
|
+
expect(cameraPitch(view)).toBeCloseTo(CAMERA_PITCH + Math.atan(grade), 2);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("follows only the configured fraction of the slope", async () => {
|
|
634
|
+
const grade = 0.7;
|
|
635
|
+
const { view } = await standOn(grade, 0, { cameraSlopeFollow: 0.5 });
|
|
636
|
+
|
|
637
|
+
expect(cameraPitch(view)).toBeCloseTo(
|
|
638
|
+
CAMERA_PITCH - Math.atan(grade) * 0.5,
|
|
639
|
+
2,
|
|
640
|
+
);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("keeps the configured pitch when the slope is not followed", async () => {
|
|
644
|
+
const { view } = await standOn(0.7, 0, { cameraSlopeFollow: 0 });
|
|
645
|
+
|
|
646
|
+
expect(cameraPitch(view)).toBeCloseTo(CAMERA_PITCH, 6);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("caps how far a near-vertical slope may swing it", async () => {
|
|
650
|
+
// A 10:1 face is 84°, far steeper than anything that can be walked, so
|
|
651
|
+
// the swing stops at the plugin's own cap rather than following it.
|
|
652
|
+
const { view } = await standOn(10, 0);
|
|
653
|
+
|
|
654
|
+
expect(cameraPitch(view)).toBeCloseTo(CAMERA_PITCH - Math.PI / 4, 2);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("tilts the FPV eye line up a climb as well", async () => {
|
|
658
|
+
const grade = 0.7;
|
|
659
|
+
const { view } = await standOn(grade, 0, {}, "fpv");
|
|
660
|
+
|
|
661
|
+
// FPV places the eye by aiming at a target far ahead: the same slope is
|
|
662
|
+
// given up there, so the view looks up the hill instead of into it.
|
|
663
|
+
expect(cameraPitch(view)).toBeCloseTo(CAMERA_PITCH - Math.atan(grade), 2);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("leaves a character flying well above the slope alone", async () => {
|
|
667
|
+
// Clamp mode with the character a chase distance above the surface: there
|
|
668
|
+
// is no hillside between it and the camera to look up or down.
|
|
669
|
+
const view = makeFakeView(northSlope(0.7));
|
|
670
|
+
const plugin = new PersonViewPlugin({
|
|
671
|
+
collision: { mode: "clamp" },
|
|
672
|
+
startLat: START.lat,
|
|
673
|
+
startLng: START.lng,
|
|
674
|
+
startHeight: CAMERA_DISTANCE * 2,
|
|
675
|
+
startHeading: 0,
|
|
676
|
+
cameraDistance: CAMERA_DISTANCE,
|
|
677
|
+
cameraPitch: CAMERA_PITCH,
|
|
678
|
+
cameraLerpSpeed: 10,
|
|
679
|
+
});
|
|
680
|
+
await initPlugin(plugin, view);
|
|
681
|
+
|
|
682
|
+
plugin.start();
|
|
683
|
+
let time = performance.now();
|
|
684
|
+
for (let i = 0; i < 120; i++) advance((time += 16));
|
|
685
|
+
|
|
686
|
+
expect(cameraPitch(view)).toBeCloseTo(CAMERA_PITCH, 6);
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe("stop", () => {
|
|
691
|
+
const walking = async () => {
|
|
692
|
+
const view = makeFakeView(() => 1000);
|
|
693
|
+
const plugin = new PersonViewPlugin({
|
|
694
|
+
collision: { mode: "ground" },
|
|
695
|
+
startLat: START.lat,
|
|
696
|
+
startLng: START.lng,
|
|
697
|
+
startHeight: 1000,
|
|
698
|
+
moveSpeed: 50,
|
|
699
|
+
});
|
|
700
|
+
await initPlugin(plugin, view);
|
|
701
|
+
plugin.start();
|
|
702
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
|
|
703
|
+
let time = performance.now();
|
|
704
|
+
for (let i = 0; i < 10; i++) advance((time += 16));
|
|
705
|
+
return { view, plugin };
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
it("hands the camera back and stops reading the keys", async () => {
|
|
709
|
+
const { view, plugin } = await walking();
|
|
710
|
+
const moved = plugin.getState();
|
|
711
|
+
|
|
712
|
+
plugin.stop();
|
|
713
|
+
|
|
714
|
+
expect(view.cameraFollow).toHaveBeenLastCalledWith(false);
|
|
715
|
+
expect(view.cameraFreeLook).toHaveBeenLastCalledWith(false);
|
|
716
|
+
expect(plugin.getState().speed).toBe(0);
|
|
717
|
+
|
|
718
|
+
// The forward key is still physically held and a frame was already
|
|
719
|
+
// scheduled, but neither drives the character now — and no further frame
|
|
720
|
+
// is scheduled behind it.
|
|
721
|
+
advance(performance.now());
|
|
722
|
+
expect(plugin.getState().lat).toBe(moved.lat);
|
|
723
|
+
expect(frames).toHaveLength(0);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("resumes from where it stopped rather than the start position", async () => {
|
|
727
|
+
const { view, plugin } = await walking();
|
|
728
|
+
plugin.stop();
|
|
729
|
+
const stopped = plugin.getState();
|
|
730
|
+
expect(stopped.lat).not.toBe(START.lat);
|
|
731
|
+
|
|
732
|
+
plugin.start();
|
|
733
|
+
advance(performance.now() + 16);
|
|
734
|
+
|
|
735
|
+
expect(plugin.getState().lat).toBeCloseTo(stopped.lat, 6);
|
|
736
|
+
expect(plugin.getState().alt).toBeCloseTo(stopped.alt, 6);
|
|
737
|
+
// The character is placed once; resuming must not add a second model.
|
|
738
|
+
expect(view.addMesh).not.toHaveBeenCalled();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("does not deliver the pause as one enormous frame", async () => {
|
|
742
|
+
const { plugin } = await walking();
|
|
743
|
+
plugin.stop();
|
|
744
|
+
const stopped = plugin.getState();
|
|
745
|
+
|
|
746
|
+
// Ten seconds of real time pass while the view drives the camera.
|
|
747
|
+
plugin.start();
|
|
748
|
+
advance(performance.now() + 10_000);
|
|
749
|
+
|
|
750
|
+
// Only the delta since resuming counts, not the whole pause: the frame
|
|
751
|
+
// clamp bounds it to a tenth of a second of walking.
|
|
752
|
+
const climbed = Math.abs(plugin.getState().lat - stopped.lat);
|
|
753
|
+
expect(climbed).toBeLessThan((0.1 * 50 * 1.5) / R);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("is safe to call when it was never started", async () => {
|
|
757
|
+
const view = makeFakeView(() => 1000);
|
|
758
|
+
const plugin = new PersonViewPlugin();
|
|
759
|
+
await initPlugin(plugin, view);
|
|
760
|
+
|
|
761
|
+
expect(() => plugin.stop()).not.toThrow();
|
|
762
|
+
expect(view.cameraFollow).not.toHaveBeenCalled();
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
describe("setCollision", () => {
|
|
767
|
+
it("merges into the current settings", async () => {
|
|
768
|
+
const view = makeFakeView(() => 1500);
|
|
769
|
+
const plugin = new PersonViewPlugin({
|
|
770
|
+
collision: { mode: "off", groundOffset: 3 },
|
|
771
|
+
});
|
|
772
|
+
await initPlugin(plugin, view);
|
|
773
|
+
|
|
774
|
+
plugin.setCollision({ mode: "ground" });
|
|
775
|
+
|
|
776
|
+
expect(plugin.getCollision()).toMatchObject({
|
|
777
|
+
mode: "ground",
|
|
778
|
+
groundOffset: 3,
|
|
779
|
+
});
|
|
780
|
+
plugin.teleport({ ...START, alt: 800 });
|
|
781
|
+
expect(plugin.getState().alt).toBe(1503);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
});
|