@worldmesh/runtime 0.2.1 → 0.2.3

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