@worldmesh/runtime 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -3
- package/dist/audio/footsteps.d.ts +37 -0
- package/dist/audio/footsteps.d.ts.map +1 -0
- package/dist/audio/footsteps.js +178 -0
- package/dist/audio/footsteps.js.map +1 -0
- package/dist/avatar/descriptor.d.ts +15 -0
- package/dist/avatar/descriptor.d.ts.map +1 -1
- package/dist/avatar/descriptor.js +19 -0
- package/dist/avatar/descriptor.js.map +1 -1
- package/dist/avatar/loader.d.ts +12 -1
- package/dist/avatar/loader.d.ts.map +1 -1
- package/dist/avatar/loader.js +16 -1
- package/dist/avatar/loader.js.map +1 -1
- package/dist/avatar/share.d.ts +30 -0
- package/dist/avatar/share.d.ts.map +1 -0
- package/dist/avatar/share.js +203 -0
- package/dist/avatar/share.js.map +1 -0
- package/dist/camera/cameraRig.d.ts +4 -0
- package/dist/camera/cameraRig.d.ts.map +1 -1
- package/dist/camera/cameraRig.js +10 -1
- package/dist/camera/cameraRig.js.map +1 -1
- package/dist/camera/viewHandoff.d.ts +20 -0
- package/dist/camera/viewHandoff.d.ts.map +1 -0
- package/dist/camera/viewHandoff.js +64 -0
- package/dist/camera/viewHandoff.js.map +1 -0
- package/dist/controls/input.d.ts +29 -0
- package/dist/controls/input.d.ts.map +1 -1
- package/dist/controls/input.js +124 -1
- package/dist/controls/input.js.map +1 -1
- package/dist/controls/touch.d.ts +10 -0
- package/dist/controls/touch.d.ts.map +1 -1
- package/dist/controls/touch.js +57 -1
- package/dist/controls/touch.js.map +1 -1
- package/dist/core/worldmesh.d.ts +9 -0
- package/dist/core/worldmesh.d.ts.map +1 -1
- package/dist/core/worldmesh.js +498 -26
- package/dist/core/worldmesh.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/net/follow.d.ts +7 -0
- package/dist/net/follow.d.ts.map +1 -0
- package/dist/net/follow.js +10 -0
- package/dist/net/follow.js.map +1 -0
- package/dist/net/presence.d.ts +126 -4
- package/dist/net/presence.d.ts.map +1 -1
- package/dist/net/presence.js +472 -25
- package/dist/net/presence.js.map +1 -1
- package/dist/net/roomFull.d.ts +44 -0
- package/dist/net/roomFull.d.ts.map +1 -0
- package/dist/net/roomFull.js +147 -0
- package/dist/net/roomFull.js.map +1 -0
- package/dist/player/avatar.d.ts +17 -4
- package/dist/player/avatar.d.ts.map +1 -1
- package/dist/player/avatar.js +85 -18
- package/dist/player/avatar.js.map +1 -1
- package/dist/player/player.d.ts +6 -2
- package/dist/player/player.d.ts.map +1 -1
- package/dist/player/player.js +13 -3
- package/dist/player/player.js.map +1 -1
- package/dist/player/stroke.d.ts +11 -0
- package/dist/player/stroke.d.ts.map +1 -0
- package/dist/player/stroke.js +96 -0
- package/dist/player/stroke.js.map +1 -0
- package/dist/portals/portals.d.ts +1 -1
- package/dist/portals/portals.d.ts.map +1 -1
- package/dist/portals/portals.js +6 -2
- package/dist/portals/portals.js.map +1 -1
- package/dist/types.d.ts +53 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/overlay.d.ts +18 -0
- package/dist/ui/overlay.d.ts.map +1 -1
- package/dist/ui/overlay.js +166 -1
- package/dist/ui/overlay.js.map +1 -1
- package/dist/xr/session.d.ts +13 -0
- package/dist/xr/session.d.ts.map +1 -0
- package/dist/xr/session.js +33 -0
- package/dist/xr/session.js.map +1 -0
- package/package.json +1 -1
- package/src/audio/footsteps.ts +185 -0
- package/src/avatar/descriptor.ts +28 -0
- package/src/avatar/loader.ts +19 -2
- package/src/avatar/share.ts +196 -0
- package/src/camera/cameraRig.ts +10 -1
- package/src/camera/viewHandoff.ts +67 -0
- package/src/controls/input.ts +119 -1
- package/src/controls/touch.ts +59 -1
- package/src/core/worldmesh.ts +495 -25
- package/src/index.ts +14 -7
- package/src/net/follow.ts +9 -0
- package/src/net/presence.ts +548 -28
- package/src/net/roomFull.ts +160 -0
- package/src/player/avatar.ts +87 -18
- package/src/player/player.ts +13 -4
- package/src/player/stroke.ts +93 -0
- package/src/portals/portals.ts +5 -2
- package/src/types.ts +52 -9
- package/src/ui/overlay.ts +171 -1
- package/src/xr/session.ts +33 -0
package/dist/net/presence.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { CanvasTexture, Group, Mesh, MeshBasicMaterial, Sprite, SpriteMaterial, Vector3 } from 'three';
|
|
2
|
-
import {
|
|
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
|
-
export const DEFAULT_PRESENCE_SERVER = 'wss://worldmesh
|
|
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
|
-
/**
|
|
8
|
-
const
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 = {
|
|
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.
|
|
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
|
-
|
|
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
|
|
725
|
+
eachOwnPart(root, (child) => {
|
|
296
726
|
if (child instanceof Mesh && child.userData.stroke === true)
|
|
297
727
|
outlined = true;
|
|
298
728
|
});
|
|
299
|
-
root
|
|
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
|
-
|
|
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) {
|