@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,11 +1,27 @@
1
1
  import { CanvasTexture, Group, Mesh, MeshBasicMaterial, Sprite, SpriteMaterial, Vector3 } from 'three';
2
- import { animateDefaultAvatar, createDefaultAvatar, setAvatarExpression } from '../player/avatar.js';
2
+ import { Footsteps } from '../audio/footsteps.js';
3
+ import { parseAvatarShare } from '../avatar/descriptor.js';
4
+ import { loadAvatarModel } from '../avatar/loader.js';
5
+ import { animateDefaultAvatar, createDefaultAvatar, setAvatarColor, setAvatarExpression } from '../player/avatar.js';
6
+ import { setFigureStroke } from '../player/stroke.js';
7
+ import { easeYaw } from './follow.js';
8
+ import { isRoomFullSignal, probeRoom } from './roomFull.js';
3
9
  /** The relay WorldMesh hosts (workers/presence). Worlds can point at their own. */
4
10
  export const DEFAULT_PRESENCE_SERVER = 'wss://relay.worldmesh.net';
5
11
  /** How often the local position goes out, per second. */
6
12
  const SEND_RATE = 10;
7
- /** Resend at least this often while standing still, so late joiners see us. */
8
- const HEARTBEAT_MS = 3000;
13
+ /** The relay takes one avatar change a second per peer; stay under it. */
14
+ const AVATAR_SEND_MS = 1200;
15
+ /** Leeway for clocks that are a little off when checking a link's expiry. */
16
+ const CLOCK_SKEW_MS = 5 * 60_000;
17
+ /**
18
+ * While standing still, send only a keep-alive this often. The relay answers
19
+ * it without waking the room, so a room where nobody moves can sleep; late
20
+ * joiners still see us, as the relay remembers our last position.
21
+ */
22
+ const KEEPALIVE_MS = 30_000;
23
+ /** After the relay says this world used today's free allowance, try again this much later. */
24
+ const ALLOWANCE_RETRY_MS = 30 * 60_000;
9
25
  const MAX_BACKOFF_MS = 30_000;
10
26
  /**
11
27
  * For peers that do not send `g`: a height change smaller than this between
@@ -33,25 +49,68 @@ export class Presence {
33
49
  closed = false;
34
50
  lastSent = 0;
35
51
  lastPayload = '';
52
+ welcomed = false;
53
+ full = false;
54
+ probeId = 0;
55
+ /** The over-allowance notice was logged once already. */
56
+ allowanceLogged = false;
57
+ /** This connection has sent a position, so the relay will take an avatar. */
58
+ stateSent = false;
59
+ /** `id url` of the avatar the relay last heard from us; null before anything was sent. */
60
+ avatarSent = null;
61
+ avatarSentAt = 0;
62
+ /** Avatars heard for someone not drawn yet. */
63
+ pendingAvatars = new Map();
64
+ /** One avatar download at a time, so a busy room does not stall the page. */
65
+ avatarQueue = Promise.resolve();
66
+ /** Tidies up an avatar left on a figure that is kept after its owner left. */
67
+ keptAvatars = new WeakMap();
36
68
  url;
37
69
  onCount;
70
+ onFull;
38
71
  getName;
39
72
  getAlias;
73
+ getColor;
74
+ getAvatar;
75
+ showAvatars;
76
+ verifyAvatar;
77
+ maxAvatarBytes;
40
78
  onArrive;
41
79
  onFigure;
80
+ onDepart;
81
+ listener;
82
+ onShared;
83
+ place;
42
84
  constructor(options) {
43
85
  this.url = options.url;
44
86
  this.onCount = options.onCount ?? (() => { });
87
+ this.onFull = options.onFull ?? (() => { });
45
88
  this.getName = options.getName ?? (() => null);
46
89
  this.getAlias = options.getAlias ?? (() => null);
90
+ this.getColor = options.getColor ?? (() => null);
91
+ this.getAvatar = options.getAvatar ?? (() => null);
92
+ this.showAvatars = options.showAvatars !== false;
93
+ this.verifyAvatar = options.verifyAvatar ?? null;
94
+ this.maxAvatarBytes = options.maxAvatarBytes;
47
95
  this.onArrive = options.onArrive ?? (() => { });
48
96
  this.onFigure = options.onFigure ?? (() => { });
97
+ this.onDepart = options.onDepart ?? (() => { });
98
+ this.listener = options.listener ?? null;
99
+ this.onShared = options.onShared ?? (() => { });
100
+ this.place = options.place;
49
101
  this.group.name = 'worldmesh:remote-players';
50
102
  options.scene.add(this.group);
51
103
  }
104
+ /** Late-bind the full-room overlay (createWorldMesh wires this after constructing Presence). */
105
+ setOnFull(handler) {
106
+ this.onFull = handler;
107
+ if (this.full)
108
+ handler(true);
109
+ }
52
110
  attach(world) {
53
111
  this.unsubscribe = world.on('update', ({ dt, state }) => {
54
112
  this.sendLocalState(state);
113
+ this.sendAvatar();
55
114
  this.update(dt);
56
115
  });
57
116
  window.addEventListener('pagehide', this.handlePageHide);
@@ -66,12 +125,38 @@ export class Presence {
66
125
  if (now - this.lastSent < 1000 / SEND_RATE)
67
126
  return;
68
127
  const [x, y, z] = state.position;
69
- const payload = JSON.stringify({ t: 's', p: [round(x), round(y), round(z)], r: round(state.facing), e: state.expression, n: this.getName() ?? '', a: this.getAlias() ?? '', g: state.onGround || state.flying ? 1 : 0 });
70
- if (payload === this.lastPayload && now - this.lastSent < HEARTBEAT_MS)
128
+ const payload = JSON.stringify({ t: 's', p: [round(x), round(y), round(z)], r: round(state.facing), e: state.expression, n: this.getName() ?? '', a: this.getAlias() ?? '', k: this.getColor() ?? '', g: state.onGround || state.flying ? 1 : 0 });
129
+ if (payload === this.lastPayload) {
130
+ if (now - this.lastSent < KEEPALIVE_MS)
131
+ return;
132
+ socket.send('ping');
133
+ this.lastSent = now;
71
134
  return;
135
+ }
72
136
  socket.send(payload);
73
137
  this.lastPayload = payload;
74
138
  this.lastSent = now;
139
+ this.stateSent = true;
140
+ }
141
+ /**
142
+ * Tell the room which avatar this visitor wears, whenever that (or its link)
143
+ * changes. The relay keeps it for whoever joins later.
144
+ */
145
+ sendAvatar() {
146
+ const socket = this.socket;
147
+ if (!socket || socket.readyState !== WebSocket.OPEN || !this.selfId || !this.stateSent)
148
+ return;
149
+ const avatar = parseAvatarShare(this.getAvatar());
150
+ const key = avatar ? `${avatar.i} ${avatar.u} ${avatar.x}` : '';
151
+ // Nothing to say on a fresh connection when there is no avatar.
152
+ if (key === (this.avatarSent ?? ''))
153
+ return;
154
+ const now = performance.now();
155
+ if (now - this.avatarSentAt < AVATAR_SEND_MS)
156
+ return;
157
+ socket.send(JSON.stringify(avatar ? { t: 'v', u: avatar.u, f: avatar.f, i: avatar.i, x: avatar.x, s: avatar.s } : { t: 'v' }));
158
+ this.avatarSent = key;
159
+ this.avatarSentAt = now;
75
160
  }
76
161
  /** Say something. Everyone else gets a bubble; this client draws its own. */
77
162
  say(text) {
@@ -81,6 +166,28 @@ export class Presence {
81
166
  return;
82
167
  socket.send(JSON.stringify({ t: 'c', m: line }));
83
168
  }
169
+ /**
170
+ * Set shared state everyone in the room sees, e.g. where a lift is going.
171
+ * Values are flat objects of numbers. The write comes back through
172
+ * `onShared` like everyone else's. False when not connected.
173
+ */
174
+ share(key, value) {
175
+ const socket = this.socket;
176
+ if (!socket || socket.readyState !== WebSocket.OPEN || !this.selfId)
177
+ return false;
178
+ socket.send(JSON.stringify({ t: 'o', k: key, v: value }));
179
+ return true;
180
+ }
181
+ /** Remove a figure kept by `onDepart` once its exit is over. */
182
+ dropFigure(root) {
183
+ const external = this.keptAvatars.get(root);
184
+ this.keptAvatars.delete(root);
185
+ if (external) {
186
+ external.object.removeFromParent();
187
+ external.dispose();
188
+ }
189
+ disposeObject(root);
190
+ }
84
191
  /** Everyone else's body where it is drawn, so walking into them stops where they appear to be. */
85
192
  bodies() {
86
193
  const bodies = [];
@@ -96,13 +203,22 @@ export class Presence {
96
203
  for (const remote of this.remotes.values()) {
97
204
  const before = remote.root.position.clone();
98
205
  remote.root.position.lerp(remote.target, blend);
99
- let delta = remote.targetYaw - remote.yaw;
100
- delta = Math.atan2(Math.sin(delta), Math.cos(delta));
101
- remote.yaw += delta * blend;
206
+ this.place?.(remote.root.position, remote.grounded);
207
+ remote.yaw = easeYaw(remote.yaw, remote.targetYaw, blend);
102
208
  remote.root.rotation.y = remote.yaw;
103
209
  const moved = Math.hypot(remote.root.position.x - before.x, remote.root.position.z - before.z);
104
210
  remote.speed += (moved / Math.max(dt, 1e-4) - remote.speed) * blend;
105
- animateDefaultAvatar(remote.root, { dt, speed: remote.speed, grounded: remote.grounded });
211
+ const motion = { dt, speed: remote.speed, grounded: remote.grounded };
212
+ let footfall = null;
213
+ if (remote.external)
214
+ remote.external.animate(motion);
215
+ else
216
+ footfall = animateDefaultAvatar(remote.body, motion);
217
+ if (remote.steps) {
218
+ remote.vertical = (remote.root.position.y - before.y) / Math.max(dt, 1e-4);
219
+ remote.steps.loudness = earshot(this.listener?.() ?? null, remote.root.position);
220
+ remote.steps.update(dt, remote.speed, remote.grounded, footfall, remote.vertical);
221
+ }
106
222
  if (remote.bubble) {
107
223
  remote.bubbleLeft -= dt;
108
224
  remote.bubble.material.opacity = remote.bubbleLeft < 1 ? Math.max(0, remote.bubbleLeft) : 1;
@@ -126,6 +242,8 @@ export class Presence {
126
242
  if (this.closed)
127
243
  return;
128
244
  window.clearTimeout(this.reconnectTimer);
245
+ this.probeId += 1;
246
+ this.leaveFull();
129
247
  this.disconnect();
130
248
  this.backoff = 1000;
131
249
  this.connect();
@@ -151,6 +269,13 @@ export class Presence {
151
269
  return;
152
270
  }
153
271
  this.socket = socket;
272
+ this.welcomed = false;
273
+ listenForHandshakeRefuse(socket, (signal) => {
274
+ if (this.socket !== socket || this.welcomed)
275
+ return;
276
+ if (isRoomFullSignal(signal))
277
+ this.enterFull();
278
+ });
154
279
  socket.addEventListener('message', (event) => {
155
280
  if (typeof event.data !== 'string')
156
281
  return;
@@ -163,17 +288,30 @@ export class Presence {
163
288
  }
164
289
  this.handleMessage(message);
165
290
  });
166
- socket.addEventListener('close', () => {
291
+ socket.addEventListener('close', (event) => {
167
292
  if (this.socket !== socket)
168
293
  return;
294
+ const welcomed = this.welcomed;
169
295
  this.socket = null;
170
296
  this.selfId = null;
297
+ this.welcomed = false;
171
298
  this.clearRemotes();
172
299
  this.onCount(null);
173
- this.scheduleReconnect();
300
+ if (this.full)
301
+ return;
302
+ if (welcomed) {
303
+ this.scheduleReconnect();
304
+ return;
305
+ }
306
+ if (isRoomFullSignal({ code: event.code, reason: event.reason })) {
307
+ this.enterFull();
308
+ return;
309
+ }
310
+ void this.classifyFailedJoin();
174
311
  });
175
312
  }
176
313
  disconnect() {
314
+ this.probeId += 1;
177
315
  const socket = this.socket;
178
316
  this.socket = null;
179
317
  this.selfId = null;
@@ -182,26 +320,95 @@ export class Presence {
182
320
  this.onCount(null);
183
321
  }
184
322
  scheduleReconnect() {
185
- if (this.closed)
323
+ if (this.closed || this.full)
186
324
  return;
187
325
  window.clearTimeout(this.reconnectTimer);
188
326
  this.reconnectTimer = window.setTimeout(() => this.connect(), this.backoff);
189
327
  this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
190
328
  }
329
+ enterFull() {
330
+ if (this.full)
331
+ return;
332
+ this.full = true;
333
+ window.clearTimeout(this.reconnectTimer);
334
+ this.probeId += 1;
335
+ const socket = this.socket;
336
+ this.socket = null;
337
+ this.selfId = null;
338
+ this.welcomed = false;
339
+ socket?.close(1000, 'full');
340
+ this.clearRemotes();
341
+ this.onCount(null);
342
+ this.onFull(true);
343
+ }
344
+ leaveFull() {
345
+ if (!this.full)
346
+ return;
347
+ this.full = false;
348
+ this.onFull(false);
349
+ }
350
+ /** Browser handshakes hide 503; confirm via HTTP before treating this as a network blip. */
351
+ async classifyFailedJoin() {
352
+ const id = ++this.probeId;
353
+ const state = await probeRoom(this.url);
354
+ if (id !== this.probeId || this.closed || this.socket || this.full)
355
+ return;
356
+ if (state === 'full')
357
+ this.enterFull();
358
+ else if (state === 'allowance')
359
+ this.waitForAllowance();
360
+ else
361
+ this.scheduleReconnect();
362
+ }
363
+ /**
364
+ * The shared relay turned this world away for today: stay single-player,
365
+ * tell the creator once in the console, and look again much later.
366
+ */
367
+ waitForAllowance() {
368
+ if (!this.allowanceLogged) {
369
+ this.allowanceLogged = true;
370
+ console.info("[WorldMesh] This world has used today's free multiplayer allowance on the shared relay, so visitors are single-player until 00:00 UTC. Run your own relay to lift the limit: https://worldmesh.net/connect.md");
371
+ }
372
+ window.clearTimeout(this.reconnectTimer);
373
+ this.reconnectTimer = window.setTimeout(() => this.connect(), ALLOWANCE_RETRY_MS);
374
+ }
191
375
  handleMessage(message) {
192
376
  switch (message.t) {
193
377
  case 'welcome':
194
378
  this.selfId = message.id;
379
+ this.welcomed = true;
195
380
  this.backoff = 1000;
196
381
  this.lastPayload = '';
197
382
  this.lastSent = 0;
198
- for (const peer of message.peers)
383
+ this.stateSent = false;
384
+ this.avatarSent = null;
385
+ this.avatarSentAt = 0;
386
+ this.leaveFull();
387
+ for (const peer of message.peers) {
388
+ if (peer.v)
389
+ this.pendingAvatars.set(peer.id, peer.v);
199
390
  this.upsert(peer.id, peer, true);
391
+ }
392
+ for (const object of message.objects ?? [])
393
+ this.shared(object.k, object.v, false, object.ms / 1000);
394
+ break;
395
+ case 'o':
396
+ this.shared(message.k, message.v, message.id === this.selfId, 0);
200
397
  break;
201
398
  case 's':
202
399
  if (message.id !== this.selfId)
203
400
  this.upsert(message.id, message, false);
204
401
  break;
402
+ case 'v': {
403
+ if (message.id === this.selfId || typeof message.id !== 'string')
404
+ break;
405
+ const remote = this.remotes.get(message.id);
406
+ if (remote)
407
+ this.wear(remote, message);
408
+ else
409
+ this.pendingAvatars.set(message.id, message);
410
+ break;
411
+ }
205
412
  case 'c': {
206
413
  const remote = message.id === this.selfId ? undefined : this.remotes.get(message.id);
207
414
  if (remote && typeof message.m === 'string')
@@ -210,14 +417,34 @@ export class Presence {
210
417
  }
211
418
  case 'leave': {
212
419
  const remote = this.remotes.get(message.id);
213
- if (remote)
214
- disposeObject(remote.root);
420
+ if (remote) {
421
+ const kept = this.onDepart(remote.root.position.x, remote.root.position.z, remote.root) === true;
422
+ remote.avatarLoad?.abort();
423
+ remote.avatarLoad = null;
424
+ if (kept) {
425
+ dropBubble(remote);
426
+ if (remote.external)
427
+ this.keptAvatars.set(remote.root, remote.external);
428
+ }
429
+ else
430
+ disposeRemote(remote);
431
+ }
215
432
  this.remotes.delete(message.id);
433
+ this.pendingAvatars.delete(message.id);
216
434
  break;
217
435
  }
218
436
  }
219
437
  this.onCount(this.remotes.size + 1);
220
438
  }
439
+ shared(key, value, self, age) {
440
+ if (typeof key !== 'string' || !value || typeof value !== 'object' || Array.isArray(value))
441
+ return;
442
+ const numbers = {};
443
+ for (const [name, v] of Object.entries(value))
444
+ if (typeof v === 'number' && Number.isFinite(v))
445
+ numbers[name] = v;
446
+ this.onShared(key, numbers, { self, age: Number.isFinite(age) ? Math.max(0, age) : 0 });
447
+ }
221
448
  upsert(id, state, snap) {
222
449
  const { p, r: yaw, e: expression, g } = state;
223
450
  if (!Array.isArray(p) || p.length !== 3 || !p.every(Number.isFinite) || !Number.isFinite(yaw))
@@ -225,15 +452,44 @@ export class Presence {
225
452
  const label = nameLabel(state.n, state.a);
226
453
  let remote = this.remotes.get(id);
227
454
  if (!remote) {
228
- const root = createDefaultAvatar();
455
+ const root = new Group();
456
+ root.name = 'worldmesh:remote-player';
457
+ const body = createDefaultAvatar();
458
+ root.add(body);
229
459
  const tag = createNameTag(GUEST);
230
460
  tag.position.y = TAG_HEIGHT;
231
461
  root.add(tag);
462
+ setFigureStroke(root, true);
232
463
  this.onFigure(root);
233
464
  this.group.add(root);
234
- remote = { root, target: new Vector3(p[0], p[1], p[2]), targetYaw: yaw, yaw, speed: 0, grounded: true, label: GUEST, tag, appear: 0, bubble: null, bubbleLeft: 0 };
465
+ remote = {
466
+ root,
467
+ body,
468
+ external: null,
469
+ avatarId: '',
470
+ avatarShare: null,
471
+ avatarLoad: null,
472
+ avatarFailed: '',
473
+ target: new Vector3(p[0], p[1], p[2]),
474
+ targetYaw: yaw,
475
+ yaw,
476
+ speed: 0,
477
+ steps: this.listener ? new Footsteps() : null,
478
+ vertical: 0,
479
+ grounded: true,
480
+ color: '',
481
+ label: GUEST,
482
+ tag,
483
+ appear: 0,
484
+ bubble: null,
485
+ bubbleLeft: 0,
486
+ };
235
487
  this.remotes.set(id, remote);
236
488
  setFigureOpacity(root, 0);
489
+ const pending = this.pendingAvatars.get(id);
490
+ this.pendingAvatars.delete(id);
491
+ if (pending)
492
+ this.wear(remote, pending);
237
493
  if (!snap)
238
494
  this.onArrive(p[0], p[2]);
239
495
  snap = true;
@@ -245,7 +501,12 @@ export class Presence {
245
501
  remote.target.set(p[0], p[1], p[2]);
246
502
  remote.targetYaw = yaw;
247
503
  if (typeof expression === 'string')
248
- setAvatarExpression(remote.root, expression);
504
+ setAvatarExpression(remote.body, expression);
505
+ const color = typeof state.k === 'string' && COLOR.test(state.k) ? state.k.toLowerCase() : '';
506
+ if (color !== remote.color) {
507
+ remote.color = color;
508
+ setAvatarColor(remote.body, color || DEFAULT_COLOR);
509
+ }
249
510
  if (label !== remote.label) {
250
511
  remote.label = label;
251
512
  disposeObject(remote.tag);
@@ -266,8 +527,105 @@ export class Presence {
266
527
  }
267
528
  clearRemotes() {
268
529
  for (const remote of this.remotes.values())
269
- disposeObject(remote.root);
530
+ disposeRemote(remote);
270
531
  this.remotes.clear();
532
+ this.pendingAvatars.clear();
533
+ }
534
+ /**
535
+ * Show the avatar someone says they wear, or their default body again.
536
+ * A new link to the same avatar only replaces the link; it is not reloaded.
537
+ */
538
+ wear(remote, avatar) {
539
+ const wanted = this.showAvatars && this.verifyAvatar ? parseAvatarShare(avatar) : null;
540
+ if (!wanted) {
541
+ if (!remote.avatarId)
542
+ return;
543
+ remote.avatarId = '';
544
+ remote.avatarShare = null;
545
+ remote.avatarLoad?.abort();
546
+ remote.avatarLoad = null;
547
+ takeOffAvatar(remote);
548
+ return;
549
+ }
550
+ const sameAvatar = wanted.i === remote.avatarId;
551
+ remote.avatarShare = wanted;
552
+ if (sameAvatar && (remote.external || remote.avatarLoad))
553
+ return;
554
+ if (remote.avatarFailed === `${wanted.i} ${wanted.u} ${wanted.x}`)
555
+ return;
556
+ remote.avatarId = wanted.i;
557
+ remote.avatarLoad?.abort();
558
+ const load = (remote.avatarLoad = new AbortController());
559
+ this.avatarQueue = this.avatarQueue.then(() => this.download(remote, load));
560
+ }
561
+ async download(remote, load) {
562
+ const share = remote.avatarShare;
563
+ if (load.signal.aborted || !share)
564
+ return;
565
+ const id = share.i;
566
+ const failed = (reason) => {
567
+ remote.avatarFailed = `${share.i} ${share.u} ${share.x}`;
568
+ console.warn('[worldmesh] Not showing another player’s avatar; showing the default body.', reason);
569
+ if (remote.avatarLoad === load)
570
+ remote.avatarLoad = null;
571
+ };
572
+ // Only links the hub signed, and not ones that ran out long ago.
573
+ if (share.x * 1000 < Date.now() - CLOCK_SKEW_MS)
574
+ return failed('its link has expired');
575
+ let vouched = false;
576
+ try {
577
+ vouched = !!this.verifyAvatar && (await this.verifyAvatar(share));
578
+ }
579
+ catch {
580
+ // Could not tell just now (the hub's key did not load): not a bad link, so check again later.
581
+ if (!load.signal.aborted)
582
+ this.retryLater(remote, load);
583
+ return;
584
+ }
585
+ if (load.signal.aborted)
586
+ return;
587
+ if (!vouched)
588
+ return failed('its link is not signed by the hub');
589
+ let timedOut = false;
590
+ const timer = window.setTimeout(() => {
591
+ timedOut = true;
592
+ load.abort();
593
+ }, AVATAR_TIMEOUT_MS);
594
+ try {
595
+ const loaded = await loadAvatarModel({ provider: 'peer', avatarId: id, name: null, thumbnail: null, format: share.f, modelUrl: share.u, expiresAt: null, sourceUrl: null }, { height: BODY_HEIGHT, maxBytes: this.maxAvatarBytes, signal: load.signal });
596
+ if (load.signal.aborted || remote.avatarId !== id) {
597
+ loaded.dispose();
598
+ return;
599
+ }
600
+ takeOffAvatar(remote);
601
+ loaded.object.userData[EXTERNAL_KEY] = true;
602
+ remote.root.add(loaded.object);
603
+ remote.body.visible = false;
604
+ remote.external = loaded;
605
+ }
606
+ catch (error) {
607
+ if (load.signal.aborted && !timedOut)
608
+ return;
609
+ remote.avatarFailed = `${share.i} ${share.u} ${share.x}`;
610
+ console.warn('[worldmesh] Could not load another player’s avatar; showing the default body.', error);
611
+ }
612
+ finally {
613
+ window.clearTimeout(timer);
614
+ if (remote.avatarLoad === load)
615
+ remote.avatarLoad = null;
616
+ }
617
+ }
618
+ /**
619
+ * Download their avatar again in a while, with whatever link they sent by
620
+ * then. Called off when the download is (they left or changed avatar).
621
+ */
622
+ retryLater(remote, load) {
623
+ const timer = window.setTimeout(() => {
624
+ if (load.signal.aborted || remote.avatarLoad !== load)
625
+ return;
626
+ this.avatarQueue = this.avatarQueue.then(() => this.download(remote, load));
627
+ }, AVATAR_RETRY_MS);
628
+ load.signal.addEventListener('abort', () => window.clearTimeout(timer), { once: true });
271
629
  }
272
630
  // Leave the room promptly when the tab navigates away (e.g. through a door),
273
631
  // and rejoin if the browser restores the page from its back/forward cache.
@@ -285,18 +643,90 @@ export class Presence {
285
643
  function round(value) {
286
644
  return Math.round(value * 100) / 100;
287
645
  }
646
+ /** Give up on someone else's avatar download after this long. */
647
+ const AVATAR_TIMEOUT_MS = 60_000;
648
+ /** Check someone's link again after this long when the hub's key could not be had. */
649
+ const AVATAR_RETRY_MS = 60_000;
650
+ /** Marks the root of a downloaded avatar on a remote figure. */
651
+ const EXTERNAL_KEY = 'worldmeshExternal';
652
+ /** Back to the default body. */
653
+ function takeOffAvatar(remote) {
654
+ const external = remote.external;
655
+ if (!external)
656
+ return;
657
+ external.object.removeFromParent();
658
+ external.dispose();
659
+ remote.external = null;
660
+ remote.body.visible = true;
661
+ }
662
+ function disposeRemote(remote) {
663
+ remote.avatarLoad?.abort();
664
+ remote.avatarLoad = null;
665
+ takeOffAvatar(remote);
666
+ disposeObject(remote.root);
667
+ }
668
+ /** Every object under `root`, except inside downloaded avatars, whose own materials are left as they are. */
669
+ function eachOwnPart(root, visit) {
670
+ visit(root);
671
+ for (const child of root.children) {
672
+ if (child.userData[EXTERNAL_KEY] === true)
673
+ continue;
674
+ eachOwnPart(child, visit);
675
+ }
676
+ }
677
+ /**
678
+ * Node's `ws` package emits `unexpected-response` with the HTTP status when
679
+ * the upgrade is refused. Browsers only get a generic `error` / close 1006.
680
+ */
681
+ function listenForHandshakeRefuse(socket, onRefuse) {
682
+ socket.addEventListener('error', (event) => {
683
+ const err = event;
684
+ const message = err.message || err.error?.message;
685
+ if (isRoomFullSignal({ message }))
686
+ onRefuse({ message });
687
+ });
688
+ const nodeSocket = socket;
689
+ nodeSocket.on?.('unexpected-response', (...args) => {
690
+ const res = args[1];
691
+ if (!res)
692
+ return;
693
+ const chunks = [];
694
+ res.on?.('data', (chunk) => {
695
+ chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
696
+ });
697
+ res.on?.('end', () => {
698
+ onRefuse({ status: res.statusCode, body: new TextDecoder().decode(concatBytes(chunks)) });
699
+ });
700
+ if (!res.on)
701
+ onRefuse({ status: res.statusCode });
702
+ });
703
+ }
704
+ function concatBytes(chunks) {
705
+ const size = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
706
+ const out = new Uint8Array(size);
707
+ let offset = 0;
708
+ for (const chunk of chunks) {
709
+ out.set(chunk, offset);
710
+ offset += chunk.byteLength;
711
+ }
712
+ return out;
713
+ }
288
714
  /** How long a newly appeared figure takes to go from transparent to opaque. */
289
715
  const APPEAR_FADE = 0.55;
290
716
  const GUEST = 'Guest';
717
+ /** What a body colour must look like on the wire. */
718
+ const COLOR = /^#[0-9a-f]{6}$/i;
719
+ /** The default avatar's own colour, for a peer that goes back to it. */
720
+ const DEFAULT_COLOR = '#ffffff';
291
721
  /** Ease every mesh on an avatar toward a shared opacity. */
292
722
  function setFigureOpacity(root, amount) {
293
723
  const solid = amount >= 0.999;
294
724
  let outlined = false;
295
- root.traverse((child) => {
725
+ eachOwnPart(root, (child) => {
296
726
  if (child instanceof Mesh && child.userData.stroke === true)
297
727
  outlined = true;
298
728
  });
299
- root.traverse((child) => {
729
+ eachOwnPart(root, (child) => {
300
730
  if (!(child instanceof Mesh))
301
731
  return;
302
732
  for (const material of Array.isArray(child.material) ? child.material : [child.material]) {
@@ -348,8 +778,8 @@ function dropBubble(remote) {
348
778
  bubble.removeFromParent();
349
779
  remote.bubble = null;
350
780
  }
351
- /** A white speech bubble. One line, cut off if they wrote a novel. */
352
- function createSpeechBubble(text) {
781
+ /** A white speech bubble. One line, cut off if they wrote a novel. Its bottom edge sits at the sprite's position. */
782
+ export function createSpeechBubble(text) {
353
783
  const scale = 2;
354
784
  const font = `600 ${20 * scale}px system-ui, -apple-system, sans-serif`;
355
785
  const canvas = document.createElement('canvas');
@@ -378,7 +808,11 @@ function createSpeechBubble(text) {
378
808
  sprite.center.set(0.5, 0);
379
809
  return sprite;
380
810
  }
381
- function createNameTag(text) {
811
+ /**
812
+ * The tag drawn over another player's head, as on the relay. 'Guest' is drawn
813
+ * dimmer. Centred on its position; the relay puts it 2.15 m above the feet.
814
+ */
815
+ export function createNameTag(text) {
382
816
  const scale = 2; // Canvas pixels per CSS pixel, for crisp text.
383
817
  const font = `600 ${22 * scale}px system-ui, -apple-system, sans-serif`;
384
818
  const canvas = document.createElement('canvas');
@@ -405,6 +839,19 @@ function createNameTag(text) {
405
839
  sprite.name = 'worldmesh:name-tag';
406
840
  return sprite;
407
841
  }
842
+ /** Footsteps are heard this far away at most, in metres. */
843
+ const EARSHOT = 30;
844
+ /** How loud someone at `source` is to a listener: full beside them, half at 8 m, nothing past earshot. */
845
+ function earshot(listener, source) {
846
+ if (!listener)
847
+ return 0;
848
+ const distance = listener.distanceTo(source);
849
+ if (distance >= EARSHOT)
850
+ return 0;
851
+ // Eased to zero at the edge so walking out of earshot does not cut a step off.
852
+ const edge = 1 - distance / EARSHOT;
853
+ return (1 / (1 + distance / 8)) * Math.min(1, edge * 4);
854
+ }
408
855
  function disposeObject(root) {
409
856
  root.traverse((child) => {
410
857
  if (child instanceof Sprite) {