@worldmesh/runtime 0.2.0 → 0.2.2
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 +33 -3
- package/dist/audio/footsteps.d.ts +37 -0
- package/dist/audio/footsteps.d.ts.map +1 -0
- package/dist/audio/footsteps.js +178 -0
- package/dist/audio/footsteps.js.map +1 -0
- package/dist/avatar/descriptor.d.ts +15 -0
- package/dist/avatar/descriptor.d.ts.map +1 -1
- package/dist/avatar/descriptor.js +19 -0
- package/dist/avatar/descriptor.js.map +1 -1
- package/dist/avatar/loader.d.ts +12 -1
- package/dist/avatar/loader.d.ts.map +1 -1
- package/dist/avatar/loader.js +16 -1
- package/dist/avatar/loader.js.map +1 -1
- package/dist/avatar/share.d.ts +30 -0
- package/dist/avatar/share.d.ts.map +1 -0
- package/dist/avatar/share.js +203 -0
- package/dist/avatar/share.js.map +1 -0
- package/dist/camera/cameraRig.d.ts +4 -0
- package/dist/camera/cameraRig.d.ts.map +1 -1
- package/dist/camera/cameraRig.js +10 -1
- package/dist/camera/cameraRig.js.map +1 -1
- package/dist/camera/viewHandoff.d.ts +20 -0
- package/dist/camera/viewHandoff.d.ts.map +1 -0
- package/dist/camera/viewHandoff.js +64 -0
- package/dist/camera/viewHandoff.js.map +1 -0
- package/dist/controls/input.d.ts +29 -0
- package/dist/controls/input.d.ts.map +1 -1
- package/dist/controls/input.js +124 -1
- package/dist/controls/input.js.map +1 -1
- package/dist/controls/touch.d.ts +10 -0
- package/dist/controls/touch.d.ts.map +1 -1
- package/dist/controls/touch.js +57 -1
- package/dist/controls/touch.js.map +1 -1
- package/dist/core/worldmesh.d.ts +9 -0
- package/dist/core/worldmesh.d.ts.map +1 -1
- package/dist/core/worldmesh.js +498 -26
- package/dist/core/worldmesh.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/net/follow.d.ts +7 -0
- package/dist/net/follow.d.ts.map +1 -0
- package/dist/net/follow.js +10 -0
- package/dist/net/follow.js.map +1 -0
- package/dist/net/presence.d.ts +126 -4
- package/dist/net/presence.d.ts.map +1 -1
- package/dist/net/presence.js +472 -25
- package/dist/net/presence.js.map +1 -1
- package/dist/net/roomFull.d.ts +44 -0
- package/dist/net/roomFull.d.ts.map +1 -0
- package/dist/net/roomFull.js +147 -0
- package/dist/net/roomFull.js.map +1 -0
- package/dist/player/avatar.d.ts +17 -4
- package/dist/player/avatar.d.ts.map +1 -1
- package/dist/player/avatar.js +85 -18
- package/dist/player/avatar.js.map +1 -1
- package/dist/player/player.d.ts +6 -2
- package/dist/player/player.d.ts.map +1 -1
- package/dist/player/player.js +13 -3
- package/dist/player/player.js.map +1 -1
- package/dist/player/stroke.d.ts +11 -0
- package/dist/player/stroke.d.ts.map +1 -0
- package/dist/player/stroke.js +96 -0
- package/dist/player/stroke.js.map +1 -0
- package/dist/portals/portals.d.ts +1 -1
- package/dist/portals/portals.d.ts.map +1 -1
- package/dist/portals/portals.js +6 -2
- package/dist/portals/portals.js.map +1 -1
- package/dist/types.d.ts +53 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/overlay.d.ts +18 -0
- package/dist/ui/overlay.d.ts.map +1 -1
- package/dist/ui/overlay.js +166 -1
- package/dist/ui/overlay.js.map +1 -1
- package/dist/xr/session.d.ts +13 -0
- package/dist/xr/session.d.ts.map +1 -0
- package/dist/xr/session.js +33 -0
- package/dist/xr/session.js.map +1 -0
- package/package.json +1 -1
- package/src/audio/footsteps.ts +185 -0
- package/src/avatar/descriptor.ts +28 -0
- package/src/avatar/loader.ts +19 -2
- package/src/avatar/share.ts +196 -0
- package/src/camera/cameraRig.ts +10 -1
- package/src/camera/viewHandoff.ts +67 -0
- package/src/controls/input.ts +119 -1
- package/src/controls/touch.ts +59 -1
- package/src/core/worldmesh.ts +495 -25
- package/src/index.ts +14 -7
- package/src/net/follow.ts +9 -0
- package/src/net/presence.ts +548 -28
- package/src/net/roomFull.ts +160 -0
- package/src/player/avatar.ts +87 -18
- package/src/player/player.ts +13 -4
- package/src/player/stroke.ts +93 -0
- package/src/portals/portals.ts +5 -2
- package/src/types.ts +52 -9
- package/src/ui/overlay.ts +171 -1
- package/src/xr/session.ts +33 -0
package/src/core/worldmesh.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { Vector3 } from 'three';
|
|
1
|
+
import { Euler, Group, PerspectiveCamera, Plane, Quaternion, Vector2, Vector3 } from 'three';
|
|
2
2
|
import { resolveAbilities } from '../abilities/abilities.js';
|
|
3
3
|
import { parseAvatarDescriptor, type AvatarDescriptor } from '../avatar/descriptor.js';
|
|
4
|
-
import { resolveWorldMeshAvatar, takeAvatarTicket } from '../avatar/handoff.js';
|
|
4
|
+
import { getAvatarTicket, resolveWorldMeshAvatar, setAvatarTicket, takeAvatarTicket } from '../avatar/handoff.js';
|
|
5
|
+
import { SharedAvatarLink, createShareVerifier } from '../avatar/share.js';
|
|
6
|
+
import { rememberView, takeViewHandoff } from '../camera/viewHandoff.js';
|
|
5
7
|
import { loadAvatarModel } from '../avatar/loader.js';
|
|
8
|
+
import { Footsteps } from '../audio/footsteps.js';
|
|
6
9
|
import { CameraRig } from '../camera/cameraRig.js';
|
|
7
10
|
import { Input } from '../controls/input.js';
|
|
8
11
|
import { CollisionWorld } from '../movement/collision.js';
|
|
9
12
|
import { MovementController } from '../movement/controller.js';
|
|
10
|
-
import { expressionForDigit, isAvatarExpression } from '../player/avatar.js';
|
|
13
|
+
import { expressionForDigit, isAvatarExpression, setAvatarColor } from '../player/avatar.js';
|
|
11
14
|
import { Player } from '../player/player.js';
|
|
12
15
|
import { PortalManager, buildTravelUrl, type ResolvedPortal } from '../portals/portals.js';
|
|
13
16
|
import type {
|
|
@@ -20,14 +23,36 @@ import type {
|
|
|
20
23
|
} from '../types.js';
|
|
21
24
|
import { Overlay } from '../ui/overlay.js';
|
|
22
25
|
import { isTouchDevice } from '../controls/touch.js';
|
|
26
|
+
import { immersiveVrSupported, requestImmersiveVr } from '../xr/session.js';
|
|
23
27
|
import { Emitter } from './events.js';
|
|
24
28
|
import { DEFAULT_PRESENCE_SERVER, Presence } from '../net/presence.js';
|
|
29
|
+
import { roomFullLabel } from '../net/roomFull.js';
|
|
25
30
|
|
|
26
31
|
/** Never simulate more than this per substep, so a stall cannot tunnel the player. */
|
|
27
32
|
const MAX_STEP = 1 / 60;
|
|
28
33
|
/** Never simulate more than this per frame, so a backgrounded tab does not catch up violently. */
|
|
29
34
|
const MAX_FRAME = 0.1;
|
|
30
35
|
const DEFAULT_HUB = 'https://worldmesh.net/';
|
|
36
|
+
/** Seconds to ease the field of view back to the world's own after taking over the hub's camera. */
|
|
37
|
+
const FOV_RETURN = 1.5;
|
|
38
|
+
/**
|
|
39
|
+
* Set by a host that draws the world live in one of its doorways (Doorway,
|
|
40
|
+
* docs/specs/doorway.md §3.1). The world stays offline, shows no UI, takes no
|
|
41
|
+
* input, and draws only when the host sends a camera (`doorway:view`), from
|
|
42
|
+
* that camera carried through the doorway onto its spawn point. When the
|
|
43
|
+
* visitor walks through (`doorway:enter`), it takes over from their pose
|
|
44
|
+
* and goes online, without reloading (§4).
|
|
45
|
+
*/
|
|
46
|
+
export const DOORWAY_VIEW_PARAM = 'doorway-view';
|
|
47
|
+
/** Metres from the doorway back to the spawn point in a live view, and where a walk-in lands. */
|
|
48
|
+
const VIEW_SPAWN_BEHIND = 0.6;
|
|
49
|
+
/** Seconds a live view waits for the player to land at spawn before saying it is ready. */
|
|
50
|
+
const VIEW_SETTLE = 2;
|
|
51
|
+
/** The pre-Doorway names, still understood: `worldmesh:*` messages. */
|
|
52
|
+
const isDoorwayMessage = (data: unknown, name: string): boolean => {
|
|
53
|
+
const type = (data as { type?: unknown } | null)?.type;
|
|
54
|
+
return type === `doorway:${name}` || type === `worldmesh:${name}`;
|
|
55
|
+
};
|
|
31
56
|
|
|
32
57
|
/**
|
|
33
58
|
* Turn an ordinary Three.js scene into a WorldMesh world.
|
|
@@ -41,18 +66,29 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
41
66
|
// Always take an avatar handoff ticket out of the address bar, even if this
|
|
42
67
|
// world shows no avatars: portals still carry it on to the next world.
|
|
43
68
|
const avatarTicket = takeAvatarTicket();
|
|
69
|
+
// A view the visitor picked here or in the world they came from wins over this world's default.
|
|
70
|
+
const rememberedView = takeViewHandoff();
|
|
44
71
|
const spawn = new Vector3(...(options.spawn ?? [0, 2, 0]));
|
|
45
72
|
const abilities = resolveAbilities(options.abilities);
|
|
46
73
|
|
|
74
|
+
// Before anything sizes the canvas: a live doorway view draws at 1×.
|
|
75
|
+
/** Drawn live in a host's doorway, not walked into yet. */
|
|
76
|
+
let viewing = isDoorwayViewRequest();
|
|
77
|
+
/** Inside a host's page (in its doorway, or walked into from it): other pages open through the host. */
|
|
78
|
+
const inHost = viewing;
|
|
47
79
|
const height = options.player?.height ?? 1.8;
|
|
48
80
|
const radius = options.player?.radius ?? 0.35;
|
|
49
81
|
|
|
50
82
|
const collision = new CollisionWorld(options.colliders ?? [], options.groundLevel ?? 0);
|
|
51
83
|
const events = new Emitter<WorldMeshEvents>();
|
|
52
84
|
|
|
85
|
+
const vrEnabled = options.vr !== false;
|
|
53
86
|
const overlay = new Overlay({
|
|
54
87
|
...options.ui,
|
|
55
88
|
onEnter: (touch) => input.requestPointerLock(touch),
|
|
89
|
+
onEnterVr: () => {
|
|
90
|
+
void enterVR();
|
|
91
|
+
},
|
|
56
92
|
onInteract: () => {
|
|
57
93
|
if (activePortal) {
|
|
58
94
|
activatePortal(activePortal);
|
|
@@ -80,7 +116,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
80
116
|
},
|
|
81
117
|
});
|
|
82
118
|
|
|
83
|
-
const cameraRig = new CameraRig({ ...options.view, camera, collision });
|
|
119
|
+
const cameraRig = new CameraRig({ ...options.view, mode: rememberedView ?? options.view?.mode, camera, collision });
|
|
84
120
|
const player = new Player({ ...options.player, height, radius });
|
|
85
121
|
if (player.root) scene.add(player.root);
|
|
86
122
|
|
|
@@ -97,13 +133,32 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
97
133
|
const portals = new PortalManager(scene, options.portals);
|
|
98
134
|
|
|
99
135
|
let running = false;
|
|
100
|
-
let frameHandle = 0;
|
|
101
136
|
let lastTime = 0;
|
|
102
137
|
let elapsed = 0;
|
|
103
138
|
let activePortal: ResolvedPortal | null = null;
|
|
104
139
|
let disposed = false;
|
|
140
|
+
const footsteps = options.footsteps ? new Footsteps() : null;
|
|
105
141
|
let avatarDescriptor: AvatarDescriptor | null = null;
|
|
106
142
|
let avatarLoad: AbortController | null = null;
|
|
143
|
+
/** The avatar other players see this visitor in; its link is renewed through the hub when it expires. */
|
|
144
|
+
const sharedAvatar = new SharedAvatarLink(
|
|
145
|
+
options.avatar?.source === 'worldmesh'
|
|
146
|
+
? () => {
|
|
147
|
+
const ticket = getAvatarTicket();
|
|
148
|
+
const hubUrl = (options.avatar?.source === 'worldmesh' && options.avatar.hubUrl) || DEFAULT_HUB;
|
|
149
|
+
return ticket ? resolveWorldMeshAvatar(hubUrl, ticket) : Promise.resolve(null);
|
|
150
|
+
}
|
|
151
|
+
: null,
|
|
152
|
+
);
|
|
153
|
+
let xrSession: XRSession | null = null;
|
|
154
|
+
const xrOrigin = new Group();
|
|
155
|
+
xrOrigin.name = 'worldmesh-xr-origin';
|
|
156
|
+
const xrHeading = new Euler(0, 0, 0, 'YXZ');
|
|
157
|
+
if (vrEnabled) {
|
|
158
|
+
renderer.xr.enabled = true;
|
|
159
|
+
renderer.xr.setReferenceSpaceType('local-floor');
|
|
160
|
+
scene.add(xrOrigin);
|
|
161
|
+
}
|
|
107
162
|
|
|
108
163
|
const handle: WorldMeshHandle = {
|
|
109
164
|
scene,
|
|
@@ -127,19 +182,30 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
127
182
|
|
|
128
183
|
setViewMode,
|
|
129
184
|
getViewMode: () => cameraRig.mode,
|
|
185
|
+
getCameraDistance: () => cameraRig.distance,
|
|
186
|
+
setCameraDistance: (distance) => {
|
|
187
|
+
if (Number.isFinite(distance)) cameraRig.distance = Math.max(0.4, distance);
|
|
188
|
+
},
|
|
130
189
|
|
|
131
190
|
addPortal: (portal) => {
|
|
132
191
|
portals.add(portal);
|
|
133
192
|
},
|
|
134
193
|
travelTo,
|
|
194
|
+
releasePointer: () => input.release(),
|
|
135
195
|
|
|
136
196
|
loadAvatar,
|
|
137
197
|
clearAvatar,
|
|
198
|
+
getSharedAvatar: () => sharedAvatar.get(),
|
|
138
199
|
|
|
139
200
|
on: (event, fn) => events.on(event, fn),
|
|
140
201
|
off: (event, fn) => events.off(event, fn),
|
|
141
202
|
|
|
142
203
|
refreshColliders: () => collision.refresh(),
|
|
204
|
+
|
|
205
|
+
enterVR,
|
|
206
|
+
exitVR,
|
|
207
|
+
isVR: () => renderer.xr.isPresenting,
|
|
208
|
+
|
|
143
209
|
};
|
|
144
210
|
|
|
145
211
|
applyViewVisibility();
|
|
@@ -149,14 +215,269 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
149
215
|
|
|
150
216
|
if (options.autoStart !== false) start();
|
|
151
217
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
218
|
+
let network: WorldMeshOptions['network'];
|
|
219
|
+
/** Easing the field of view back to the world's own, after a walk-in took over the hub's. */
|
|
220
|
+
let fovReturn: { from: number; to: number; t: number } | null = null;
|
|
221
|
+
/** Live doorway view: said ready to the host, and drawn a view for it. */
|
|
222
|
+
let viewReady = false;
|
|
223
|
+
let viewFramed = false;
|
|
224
|
+
const viewStart = performance.now();
|
|
225
|
+
/** The host's latest view, not drawn yet. */
|
|
226
|
+
let pendingView: DoorwayView | null = null;
|
|
227
|
+
if (viewing) serveDoorwayView();
|
|
228
|
+
else goOnline();
|
|
229
|
+
|
|
230
|
+
if (!viewing) startAvatar();
|
|
231
|
+
if (vrEnabled && !viewing) void offerVr();
|
|
157
232
|
|
|
158
233
|
return handle;
|
|
159
234
|
|
|
235
|
+
function goOnline(): void {
|
|
236
|
+
network = options.network ?? createMultiplayer();
|
|
237
|
+
if (network instanceof Presence) {
|
|
238
|
+
const presence = network;
|
|
239
|
+
presence.setOnFull((full) => {
|
|
240
|
+
overlay.setRoomFull(full ? roomFullLabel(presence.url) : null, {
|
|
241
|
+
onRetry: full ? () => presence.rejoin() : undefined,
|
|
242
|
+
});
|
|
243
|
+
if (full) input.exitPointerLock();
|
|
244
|
+
events.emit('room:full', { full });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
network?.attach(handle);
|
|
248
|
+
const bodies = network?.bodies?.bind(network);
|
|
249
|
+
if (bodies) controller.bodies = () => bodies();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Drawn live in a host's doorway (DOORWAY_VIEW_PARAM): offline, no overlay,
|
|
254
|
+
* no input, no body of its own (the visitor's is in the host). The world
|
|
255
|
+
* keeps running (water, animations) and draws, once a frame, the latest
|
|
256
|
+
* view the host sent; none sent, nothing drawn. Ready once the player has
|
|
257
|
+
* landed at spawn, so the first view doesn't sink.
|
|
258
|
+
*/
|
|
259
|
+
function serveDoorwayView(): void {
|
|
260
|
+
// Kept, out of sight: walked into, the world shows them again.
|
|
261
|
+
injectDoorwayViewStyle();
|
|
262
|
+
document.documentElement.classList.add(DOORWAY_VIEW_CLASS);
|
|
263
|
+
applyViewVisibility();
|
|
264
|
+
const onMessage = (event: MessageEvent) => {
|
|
265
|
+
if (event.source !== window.parent || disposed || !viewReady) return;
|
|
266
|
+
const view = event.data as Partial<DoorwayView> & { type?: unknown };
|
|
267
|
+
// Only the latest counts: a world slower than the host never falls behind.
|
|
268
|
+
if (view?.type === 'doorway:view' && validDoorwayView(view)) pendingView = view;
|
|
269
|
+
else if (isDoorwayMessage(event.data, 'enter')) {
|
|
270
|
+
window.removeEventListener('message', onMessage);
|
|
271
|
+
enterFromView(event.data);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
window.addEventListener('message', onMessage);
|
|
275
|
+
awaitCharacter();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Walked into from the doorway: the pose comes in the door's frame, like
|
|
280
|
+
* the views, and lands the same way, on the spawn point beyond it. Then the
|
|
281
|
+
* world is itself again: drawing on its own, its UI and the visitor's body
|
|
282
|
+
* back, online. The host hears `doorway:entered` once a frame from there
|
|
283
|
+
* is drawn.
|
|
284
|
+
*/
|
|
285
|
+
function enterFromView(message: { url?: unknown; pose?: unknown }): void {
|
|
286
|
+
const url = typeof message.url === 'string' ? message.url : null;
|
|
287
|
+
try {
|
|
288
|
+
if (url && new URL(url).origin === window.location.origin) window.history.replaceState(null, '', url);
|
|
289
|
+
} catch {
|
|
290
|
+
// Keep the view address.
|
|
291
|
+
}
|
|
292
|
+
if (disposed) return;
|
|
293
|
+
takePose(poseFromDoor(message.pose));
|
|
294
|
+
viewing = false;
|
|
295
|
+
pendingView = null;
|
|
296
|
+
document.documentElement.classList.remove(DOORWAY_VIEW_CLASS);
|
|
297
|
+
handleResize();
|
|
298
|
+
applyViewVisibility();
|
|
299
|
+
input.requestPointerLock(isTouchDevice());
|
|
300
|
+
window.addEventListener('message', onCarry);
|
|
301
|
+
goOnline();
|
|
302
|
+
requestAnimationFrame(() => requestAnimationFrame(() => window.parent.postMessage({ type: 'doorway:entered' }, '*')));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A pose in the door's frame (doorway:enter from a live view), in this world's coordinates; null if malformed. */
|
|
306
|
+
function poseFromDoor(pose: unknown): unknown {
|
|
307
|
+
const p = pose as Record<string, unknown> | null;
|
|
308
|
+
const vec = (v: unknown): v is Vec3Tuple => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
309
|
+
if (!p || !vec(p.position)) return null;
|
|
310
|
+
const spawnYaw = cameraRig.yaw;
|
|
311
|
+
viewTurn.setFromAxisAngle(UP, spawnYaw);
|
|
312
|
+
viewThreshold.set(0, 0, VIEW_SPAWN_BEHIND).applyQuaternion(viewTurn).add(controller.position);
|
|
313
|
+
const place = (v: Vec3Tuple) => new Vector3(...v).applyQuaternion(viewTurn).add(viewThreshold).toArray();
|
|
314
|
+
const turn = (n: unknown) => (typeof n === 'number' && Number.isFinite(n) ? n + spawnYaw : n);
|
|
315
|
+
return {
|
|
316
|
+
...p,
|
|
317
|
+
position: place(p.position),
|
|
318
|
+
velocity: vec(p.velocity) ? new Vector3(...p.velocity).applyQuaternion(viewTurn).toArray() : p.velocity,
|
|
319
|
+
camera: vec(p.camera) ? place(p.camera) : p.camera,
|
|
320
|
+
yaw: turn(p.yaw),
|
|
321
|
+
facing: turn(p.facing),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** In the loop, instead of drawing: say ready once settled at spawn, then draw the latest view. */
|
|
326
|
+
function stepDoorwayView(): void {
|
|
327
|
+
if (!viewReady && (controller.onGround || performance.now() - viewStart > VIEW_SETTLE * 1000)) {
|
|
328
|
+
viewReady = true;
|
|
329
|
+
window.parent.postMessage({ type: 'doorway:view-ready' }, '*');
|
|
330
|
+
}
|
|
331
|
+
const view = pendingView;
|
|
332
|
+
if (!view) return;
|
|
333
|
+
pendingView = null;
|
|
334
|
+
drawDoorwayView(view);
|
|
335
|
+
if (!viewFramed) {
|
|
336
|
+
viewFramed = true;
|
|
337
|
+
window.parent.postMessage({ type: 'doorway:view-frame' }, '*');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* One view: the host's camera, in the door's frame (threshold at the
|
|
343
|
+
* origin, +Y up, −Z through the doorway), carried onto the spawn point
|
|
344
|
+
* VIEW_SPAWN_BEHIND metres beyond it, facing the way the player does.
|
|
345
|
+
* Only the doorway's part of the screen, and nothing on the host's side.
|
|
346
|
+
*/
|
|
347
|
+
function drawDoorwayView(view: DoorwayView): void {
|
|
348
|
+
const { width, height: viewHeight } = view.viewport;
|
|
349
|
+
renderer.getSize(viewSize);
|
|
350
|
+
if (viewSize.x !== width || viewSize.y !== viewHeight) renderer.setSize(width, viewHeight);
|
|
351
|
+
if (camera instanceof PerspectiveCamera) {
|
|
352
|
+
camera.fov = view.camera.fov;
|
|
353
|
+
camera.aspect = width / viewHeight;
|
|
354
|
+
camera.updateProjectionMatrix();
|
|
355
|
+
}
|
|
356
|
+
viewTurn.setFromAxisAngle(UP, cameraRig.yaw);
|
|
357
|
+
viewThreshold.set(0, 0, VIEW_SPAWN_BEHIND).applyQuaternion(viewTurn).add(controller.position);
|
|
358
|
+
camera.position.fromArray(view.camera.position).applyQuaternion(viewTurn).add(viewThreshold);
|
|
359
|
+
camera.quaternion.copy(viewTurn).multiply(viewQuaternion.fromArray(view.camera.quaternion));
|
|
360
|
+
camera.updateMatrixWorld();
|
|
361
|
+
viewClip.setFromNormalAndCoplanarPoint(viewForward.set(0, 0, -1).applyQuaternion(viewTurn), viewThreshold);
|
|
362
|
+
const worldPlanes = renderer.clippingPlanes;
|
|
363
|
+
renderer.clippingPlanes = [...worldPlanes, viewClip];
|
|
364
|
+
const [left, top, right, bottom] = view.rect;
|
|
365
|
+
renderer.setScissorTest(true);
|
|
366
|
+
renderer.setScissor(left, viewHeight - bottom, right - left, bottom - top);
|
|
367
|
+
renderer.render(scene, camera);
|
|
368
|
+
renderer.setScissorTest(false);
|
|
369
|
+
renderer.clippingPlanes = worldPlanes;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The hub's joystick, still held as the visitor walked in, keeps them walking until it is let go. */
|
|
373
|
+
function onCarry(event: MessageEvent): void {
|
|
374
|
+
if (event.source !== window.parent || !isDoorwayMessage(event.data, 'carry')) return;
|
|
375
|
+
input.carry(Number(event.data.x) || 0, Number(event.data.z) || 0);
|
|
376
|
+
if (!event.data.x && !event.data.z) window.removeEventListener('message', onCarry);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* In a doorway: the host hands over the visitor's character while the world
|
|
381
|
+
* still waits, so it is on screen from the first frame they walk in
|
|
382
|
+
* (Doorway §6): `{ type: 'doorway:character', format, name, data }`, the
|
|
383
|
+
* model file itself (nothing is fetched twice), or `data: null` for the
|
|
384
|
+
* default body. Taken from any host, only if the world shows visitors'
|
|
385
|
+
* characters at all. From this world's own hub (`avatar.hubUrl`), the
|
|
386
|
+
* WorldMesh extras under `worldmesh` are used too: the Avatar Wallet
|
|
387
|
+
* descriptor (for credits) and the handoff ticket for portals onwards. The
|
|
388
|
+
* pre-Doorway `worldmesh:avatar` from the hub is still understood. Answers
|
|
389
|
+
* `doorway:character-ready` (or `worldmesh:avatar-ready`).
|
|
390
|
+
*/
|
|
391
|
+
function awaitCharacter(): void {
|
|
392
|
+
const config = options.avatar;
|
|
393
|
+
if (config?.source !== 'worldmesh') return;
|
|
394
|
+
let hubOrigin: string;
|
|
395
|
+
try {
|
|
396
|
+
hubOrigin = new URL(config.hubUrl ?? DEFAULT_HUB).origin;
|
|
397
|
+
} catch {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
let latest = 0;
|
|
401
|
+
window.addEventListener('message', async (event: MessageEvent) => {
|
|
402
|
+
if (event.source !== window.parent) return;
|
|
403
|
+
const message = event.data as Record<string, unknown> | null;
|
|
404
|
+
const fromHub = event.origin === hubOrigin;
|
|
405
|
+
const neutral = message?.type === 'doorway:character';
|
|
406
|
+
if (!neutral && !(fromHub && message?.type === 'worldmesh:avatar')) return;
|
|
407
|
+
const turn = ++latest;
|
|
408
|
+
|
|
409
|
+
const extras = (neutral ? message.worldmesh : message) as { descriptor?: unknown; ticket?: unknown } | undefined;
|
|
410
|
+
if (fromHub && typeof extras?.ticket === 'string') setAvatarTicket(extras.ticket);
|
|
411
|
+
const data = message.data instanceof ArrayBuffer ? message.data : undefined;
|
|
412
|
+
let descriptor = fromHub ? parseAvatarDescriptor(extras?.descriptor) : null;
|
|
413
|
+
if (!descriptor && neutral && data && (message.format === 'vrm' || message.format === 'glb')) {
|
|
414
|
+
descriptor = {
|
|
415
|
+
provider: 'doorway',
|
|
416
|
+
avatarId: '',
|
|
417
|
+
name: typeof message.name === 'string' ? message.name.slice(0, 64) : null,
|
|
418
|
+
thumbnail: null,
|
|
419
|
+
format: message.format,
|
|
420
|
+
// Never fetched: the file came with the message.
|
|
421
|
+
modelUrl: '',
|
|
422
|
+
expiresAt: null,
|
|
423
|
+
sourceUrl: null,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
let loaded = false;
|
|
427
|
+
if (descriptor) {
|
|
428
|
+
loaded = await loadAvatar(descriptor, data);
|
|
429
|
+
} else if (neutral ? message.data === null : extras?.descriptor === null) {
|
|
430
|
+
// Back to the default body (private mode, or no character any more).
|
|
431
|
+
clearAvatar();
|
|
432
|
+
}
|
|
433
|
+
if (turn !== latest || disposed) return;
|
|
434
|
+
const answer = neutral ? 'doorway:character-ready' : 'worldmesh:avatar-ready';
|
|
435
|
+
window.parent.postMessage({ type: answer, loaded }, event.origin);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Where a hub had the visitor as they walked in, in this world's coordinates. Ignores anything malformed. */
|
|
440
|
+
function takePose(pose: unknown): void {
|
|
441
|
+
const p = pose as Partial<{
|
|
442
|
+
position: Vec3Tuple;
|
|
443
|
+
velocity: Vec3Tuple;
|
|
444
|
+
camera: Vec3Tuple;
|
|
445
|
+
mode: ViewMode;
|
|
446
|
+
yaw: number;
|
|
447
|
+
pitch: number;
|
|
448
|
+
facing: number;
|
|
449
|
+
fov: number;
|
|
450
|
+
color: string;
|
|
451
|
+
}> | null;
|
|
452
|
+
const vec = (v: unknown): v is Vec3Tuple => Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n));
|
|
453
|
+
const num = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
|
|
454
|
+
if (!p || !vec(p.position)) return;
|
|
455
|
+
controller.reset(new Vector3(...p.position));
|
|
456
|
+
if (vec(p.velocity)) controller.velocity.set(...p.velocity);
|
|
457
|
+
if (num(p.yaw)) cameraRig.yaw = p.yaw;
|
|
458
|
+
if (num(p.pitch)) cameraRig.pitch = p.pitch;
|
|
459
|
+
if (num(p.facing)) player.facing = p.facing;
|
|
460
|
+
if (p.mode === 'first' || p.mode === 'third') setViewMode(p.mode);
|
|
461
|
+
if (cameraRig.mode === 'third' && vec(p.camera)) {
|
|
462
|
+
// Same boom as the hub's camera had, measured from this world's eyes; it eases back out from there.
|
|
463
|
+
const eyes = new Vector3(...p.position).add(new Vector3(0, player.eyeHeight * (controller.height / height), 0));
|
|
464
|
+
cameraRig.snapBoom(eyes.distanceTo(new Vector3(...p.camera)));
|
|
465
|
+
}
|
|
466
|
+
// The figure keeps the colour it had in the hub (the default body only).
|
|
467
|
+
if (typeof p.color === 'string' && /^#[0-9a-f]{6}$/i.test(p.color) && player.root && !avatarDescriptor) setAvatarColor(player.root, p.color);
|
|
468
|
+
if (num(p.fov) && camera instanceof PerspectiveCamera && Math.abs(camera.fov - p.fov) > 0.01) {
|
|
469
|
+
fovReturn = { from: p.fov, to: camera.fov, t: 0 };
|
|
470
|
+
camera.fov = p.fov;
|
|
471
|
+
camera.updateProjectionMatrix();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Leave for another page; a world inside a host's page asks the host to go there instead. */
|
|
476
|
+
function leaveFor(url: string): void {
|
|
477
|
+
if (inHost) window.parent.postMessage({ type: 'doorway:navigate', url }, '*');
|
|
478
|
+
else window.location.href = url;
|
|
479
|
+
}
|
|
480
|
+
|
|
160
481
|
/** Brings in the visitor's own avatar, if the world asked for one. The default body stays until it is ready. */
|
|
161
482
|
function startAvatar(): void {
|
|
162
483
|
const config = options.avatar;
|
|
@@ -176,7 +497,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
176
497
|
);
|
|
177
498
|
}
|
|
178
499
|
|
|
179
|
-
async function loadAvatar(descriptor: AvatarDescriptor): Promise<boolean> {
|
|
500
|
+
async function loadAvatar(descriptor: AvatarDescriptor, data?: ArrayBuffer): Promise<boolean> {
|
|
180
501
|
if (disposed || !player.root) return false;
|
|
181
502
|
avatarLoad?.abort();
|
|
182
503
|
const load = (avatarLoad = new AbortController());
|
|
@@ -185,6 +506,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
185
506
|
height,
|
|
186
507
|
maxBytes: options.avatar?.maxBytes,
|
|
187
508
|
signal: load.signal,
|
|
509
|
+
data,
|
|
188
510
|
});
|
|
189
511
|
if (disposed || load.signal.aborted) {
|
|
190
512
|
loaded.dispose();
|
|
@@ -192,6 +514,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
192
514
|
}
|
|
193
515
|
if (!player.setExternalBody(loaded)) return false;
|
|
194
516
|
avatarDescriptor = descriptor;
|
|
517
|
+
sharedAvatar.set(descriptor);
|
|
195
518
|
applyViewVisibility();
|
|
196
519
|
events.emit('avatar:load', { descriptor });
|
|
197
520
|
return true;
|
|
@@ -209,29 +532,94 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
209
532
|
avatarLoad?.abort();
|
|
210
533
|
player.clearExternalBody();
|
|
211
534
|
avatarDescriptor = null;
|
|
535
|
+
sharedAvatar.set(null);
|
|
212
536
|
}
|
|
213
537
|
|
|
214
538
|
function start(): void {
|
|
215
539
|
if (running || disposed) return;
|
|
216
540
|
running = true;
|
|
217
541
|
lastTime = performance.now();
|
|
218
|
-
|
|
542
|
+
// setAnimationLoop is the WebXR frame pump; it also drives the flat canvas.
|
|
543
|
+
renderer.setAnimationLoop(frame);
|
|
219
544
|
}
|
|
220
545
|
|
|
221
546
|
function stop(): void {
|
|
222
547
|
running = false;
|
|
223
|
-
|
|
224
|
-
frameHandle = 0;
|
|
548
|
+
renderer.setAnimationLoop(null);
|
|
225
549
|
}
|
|
226
550
|
|
|
227
551
|
function frame(now: number): void {
|
|
228
552
|
if (!running) return;
|
|
229
|
-
frameHandle = requestAnimationFrame(frame);
|
|
230
553
|
const dt = Math.min((now - lastTime) / 1000, MAX_FRAME);
|
|
231
554
|
lastTime = now;
|
|
232
555
|
update(dt);
|
|
233
556
|
options.onUpdate?.(dt, handle);
|
|
234
|
-
|
|
557
|
+
if (fovReturn && camera instanceof PerspectiveCamera) {
|
|
558
|
+
fovReturn.t = Math.min(1, fovReturn.t + dt / FOV_RETURN);
|
|
559
|
+
const k = fovReturn.t * fovReturn.t * (3 - 2 * fovReturn.t);
|
|
560
|
+
camera.fov = fovReturn.from + (fovReturn.to - fovReturn.from) * k;
|
|
561
|
+
camera.updateProjectionMatrix();
|
|
562
|
+
if (fovReturn.t >= 1) fovReturn = null;
|
|
563
|
+
}
|
|
564
|
+
// A live doorway view draws the host's latest camera instead (stepDoorwayView).
|
|
565
|
+
// Drawing is what places things otherwise, and colliders need them placed.
|
|
566
|
+
if (viewing) {
|
|
567
|
+
scene.updateMatrixWorld();
|
|
568
|
+
stepDoorwayView();
|
|
569
|
+
} else {
|
|
570
|
+
renderer.render(scene, camera);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function offerVr(): Promise<void> {
|
|
575
|
+
if (disposed || !(await immersiveVrSupported())) return;
|
|
576
|
+
overlay.setVrAvailable(true);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function enterVR(): Promise<boolean> {
|
|
580
|
+
if (!vrEnabled || disposed) return false;
|
|
581
|
+
if (renderer.xr.isPresenting) return true;
|
|
582
|
+
try {
|
|
583
|
+
input.exitPointerLock();
|
|
584
|
+
const session = await requestImmersiveVr(renderer);
|
|
585
|
+
bindXrSession(session);
|
|
586
|
+
return true;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
console.warn('[worldmesh] Could not start VR.', error);
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function exitVR(): Promise<void> {
|
|
594
|
+
if (!xrSession) return;
|
|
595
|
+
try {
|
|
596
|
+
await xrSession.end();
|
|
597
|
+
} catch {
|
|
598
|
+
unbindXrSession();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function bindXrSession(session: XRSession): void {
|
|
603
|
+
xrSession = session;
|
|
604
|
+
input.setXrSession(session);
|
|
605
|
+
if (camera.parent !== xrOrigin) xrOrigin.add(camera);
|
|
606
|
+
overlay.setVrPresenting(true);
|
|
607
|
+
applyViewVisibility();
|
|
608
|
+
session.addEventListener('end', unbindXrSession);
|
|
609
|
+
events.emit('vr:enter', {});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function unbindXrSession(): void {
|
|
613
|
+
if (!xrSession) return;
|
|
614
|
+
xrSession.removeEventListener('end', unbindXrSession);
|
|
615
|
+
xrSession = null;
|
|
616
|
+
input.setXrSession(null);
|
|
617
|
+
if (camera.parent === xrOrigin) xrOrigin.remove(camera);
|
|
618
|
+
xrOrigin.position.set(0, 0, 0);
|
|
619
|
+
xrOrigin.rotation.set(0, 0, 0);
|
|
620
|
+
overlay.setVrPresenting(false);
|
|
621
|
+
applyViewVisibility();
|
|
622
|
+
events.emit('vr:exit', {});
|
|
235
623
|
}
|
|
236
624
|
|
|
237
625
|
/** Advance the simulation. Split from rendering so a fixed tick can drive it. */
|
|
@@ -239,9 +627,11 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
239
627
|
if (dt <= 0) return;
|
|
240
628
|
elapsed += dt;
|
|
241
629
|
|
|
630
|
+
const presenting = renderer.xr.isPresenting;
|
|
631
|
+
if (presenting) input.pollXr(dt);
|
|
242
632
|
const look = input.readLook();
|
|
243
633
|
const playing = isPlaying();
|
|
244
|
-
if (playing) {
|
|
634
|
+
if (playing && !presenting) {
|
|
245
635
|
if (input.locked || isTouchDevice() || options.ui?.moveBeforeLock === true) {
|
|
246
636
|
cameraRig.look(look.dx, look.dy);
|
|
247
637
|
if (input.locked) cameraRig.zoom(look.wheel);
|
|
@@ -252,6 +642,12 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
252
642
|
if (expression) player.setExpression(expression);
|
|
253
643
|
}
|
|
254
644
|
|
|
645
|
+
if (presenting) {
|
|
646
|
+
xrOrigin.rotation.y += input.readXrTurn();
|
|
647
|
+
xrHeading.setFromQuaternion(renderer.xr.getCamera().quaternion, 'YXZ');
|
|
648
|
+
cameraRig.yaw = xrHeading.y;
|
|
649
|
+
}
|
|
650
|
+
|
|
255
651
|
// Fixed substeps keep movement stable regardless of frame rate.
|
|
256
652
|
let remaining = dt;
|
|
257
653
|
while (remaining > 0) {
|
|
@@ -262,13 +658,18 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
262
658
|
|
|
263
659
|
if (controller.position.y < controller.tuning.fallLimit) doRespawn('fell');
|
|
264
660
|
|
|
265
|
-
|
|
266
|
-
|
|
661
|
+
if (presenting) {
|
|
662
|
+
xrOrigin.position.copy(controller.position);
|
|
663
|
+
} else {
|
|
664
|
+
cameraRig.update(dt, controller.position, player.eyeHeight * (controller.height / height));
|
|
665
|
+
}
|
|
666
|
+
const footfall = player.sync(controller.position, cameraRig.yaw, controller.height, {
|
|
267
667
|
dt,
|
|
268
668
|
speed: Math.hypot(controller.velocity.x, controller.velocity.z),
|
|
269
669
|
grounded: controller.onGround || controller.flying,
|
|
270
670
|
heading: Math.atan2(-controller.velocity.x, -controller.velocity.z),
|
|
271
671
|
}, cameraRig.mode === 'third');
|
|
672
|
+
footsteps?.update(dt, playing ? Math.hypot(controller.velocity.x, controller.velocity.z) : 0, controller.onGround, footfall, controller.velocity.y, controller.flying);
|
|
272
673
|
portals.animate(elapsed);
|
|
273
674
|
|
|
274
675
|
updatePortals();
|
|
@@ -279,6 +680,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
279
680
|
|
|
280
681
|
/** Walking, until Esc opens the pause screen. */
|
|
281
682
|
function isPlaying(): boolean {
|
|
683
|
+
if (renderer.xr.isPresenting) return true;
|
|
282
684
|
return !overlay.isPaused() && (input.locked || options.ui?.moveBeforeLock === true);
|
|
283
685
|
}
|
|
284
686
|
|
|
@@ -321,20 +723,22 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
321
723
|
},
|
|
322
724
|
});
|
|
323
725
|
if (cancelled) return;
|
|
324
|
-
input.
|
|
325
|
-
|
|
726
|
+
input.release();
|
|
727
|
+
leaveFor(url);
|
|
326
728
|
}
|
|
327
729
|
|
|
328
730
|
function setViewMode(mode: ViewMode): void {
|
|
329
731
|
if (cameraRig.mode === mode) return;
|
|
330
732
|
cameraRig.setMode(mode);
|
|
733
|
+
rememberView(mode);
|
|
331
734
|
applyViewVisibility();
|
|
332
735
|
events.emit('view:change', { mode });
|
|
333
736
|
}
|
|
334
737
|
|
|
335
738
|
/** In first person the avatar would fill the screen. */
|
|
336
739
|
function applyViewVisibility(): void {
|
|
337
|
-
|
|
740
|
+
// In a doorway the visitor's own body is the host's: none here.
|
|
741
|
+
player.setVisible(!viewing && (cameraRig.mode === 'third' || renderer.xr.isPresenting));
|
|
338
742
|
}
|
|
339
743
|
|
|
340
744
|
function getState(): PlayerState {
|
|
@@ -375,16 +779,25 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
375
779
|
}
|
|
376
780
|
|
|
377
781
|
function travelTo(url: string): void {
|
|
378
|
-
input.
|
|
379
|
-
|
|
782
|
+
input.release();
|
|
783
|
+
leaveFor(buildTravelUrl(url));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function isDoorwayViewRequest(): boolean {
|
|
787
|
+
try {
|
|
788
|
+
return window.parent !== window && new URLSearchParams(window.location.search).has(DOORWAY_VIEW_PARAM);
|
|
789
|
+
} catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
380
792
|
}
|
|
381
793
|
|
|
382
794
|
function handleResize(): void {
|
|
795
|
+
if (renderer.xr.isPresenting) return;
|
|
383
796
|
const width = window.innerWidth;
|
|
384
797
|
const viewHeight = window.innerHeight;
|
|
385
798
|
// Pixel ratio first: setSize derives the drawing buffer from it, and it
|
|
386
799
|
// must also update the canvas CSS size or the canvas overflows the window.
|
|
387
|
-
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
800
|
+
renderer.setPixelRatio(viewing ? 1 : Math.min(window.devicePixelRatio, 2));
|
|
388
801
|
renderer.setSize(width, viewHeight);
|
|
389
802
|
cameraRig.resize(width, viewHeight);
|
|
390
803
|
}
|
|
@@ -396,6 +809,10 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
396
809
|
url: `${server.replace(/\/$/, '')}/world`,
|
|
397
810
|
scene,
|
|
398
811
|
onCount: (count) => events.emit('players', { count }),
|
|
812
|
+
getAvatar: () => sharedAvatar.get(),
|
|
813
|
+
// Other players' avatars are shown only from links this world's hub signed.
|
|
814
|
+
verifyAvatar: createShareVerifier((options.avatar?.source === 'worldmesh' && options.avatar.hubUrl) || DEFAULT_HUB),
|
|
815
|
+
maxAvatarBytes: options.avatar?.maxBytes,
|
|
399
816
|
});
|
|
400
817
|
}
|
|
401
818
|
|
|
@@ -403,13 +820,66 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
|
403
820
|
if (disposed) return;
|
|
404
821
|
disposed = true;
|
|
405
822
|
stop();
|
|
823
|
+
const session = xrSession;
|
|
824
|
+
if (session) {
|
|
825
|
+
session.removeEventListener('end', unbindXrSession);
|
|
826
|
+
unbindXrSession();
|
|
827
|
+
void session.end().catch(() => undefined);
|
|
828
|
+
}
|
|
406
829
|
avatarLoad?.abort();
|
|
830
|
+
sharedAvatar.dispose();
|
|
407
831
|
window.removeEventListener('resize', handleResize);
|
|
408
832
|
network?.detach?.();
|
|
409
833
|
input.dispose();
|
|
410
834
|
overlay.dispose();
|
|
411
835
|
portals.dispose();
|
|
412
836
|
player.dispose();
|
|
837
|
+
footsteps?.dispose();
|
|
838
|
+
if (vrEnabled) scene.remove(xrOrigin);
|
|
413
839
|
events.clear();
|
|
414
840
|
}
|
|
415
841
|
}
|
|
842
|
+
|
|
843
|
+
/** What a host sends a live doorway view every frame (docs/specs/doorway.md §3.1). */
|
|
844
|
+
interface DoorwayView {
|
|
845
|
+
camera: { position: Vec3Tuple; quaternion: [number, number, number, number]; fov: number };
|
|
846
|
+
viewport: { width: number; height: number };
|
|
847
|
+
/** The doorway on screen, [left, top, right, bottom] in CSS pixels: the only part seen. */
|
|
848
|
+
rect: [number, number, number, number];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function validDoorwayView(view: Partial<DoorwayView>): view is DoorwayView {
|
|
852
|
+
const numbers = (value: unknown, length: number) =>
|
|
853
|
+
Array.isArray(value) && value.length === length && value.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
854
|
+
const { camera, viewport } = view;
|
|
855
|
+
return (
|
|
856
|
+
!!camera &&
|
|
857
|
+
numbers(camera.position, 3) &&
|
|
858
|
+
numbers(camera.quaternion, 4) &&
|
|
859
|
+
typeof camera.fov === 'number' &&
|
|
860
|
+
camera.fov > 0 &&
|
|
861
|
+
!!viewport &&
|
|
862
|
+
viewport.width > 0 &&
|
|
863
|
+
viewport.height > 0 &&
|
|
864
|
+
numbers(view.rect, 4)
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const UP = new Vector3(0, 1, 0);
|
|
869
|
+
const viewSize = new Vector2();
|
|
870
|
+
const viewTurn = new Quaternion();
|
|
871
|
+
const viewQuaternion = new Quaternion();
|
|
872
|
+
const viewThreshold = new Vector3();
|
|
873
|
+
const viewForward = new Vector3();
|
|
874
|
+
const viewClip = new Plane();
|
|
875
|
+
|
|
876
|
+
/** On the page while it is drawn in a host's doorway: its overlay and touch controls are kept, unseen. */
|
|
877
|
+
const DOORWAY_VIEW_CLASS = 'wm-doorway-view';
|
|
878
|
+
|
|
879
|
+
function injectDoorwayViewStyle(): void {
|
|
880
|
+
if (document.getElementById(DOORWAY_VIEW_CLASS)) return;
|
|
881
|
+
const style = document.createElement('style');
|
|
882
|
+
style.id = DOORWAY_VIEW_CLASS;
|
|
883
|
+
style.textContent = `html.${DOORWAY_VIEW_CLASS} .wm-overlay, html.${DOORWAY_VIEW_CLASS} .wm-touch-root { display: none !important; }`;
|
|
884
|
+
document.head.appendChild(style);
|
|
885
|
+
}
|