@worldmesh/runtime 0.2.1 → 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.
Files changed (100) hide show
  1. package/README.md +33 -3
  2. package/dist/audio/footsteps.d.ts +37 -0
  3. package/dist/audio/footsteps.d.ts.map +1 -0
  4. package/dist/audio/footsteps.js +178 -0
  5. package/dist/audio/footsteps.js.map +1 -0
  6. package/dist/avatar/descriptor.d.ts +15 -0
  7. package/dist/avatar/descriptor.d.ts.map +1 -1
  8. package/dist/avatar/descriptor.js +19 -0
  9. package/dist/avatar/descriptor.js.map +1 -1
  10. package/dist/avatar/loader.d.ts +12 -1
  11. package/dist/avatar/loader.d.ts.map +1 -1
  12. package/dist/avatar/loader.js +16 -1
  13. package/dist/avatar/loader.js.map +1 -1
  14. package/dist/avatar/share.d.ts +30 -0
  15. package/dist/avatar/share.d.ts.map +1 -0
  16. package/dist/avatar/share.js +203 -0
  17. package/dist/avatar/share.js.map +1 -0
  18. package/dist/camera/cameraRig.d.ts +4 -0
  19. package/dist/camera/cameraRig.d.ts.map +1 -1
  20. package/dist/camera/cameraRig.js +10 -1
  21. package/dist/camera/cameraRig.js.map +1 -1
  22. package/dist/camera/viewHandoff.d.ts +20 -0
  23. package/dist/camera/viewHandoff.d.ts.map +1 -0
  24. package/dist/camera/viewHandoff.js +64 -0
  25. package/dist/camera/viewHandoff.js.map +1 -0
  26. package/dist/controls/input.d.ts +29 -0
  27. package/dist/controls/input.d.ts.map +1 -1
  28. package/dist/controls/input.js +124 -1
  29. package/dist/controls/input.js.map +1 -1
  30. package/dist/controls/touch.d.ts +10 -0
  31. package/dist/controls/touch.d.ts.map +1 -1
  32. package/dist/controls/touch.js +57 -1
  33. package/dist/controls/touch.js.map +1 -1
  34. package/dist/core/worldmesh.d.ts +9 -0
  35. package/dist/core/worldmesh.d.ts.map +1 -1
  36. package/dist/core/worldmesh.js +498 -26
  37. package/dist/core/worldmesh.js.map +1 -1
  38. package/dist/index.d.ts +14 -7
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +11 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/net/follow.d.ts +7 -0
  43. package/dist/net/follow.d.ts.map +1 -0
  44. package/dist/net/follow.js +10 -0
  45. package/dist/net/follow.js.map +1 -0
  46. package/dist/net/presence.d.ts +125 -3
  47. package/dist/net/presence.d.ts.map +1 -1
  48. package/dist/net/presence.js +471 -24
  49. package/dist/net/presence.js.map +1 -1
  50. package/dist/net/roomFull.d.ts +44 -0
  51. package/dist/net/roomFull.d.ts.map +1 -0
  52. package/dist/net/roomFull.js +147 -0
  53. package/dist/net/roomFull.js.map +1 -0
  54. package/dist/player/avatar.d.ts +17 -4
  55. package/dist/player/avatar.d.ts.map +1 -1
  56. package/dist/player/avatar.js +85 -18
  57. package/dist/player/avatar.js.map +1 -1
  58. package/dist/player/player.d.ts +6 -2
  59. package/dist/player/player.d.ts.map +1 -1
  60. package/dist/player/player.js +13 -3
  61. package/dist/player/player.js.map +1 -1
  62. package/dist/player/stroke.d.ts +11 -0
  63. package/dist/player/stroke.d.ts.map +1 -0
  64. package/dist/player/stroke.js +96 -0
  65. package/dist/player/stroke.js.map +1 -0
  66. package/dist/portals/portals.d.ts +1 -1
  67. package/dist/portals/portals.d.ts.map +1 -1
  68. package/dist/portals/portals.js +6 -2
  69. package/dist/portals/portals.js.map +1 -1
  70. package/dist/types.d.ts +53 -9
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/ui/overlay.d.ts +18 -0
  73. package/dist/ui/overlay.d.ts.map +1 -1
  74. package/dist/ui/overlay.js +166 -1
  75. package/dist/ui/overlay.js.map +1 -1
  76. package/dist/xr/session.d.ts +13 -0
  77. package/dist/xr/session.d.ts.map +1 -0
  78. package/dist/xr/session.js +33 -0
  79. package/dist/xr/session.js.map +1 -0
  80. package/package.json +1 -1
  81. package/src/audio/footsteps.ts +185 -0
  82. package/src/avatar/descriptor.ts +28 -0
  83. package/src/avatar/loader.ts +19 -2
  84. package/src/avatar/share.ts +196 -0
  85. package/src/camera/cameraRig.ts +10 -1
  86. package/src/camera/viewHandoff.ts +67 -0
  87. package/src/controls/input.ts +119 -1
  88. package/src/controls/touch.ts +59 -1
  89. package/src/core/worldmesh.ts +495 -25
  90. package/src/index.ts +14 -7
  91. package/src/net/follow.ts +9 -0
  92. package/src/net/presence.ts +547 -27
  93. package/src/net/roomFull.ts +160 -0
  94. package/src/player/avatar.ts +87 -18
  95. package/src/player/player.ts +13 -4
  96. package/src/player/stroke.ts +93 -0
  97. package/src/portals/portals.ts +5 -2
  98. package/src/types.ts +52 -9
  99. package/src/ui/overlay.ts +171 -1
  100. package/src/xr/session.ts +33 -0
@@ -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
- const network = options.network ?? createMultiplayer();
153
- network?.attach(handle);
154
- if (network?.bodies) controller.bodies = () => network.bodies!();
155
-
156
- startAvatar();
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
- frameHandle = requestAnimationFrame(frame);
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
- if (frameHandle) cancelAnimationFrame(frameHandle);
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
- renderer.render(scene, camera);
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
- cameraRig.update(dt, controller.position, player.eyeHeight * (controller.height / height));
266
- player.sync(controller.position, cameraRig.yaw, controller.height, {
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.exitPointerLock();
325
- window.location.href = url;
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
- player.setVisible(cameraRig.mode === 'third');
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.exitPointerLock();
379
- window.location.href = buildTravelUrl(url);
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
+ }