@worldmesh/runtime 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +126 -4
  47. package/dist/net/presence.d.ts.map +1 -1
  48. package/dist/net/presence.js +472 -25
  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 +548 -28
  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,9 +1,15 @@
1
1
  import { CanvasTexture, Group, Mesh, MeshBasicMaterial, Sprite, SpriteMaterial, Vector3, type Object3D, type Scene } from 'three';
2
- import { animateDefaultAvatar, createDefaultAvatar, setAvatarExpression } from '../player/avatar.js';
2
+ import { Footsteps } from '../audio/footsteps.js';
3
+ import { parseAvatarShare, type SignedAvatarShare } from '../avatar/descriptor.js';
4
+ import { loadAvatarModel, type LoadedAvatar } from '../avatar/loader.js';
5
+ import { animateDefaultAvatar, createDefaultAvatar, setAvatarColor, setAvatarExpression } from '../player/avatar.js';
6
+ import { setFigureStroke } from '../player/stroke.js';
3
7
  import type { NetworkAdapter, PeerBody, PlayerState, WorldMeshHandle } from '../types.js';
8
+ import { easeYaw } from './follow.js';
9
+ import { isRoomFullSignal, probeRoom } from './roomFull.js';
4
10
 
5
11
  /** The relay WorldMesh hosts (workers/presence). Worlds can point at their own. */
6
- export const DEFAULT_PRESENCE_SERVER = 'wss://worldmesh-presence.elias-willnat.workers.dev';
12
+ export const DEFAULT_PRESENCE_SERVER = 'wss://relay.worldmesh.net';
7
13
 
8
14
  export interface PresenceOptions {
9
15
  /** Full WebSocket URL of the room, e.g. `wss://relay.example/world`. */
@@ -14,30 +20,109 @@ export interface PresenceOptions {
14
20
  getName?: () => string | null;
15
21
  /** Made-up name shown instead of the username in private mode. */
16
22
  getAlias?: () => string | null;
23
+ /** Body colour ('#rrggbb') everyone else draws this visitor in, or null for the default. */
24
+ getColor?: () => string | null;
25
+ /**
26
+ * The avatar everyone else should see this visitor wearing: the link the hub
27
+ * signed for it (`descriptor.share`), or null for the default body.
28
+ */
29
+ getAvatar?: () => SharedAvatar | null;
30
+ /**
31
+ * Whether a link someone else sent was signed by the hub (createShareVerifier).
32
+ * Models are downloaded only when it says yes; without it, everyone else is
33
+ * drawn in the default body. Throwing means it cannot tell right now (the
34
+ * hub's key did not load): the link is checked again a minute later.
35
+ */
36
+ verifyAvatar?: (share: SharedAvatar) => Promise<boolean>;
37
+ /** Draw other people in the avatars they wear. Defaults to true. */
38
+ showAvatars?: boolean;
39
+ /** Largest avatar model downloaded for someone else. Defaults to 40 MB. */
40
+ maxAvatarBytes?: number;
17
41
  /**
18
42
  * Someone new just started walking here, first seen at (x, z). Not called
19
43
  * for the people already in the room when this visitor joins.
20
44
  */
21
45
  onArrive?: (x: number, z: number) => void;
22
- /** Called with each remote figure as it is made, e.g. to add an outline. */
46
+ /** Called with each remote figure as it is made, already outlined. */
23
47
  onFigure?: (root: Object3D) => void;
48
+ /**
49
+ * Someone in the room left, last seen at (x, z). Return true to keep their
50
+ * figure in the scene a little longer (to finish an exit effect); hand it
51
+ * back with `dropFigure` when done. Otherwise it is removed straight away.
52
+ */
53
+ onDepart?: (x: number, z: number, root: Object3D) => boolean | void;
54
+ /**
55
+ * Where this visitor is listening from. When set, everyone else's footsteps,
56
+ * jumps and landings are heard, fading with distance and silent far off.
57
+ */
58
+ listener?: () => Vector3 | null;
59
+ /**
60
+ * The relay refused this join because the room is full. Not used for
61
+ * ordinary disconnects — those still reconnect in the background.
62
+ */
63
+ onFull?: (full: boolean) => void;
64
+ /**
65
+ * Shared state of something in the room changed: a lift, a door. Called
66
+ * for every write in the order the relay took them, this visitor's own
67
+ * included (`self`), and for the room's remembered state on joining, with
68
+ * how old it is in seconds (`age`, 0 for live writes).
69
+ */
70
+ onShared?: (key: string, value: Record<string, number>, info: { self: boolean; age: number }) => void;
71
+ /**
72
+ * Adjust where a remote figure is drawn this frame, after it eased toward
73
+ * its last reported spot. Lets a world keep someone standing on a moving
74
+ * platform, which they reported a moment ago from a little lower down.
75
+ */
76
+ place?: (position: Vector3, grounded: boolean) => void;
24
77
  }
25
78
 
79
+ /** An avatar as it goes to everyone else in the room: a link the hub signed. */
80
+ export type SharedAvatar = SignedAvatarShare;
81
+
26
82
  /** How often the local position goes out, per second. */
27
83
  const SEND_RATE = 10;
28
- /** Resend at least this often while standing still, so late joiners see us. */
29
- const HEARTBEAT_MS = 3000;
84
+ /** The relay takes one avatar change a second per peer; stay under it. */
85
+ const AVATAR_SEND_MS = 1200;
86
+ /** Leeway for clocks that are a little off when checking a link's expiry. */
87
+ const CLOCK_SKEW_MS = 5 * 60_000;
88
+ /**
89
+ * While standing still, send only a keep-alive this often. The relay answers
90
+ * it without waking the room, so a room where nobody moves can sleep; late
91
+ * joiners still see us, as the relay remembers our last position.
92
+ */
93
+ const KEEPALIVE_MS = 30_000;
94
+ /** After the relay says this world used today's free allowance, try again this much later. */
95
+ const ALLOWANCE_RETRY_MS = 30 * 60_000;
30
96
  const MAX_BACKOFF_MS = 30_000;
31
97
 
32
98
  interface Remote {
99
+ /** Holds the body, name tag and bubble; moved and turned as one. */
33
100
  root: Object3D;
101
+ /** The default figure, hidden while an avatar of theirs is shown. */
102
+ body: Object3D;
103
+ /** The avatar they wear, once downloaded. */
104
+ external: LoadedAvatar | null;
105
+ /** Id of the avatar they say they wear, '' for the default body. */
106
+ avatarId: string;
107
+ /** Their latest signed link to it. */
108
+ avatarShare: SharedAvatar | null;
109
+ /** Download in progress. */
110
+ avatarLoad: AbortController | null;
111
+ /** `id url` of the last download that failed, so it is not tried again. */
112
+ avatarFailed: string;
34
113
  target: Vector3;
35
114
  targetYaw: number;
36
115
  yaw: number;
37
116
  /** Smoothed horizontal speed, estimated from how far the avatar moves. */
38
117
  speed: number;
118
+ /** Their footsteps, jumps and landings, when this room hears them. */
119
+ steps: Footsteps | null;
120
+ /** Smoothed vertical speed, from how far the avatar rises or falls. */
121
+ vertical: number;
39
122
  /** Standing on something (floor, platform, step) rather than in the air. */
40
123
  grounded: boolean;
124
+ /** Body colour currently applied, '' for the default. */
125
+ color: string;
41
126
  /** Text currently drawn on the tag. */
42
127
  label: string;
43
128
  tag: Sprite;
@@ -69,10 +154,25 @@ interface PeerState {
69
154
  n?: string;
70
155
  a?: string;
71
156
  g?: number;
157
+ /** Body colour, '#rrggbb'; '' or absent for the default. */
158
+ k?: string;
159
+ /** The avatar they wear (welcome only). */
160
+ v?: WireAvatar;
161
+ }
162
+
163
+ /** An avatar on the wire: a signed link (SignedAvatarShare). No `u` means the default body. */
164
+ interface WireAvatar {
165
+ u?: unknown;
166
+ f?: unknown;
167
+ i?: unknown;
168
+ x?: unknown;
169
+ s?: unknown;
72
170
  }
73
171
 
74
172
  type ServerMessage =
75
- | { t: 'welcome'; id: string; peers: (PeerState & { id: string })[] }
173
+ | { t: 'welcome'; id: string; peers: (PeerState & { id: string })[]; objects?: { k: string; v: unknown; ms: number }[] }
174
+ | ({ t: 'v'; id: string } & WireAvatar)
175
+ | { t: 'o'; id: string; k: string; v: unknown }
76
176
  | ({ t: 's'; id: string } & PeerState)
77
177
  | { t: 'c'; id: string; m: string }
78
178
  | { t: 'leave'; id: string };
@@ -93,28 +193,71 @@ export class Presence implements NetworkAdapter {
93
193
  private closed = false;
94
194
  private lastSent = 0;
95
195
  private lastPayload = '';
96
-
97
- private url: string;
196
+ private welcomed = false;
197
+ private full = false;
198
+ private probeId = 0;
199
+ /** The over-allowance notice was logged once already. */
200
+ private allowanceLogged = false;
201
+ /** This connection has sent a position, so the relay will take an avatar. */
202
+ private stateSent = false;
203
+ /** `id url` of the avatar the relay last heard from us; null before anything was sent. */
204
+ private avatarSent: string | null = null;
205
+ private avatarSentAt = 0;
206
+ /** Avatars heard for someone not drawn yet. */
207
+ private pendingAvatars = new Map<string, WireAvatar>();
208
+ /** One avatar download at a time, so a busy room does not stall the page. */
209
+ private avatarQueue: Promise<void> = Promise.resolve();
210
+ /** Tidies up an avatar left on a figure that is kept after its owner left. */
211
+ private keptAvatars = new WeakMap<Object3D, LoadedAvatar>();
212
+
213
+ readonly url: string;
98
214
  private onCount: (count: number | null) => void;
215
+ private onFull: (full: boolean) => void;
99
216
  private getName: () => string | null;
100
217
  private getAlias: () => string | null;
218
+ private getColor: () => string | null;
219
+ private getAvatar: () => SharedAvatar | null;
220
+ private showAvatars: boolean;
221
+ private verifyAvatar: ((share: SharedAvatar) => Promise<boolean>) | null;
222
+ private maxAvatarBytes: number | undefined;
101
223
  private onArrive: (x: number, z: number) => void;
102
224
  private onFigure: (root: Object3D) => void;
225
+ private onDepart: NonNullable<PresenceOptions['onDepart']>;
226
+ private listener: (() => Vector3 | null) | null;
227
+ private onShared: NonNullable<PresenceOptions['onShared']>;
228
+ private place: PresenceOptions['place'];
103
229
 
104
230
  constructor(options: PresenceOptions) {
105
231
  this.url = options.url;
106
232
  this.onCount = options.onCount ?? (() => {});
233
+ this.onFull = options.onFull ?? (() => {});
107
234
  this.getName = options.getName ?? (() => null);
108
235
  this.getAlias = options.getAlias ?? (() => null);
236
+ this.getColor = options.getColor ?? (() => null);
237
+ this.getAvatar = options.getAvatar ?? (() => null);
238
+ this.showAvatars = options.showAvatars !== false;
239
+ this.verifyAvatar = options.verifyAvatar ?? null;
240
+ this.maxAvatarBytes = options.maxAvatarBytes;
109
241
  this.onArrive = options.onArrive ?? (() => {});
110
242
  this.onFigure = options.onFigure ?? (() => {});
243
+ this.onDepart = options.onDepart ?? (() => {});
244
+ this.listener = options.listener ?? null;
245
+ this.onShared = options.onShared ?? (() => {});
246
+ this.place = options.place;
111
247
  this.group.name = 'worldmesh:remote-players';
112
248
  options.scene.add(this.group);
113
249
  }
114
250
 
251
+ /** Late-bind the full-room overlay (createWorldMesh wires this after constructing Presence). */
252
+ setOnFull(handler: (full: boolean) => void): void {
253
+ this.onFull = handler;
254
+ if (this.full) handler(true);
255
+ }
256
+
115
257
  attach(world: WorldMeshHandle): void {
116
258
  this.unsubscribe = world.on('update', ({ dt, state }) => {
117
259
  this.sendLocalState(state);
260
+ this.sendAvatar();
118
261
  this.update(dt);
119
262
  });
120
263
  window.addEventListener('pagehide', this.handlePageHide);
@@ -130,12 +273,36 @@ export class Presence implements NetworkAdapter {
130
273
  if (now - this.lastSent < 1000 / SEND_RATE) return;
131
274
 
132
275
  const [x, y, z] = state.position;
133
- 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 });
134
- if (payload === this.lastPayload && now - this.lastSent < HEARTBEAT_MS) return;
276
+ 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 });
277
+ if (payload === this.lastPayload) {
278
+ if (now - this.lastSent < KEEPALIVE_MS) return;
279
+ socket.send('ping');
280
+ this.lastSent = now;
281
+ return;
282
+ }
135
283
 
136
284
  socket.send(payload);
137
285
  this.lastPayload = payload;
138
286
  this.lastSent = now;
287
+ this.stateSent = true;
288
+ }
289
+
290
+ /**
291
+ * Tell the room which avatar this visitor wears, whenever that (or its link)
292
+ * changes. The relay keeps it for whoever joins later.
293
+ */
294
+ private sendAvatar(): void {
295
+ const socket = this.socket;
296
+ if (!socket || socket.readyState !== WebSocket.OPEN || !this.selfId || !this.stateSent) return;
297
+ const avatar = parseAvatarShare(this.getAvatar());
298
+ const key = avatar ? `${avatar.i} ${avatar.u} ${avatar.x}` : '';
299
+ // Nothing to say on a fresh connection when there is no avatar.
300
+ if (key === (this.avatarSent ?? '')) return;
301
+ const now = performance.now();
302
+ if (now - this.avatarSentAt < AVATAR_SEND_MS) return;
303
+ socket.send(JSON.stringify(avatar ? { t: 'v', u: avatar.u, f: avatar.f, i: avatar.i, x: avatar.x, s: avatar.s } : { t: 'v' }));
304
+ this.avatarSent = key;
305
+ this.avatarSentAt = now;
139
306
  }
140
307
 
141
308
  /** Say something. Everyone else gets a bubble; this client draws its own. */
@@ -146,6 +313,29 @@ export class Presence implements NetworkAdapter {
146
313
  socket.send(JSON.stringify({ t: 'c', m: line }));
147
314
  }
148
315
 
316
+ /**
317
+ * Set shared state everyone in the room sees, e.g. where a lift is going.
318
+ * Values are flat objects of numbers. The write comes back through
319
+ * `onShared` like everyone else's. False when not connected.
320
+ */
321
+ share(key: string, value: Record<string, number>): boolean {
322
+ const socket = this.socket;
323
+ if (!socket || socket.readyState !== WebSocket.OPEN || !this.selfId) return false;
324
+ socket.send(JSON.stringify({ t: 'o', k: key, v: value }));
325
+ return true;
326
+ }
327
+
328
+ /** Remove a figure kept by `onDepart` once its exit is over. */
329
+ dropFigure(root: Object3D): void {
330
+ const external = this.keptAvatars.get(root);
331
+ this.keptAvatars.delete(root);
332
+ if (external) {
333
+ external.object.removeFromParent();
334
+ external.dispose();
335
+ }
336
+ disposeObject(root);
337
+ }
338
+
149
339
  /** Everyone else's body where it is drawn, so walking into them stops where they appear to be. */
150
340
  bodies(): PeerBody[] {
151
341
  const bodies: PeerBody[] = [];
@@ -162,15 +352,22 @@ export class Presence implements NetworkAdapter {
162
352
  for (const remote of this.remotes.values()) {
163
353
  const before = remote.root.position.clone();
164
354
  remote.root.position.lerp(remote.target, blend);
355
+ this.place?.(remote.root.position, remote.grounded);
165
356
 
166
- let delta = remote.targetYaw - remote.yaw;
167
- delta = Math.atan2(Math.sin(delta), Math.cos(delta));
168
- remote.yaw += delta * blend;
357
+ remote.yaw = easeYaw(remote.yaw, remote.targetYaw, blend);
169
358
  remote.root.rotation.y = remote.yaw;
170
359
 
171
360
  const moved = Math.hypot(remote.root.position.x - before.x, remote.root.position.z - before.z);
172
361
  remote.speed += (moved / Math.max(dt, 1e-4) - remote.speed) * blend;
173
- animateDefaultAvatar(remote.root, { dt, speed: remote.speed, grounded: remote.grounded });
362
+ const motion = { dt, speed: remote.speed, grounded: remote.grounded };
363
+ let footfall: boolean | null = null;
364
+ if (remote.external) remote.external.animate(motion);
365
+ else footfall = animateDefaultAvatar(remote.body, motion);
366
+ if (remote.steps) {
367
+ remote.vertical = (remote.root.position.y - before.y) / Math.max(dt, 1e-4);
368
+ remote.steps.loudness = earshot(this.listener?.() ?? null, remote.root.position);
369
+ remote.steps.update(dt, remote.speed, remote.grounded, footfall, remote.vertical);
370
+ }
174
371
 
175
372
  if (remote.bubble) {
176
373
  remote.bubbleLeft -= dt;
@@ -195,6 +392,8 @@ export class Presence implements NetworkAdapter {
195
392
  rejoin(): void {
196
393
  if (this.closed) return;
197
394
  window.clearTimeout(this.reconnectTimer);
395
+ this.probeId += 1;
396
+ this.leaveFull();
198
397
  this.disconnect();
199
398
  this.backoff = 1000;
200
399
  this.connect();
@@ -221,6 +420,12 @@ export class Presence implements NetworkAdapter {
221
420
  return;
222
421
  }
223
422
  this.socket = socket;
423
+ this.welcomed = false;
424
+
425
+ listenForHandshakeRefuse(socket, (signal) => {
426
+ if (this.socket !== socket || this.welcomed) return;
427
+ if (isRoomFullSignal(signal)) this.enterFull();
428
+ });
224
429
 
225
430
  socket.addEventListener('message', (event) => {
226
431
  if (typeof event.data !== 'string') return;
@@ -233,17 +438,29 @@ export class Presence implements NetworkAdapter {
233
438
  this.handleMessage(message);
234
439
  });
235
440
 
236
- socket.addEventListener('close', () => {
441
+ socket.addEventListener('close', (event) => {
237
442
  if (this.socket !== socket) return;
443
+ const welcomed = this.welcomed;
238
444
  this.socket = null;
239
445
  this.selfId = null;
446
+ this.welcomed = false;
240
447
  this.clearRemotes();
241
448
  this.onCount(null);
242
- this.scheduleReconnect();
449
+ if (this.full) return;
450
+ if (welcomed) {
451
+ this.scheduleReconnect();
452
+ return;
453
+ }
454
+ if (isRoomFullSignal({ code: event.code, reason: event.reason })) {
455
+ this.enterFull();
456
+ return;
457
+ }
458
+ void this.classifyFailedJoin();
243
459
  });
244
460
  }
245
461
 
246
462
  private disconnect(): void {
463
+ this.probeId += 1;
247
464
  const socket = this.socket;
248
465
  this.socket = null;
249
466
  this.selfId = null;
@@ -253,24 +470,89 @@ export class Presence implements NetworkAdapter {
253
470
  }
254
471
 
255
472
  private scheduleReconnect(): void {
256
- if (this.closed) return;
473
+ if (this.closed || this.full) return;
257
474
  window.clearTimeout(this.reconnectTimer);
258
475
  this.reconnectTimer = window.setTimeout(() => this.connect(), this.backoff);
259
476
  this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
260
477
  }
261
478
 
479
+ private enterFull(): void {
480
+ if (this.full) return;
481
+ this.full = true;
482
+ window.clearTimeout(this.reconnectTimer);
483
+ this.probeId += 1;
484
+ const socket = this.socket;
485
+ this.socket = null;
486
+ this.selfId = null;
487
+ this.welcomed = false;
488
+ socket?.close(1000, 'full');
489
+ this.clearRemotes();
490
+ this.onCount(null);
491
+ this.onFull(true);
492
+ }
493
+
494
+ private leaveFull(): void {
495
+ if (!this.full) return;
496
+ this.full = false;
497
+ this.onFull(false);
498
+ }
499
+
500
+ /** Browser handshakes hide 503; confirm via HTTP before treating this as a network blip. */
501
+ private async classifyFailedJoin(): Promise<void> {
502
+ const id = ++this.probeId;
503
+ const state = await probeRoom(this.url);
504
+ if (id !== this.probeId || this.closed || this.socket || this.full) return;
505
+ if (state === 'full') this.enterFull();
506
+ else if (state === 'allowance') this.waitForAllowance();
507
+ else this.scheduleReconnect();
508
+ }
509
+
510
+ /**
511
+ * The shared relay turned this world away for today: stay single-player,
512
+ * tell the creator once in the console, and look again much later.
513
+ */
514
+ private waitForAllowance(): void {
515
+ if (!this.allowanceLogged) {
516
+ this.allowanceLogged = true;
517
+ console.info(
518
+ "[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",
519
+ );
520
+ }
521
+ window.clearTimeout(this.reconnectTimer);
522
+ this.reconnectTimer = window.setTimeout(() => this.connect(), ALLOWANCE_RETRY_MS);
523
+ }
524
+
262
525
  private handleMessage(message: ServerMessage): void {
263
526
  switch (message.t) {
264
527
  case 'welcome':
265
528
  this.selfId = message.id;
529
+ this.welcomed = true;
266
530
  this.backoff = 1000;
267
531
  this.lastPayload = '';
268
532
  this.lastSent = 0;
269
- for (const peer of message.peers) this.upsert(peer.id, peer, true);
533
+ this.stateSent = false;
534
+ this.avatarSent = null;
535
+ this.avatarSentAt = 0;
536
+ this.leaveFull();
537
+ for (const peer of message.peers) {
538
+ if (peer.v) this.pendingAvatars.set(peer.id, peer.v);
539
+ this.upsert(peer.id, peer, true);
540
+ }
541
+ for (const object of message.objects ?? []) this.shared(object.k, object.v, false, object.ms / 1000);
542
+ break;
543
+ case 'o':
544
+ this.shared(message.k, message.v, message.id === this.selfId, 0);
270
545
  break;
271
546
  case 's':
272
547
  if (message.id !== this.selfId) this.upsert(message.id, message, false);
273
548
  break;
549
+ case 'v': {
550
+ if (message.id === this.selfId || typeof message.id !== 'string') break;
551
+ const remote = this.remotes.get(message.id);
552
+ if (remote) this.wear(remote, message);
553
+ else this.pendingAvatars.set(message.id, message);
554
+ break;
555
+ }
274
556
  case 'c': {
275
557
  const remote = message.id === this.selfId ? undefined : this.remotes.get(message.id);
276
558
  if (remote && typeof message.m === 'string') showBubble(remote, message.m);
@@ -278,14 +560,30 @@ export class Presence implements NetworkAdapter {
278
560
  }
279
561
  case 'leave': {
280
562
  const remote = this.remotes.get(message.id);
281
- if (remote) disposeObject(remote.root);
563
+ if (remote) {
564
+ const kept = this.onDepart(remote.root.position.x, remote.root.position.z, remote.root) === true;
565
+ remote.avatarLoad?.abort();
566
+ remote.avatarLoad = null;
567
+ if (kept) {
568
+ dropBubble(remote);
569
+ if (remote.external) this.keptAvatars.set(remote.root, remote.external);
570
+ } else disposeRemote(remote);
571
+ }
282
572
  this.remotes.delete(message.id);
573
+ this.pendingAvatars.delete(message.id);
283
574
  break;
284
575
  }
285
576
  }
286
577
  this.onCount(this.remotes.size + 1);
287
578
  }
288
579
 
580
+ private shared(key: unknown, value: unknown, self: boolean, age: number): void {
581
+ if (typeof key !== 'string' || !value || typeof value !== 'object' || Array.isArray(value)) return;
582
+ const numbers: Record<string, number> = {};
583
+ for (const [name, v] of Object.entries(value)) if (typeof v === 'number' && Number.isFinite(v)) numbers[name] = v;
584
+ this.onShared(key, numbers, { self, age: Number.isFinite(age) ? Math.max(0, age) : 0 });
585
+ }
586
+
289
587
  private upsert(id: string, state: PeerState, snap: boolean): void {
290
588
  const { p, r: yaw, e: expression, g } = state;
291
589
  if (!Array.isArray(p) || p.length !== 3 || !p.every(Number.isFinite) || !Number.isFinite(yaw)) return;
@@ -293,15 +591,43 @@ export class Presence implements NetworkAdapter {
293
591
 
294
592
  let remote = this.remotes.get(id);
295
593
  if (!remote) {
296
- const root = createDefaultAvatar();
594
+ const root = new Group();
595
+ root.name = 'worldmesh:remote-player';
596
+ const body = createDefaultAvatar();
597
+ root.add(body);
297
598
  const tag = createNameTag(GUEST);
298
599
  tag.position.y = TAG_HEIGHT;
299
600
  root.add(tag);
601
+ setFigureStroke(root, true);
300
602
  this.onFigure(root);
301
603
  this.group.add(root);
302
- 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 };
604
+ remote = {
605
+ root,
606
+ body,
607
+ external: null,
608
+ avatarId: '',
609
+ avatarShare: null,
610
+ avatarLoad: null,
611
+ avatarFailed: '',
612
+ target: new Vector3(p[0], p[1], p[2]),
613
+ targetYaw: yaw,
614
+ yaw,
615
+ speed: 0,
616
+ steps: this.listener ? new Footsteps() : null,
617
+ vertical: 0,
618
+ grounded: true,
619
+ color: '',
620
+ label: GUEST,
621
+ tag,
622
+ appear: 0,
623
+ bubble: null,
624
+ bubbleLeft: 0,
625
+ };
303
626
  this.remotes.set(id, remote);
304
627
  setFigureOpacity(root, 0);
628
+ const pending = this.pendingAvatars.get(id);
629
+ this.pendingAvatars.delete(id);
630
+ if (pending) this.wear(remote, pending);
305
631
  if (!snap) this.onArrive(p[0], p[2]);
306
632
  snap = true;
307
633
  }
@@ -311,7 +637,12 @@ export class Presence implements NetworkAdapter {
311
637
  remote.grounded = g === 0 || g === 1 ? g === 1 : p[1] < 0.05 || Math.abs(p[1] - remote.target.y) < GROUND_HOLD;
312
638
  remote.target.set(p[0], p[1], p[2]);
313
639
  remote.targetYaw = yaw;
314
- if (typeof expression === 'string') setAvatarExpression(remote.root, expression);
640
+ if (typeof expression === 'string') setAvatarExpression(remote.body, expression);
641
+ const color = typeof state.k === 'string' && COLOR.test(state.k) ? state.k.toLowerCase() : '';
642
+ if (color !== remote.color) {
643
+ remote.color = color;
644
+ setAvatarColor(remote.body, color || DEFAULT_COLOR);
645
+ }
315
646
  if (label !== remote.label) {
316
647
  remote.label = label;
317
648
  disposeObject(remote.tag);
@@ -332,8 +663,96 @@ export class Presence implements NetworkAdapter {
332
663
  }
333
664
 
334
665
  private clearRemotes(): void {
335
- for (const remote of this.remotes.values()) disposeObject(remote.root);
666
+ for (const remote of this.remotes.values()) disposeRemote(remote);
336
667
  this.remotes.clear();
668
+ this.pendingAvatars.clear();
669
+ }
670
+
671
+ /**
672
+ * Show the avatar someone says they wear, or their default body again.
673
+ * A new link to the same avatar only replaces the link; it is not reloaded.
674
+ */
675
+ private wear(remote: Remote, avatar: WireAvatar): void {
676
+ const wanted = this.showAvatars && this.verifyAvatar ? parseAvatarShare(avatar) : null;
677
+ if (!wanted) {
678
+ if (!remote.avatarId) return;
679
+ remote.avatarId = '';
680
+ remote.avatarShare = null;
681
+ remote.avatarLoad?.abort();
682
+ remote.avatarLoad = null;
683
+ takeOffAvatar(remote);
684
+ return;
685
+ }
686
+ const sameAvatar = wanted.i === remote.avatarId;
687
+ remote.avatarShare = wanted;
688
+ if (sameAvatar && (remote.external || remote.avatarLoad)) return;
689
+ if (remote.avatarFailed === `${wanted.i} ${wanted.u} ${wanted.x}`) return;
690
+ remote.avatarId = wanted.i;
691
+ remote.avatarLoad?.abort();
692
+ const load = (remote.avatarLoad = new AbortController());
693
+ this.avatarQueue = this.avatarQueue.then(() => this.download(remote, load));
694
+ }
695
+
696
+ private async download(remote: Remote, load: AbortController): Promise<void> {
697
+ const share = remote.avatarShare;
698
+ if (load.signal.aborted || !share) return;
699
+ const id = share.i;
700
+ const failed = (reason: unknown) => {
701
+ remote.avatarFailed = `${share.i} ${share.u} ${share.x}`;
702
+ console.warn('[worldmesh] Not showing another player’s avatar; showing the default body.', reason);
703
+ if (remote.avatarLoad === load) remote.avatarLoad = null;
704
+ };
705
+ // Only links the hub signed, and not ones that ran out long ago.
706
+ if (share.x * 1000 < Date.now() - CLOCK_SKEW_MS) return failed('its link has expired');
707
+ let vouched = false;
708
+ try {
709
+ vouched = !!this.verifyAvatar && (await this.verifyAvatar(share));
710
+ } catch {
711
+ // Could not tell just now (the hub's key did not load): not a bad link, so check again later.
712
+ if (!load.signal.aborted) this.retryLater(remote, load);
713
+ return;
714
+ }
715
+ if (load.signal.aborted) return;
716
+ if (!vouched) return failed('its link is not signed by the hub');
717
+ let timedOut = false;
718
+ const timer = window.setTimeout(() => {
719
+ timedOut = true;
720
+ load.abort();
721
+ }, AVATAR_TIMEOUT_MS);
722
+ try {
723
+ const loaded = await loadAvatarModel(
724
+ { provider: 'peer', avatarId: id, name: null, thumbnail: null, format: share.f, modelUrl: share.u, expiresAt: null, sourceUrl: null },
725
+ { height: BODY_HEIGHT, maxBytes: this.maxAvatarBytes, signal: load.signal },
726
+ );
727
+ if (load.signal.aborted || remote.avatarId !== id) {
728
+ loaded.dispose();
729
+ return;
730
+ }
731
+ takeOffAvatar(remote);
732
+ loaded.object.userData[EXTERNAL_KEY] = true;
733
+ remote.root.add(loaded.object);
734
+ remote.body.visible = false;
735
+ remote.external = loaded;
736
+ } catch (error) {
737
+ if (load.signal.aborted && !timedOut) return;
738
+ remote.avatarFailed = `${share.i} ${share.u} ${share.x}`;
739
+ console.warn('[worldmesh] Could not load another player’s avatar; showing the default body.', error);
740
+ } finally {
741
+ window.clearTimeout(timer);
742
+ if (remote.avatarLoad === load) remote.avatarLoad = null;
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Download their avatar again in a while, with whatever link they sent by
748
+ * then. Called off when the download is (they left or changed avatar).
749
+ */
750
+ private retryLater(remote: Remote, load: AbortController): void {
751
+ const timer = window.setTimeout(() => {
752
+ if (load.signal.aborted || remote.avatarLoad !== load) return;
753
+ this.avatarQueue = this.avatarQueue.then(() => this.download(remote, load));
754
+ }, AVATAR_RETRY_MS);
755
+ load.signal.addEventListener('abort', () => window.clearTimeout(timer), { once: true });
337
756
  }
338
757
 
339
758
  // Leave the room promptly when the tab navigates away (e.g. through a door),
@@ -355,19 +774,103 @@ function round(value: number): number {
355
774
  return Math.round(value * 100) / 100;
356
775
  }
357
776
 
777
+ /** Give up on someone else's avatar download after this long. */
778
+ const AVATAR_TIMEOUT_MS = 60_000;
779
+ /** Check someone's link again after this long when the hub's key could not be had. */
780
+ const AVATAR_RETRY_MS = 60_000;
781
+ /** Marks the root of a downloaded avatar on a remote figure. */
782
+ const EXTERNAL_KEY = 'worldmeshExternal';
783
+
784
+ /** Back to the default body. */
785
+ function takeOffAvatar(remote: Remote): void {
786
+ const external = remote.external;
787
+ if (!external) return;
788
+ external.object.removeFromParent();
789
+ external.dispose();
790
+ remote.external = null;
791
+ remote.body.visible = true;
792
+ }
793
+
794
+ function disposeRemote(remote: Remote): void {
795
+ remote.avatarLoad?.abort();
796
+ remote.avatarLoad = null;
797
+ takeOffAvatar(remote);
798
+ disposeObject(remote.root);
799
+ }
800
+
801
+ /** Every object under `root`, except inside downloaded avatars, whose own materials are left as they are. */
802
+ function eachOwnPart(root: Object3D, visit: (child: Object3D) => void): void {
803
+ visit(root);
804
+ for (const child of root.children) {
805
+ if (child.userData[EXTERNAL_KEY] === true) continue;
806
+ eachOwnPart(child, visit);
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Node's `ws` package emits `unexpected-response` with the HTTP status when
812
+ * the upgrade is refused. Browsers only get a generic `error` / close 1006.
813
+ */
814
+ function listenForHandshakeRefuse(
815
+ socket: WebSocket,
816
+ onRefuse: (signal: { status?: number; message?: string; body?: string }) => void,
817
+ ): void {
818
+ socket.addEventListener('error', (event) => {
819
+ const err = event as ErrorEvent & { error?: { message?: string } };
820
+ const message = err.message || err.error?.message;
821
+ if (isRoomFullSignal({ message })) onRefuse({ message });
822
+ });
823
+ const nodeSocket = socket as WebSocket & {
824
+ on?(event: string, listener: (...args: unknown[]) => void): void;
825
+ };
826
+ nodeSocket.on?.('unexpected-response', (...args: unknown[]) => {
827
+ const res = args[1] as NodeHandshakeResponse | undefined;
828
+ if (!res) return;
829
+ const chunks: Uint8Array[] = [];
830
+ res.on?.('data', (chunk) => {
831
+ chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
832
+ });
833
+ res.on?.('end', () => {
834
+ onRefuse({ status: res.statusCode, body: new TextDecoder().decode(concatBytes(chunks)) });
835
+ });
836
+ if (!res.on) onRefuse({ status: res.statusCode });
837
+ });
838
+ }
839
+
840
+ interface NodeHandshakeResponse {
841
+ statusCode?: number;
842
+ on?(event: string, listener: (chunk: string | Uint8Array) => void): void;
843
+ }
844
+
845
+ function concatBytes(chunks: Uint8Array[]): Uint8Array {
846
+ const size = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
847
+ const out = new Uint8Array(size);
848
+ let offset = 0;
849
+ for (const chunk of chunks) {
850
+ out.set(chunk, offset);
851
+ offset += chunk.byteLength;
852
+ }
853
+ return out;
854
+ }
855
+
358
856
  /** How long a newly appeared figure takes to go from transparent to opaque. */
359
857
  const APPEAR_FADE = 0.55;
360
858
 
361
859
  const GUEST = 'Guest';
362
860
 
861
+ /** What a body colour must look like on the wire. */
862
+ const COLOR = /^#[0-9a-f]{6}$/i;
863
+ /** The default avatar's own colour, for a peer that goes back to it. */
864
+ const DEFAULT_COLOR = '#ffffff';
865
+
363
866
  /** Ease every mesh on an avatar toward a shared opacity. */
364
867
  function setFigureOpacity(root: Object3D, amount: number): void {
365
868
  const solid = amount >= 0.999;
366
869
  let outlined = false;
367
- root.traverse((child) => {
870
+ eachOwnPart(root, (child) => {
368
871
  if (child instanceof Mesh && child.userData.stroke === true) outlined = true;
369
872
  });
370
- root.traverse((child) => {
873
+ eachOwnPart(root, (child) => {
371
874
  if (!(child instanceof Mesh)) return;
372
875
  for (const material of Array.isArray(child.material) ? child.material : [child.material]) {
373
876
  const stroke = material.userData.stroke === true;
@@ -419,8 +922,8 @@ function dropBubble(remote: Remote): void {
419
922
  remote.bubble = null;
420
923
  }
421
924
 
422
- /** A white speech bubble. One line, cut off if they wrote a novel. */
423
- function createSpeechBubble(text: string): Sprite {
925
+ /** A white speech bubble. One line, cut off if they wrote a novel. Its bottom edge sits at the sprite's position. */
926
+ export function createSpeechBubble(text: string): Sprite {
424
927
  const scale = 2;
425
928
  const font = `600 ${20 * scale}px system-ui, -apple-system, sans-serif`;
426
929
  const canvas = document.createElement('canvas');
@@ -452,7 +955,11 @@ function createSpeechBubble(text: string): Sprite {
452
955
  return sprite;
453
956
  }
454
957
 
455
- function createNameTag(text: string): Sprite {
958
+ /**
959
+ * The tag drawn over another player's head, as on the relay. 'Guest' is drawn
960
+ * dimmer. Centred on its position; the relay puts it 2.15 m above the feet.
961
+ */
962
+ export function createNameTag(text: string): Sprite {
456
963
  const scale = 2; // Canvas pixels per CSS pixel, for crisp text.
457
964
  const font = `600 ${22 * scale}px system-ui, -apple-system, sans-serif`;
458
965
  const canvas = document.createElement('canvas');
@@ -482,6 +989,19 @@ function createNameTag(text: string): Sprite {
482
989
  return sprite;
483
990
  }
484
991
 
992
+ /** Footsteps are heard this far away at most, in metres. */
993
+ const EARSHOT = 30;
994
+
995
+ /** How loud someone at `source` is to a listener: full beside them, half at 8 m, nothing past earshot. */
996
+ function earshot(listener: Vector3 | null, source: Vector3): number {
997
+ if (!listener) return 0;
998
+ const distance = listener.distanceTo(source);
999
+ if (distance >= EARSHOT) return 0;
1000
+ // Eased to zero at the edge so walking out of earshot does not cut a step off.
1001
+ const edge = 1 - distance / EARSHOT;
1002
+ return (1 / (1 + distance / 8)) * Math.min(1, edge * 4);
1003
+ }
1004
+
485
1005
  function disposeObject(root: Object3D): void {
486
1006
  root.traverse((child) => {
487
1007
  if (child instanceof Sprite) {