@relu-interactives/spatial-ecs 0.8.0 → 0.10.0

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 (56) hide show
  1. package/dist/{System-8D-XBDKZ.d.cts → System-BldSHxZV.d.cts} +214 -81
  2. package/dist/{System-DPMAW30g.d.ts → System-CADhPyQs.d.ts} +214 -81
  3. package/dist/{XRHandPose-G0Dl-F0w.d.ts → XRHandPose-BNSwd1y9.d.ts} +145 -2
  4. package/dist/{XRHandPose-VQMT1WXj.d.cts → XRHandPose-D3Ab1Dfs.d.cts} +145 -2
  5. package/dist/{chunk-G4XJ53LS.js → chunk-JEPUCLZX.js} +3 -3
  6. package/dist/{chunk-G4XJ53LS.js.map → chunk-JEPUCLZX.js.map} +1 -1
  7. package/dist/{chunk-NNDUOQ7O.js → chunk-JNYPJB55.js} +250 -3
  8. package/dist/chunk-JNYPJB55.js.map +1 -0
  9. package/dist/{chunk-SGF73YTF.js → chunk-NXASMBDS.js} +4 -4
  10. package/dist/{chunk-SGF73YTF.js.map → chunk-NXASMBDS.js.map} +1 -1
  11. package/dist/{chunk-JIJQFW6P.js → chunk-TY3XCHOR.js} +1390 -485
  12. package/dist/chunk-TY3XCHOR.js.map +1 -0
  13. package/dist/{chunk-U727NTWQ.js → chunk-WWP46AEF.js} +26 -4
  14. package/dist/chunk-WWP46AEF.js.map +1 -0
  15. package/dist/components/index.cjs +151 -0
  16. package/dist/components/index.cjs.map +1 -1
  17. package/dist/components/index.d.cts +3 -3
  18. package/dist/components/index.d.ts +3 -3
  19. package/dist/components/index.js +1 -1
  20. package/dist/docs/guide/bone-attachments-and-ik.md +1 -1
  21. package/dist/docs/guide/multiplayer.md +65 -0
  22. package/dist/docs/guide/spatial-ui.md +83 -7
  23. package/dist/factories/index.cjs +151 -0
  24. package/dist/factories/index.cjs.map +1 -1
  25. package/dist/factories/index.d.cts +2 -2
  26. package/dist/factories/index.d.ts +2 -2
  27. package/dist/factories/index.js +3 -3
  28. package/dist/{index-C61n65PE.d.ts → index-6pZO_gV7.d.ts} +27 -2
  29. package/dist/{index-D9DjrUn3.d.cts → index-BXjwEdNq.d.cts} +6 -3
  30. package/dist/{index-C70N4BYG.d.cts → index-BdghvXKE.d.cts} +27 -2
  31. package/dist/{index-DppafVu4.d.ts → index-CVG-hREM.d.ts} +6 -3
  32. package/dist/index.cjs +1800 -626
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +8 -30
  35. package/dist/index.d.ts +8 -30
  36. package/dist/index.js +10 -10
  37. package/dist/network/index.cjs +858 -8
  38. package/dist/network/index.cjs.map +1 -1
  39. package/dist/network/index.d.cts +126 -5
  40. package/dist/network/index.d.ts +126 -5
  41. package/dist/network/index.js +683 -8
  42. package/dist/network/index.js.map +1 -1
  43. package/dist/systems/index.cjs +1702 -528
  44. package/dist/systems/index.cjs.map +1 -1
  45. package/dist/systems/index.d.cts +2 -2
  46. package/dist/systems/index.d.ts +2 -2
  47. package/dist/systems/index.js +4 -4
  48. package/dist/types.d.cts +1 -1
  49. package/dist/types.d.ts +1 -1
  50. package/dist/webgpu/index.d.ts +2 -2
  51. package/dist/worldSnapshotUtils.d.cts +1 -1
  52. package/dist/worldSnapshotUtils.d.ts +1 -1
  53. package/package.json +1 -1
  54. package/dist/chunk-JIJQFW6P.js.map +0 -1
  55. package/dist/chunk-NNDUOQ7O.js.map +0 -1
  56. package/dist/chunk-U727NTWQ.js.map +0 -1
@@ -1,6 +1,6 @@
1
- import { spawnPrefab, despawnPrefab, Behaviour, reportBehaviourHookError } from '../chunk-U727NTWQ.js';
2
- import '../chunk-G4XJ53LS.js';
3
- import { NetworkIdentityComponent, TransformComponent, NetworkedPropsComponent, NetworkSpawnerComponent, NetworkTransformComponent, ScriptComponent } from '../chunk-NNDUOQ7O.js';
1
+ import { spawnPrefab, despawnPrefab, Behaviour, reportBehaviourHookError } from '../chunk-WWP46AEF.js';
2
+ import '../chunk-JEPUCLZX.js';
3
+ import { NetworkIdentityComponent, TransformComponent, NetworkedPropsComponent, NetworkSpawnerComponent, NetworkTransformComponent, ScriptComponent } from '../chunk-JNYPJB55.js';
4
4
  import '../chunk-CKTHDXPS.js';
5
5
  import '../chunk-GSWC6LLD.js';
6
6
  import { __name } from '../chunk-XE4OXN2W.js';
@@ -69,6 +69,12 @@ var _ColyseusTransport = class _ColyseusTransport {
69
69
  onCorrect(cb) {
70
70
  this.room.onMessage("correct", (m) => cb(m.id, m));
71
71
  }
72
+ onSound(cb) {
73
+ this.room.onMessage("sound", (m) => cb(m, m.from));
74
+ }
75
+ onVoice(cb) {
76
+ this.room.onMessage("voice", cb);
77
+ }
72
78
  onClose(cb) {
73
79
  this.room.onLeave(() => cb());
74
80
  }
@@ -90,6 +96,12 @@ var _ColyseusTransport = class _ColyseusTransport {
90
96
  send(name, payload) {
91
97
  this.room.send("m", { name, payload });
92
98
  }
99
+ sendSound(sound) {
100
+ this.room.send("sound", sound);
101
+ }
102
+ sendVoice(message) {
103
+ this.room.send("voice", message);
104
+ }
93
105
  leave() {
94
106
  void this.room.leave();
95
107
  }
@@ -172,6 +184,623 @@ var _SnapshotBuffer = class _SnapshotBuffer {
172
184
  __name(_SnapshotBuffer, "SnapshotBuffer");
173
185
  var SnapshotBuffer = _SnapshotBuffer;
174
186
 
187
+ // core/network/sdp.ts
188
+ var OPUS_VOICE = {
189
+ useinbandfec: "1",
190
+ usedtx: "1",
191
+ stereo: "0",
192
+ "sprop-stereo": "0",
193
+ maxaveragebitrate: "20000",
194
+ maxplaybackrate: "24000"
195
+ };
196
+ var VOICE_PTIME = 40;
197
+ function mergeFmtp(fmtp, params) {
198
+ const pairs = fmtp.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
199
+ const i = part.indexOf("=");
200
+ return i < 0 ? [part, ""] : [part.slice(0, i), part.slice(i + 1)];
201
+ });
202
+ for (const [key, value] of Object.entries(params)) {
203
+ const found = pairs.find((pair) => pair[0].toLowerCase() === key.toLowerCase());
204
+ if (found) found[1] = value;
205
+ else pairs.push([key, value]);
206
+ }
207
+ return pairs.map(([k, v]) => v === "" ? k : `${k}=${v}`).join(";");
208
+ }
209
+ __name(mergeFmtp, "mergeFmtp");
210
+ function tuneOpus(sdp, params = OPUS_VOICE, ptime = VOICE_PTIME) {
211
+ const eol = sdp.includes("\r\n") ? "\r\n" : "\n";
212
+ const sections = [[]];
213
+ for (const line of sdp.split(eol)) {
214
+ if (line.startsWith("m=")) sections.push([]);
215
+ sections[sections.length - 1].push(line);
216
+ }
217
+ const out = [];
218
+ for (const section of sections) {
219
+ const opus = section.flatMap((line) => {
220
+ const match = /^a=rtpmap:(\d+) opus\/48000/i.exec(line);
221
+ return match ? [match[1]] : [];
222
+ });
223
+ if (!section[0]?.startsWith("m=audio") || !opus.length) {
224
+ out.push(...section);
225
+ continue;
226
+ }
227
+ const tuned = [];
228
+ for (const line of section) {
229
+ const fmtp = /^a=fmtp:(\d+) (.*)$/.exec(line);
230
+ if (fmtp && opus.includes(fmtp[1])) {
231
+ tuned.push(`a=fmtp:${fmtp[1]} ${mergeFmtp(fmtp[2], params)}`);
232
+ continue;
233
+ }
234
+ tuned.push(line);
235
+ const rtpmap = /^a=rtpmap:(\d+) opus\/48000/i.exec(line);
236
+ if (rtpmap && !section.some((l) => l.startsWith(`a=fmtp:${rtpmap[1]} `))) {
237
+ tuned.push(`a=fmtp:${rtpmap[1]} ${mergeFmtp("", params)}`);
238
+ }
239
+ }
240
+ if (ptime && !tuned.some((line) => line.startsWith("a=ptime:"))) {
241
+ const at = tuned[tuned.length - 1] === "" ? tuned.length - 1 : tuned.length;
242
+ tuned.splice(at, 0, `a=ptime:${ptime}`);
243
+ }
244
+ out.push(...tuned);
245
+ }
246
+ return out.join(eol);
247
+ }
248
+ __name(tuneOpus, "tuneOpus");
249
+
250
+ // core/network/voice.ts
251
+ var LEVEL_BPS = [24e3, 16e3, 1e4];
252
+ var WORST_LEVEL = 2;
253
+ var TICK_MS = 2e3;
254
+ var POLL_MS = 100;
255
+ var GOOD_TO_STEP_UP_MS = 1e4;
256
+ var MAX_LOSS = 0.05;
257
+ var MAX_RTT_MS = 400;
258
+ var TALKING_LEVEL = 0.06;
259
+ var DUCK_SECONDS = 0.6;
260
+ var MAX_RESTARTS = 3;
261
+ function startingLevel(others) {
262
+ if (others <= 2) return 0;
263
+ return others <= 4 ? 1 : 2;
264
+ }
265
+ __name(startingLevel, "startingLevel");
266
+ function adaptLevel(level, best, bad, goodMs) {
267
+ let next = Math.max(level, best);
268
+ if (bad) {
269
+ return { level: Math.min(WORST_LEVEL, next + 1), goodMs: 0 };
270
+ }
271
+ let good = goodMs + TICK_MS;
272
+ if (good >= GOOD_TO_STEP_UP_MS && next > best) {
273
+ next -= 1;
274
+ good = 0;
275
+ }
276
+ return { level: next, goodMs: good };
277
+ }
278
+ __name(adaptLevel, "adaptLevel");
279
+ function micError(error) {
280
+ const name = error.name;
281
+ if (name === "NotAllowedError") return "Microphone blocked: allow it in your browser settings";
282
+ if (name === "NotFoundError") return "No microphone found";
283
+ return "Could not start the microphone";
284
+ }
285
+ __name(micError, "micError");
286
+ async function readLinkStats(pc) {
287
+ const stats = await pc.getStats();
288
+ let loss = 0;
289
+ let rttMs = 0;
290
+ stats.forEach((report) => {
291
+ const r = report;
292
+ if (r.type === "remote-inbound-rtp" && r.kind === "audio") {
293
+ if (typeof r.fractionLost === "number") loss = Math.max(loss, r.fractionLost);
294
+ }
295
+ if (r.type === "candidate-pair" && r.state === "succeeded" && r.nominated !== false) {
296
+ if (typeof r.currentRoundTripTime === "number") {
297
+ rttMs = Math.max(rttMs, r.currentRoundTripTime * 1e3);
298
+ }
299
+ }
300
+ });
301
+ return { loss, rttMs };
302
+ }
303
+ __name(readLinkStats, "readLinkStats");
304
+ var _VoiceChat = class _VoiceChat {
305
+ constructor(transport, deps) {
306
+ this.transport = transport;
307
+ this.deps = deps;
308
+ this.available = false;
309
+ this.state = "off";
310
+ this.micMuted = false;
311
+ this.talking = false;
312
+ this.error = "";
313
+ /** Hear each voice from its character, when there is one. Off, everyone's in your ear. */
314
+ this.positional = true;
315
+ this.peersById = /* @__PURE__ */ new Map();
316
+ this.listeners = /* @__PURE__ */ new Set();
317
+ this.config = {};
318
+ this.track = null;
319
+ this.meter = null;
320
+ this.level = 0;
321
+ this.goodMs = 0;
322
+ this.stopTimers = null;
323
+ this.disposed = false;
324
+ transport.onVoice((message) => this.fromRoom(message));
325
+ transport.sendVoice({ t: "hello" });
326
+ }
327
+ get peers() {
328
+ return [...this.peersById.values()].map((p) => ({
329
+ id: p.id,
330
+ connected: p.connected,
331
+ talking: p.talking,
332
+ micOn: p.micOn,
333
+ muted: p.muted,
334
+ volume: p.volume
335
+ }));
336
+ }
337
+ onChange(listener) {
338
+ this.listeners.add(listener);
339
+ return () => {
340
+ this.listeners.delete(listener);
341
+ };
342
+ }
343
+ changed() {
344
+ for (const listener of this.listeners) listener();
345
+ }
346
+ /** Turn voice on. Call from a tap or key: that's what lets the browser ask for the mic. */
347
+ async enable() {
348
+ if (this.state !== "off") return true;
349
+ if (!this.available) return false;
350
+ this.error = "";
351
+ this.state = "connecting";
352
+ this.changed();
353
+ this.deps.setCapture(true);
354
+ try {
355
+ await this.acquire();
356
+ } catch (error) {
357
+ this.deps.setCapture(false);
358
+ this.state = "off";
359
+ this.error = micError(error);
360
+ this.changed();
361
+ return false;
362
+ }
363
+ if (this.disposed || this.state !== "connecting") {
364
+ this.release();
365
+ return false;
366
+ }
367
+ this.micMuted = false;
368
+ this.transport.sendVoice({ t: "join" });
369
+ return true;
370
+ }
371
+ async acquire() {
372
+ const stream = await this.deps.getMic({
373
+ audio: {
374
+ echoCancellation: true,
375
+ noiseSuppression: true,
376
+ autoGainControl: true,
377
+ channelCount: 1
378
+ },
379
+ video: false
380
+ });
381
+ this.track = stream.getAudioTracks()[0] ?? null;
382
+ if (this.track) this.track.onended = () => this.lost();
383
+ this.meter?.close();
384
+ this.meter = this.deps.analyse(stream);
385
+ }
386
+ release() {
387
+ try {
388
+ this.track?.stop();
389
+ } catch {
390
+ }
391
+ this.track = null;
392
+ this.meter?.close();
393
+ this.meter = null;
394
+ this.talking = false;
395
+ this.deps.setCapture(false);
396
+ }
397
+ /** The mic stopped by itself: the phone took it for a call, or the device went away. */
398
+ lost() {
399
+ if (this.state === "off") return;
400
+ this.track = null;
401
+ this.micMuted = true;
402
+ this.error = "Your mic stopped: tap the mic to turn it back on";
403
+ this.transport.sendVoice({ t: "mic", on: false });
404
+ this.sendMicToAll();
405
+ this.changed();
406
+ }
407
+ /** Turn voice off. The mic stops at once. */
408
+ disable() {
409
+ if (this.state === "off") return;
410
+ this.transport.sendVoice({ t: "leave" });
411
+ this.shutDown();
412
+ this.changed();
413
+ }
414
+ shutDown() {
415
+ this.state = "off";
416
+ this.micMuted = false;
417
+ this.stopTimers?.();
418
+ this.stopTimers = null;
419
+ for (const peer of [...this.peersById.values()]) this.closePeer(peer);
420
+ this.release();
421
+ }
422
+ /** Mute or unmute this player's mic. Everyone in voice sees it. */
423
+ async setMicMuted(muted) {
424
+ if (this.state !== "on") return;
425
+ if (!muted && !this.track) {
426
+ try {
427
+ this.deps.setCapture(true);
428
+ await this.acquire();
429
+ this.error = "";
430
+ } catch (error) {
431
+ this.error = micError(error);
432
+ this.changed();
433
+ return;
434
+ }
435
+ }
436
+ this.micMuted = muted;
437
+ this.transport.sendVoice({ t: "mic", on: !muted });
438
+ this.sendMicToAll();
439
+ this.changed();
440
+ }
441
+ /** Stop hearing someone. They aren't told. */
442
+ setPeerMuted(id, muted) {
443
+ const peer = this.peersById.get(id);
444
+ if (!peer) return;
445
+ peer.muted = muted;
446
+ peer.player?.setVolume(muted ? 0 : peer.volume);
447
+ this.changed();
448
+ }
449
+ setPeerVolume(id, volume) {
450
+ const peer = this.peersById.get(id);
451
+ if (!peer) return;
452
+ peer.volume = volume;
453
+ if (!peer.muted) peer.player?.setVolume(volume);
454
+ this.changed();
455
+ }
456
+ fromRoom(m) {
457
+ if (m.t === "caps") {
458
+ this.available = m.available && this.deps.supported();
459
+ this.changed();
460
+ } else if (m.t === "refused") {
461
+ if (this.state === "off") return;
462
+ this.shutDown();
463
+ this.error = m.reason === "full" ? "Voice is full in this room" : "Voice chat isn't available in this game";
464
+ this.changed();
465
+ } else if (m.t === "welcome") {
466
+ if (this.state !== "connecting") return;
467
+ this.config = {
468
+ iceServers: m.iceServers,
469
+ iceTransportPolicy: m.relayOnly ? "relay" : "all"
470
+ };
471
+ this.state = "on";
472
+ this.level = startingLevel(m.peers.length);
473
+ this.startTimers();
474
+ for (const other of m.peers) {
475
+ void this.call(this.addPeer(other.id, true, other.mic));
476
+ }
477
+ this.changed();
478
+ } else if (this.state !== "on") ; else if (m.t === "joined") {
479
+ this.addPeer(m.id, false, true);
480
+ this.retune();
481
+ this.changed();
482
+ } else if (m.t === "left") {
483
+ const peer = this.peersById.get(m.id);
484
+ if (peer) this.closePeer(peer);
485
+ this.retune();
486
+ this.changed();
487
+ } else if (m.t === "mic") {
488
+ const peer = this.peersById.get(m.id);
489
+ if (peer) peer.micOn = m.on;
490
+ this.changed();
491
+ } else if (m.t === "signal") {
492
+ void this.onSignal(m.from, m.signal);
493
+ }
494
+ }
495
+ addPeer(id, caller, micOn) {
496
+ const existing = this.peersById.get(id);
497
+ if (existing) return existing;
498
+ const pc = this.deps.createConnection(this.config);
499
+ const peer = {
500
+ id,
501
+ pc,
502
+ caller,
503
+ audio: null,
504
+ pendingIce: [],
505
+ player: null,
506
+ meter: null,
507
+ connected: false,
508
+ micOn,
509
+ muted: false,
510
+ volume: 1,
511
+ talking: false,
512
+ restarts: 0
513
+ };
514
+ this.peersById.set(id, peer);
515
+ pc.ontrack = (event) => {
516
+ if (event.track.kind === "audio") this.hear(peer, event.track);
517
+ };
518
+ pc.onicecandidate = (event) => {
519
+ if (event.candidate) this.signal(id, { kind: "ice", candidate: event.candidate.toJSON() });
520
+ };
521
+ pc.onconnectionstatechange = () => this.connectionChanged(peer);
522
+ return peer;
523
+ }
524
+ signal(to, signal) {
525
+ this.transport.sendVoice({ t: "signal", to, signal });
526
+ }
527
+ hear(peer, track) {
528
+ peer.player?.stop();
529
+ peer.meter?.close();
530
+ peer.player = this.deps.playRemote(track);
531
+ peer.player.setVolume(peer.muted ? 0 : peer.volume);
532
+ peer.meter = this.deps.analyse(track);
533
+ }
534
+ connectionChanged(peer) {
535
+ if (this.peersById.get(peer.id) !== peer) return;
536
+ const state = peer.pc.connectionState;
537
+ if (state === "connected") {
538
+ peer.connected = true;
539
+ peer.restarts = 0;
540
+ void this.applyBitrate(peer);
541
+ this.changed();
542
+ } else if (state === "failed") {
543
+ peer.connected = false;
544
+ this.changed();
545
+ if (peer.caller && peer.restarts < MAX_RESTARTS) {
546
+ peer.restarts += 1;
547
+ void this.offer(peer, true);
548
+ }
549
+ }
550
+ }
551
+ async call(peer) {
552
+ peer.audio = peer.pc.addTransceiver("audio", { direction: "sendrecv" });
553
+ await this.sendMic(peer);
554
+ await this.offer(peer, false);
555
+ }
556
+ async offer(peer, restart) {
557
+ try {
558
+ if (restart) peer.pc.restartIce();
559
+ await peer.pc.setLocalDescription(await peer.pc.createOffer({ iceRestart: restart }));
560
+ const sdp = peer.pc.localDescription?.sdp;
561
+ if (sdp) this.signal(peer.id, { kind: "offer", sdp });
562
+ } catch (error) {
563
+ console.warn("[relu] Couldn't start a voice connection.", error);
564
+ }
565
+ }
566
+ async onSignal(from, signal) {
567
+ const peer = this.addPeer(from, false, true);
568
+ try {
569
+ if (signal.kind === "ice") {
570
+ if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.candidate);
571
+ else peer.pendingIce.push(signal.candidate);
572
+ return;
573
+ }
574
+ await peer.pc.setRemoteDescription({ type: signal.kind, sdp: tuneOpus(signal.sdp) });
575
+ for (const candidate of peer.pendingIce.splice(0)) {
576
+ await peer.pc.addIceCandidate(candidate);
577
+ }
578
+ if (signal.kind === "answer") return;
579
+ if (!peer.audio) {
580
+ const slot = peer.pc.getTransceivers().find((t) => t.receiver.track.kind === "audio");
581
+ if (slot) {
582
+ slot.direction = "sendrecv";
583
+ peer.audio = slot;
584
+ }
585
+ }
586
+ await this.sendMic(peer);
587
+ await peer.pc.setLocalDescription(await peer.pc.createAnswer());
588
+ const sdp = peer.pc.localDescription?.sdp;
589
+ if (sdp) this.signal(from, { kind: "answer", sdp });
590
+ } catch (error) {
591
+ console.warn("[relu] A voice connection failed to set up.", error);
592
+ }
593
+ }
594
+ async sendMic(peer) {
595
+ const sender = peer.audio?.sender;
596
+ if (!sender) return;
597
+ await sender.replaceTrack(this.micMuted ? null : this.track).catch(() => void 0);
598
+ await this.applyBitrate(peer);
599
+ }
600
+ sendMicToAll() {
601
+ for (const peer of this.peersById.values()) void this.sendMic(peer);
602
+ }
603
+ async applyBitrate(peer) {
604
+ const sender = peer.audio?.sender;
605
+ if (!sender) return;
606
+ try {
607
+ const parameters = sender.getParameters();
608
+ if (!parameters.encodings?.length) parameters.encodings = [{}];
609
+ parameters.encodings[0].maxBitrate = LEVEL_BPS[this.level];
610
+ await sender.setParameters(parameters);
611
+ } catch {
612
+ }
613
+ }
614
+ setLevel(level) {
615
+ if (level === this.level) return;
616
+ this.level = level;
617
+ for (const peer of this.peersById.values()) void this.applyBitrate(peer);
618
+ }
619
+ /** Someone came or went: the rate each connection gets changes with it. */
620
+ retune() {
621
+ this.setLevel(Math.max(this.level, startingLevel(this.peersById.size)));
622
+ }
623
+ closePeer(peer) {
624
+ peer.player?.stop();
625
+ peer.meter?.close();
626
+ peer.pc.ontrack = null;
627
+ peer.pc.onicecandidate = null;
628
+ peer.pc.onconnectionstatechange = null;
629
+ peer.pc.close();
630
+ this.peersById.delete(peer.id);
631
+ }
632
+ startTimers() {
633
+ const stopPoll = this.deps.every(POLL_MS, () => this.poll());
634
+ const stopTick = this.deps.every(TICK_MS, () => void this.tick());
635
+ this.stopTimers = () => {
636
+ stopPoll();
637
+ stopTick();
638
+ };
639
+ }
640
+ /** Who's talking, where they are, and the music under them. */
641
+ poll() {
642
+ let anyone = false;
643
+ let moved2 = false;
644
+ for (const peer of this.peersById.values()) {
645
+ const talking = !peer.muted && (peer.meter?.level() ?? 0) > TALKING_LEVEL;
646
+ if (talking !== peer.talking) moved2 = true;
647
+ peer.talking = talking;
648
+ anyone || (anyone = talking);
649
+ peer.player?.setPosition(this.positional ? this.deps.positionOf(peer.id) : null);
650
+ }
651
+ const me = !this.micMuted && (this.meter?.level() ?? 0) > TALKING_LEVEL;
652
+ if (me !== this.talking) moved2 = true;
653
+ this.talking = me;
654
+ if (anyone) this.deps.duck(DUCK_SECONDS);
655
+ if (moved2) this.changed();
656
+ }
657
+ /** Step quality to what the connections can carry. */
658
+ async tick() {
659
+ let bad = false;
660
+ for (const peer of this.peersById.values()) {
661
+ if (!peer.connected) continue;
662
+ const stats = await readLinkStats(peer.pc).catch(() => null);
663
+ if (stats && (stats.loss > MAX_LOSS || stats.rttMs > MAX_RTT_MS)) bad = true;
664
+ }
665
+ if (this.state !== "on") return;
666
+ const step = adaptLevel(this.level, startingLevel(this.peersById.size), bad, this.goodMs);
667
+ this.goodMs = step.goodMs;
668
+ this.setLevel(step.level);
669
+ }
670
+ /** The room is going: stop everything. There's no one left to tell. */
671
+ dispose() {
672
+ this.disposed = true;
673
+ this.shutDown();
674
+ this.available = false;
675
+ this.changed();
676
+ this.listeners.clear();
677
+ }
678
+ };
679
+ __name(_VoiceChat, "VoiceChat");
680
+ var VoiceChat = _VoiceChat;
681
+
682
+ // core/network/voiceBrowser.ts
683
+ var FULL_VOLUME_DISTANCE = 2;
684
+ var SILENT_DISTANCE = 60;
685
+ var POSITION_SMOOTHING = 0.05;
686
+ function voiceGraph(mixer, stream) {
687
+ const context = mixer?.listener.context;
688
+ if (!mixer || !context || context.state !== "running") return null;
689
+ const bus = mixer.input("voice");
690
+ const source = context.createMediaStreamSource(stream);
691
+ const gain = context.createGain();
692
+ const panner = context.createPanner();
693
+ panner.panningModel = "equalpower";
694
+ panner.distanceModel = "inverse";
695
+ panner.refDistance = FULL_VOLUME_DISTANCE;
696
+ panner.maxDistance = SILENT_DISTANCE;
697
+ source.connect(gain);
698
+ gain.connect(bus);
699
+ let placed = false;
700
+ return {
701
+ setVolume(volume) {
702
+ gain.gain.setTargetAtTime(Math.max(0, Math.min(2, volume)), context.currentTime, 0.02);
703
+ },
704
+ setPosition(position) {
705
+ if (Boolean(position) !== placed) {
706
+ placed = !placed;
707
+ gain.disconnect();
708
+ panner.disconnect();
709
+ if (placed) {
710
+ gain.connect(panner);
711
+ panner.connect(bus);
712
+ } else {
713
+ gain.connect(bus);
714
+ }
715
+ }
716
+ if (!position) return;
717
+ const now = context.currentTime;
718
+ panner.positionX.setTargetAtTime(position.x, now, POSITION_SMOOTHING);
719
+ panner.positionY.setTargetAtTime(position.y, now, POSITION_SMOOTHING);
720
+ panner.positionZ.setTargetAtTime(position.z, now, POSITION_SMOOTHING);
721
+ },
722
+ stop() {
723
+ source.disconnect();
724
+ gain.disconnect();
725
+ panner.disconnect();
726
+ }
727
+ };
728
+ }
729
+ __name(voiceGraph, "voiceGraph");
730
+ function playRemote(mixer, track) {
731
+ const stream = new MediaStream([track]);
732
+ const element = document.createElement("audio");
733
+ element.autoplay = true;
734
+ element.setAttribute("playsinline", "");
735
+ element.srcObject = stream;
736
+ element.style.display = "none";
737
+ const graph = voiceGraph(mixer, stream);
738
+ element.muted = graph !== null;
739
+ document.body.appendChild(element);
740
+ void element.play().catch(() => void 0);
741
+ return {
742
+ setVolume(volume) {
743
+ if (graph) graph.setVolume(volume);
744
+ else element.volume = Math.max(0, Math.min(1, volume));
745
+ },
746
+ setPosition(position) {
747
+ graph?.setPosition(position);
748
+ },
749
+ stop() {
750
+ graph?.stop();
751
+ element.pause();
752
+ element.srcObject = null;
753
+ element.remove();
754
+ }
755
+ };
756
+ }
757
+ __name(playRemote, "playRemote");
758
+ function analyse(mixer, from) {
759
+ const context = mixer?.listener.context;
760
+ if (!context) return null;
761
+ const stream = from instanceof MediaStream ? from : new MediaStream([from]);
762
+ const source = context.createMediaStreamSource(stream);
763
+ const analyser = context.createAnalyser();
764
+ analyser.fftSize = 512;
765
+ source.connect(analyser);
766
+ const samples = new Float32Array(analyser.fftSize);
767
+ return {
768
+ level() {
769
+ analyser.getFloatTimeDomainData(samples);
770
+ let sum = 0;
771
+ for (const sample of samples) sum += sample * sample;
772
+ return Math.min(1, Math.sqrt(sum / samples.length) * 3);
773
+ },
774
+ close() {
775
+ source.disconnect();
776
+ analyser.disconnect();
777
+ }
778
+ };
779
+ }
780
+ __name(analyse, "analyse");
781
+ function browserVoiceDeps(world, positionOf) {
782
+ const mixer = /* @__PURE__ */ __name(() => world.getAudioSystem()?.getMixer() ?? null, "mixer");
783
+ return {
784
+ supported: /* @__PURE__ */ __name(() => typeof RTCPeerConnection !== "undefined" && globalThis.isSecureContext && Boolean(navigator.mediaDevices?.getUserMedia), "supported"),
785
+ createConnection: /* @__PURE__ */ __name((config) => new RTCPeerConnection(config), "createConnection"),
786
+ getMic: /* @__PURE__ */ __name((constraints) => navigator.mediaDevices.getUserMedia(constraints), "getMic"),
787
+ setCapture(on) {
788
+ const session = navigator.audioSession;
789
+ if (session) session.type = on ? "play-and-record" : "playback";
790
+ if (on) void mixer()?.listener.context.resume();
791
+ },
792
+ analyse: /* @__PURE__ */ __name((from) => analyse(mixer(), from), "analyse"),
793
+ playRemote: /* @__PURE__ */ __name((track) => playRemote(mixer(), track), "playRemote"),
794
+ duck: /* @__PURE__ */ __name((seconds) => mixer()?.duckMusic(seconds), "duck"),
795
+ positionOf,
796
+ every(ms, fn) {
797
+ const timer = setInterval(fn, ms);
798
+ return () => clearInterval(timer);
799
+ }
800
+ };
801
+ }
802
+ __name(browserVoiceDeps, "browserVoiceDeps");
803
+
175
804
  // core/network/NetworkSystem.ts
176
805
  var POSITION_EPSILON = 1e-3;
177
806
  var ROTATION_EPSILON = 5e-4;
@@ -211,9 +840,14 @@ var _NetworkSystem = class _NetworkSystem {
211
840
  this.listeners = /* @__PURE__ */ new Map();
212
841
  this.handles = /* @__PURE__ */ new Map();
213
842
  this.spawnedAvatar = false;
843
+ /** Voice chat, once connected with it on. */
844
+ this.voice = null;
214
845
  }
215
- /** Join the shared world. Call after hydrating, so scene objects exist. */
216
- connect(world, transport) {
846
+ /**
847
+ * Join the shared world. Call after hydrating, so scene objects exist.
848
+ * `voice: true` adds voice chat; the room still decides whether it's offered.
849
+ */
850
+ connect(world, transport, options = {}) {
217
851
  this.world = world;
218
852
  this.transport = transport;
219
853
  world.setNetwork(this);
@@ -226,7 +860,14 @@ var _NetworkSystem = class _NetworkSystem {
226
860
  for (const fn of this.listeners.get(name) ?? []) fn(payload, from);
227
861
  });
228
862
  transport.onCorrect((id, pose) => this.corrected(id, pose));
863
+ transport.onSound((sound) => this.heardSound(sound));
229
864
  transport.onClose(() => this.disconnected());
865
+ if (options.voice) {
866
+ this.voice = new VoiceChat(
867
+ transport,
868
+ options.voice === true ? browserVoiceDeps(world, (id) => this.positionOf(id)) : options.voice
869
+ );
870
+ }
230
871
  this.registerScene();
231
872
  this.spawnAvatar();
232
873
  }
@@ -270,11 +911,12 @@ var _NetworkSystem = class _NetworkSystem {
270
911
  }
271
912
  });
272
913
  }
273
- track(id, entityId, owner, view2) {
914
+ track(id, entityId, owner, view2, prefab = "") {
274
915
  const entry = {
275
916
  id,
276
917
  entityId,
277
918
  owner,
919
+ prefab,
278
920
  buffer: new SnapshotBuffer(),
279
921
  sentAt: 0,
280
922
  sent: null,
@@ -307,7 +949,7 @@ var _NetworkSystem = class _NetworkSystem {
307
949
  this.entityChanged(view2);
308
950
  return;
309
951
  }
310
- const entry = this.track(view2.id, null, view2.owner, view2);
952
+ const entry = this.track(view2.id, null, view2.owner, view2, view2.prefab);
311
953
  if (!view2.prefab || !this.world) return;
312
954
  euler.setFromQuaternion(quat.set(view2.qx, view2.qy, view2.qz, view2.qw));
313
955
  void spawnPrefab(this.world, view2.prefab, {
@@ -364,7 +1006,23 @@ var _NetworkSystem = class _NetworkSystem {
364
1006
  if (transform) applyPose(transform, pose, true);
365
1007
  entry.sent = pose;
366
1008
  }
1009
+ heardSound(sound) {
1010
+ const { id, volume, x, y, z } = sound;
1011
+ const placed = x !== void 0 && y !== void 0 && z !== void 0;
1012
+ this.world?.getAudioSystem()?.playSlot(id, { volume, position: placed ? { x, y, z } : void 0 });
1013
+ }
1014
+ /** Where a player's character is: the first thing they spawned, which is their avatar. */
1015
+ positionOf(peerId) {
1016
+ for (const entry of this.tracked.values()) {
1017
+ if (entry.owner !== peerId || !entry.prefab || entry.entityId === null) continue;
1018
+ const position = this.world?.getComponent(entry.entityId, TransformComponent)?.position;
1019
+ return position ? { x: position.x, y: position.y, z: position.z } : null;
1020
+ }
1021
+ return null;
1022
+ }
367
1023
  disconnected() {
1024
+ this.voice?.dispose();
1025
+ this.voice = null;
368
1026
  this.transport = null;
369
1027
  this.world?.setNetwork(null);
370
1028
  }
@@ -408,6 +1066,8 @@ var _NetworkSystem = class _NetworkSystem {
408
1066
  }
409
1067
  }
410
1068
  dispose() {
1069
+ this.voice?.dispose();
1070
+ this.voice = null;
411
1071
  this.transport?.leave();
412
1072
  this.transport = null;
413
1073
  this.world?.setNetwork(null);
@@ -454,7 +1114,22 @@ var _NetworkSystem = class _NetworkSystem {
454
1114
  get players() {
455
1115
  return self.transport?.players ?? [];
456
1116
  },
1117
+ get voice() {
1118
+ return self.voice;
1119
+ },
457
1120
  send: /* @__PURE__ */ __name((name, payload) => self.transport?.send(name, payload ?? null), "send"),
1121
+ playSlot: /* @__PURE__ */ __name((id, options = {}) => {
1122
+ const played = self.world?.getAudioSystem()?.playSlot(id, options) ?? false;
1123
+ if (played) {
1124
+ const { volume, position } = options;
1125
+ self.transport?.sendSound({
1126
+ id,
1127
+ ...volume === void 0 ? {} : { volume },
1128
+ ...position ? { x: position.x, y: position.y, z: position.z } : {}
1129
+ });
1130
+ }
1131
+ return played;
1132
+ }, "playSlot"),
458
1133
  onMessage: /* @__PURE__ */ __name((name, fn) => {
459
1134
  const set = self.listeners.get(name) ?? /* @__PURE__ */ new Set();
460
1135
  self.listeners.set(name, set);
@@ -488,6 +1163,6 @@ var _NetworkSystem = class _NetworkSystem {
488
1163
  __name(_NetworkSystem, "NetworkSystem");
489
1164
  var NetworkSystem = _NetworkSystem;
490
1165
 
491
- export { ColyseusTransport, NetworkSystem, SnapshotBuffer, withLatency };
1166
+ export { ColyseusTransport, NetworkSystem, SnapshotBuffer, VoiceChat, browserVoiceDeps, withLatency };
492
1167
  //# sourceMappingURL=index.js.map
493
1168
  //# sourceMappingURL=index.js.map