@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.
- 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 +125 -3
- package/dist/net/presence.d.ts.map +1 -1
- package/dist/net/presence.js +471 -24
- 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 +547 -27
- 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/src/net/presence.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { CanvasTexture, Group, Mesh, MeshBasicMaterial, Sprite, SpriteMaterial, Vector3, type Object3D, type Scene } from 'three';
|
|
2
|
-
import {
|
|
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
12
|
export const DEFAULT_PRESENCE_SERVER = 'wss://relay.worldmesh.net';
|
|
@@ -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,
|
|
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
|
-
/**
|
|
29
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 = {
|
|
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.
|
|
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())
|
|
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
|
|
870
|
+
eachOwnPart(root, (child) => {
|
|
368
871
|
if (child instanceof Mesh && child.userData.stroke === true) outlined = true;
|
|
369
872
|
});
|
|
370
|
-
root
|
|
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
|
-
|
|
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) {
|