@genex-ai/multiplayer 0.13.0 → 0.16.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.
- package/README.md +74 -1
- package/dist/index.d.ts +127 -1
- package/dist/index.js +557 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -223,12 +223,85 @@ and seed its world from each object's `stateRaw`, so simulation survives host mi
|
|
|
223
223
|
|
|
224
224
|
## `send` — custom one-off events
|
|
225
225
|
|
|
226
|
-
Fire-and-forget to all **other** clients (not stored in state). For shots, emotes,
|
|
226
|
+
Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, pings.
|
|
227
227
|
|
|
228
228
|
```ts
|
|
229
229
|
room.send('hit', { from: room.id, target: 'xyz', dmg: 10 });
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
+
Text chat has its own channel — use `room.chat`, not this.
|
|
233
|
+
|
|
234
|
+
## `chat` — room text chat
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
room.chat.send('on my way');
|
|
238
|
+
|
|
239
|
+
room.chat.on((m) => { // fires once per line you hadn't seen
|
|
240
|
+
addLine(`${m.name}: ${m.text}`); // m.text is already sanitized — render as TEXT
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
room.chat.history; // every known line, oldest first — render on mount
|
|
244
|
+
room.chat.mute(playerId); // local only; nothing is sent, nobody else is affected
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Chat is not `send`** — three differences that matter:
|
|
248
|
+
|
|
249
|
+
- **Your own lines come back to you**, in server order with the server's timestamp. Render only
|
|
250
|
+
what arrives in `on`; appending your own line locally on submit shows it twice.
|
|
251
|
+
- **The relay stamps `from` and `name`** from the verified session. A player cannot post under
|
|
252
|
+
someone else's name, so the name is safe to display as-is.
|
|
253
|
+
- **History replays on connect** (last ~50 lines), so a player joining mid-game sees the recent
|
|
254
|
+
conversation. `on` fires for those too, de-duplicated by `id`, and a reconnect backfills exactly
|
|
255
|
+
what the blip missed — a handler that just appends stays correct.
|
|
256
|
+
|
|
257
|
+
The relay trims each line, strips control and bidi characters, and clamps to 500 code points.
|
|
258
|
+
Chat has its own rate budget (~2 lines/second), separate from gameplay traffic, so a busy room
|
|
259
|
+
can't starve it and a chat flood can't starve movement.
|
|
260
|
+
|
|
261
|
+
> **Render `m.text` with `textContent`, never `innerHTML`.** It is another player's input.
|
|
262
|
+
|
|
263
|
+
## `voice` — mesh voice chat
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// MUST be inside a click/tap handler — browsers gate the mic prompt AND playback on a gesture.
|
|
267
|
+
joinBtn.onclick = async () => {
|
|
268
|
+
try { await room.voice.join(); } // rejects if the player denies the microphone
|
|
269
|
+
catch (err) { showNotice('Microphone needed for voice'); }
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
room.on('voice', (participants) => renderVoiceHud(participants)); // joined/left/speaking/connected
|
|
273
|
+
room.on('voice:rejected', (info) => showNotice(`Voice is full (${info.limit})`));
|
|
274
|
+
|
|
275
|
+
room.voice.setMicEnabled(false); // push-to-talk gate — instant, no renegotiation
|
|
276
|
+
room.voice.mute(playerId); // local only
|
|
277
|
+
room.voice.leave(); // releases the mic (turns the recording indicator off)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Party-sized, not room-sized — max 6.** Audio goes peer-to-peer in a full mesh: everyone
|
|
281
|
+
uploads to everyone. The relay carries no audio at all, only the roster and the WebRTC
|
|
282
|
+
handshake. Past a small party what breaks first is not bandwidth but reliability — a joiner
|
|
283
|
+
must negotiate with everyone already present, and every leg can fail. The 7th `join()` is
|
|
284
|
+
refused with `voice:rejected`; the room still holds 64 players.
|
|
285
|
+
|
|
286
|
+
**TURN is automatic — you do not configure it.** Roughly 15% of players sit behind symmetric
|
|
287
|
+
NAT, carrier CGNAT, or a firewall that blocks UDP, and cannot connect peer-to-peer at all. In
|
|
288
|
+
a mesh that is not a private problem: they fail *every* leg, so nobody in the party hears them
|
|
289
|
+
and they hear nobody, while everyone else works fine. `join()` fetches short-lived relay
|
|
290
|
+
credentials from the Genex relay before it opens any peer connection, so this is handled for
|
|
291
|
+
you with no key to obtain, nothing to put in your bundle, and no code to write.
|
|
292
|
+
|
|
293
|
+
Pass `iceServers` only to run your own TURN — it is an override, and it suppresses the
|
|
294
|
+
platform request entirely:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
await room.voice.join({ iceServers: [{ urls: 'turn:…', username: '…', credential: '…' }] });
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Spatial voice** (`npx genex controller voice`) is the version worth shipping in a 3D game:
|
|
301
|
+
join with `{ spatial: true }` and the SDK stops playing audio flat, handing you `p.stream`
|
|
302
|
+
per participant to drive a `THREE.PositionalAudio`. Distance falloff also softens the cap —
|
|
303
|
+
six audible neighbours read as a populated world rather than as a limit.
|
|
304
|
+
|
|
232
305
|
## `leave`
|
|
233
306
|
|
|
234
307
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
export { PlayerState, RoomState } from './schema.js';
|
|
2
2
|
import '@colyseus/schema';
|
|
3
3
|
|
|
4
|
+
/** One remote participant in the voice mesh. */
|
|
5
|
+
type VoiceParticipant = {
|
|
6
|
+
id: string;
|
|
7
|
+
/** Relay-verified display name (same source as `players`). */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Their audio. Null until the peer connection produces a track. */
|
|
10
|
+
stream: MediaStream | null;
|
|
11
|
+
/** True while they are above the speaking threshold. */
|
|
12
|
+
speaking: boolean;
|
|
13
|
+
/** Muted by YOU, locally. Nothing is sent to them or to the relay. */
|
|
14
|
+
muted: boolean;
|
|
15
|
+
/** 0..1, applied to the default playback element (ignored in spatial mode). */
|
|
16
|
+
volume: number;
|
|
17
|
+
connection: 'connecting' | 'connected' | 'failed';
|
|
18
|
+
};
|
|
19
|
+
type VoiceJoinOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* OVERRIDE the ICE servers. You do not need this: `join()` asks the relay for short-lived
|
|
22
|
+
* TURN credentials automatically, so the ~15% of players behind symmetric NAT, carrier
|
|
23
|
+
* CGNAT or a UDP-blocking firewall connect without any work from the game. Pass this only
|
|
24
|
+
* to run your own TURN — doing so suppresses the relay request entirely.
|
|
25
|
+
*/
|
|
26
|
+
iceServers?: RTCIceServer[];
|
|
27
|
+
/** Join with the mic gated off — the right default for push-to-talk games. */
|
|
28
|
+
startMuted?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Spatial mode: the SDK does NOT play remote audio, it only hands you `p.stream` so you
|
|
31
|
+
* can drive a THREE.PositionalAudio yourself (`genex controller voice` does this). A
|
|
32
|
+
* muted sink element is still created per peer — without one attached, Chrome does not
|
|
33
|
+
* flow a remote WebRTC stream into WebAudio at all, which reads as "spatial voice is
|
|
34
|
+
* silent" and is the single most confusing bug in this lane.
|
|
35
|
+
*/
|
|
36
|
+
spatial?: boolean;
|
|
37
|
+
/** RMS threshold for the speaking flag, 0..1. Default 0.02. */
|
|
38
|
+
speakingThreshold?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
4
41
|
type ColyseusAuth = {
|
|
5
42
|
embedToken: string;
|
|
6
43
|
};
|
|
@@ -181,6 +218,15 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
181
218
|
on(event: 'object', cb: (id: string) => void): () => void;
|
|
182
219
|
/** The room host changed (fires with the new host's id, or `undefined`). */
|
|
183
220
|
on(event: 'host', cb: (host: string | undefined) => void): () => void;
|
|
221
|
+
/** The voice roster or a participant's state changed (joined, left, started/stopped
|
|
222
|
+
* speaking, connected, muted). Fires with the full participant list — re-render from it. */
|
|
223
|
+
on(event: 'voice', cb: (participants: VoiceParticipant[]) => void): () => void;
|
|
224
|
+
/** Your `voice.join()` was refused by the relay — the room is at the 6-participant cap.
|
|
225
|
+
* The mic is already released; show the reason rather than retrying in a loop. */
|
|
226
|
+
on(event: 'voice:rejected', cb: (info: {
|
|
227
|
+
reason: string;
|
|
228
|
+
limit?: number;
|
|
229
|
+
}) => void): () => void;
|
|
184
230
|
/**
|
|
185
231
|
* The connection dropped unexpectedly and auto-reconnect is underway (attempts run
|
|
186
232
|
* ~0.5s→8s backoff inside the server's grace window). Show a "reconnecting…" overlay;
|
|
@@ -204,6 +250,10 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
204
250
|
* by the relay itself (`state`, `shared`, `claim`, `obj`, `release`, `destroy`, and the
|
|
205
251
|
* `match:`/`__` prefixes) are refused with a console warning — pick another name. */
|
|
206
252
|
send(type: string, payload?: unknown): void;
|
|
253
|
+
/** Text chat for this room. See {@link ChatApi}. */
|
|
254
|
+
chat: ChatApi;
|
|
255
|
+
/** Mesh voice chat for this room. See {@link VoiceApi}. */
|
|
256
|
+
voice: VoiceApi;
|
|
207
257
|
/**
|
|
208
258
|
* HOST ONLY. Stop (or resume) the matchmaker placing NEW players into THIS room.
|
|
209
259
|
*
|
|
@@ -220,6 +270,82 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
220
270
|
/** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
|
|
221
271
|
leave(): void;
|
|
222
272
|
};
|
|
273
|
+
/** One chat line, as the relay stamped it. Only `text` came from the sender's client —
|
|
274
|
+
* `from` and `name` are filled in server-side from the verified session, so a player
|
|
275
|
+
* cannot post under someone else's name. */
|
|
276
|
+
type ChatMessage = {
|
|
277
|
+
/** Monotonic per room, ascending. Stable across a reconnect — use it as a render key. */
|
|
278
|
+
id: number;
|
|
279
|
+
/** Sender's player id (`room.id` for your own lines). */
|
|
280
|
+
from: string;
|
|
281
|
+
/** Sender's verified display name. */
|
|
282
|
+
name: string;
|
|
283
|
+
/** Sanitized text: ≤500 code points, no control or bidi characters, single-spaced. */
|
|
284
|
+
text: string;
|
|
285
|
+
/** Server timestamp (epoch ms). */
|
|
286
|
+
at: number;
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Room text chat. The relay keeps the last ~50 lines and replays them on connect, so a
|
|
290
|
+
* player joining mid-game sees the recent conversation.
|
|
291
|
+
*
|
|
292
|
+
* IMPORTANT — chat is NOT `send()`: your own lines come back to you, in server order
|
|
293
|
+
* with the server's timestamp. Render only what arrives here; do not also append your
|
|
294
|
+
* own line locally on submit, or it will appear twice.
|
|
295
|
+
*/
|
|
296
|
+
type ChatApi = {
|
|
297
|
+
/** Send a line. Empty/whitespace-only text is ignored; the relay trims, strips control
|
|
298
|
+
* and bidi characters, and clamps to 500 code points. Over ~2 lines/second the relay
|
|
299
|
+
* drops the excess (and warns via `rate:drop`). */
|
|
300
|
+
send(text: string): void;
|
|
301
|
+
/**
|
|
302
|
+
* Fires once per line you had not seen — live messages, plus any the replayed history
|
|
303
|
+
* fills in on connect or after a reconnect blip. Append-only and de-duplicated by `id`,
|
|
304
|
+
* so a handler that just appends stays correct across reconnects. Returns a disposer.
|
|
305
|
+
*/
|
|
306
|
+
on(cb: (message: ChatMessage) => void): () => void;
|
|
307
|
+
/** Every line known to this client, oldest first, de-duplicated. Safe to render on mount. */
|
|
308
|
+
readonly history: ChatMessage[];
|
|
309
|
+
/** Hide a player's lines locally (client-side only — nothing is sent to the server and
|
|
310
|
+
* other players are unaffected). Applies to messages received from now on. */
|
|
311
|
+
mute(playerId: string): void;
|
|
312
|
+
unmute(playerId: string): void;
|
|
313
|
+
/** Currently muted player ids. */
|
|
314
|
+
readonly muted: string[];
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Mesh voice chat — real-time talking between players. Audio travels peer-to-peer; the
|
|
318
|
+
* relay only keeps the roster and couriers the WebRTC handshake.
|
|
319
|
+
*
|
|
320
|
+
* **Party-sized, not room-sized.** The relay caps voice at 6 participants because a mesh
|
|
321
|
+
* makes every participant upload to every other one, and a joiner has to negotiate with
|
|
322
|
+
* everyone already present — reliability, not bandwidth, is what breaks first. A 7th
|
|
323
|
+
* `join()` is refused with `voice:rejected`; the room itself still holds up to 64 players.
|
|
324
|
+
*
|
|
325
|
+
* `join()` must be called from a click or tap: browsers gate both the microphone prompt
|
|
326
|
+
* and audio playback on a user gesture. It rejects if the player denies the mic — that is
|
|
327
|
+
* an ordinary outcome to show in the UI, not an error to swallow.
|
|
328
|
+
*/
|
|
329
|
+
type VoiceApi = {
|
|
330
|
+
/** Ask for the mic and enter the room's voice roster. Rejects if permission is denied. */
|
|
331
|
+
join(options?: VoiceJoinOptions): Promise<void>;
|
|
332
|
+
/** Leave voice and release the microphone (this is what turns the recording indicator off). */
|
|
333
|
+
leave(): void;
|
|
334
|
+
readonly active: boolean;
|
|
335
|
+
/** Everyone else in voice, with their stream, speaking flag and connection state. */
|
|
336
|
+
readonly participants: VoiceParticipant[];
|
|
337
|
+
/** Push-to-talk: gate your outgoing audio instantly, without renegotiating. */
|
|
338
|
+
setMicEnabled(on: boolean): void;
|
|
339
|
+
readonly micEnabled: boolean;
|
|
340
|
+
/** True while YOU are above the speaking threshold and un-gated. */
|
|
341
|
+
readonly speaking: boolean;
|
|
342
|
+
/** Silence a player locally. Nothing is sent to them, to the relay, or to anyone else. */
|
|
343
|
+
mute(playerId: string): void;
|
|
344
|
+
unmute(playerId: string): void;
|
|
345
|
+
/** 0..1 playback volume for one player (ignored in `spatial` mode — you own the audio then). */
|
|
346
|
+
setVolume(playerId: string, volume: number): void;
|
|
347
|
+
readonly muted: string[];
|
|
348
|
+
};
|
|
223
349
|
/**
|
|
224
350
|
* Connect to a genex multiplayer room. You must supply your player-state type:
|
|
225
351
|
*
|
|
@@ -343,4 +469,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
|
|
|
343
469
|
*/
|
|
344
470
|
declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
|
|
345
471
|
|
|
346
|
-
export { type AuthSource, type ColyseusAuth, type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
|
|
472
|
+
export { type AuthSource, type ChatApi, type ChatMessage, type ColyseusAuth, type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, type VoiceApi, type VoiceJoinOptions, type VoiceParticipant, connect, createPrivate, joinPrivate, matchmake };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,382 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Client, getStateCallbacks } from "@colyseus/sdk";
|
|
3
|
+
|
|
4
|
+
// src/ice-request.ts
|
|
5
|
+
function createIceRequester(send, timeoutMs) {
|
|
6
|
+
let inFlight = null;
|
|
7
|
+
let settle = null;
|
|
8
|
+
return {
|
|
9
|
+
request() {
|
|
10
|
+
if (inFlight) return inFlight;
|
|
11
|
+
inFlight = new Promise((resolve) => {
|
|
12
|
+
let timer;
|
|
13
|
+
const done = (cfg) => {
|
|
14
|
+
if (!settle) return;
|
|
15
|
+
settle = null;
|
|
16
|
+
inFlight = null;
|
|
17
|
+
if (timer) clearTimeout(timer);
|
|
18
|
+
resolve(cfg);
|
|
19
|
+
};
|
|
20
|
+
settle = done;
|
|
21
|
+
timer = setTimeout(() => done(null), timeoutMs);
|
|
22
|
+
send();
|
|
23
|
+
});
|
|
24
|
+
return inFlight;
|
|
25
|
+
},
|
|
26
|
+
deliver(cfg) {
|
|
27
|
+
settle?.(cfg);
|
|
28
|
+
},
|
|
29
|
+
get pending() {
|
|
30
|
+
return inFlight !== null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/voice.ts
|
|
36
|
+
var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
37
|
+
var SPEAKING_POLL_MS = 120;
|
|
38
|
+
var DEFAULT_SPEAKING_THRESHOLD = 0.02;
|
|
39
|
+
var SPEAKING_RELEASE_RATIO = 0.6;
|
|
40
|
+
var VoiceMesh = class {
|
|
41
|
+
transport;
|
|
42
|
+
peers = /* @__PURE__ */ new Map();
|
|
43
|
+
listeners = /* @__PURE__ */ new Set();
|
|
44
|
+
roster = [];
|
|
45
|
+
local = null;
|
|
46
|
+
audio = null;
|
|
47
|
+
localAnalyser = null;
|
|
48
|
+
levelTimer = null;
|
|
49
|
+
iceServers = DEFAULT_ICE;
|
|
50
|
+
spatial = false;
|
|
51
|
+
threshold = DEFAULT_SPEAKING_THRESHOLD;
|
|
52
|
+
micOn = true;
|
|
53
|
+
selfSpeaking = false;
|
|
54
|
+
running = false;
|
|
55
|
+
// Peering is HELD between acquiring the mic and announcing the join, because both things
|
|
56
|
+
// that happen in that window must land first: the relay's TURN credentials (a peer built
|
|
57
|
+
// without them is stuck on STUN for its whole life), and our own `__voice:join` (the relay
|
|
58
|
+
// drops a signal from a session that is not yet in the roster, and a dropped offer is never
|
|
59
|
+
// retried). Without the hold, another player joining voice in that window patches `__voice`
|
|
60
|
+
// and builds us a peer that can neither relay nor negotiate.
|
|
61
|
+
peeringHeld = false;
|
|
62
|
+
constructor(transport) {
|
|
63
|
+
this.transport = transport;
|
|
64
|
+
}
|
|
65
|
+
get active() {
|
|
66
|
+
return this.running;
|
|
67
|
+
}
|
|
68
|
+
get micEnabled() {
|
|
69
|
+
return this.micOn;
|
|
70
|
+
}
|
|
71
|
+
get speaking() {
|
|
72
|
+
return this.selfSpeaking;
|
|
73
|
+
}
|
|
74
|
+
get participants() {
|
|
75
|
+
return [...this.peers.values()].map((p) => ({
|
|
76
|
+
id: p.id,
|
|
77
|
+
name: this.transport.nameOf(p.id),
|
|
78
|
+
stream: p.stream,
|
|
79
|
+
speaking: p.speaking,
|
|
80
|
+
muted: p.muted,
|
|
81
|
+
volume: p.volume,
|
|
82
|
+
connection: p.connection
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
onChange(cb) {
|
|
86
|
+
this.listeners.add(cb);
|
|
87
|
+
return () => this.listeners.delete(cb);
|
|
88
|
+
}
|
|
89
|
+
/** Acquire the microphone. Throws if the user denies it or no device exists — the caller
|
|
90
|
+
* surfaces that; it is a normal outcome, not a bug. Must be called from a user gesture:
|
|
91
|
+
* browsers gate both the permission prompt and audio playback on one. */
|
|
92
|
+
async start(options = {}, holdPeering = false) {
|
|
93
|
+
if (this.running) return;
|
|
94
|
+
this.peeringHeld = holdPeering;
|
|
95
|
+
this.iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE;
|
|
96
|
+
this.spatial = options.spatial === true;
|
|
97
|
+
this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
|
|
98
|
+
this.micOn = options.startMuted !== true;
|
|
99
|
+
this.local = await navigator.mediaDevices.getUserMedia({
|
|
100
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
101
|
+
video: false
|
|
102
|
+
});
|
|
103
|
+
for (const t of this.local.getAudioTracks()) t.enabled = this.micOn;
|
|
104
|
+
this.audio = new AudioContext();
|
|
105
|
+
this.localAnalyser = this.makeAnalyser(this.local);
|
|
106
|
+
this.running = true;
|
|
107
|
+
this.levelTimer = setInterval(() => this.pollLevels(), SPEAKING_POLL_MS);
|
|
108
|
+
}
|
|
109
|
+
/** Tear everything down. Safe to call when not running. */
|
|
110
|
+
stop() {
|
|
111
|
+
this.running = false;
|
|
112
|
+
this.peeringHeld = false;
|
|
113
|
+
if (this.levelTimer) {
|
|
114
|
+
clearInterval(this.levelTimer);
|
|
115
|
+
this.levelTimer = null;
|
|
116
|
+
}
|
|
117
|
+
for (const id of [...this.peers.keys()]) this.destroyPeer(id);
|
|
118
|
+
for (const t of this.local?.getTracks() ?? []) t.stop();
|
|
119
|
+
this.local = null;
|
|
120
|
+
this.localAnalyser = null;
|
|
121
|
+
void this.audio?.close().catch(() => {
|
|
122
|
+
});
|
|
123
|
+
this.audio = null;
|
|
124
|
+
this.roster = [];
|
|
125
|
+
this.selfSpeaking = false;
|
|
126
|
+
this.emit();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Swap in ICE servers obtained after `start()` — in practice the relay's short-lived TURN
|
|
130
|
+
* credentials, which are fetched between acquiring the mic and announcing the join so the
|
|
131
|
+
* network round trip never spends the user gesture `getUserMedia` needs.
|
|
132
|
+
*
|
|
133
|
+
* Peers built from here on use them. Live ones are updated too, but `setConfiguration`
|
|
134
|
+
* governs FUTURE gathering only: an allocation already relaying media keeps the credential
|
|
135
|
+
* it was created with. That is why the credential's TTL is sized to outlast a session
|
|
136
|
+
* rather than to reach the next refresh.
|
|
137
|
+
*/
|
|
138
|
+
setIceServers(servers) {
|
|
139
|
+
if (!servers.length) return;
|
|
140
|
+
this.iceServers = servers;
|
|
141
|
+
for (const peer of this.peers.values()) {
|
|
142
|
+
try {
|
|
143
|
+
peer.pc.setConfiguration({ iceServers: servers });
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Release the hold taken by `start(…, true)` and reconcile whatever roster arrived while it
|
|
150
|
+
* was up. The caller MUST reach this on every path, including the one where no credentials
|
|
151
|
+
* came back: a mesh that never peers is worse than one peering without TURN.
|
|
152
|
+
*/
|
|
153
|
+
releasePeering() {
|
|
154
|
+
if (!this.peeringHeld) return;
|
|
155
|
+
this.peeringHeld = false;
|
|
156
|
+
this.setRoster(this.roster);
|
|
157
|
+
}
|
|
158
|
+
/** The relay's authoritative roster changed: open connections to newcomers, drop leavers. */
|
|
159
|
+
setRoster(ids) {
|
|
160
|
+
this.roster = ids;
|
|
161
|
+
if (!this.running) return;
|
|
162
|
+
if (this.peeringHeld) return;
|
|
163
|
+
const self = this.transport.selfId();
|
|
164
|
+
const wanted = new Set(ids.filter((id) => id !== self));
|
|
165
|
+
for (const id of wanted) if (!this.peers.has(id)) this.createPeer(id);
|
|
166
|
+
for (const id of [...this.peers.keys()]) if (!wanted.has(id)) this.destroyPeer(id);
|
|
167
|
+
this.emit();
|
|
168
|
+
}
|
|
169
|
+
/** Push-to-talk. Gates the track rather than renegotiating — instant, and it keeps the
|
|
170
|
+
* peer connections warm so the first syllable after a key press is not clipped. */
|
|
171
|
+
setMicEnabled(on) {
|
|
172
|
+
this.micOn = on;
|
|
173
|
+
for (const t of this.local?.getAudioTracks() ?? []) t.enabled = on;
|
|
174
|
+
if (!on && this.selfSpeaking) {
|
|
175
|
+
this.selfSpeaking = false;
|
|
176
|
+
this.emit();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
mute(id) {
|
|
180
|
+
this.setMuted(id, true);
|
|
181
|
+
}
|
|
182
|
+
unmute(id) {
|
|
183
|
+
this.setMuted(id, false);
|
|
184
|
+
}
|
|
185
|
+
setVolume(id, volume) {
|
|
186
|
+
const p = this.peers.get(id);
|
|
187
|
+
if (!p) return;
|
|
188
|
+
p.volume = Math.max(0, Math.min(1, volume));
|
|
189
|
+
if (p.el) p.el.volume = p.volume;
|
|
190
|
+
this.emit();
|
|
191
|
+
}
|
|
192
|
+
/** A signal arrived from `from` (the relay stamped it — it cannot be spoofed). */
|
|
193
|
+
async handleSignal(from, raw) {
|
|
194
|
+
if (!this.running) return;
|
|
195
|
+
const signal = raw;
|
|
196
|
+
if (!signal || typeof signal !== "object" || typeof signal.t !== "string") return;
|
|
197
|
+
const peer = this.peers.get(from) ?? (this.roster.includes(from) ? this.createPeer(from) : void 0);
|
|
198
|
+
if (!peer) return;
|
|
199
|
+
try {
|
|
200
|
+
if (signal.t === "offer") {
|
|
201
|
+
await peer.pc.setRemoteDescription(signal.sdp);
|
|
202
|
+
await this.flushIce(peer);
|
|
203
|
+
const answer = await peer.pc.createAnswer();
|
|
204
|
+
await peer.pc.setLocalDescription(answer);
|
|
205
|
+
this.transport.sendSignal(from, { t: "answer", sdp: answer });
|
|
206
|
+
} else if (signal.t === "answer") {
|
|
207
|
+
if (peer.pc.signalingState !== "have-local-offer") return;
|
|
208
|
+
await peer.pc.setRemoteDescription(signal.sdp);
|
|
209
|
+
await this.flushIce(peer);
|
|
210
|
+
} else if (signal.t === "ice") {
|
|
211
|
+
if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.c);
|
|
212
|
+
else peer.pendingIce.push(signal.c);
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.warn(`[multiplayer] voice signaling failed with ${from}`, err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ---- internals ----
|
|
219
|
+
setMuted(id, muted) {
|
|
220
|
+
const p = this.peers.get(id);
|
|
221
|
+
if (!p || p.muted === muted) return;
|
|
222
|
+
p.muted = muted;
|
|
223
|
+
if (p.el) p.el.muted = muted || this.spatial;
|
|
224
|
+
if (muted && p.speaking) p.speaking = false;
|
|
225
|
+
this.emit();
|
|
226
|
+
}
|
|
227
|
+
createPeer(id) {
|
|
228
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
229
|
+
const peer = {
|
|
230
|
+
id,
|
|
231
|
+
pc,
|
|
232
|
+
el: null,
|
|
233
|
+
stream: null,
|
|
234
|
+
analyser: null,
|
|
235
|
+
pendingIce: [],
|
|
236
|
+
speaking: false,
|
|
237
|
+
muted: false,
|
|
238
|
+
volume: 1,
|
|
239
|
+
connection: "connecting"
|
|
240
|
+
};
|
|
241
|
+
this.peers.set(id, peer);
|
|
242
|
+
for (const track of this.local?.getAudioTracks() ?? []) {
|
|
243
|
+
if (this.local) pc.addTrack(track, this.local);
|
|
244
|
+
}
|
|
245
|
+
pc.onicecandidate = (e) => {
|
|
246
|
+
if (e.candidate) this.transport.sendSignal(id, { t: "ice", c: e.candidate.toJSON() });
|
|
247
|
+
};
|
|
248
|
+
pc.ontrack = (e) => {
|
|
249
|
+
const stream = e.streams[0] ?? new MediaStream([e.track]);
|
|
250
|
+
peer.stream = stream;
|
|
251
|
+
peer.el = this.attachSink(stream, peer);
|
|
252
|
+
peer.analyser = this.makeAnalyser(stream);
|
|
253
|
+
this.emit();
|
|
254
|
+
};
|
|
255
|
+
pc.onconnectionstatechange = () => {
|
|
256
|
+
const s = pc.connectionState;
|
|
257
|
+
const next = s === "connected" ? "connected" : s === "failed" || s === "closed" ? "failed" : "connecting";
|
|
258
|
+
if (next !== peer.connection) {
|
|
259
|
+
peer.connection = next;
|
|
260
|
+
if (next === "failed") {
|
|
261
|
+
const relayed = this.iceServers.some((s2) => hasTurnUrl(s2));
|
|
262
|
+
console.warn(
|
|
263
|
+
`[multiplayer] voice connection to ${id} failed` + (relayed ? "" : " \u2014 no TURN in the ICE list (relay offered none?)")
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
this.emit();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
if (this.transport.selfId() < id) void this.offer(peer);
|
|
270
|
+
return peer;
|
|
271
|
+
}
|
|
272
|
+
async offer(peer) {
|
|
273
|
+
try {
|
|
274
|
+
const offer = await peer.pc.createOffer();
|
|
275
|
+
await peer.pc.setLocalDescription(offer);
|
|
276
|
+
this.transport.sendSignal(peer.id, { t: "offer", sdp: offer });
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.warn(`[multiplayer] voice offer to ${peer.id} failed`, err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async flushIce(peer) {
|
|
282
|
+
const queued = peer.pendingIce.splice(0);
|
|
283
|
+
for (const c of queued) {
|
|
284
|
+
try {
|
|
285
|
+
await peer.pc.addIceCandidate(c);
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
destroyPeer(id) {
|
|
291
|
+
const peer = this.peers.get(id);
|
|
292
|
+
if (!peer) return;
|
|
293
|
+
this.peers.delete(id);
|
|
294
|
+
peer.pc.onicecandidate = null;
|
|
295
|
+
peer.pc.ontrack = null;
|
|
296
|
+
peer.pc.onconnectionstatechange = null;
|
|
297
|
+
try {
|
|
298
|
+
peer.pc.close();
|
|
299
|
+
} catch {
|
|
300
|
+
}
|
|
301
|
+
if (peer.el) {
|
|
302
|
+
peer.el.srcObject = null;
|
|
303
|
+
peer.el.remove();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** Attach the stream to an element. Required even in spatial mode (see VoiceJoinOptions). */
|
|
307
|
+
attachSink(stream, peer) {
|
|
308
|
+
const el = document.createElement("audio");
|
|
309
|
+
el.autoplay = true;
|
|
310
|
+
el.srcObject = stream;
|
|
311
|
+
el.muted = this.spatial || peer.muted;
|
|
312
|
+
el.volume = peer.volume;
|
|
313
|
+
el.style.display = "none";
|
|
314
|
+
document.body.appendChild(el);
|
|
315
|
+
void el.play().catch((err) => {
|
|
316
|
+
console.warn("[multiplayer] voice playback was blocked \u2014 join voice from a click/tap", err);
|
|
317
|
+
});
|
|
318
|
+
return el;
|
|
319
|
+
}
|
|
320
|
+
makeAnalyser(stream) {
|
|
321
|
+
if (!this.audio) return null;
|
|
322
|
+
try {
|
|
323
|
+
const src = this.audio.createMediaStreamSource(stream);
|
|
324
|
+
const analyser = this.audio.createAnalyser();
|
|
325
|
+
analyser.fftSize = 512;
|
|
326
|
+
src.connect(analyser);
|
|
327
|
+
return analyser;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
pollLevels() {
|
|
333
|
+
let changed = false;
|
|
334
|
+
const localLevel = this.micOn ? rms(this.localAnalyser) : 0;
|
|
335
|
+
const nextSelf = this.crossed(localLevel, this.selfSpeaking);
|
|
336
|
+
if (nextSelf !== this.selfSpeaking) {
|
|
337
|
+
this.selfSpeaking = nextSelf;
|
|
338
|
+
changed = true;
|
|
339
|
+
}
|
|
340
|
+
for (const peer of this.peers.values()) {
|
|
341
|
+
const next = peer.muted ? false : this.crossed(rms(peer.analyser), peer.speaking);
|
|
342
|
+
if (next !== peer.speaking) {
|
|
343
|
+
peer.speaking = next;
|
|
344
|
+
changed = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (changed) this.emit();
|
|
348
|
+
}
|
|
349
|
+
/** Threshold with hysteresis so a name tag does not strobe between syllables. */
|
|
350
|
+
crossed(level, was) {
|
|
351
|
+
return level >= (was ? this.threshold * SPEAKING_RELEASE_RATIO : this.threshold);
|
|
352
|
+
}
|
|
353
|
+
emit() {
|
|
354
|
+
for (const cb of [...this.listeners]) {
|
|
355
|
+
try {
|
|
356
|
+
cb();
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.error("[multiplayer] a voice listener threw", err);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
function hasTurnUrl(server) {
|
|
364
|
+
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
365
|
+
return urls.some((u) => typeof u === "string" && u.startsWith("turn"));
|
|
366
|
+
}
|
|
367
|
+
function rms(analyser) {
|
|
368
|
+
if (!analyser) return 0;
|
|
369
|
+
const buf = new Uint8Array(analyser.fftSize);
|
|
370
|
+
analyser.getByteTimeDomainData(buf);
|
|
371
|
+
let sum = 0;
|
|
372
|
+
for (let i = 0; i < buf.length; i++) {
|
|
373
|
+
const v = ((buf[i] ?? 128) - 128) / 128;
|
|
374
|
+
sum += v * v;
|
|
375
|
+
}
|
|
376
|
+
return Math.sqrt(sum / buf.length);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/index.ts
|
|
3
380
|
var RELAY_ROOM_TYPE = "play";
|
|
4
381
|
var QUEUE_ROOM_TYPE = "queue";
|
|
5
382
|
var MATCH_ROOM_TYPE = "match";
|
|
@@ -22,6 +399,17 @@ var MATCH_KEY = "__match";
|
|
|
22
399
|
var INPUT_MSG = "__input";
|
|
23
400
|
var CAPABILITIES_MSG = "__capabilities";
|
|
24
401
|
var CAPABILITIES_REQUEST_MSG = "__capabilities:request";
|
|
402
|
+
var CHAT_MSG = "__chat";
|
|
403
|
+
var CHAT_HISTORY_MSG = "__chat:history";
|
|
404
|
+
var CHAT_HISTORY_REQUEST_MSG = "__chat:history:request";
|
|
405
|
+
var VOICE_KEY = "__voice";
|
|
406
|
+
var VOICE_JOIN_MSG = "__voice:join";
|
|
407
|
+
var VOICE_LEAVE_MSG = "__voice:leave";
|
|
408
|
+
var VOICE_SIGNAL_MSG = "__voice:signal";
|
|
409
|
+
var VOICE_REJECTED_MSG = "__voice:rejected";
|
|
410
|
+
var VOICE_ICE_REQUEST_MSG = "__voice:ice:request";
|
|
411
|
+
var VOICE_ICE_MSG = "__voice:ice";
|
|
412
|
+
var ICE_REQUEST_TIMEOUT_MS = 3e3;
|
|
25
413
|
var OBJECT_CONTROL_RESULT_MSG = "__object:result";
|
|
26
414
|
var OBJECT_CONTROL_VERSION = 2;
|
|
27
415
|
var STATE_SNAP_MSG = "__state:snap";
|
|
@@ -320,6 +708,10 @@ function attachSession(client, initialRoom) {
|
|
|
320
708
|
"reconnecting",
|
|
321
709
|
"reconnected",
|
|
322
710
|
"disconnect",
|
|
711
|
+
// Locally emitted by the voice mesh — never relay message types. Without these here,
|
|
712
|
+
// room.on('voice', …) would register a stray room.onMessage('voice') as well.
|
|
713
|
+
"voice",
|
|
714
|
+
"voice:rejected",
|
|
323
715
|
INPUT_MSG,
|
|
324
716
|
// inputs.on() has its own channel — never bridged as a custom event
|
|
325
717
|
"rate:drop",
|
|
@@ -328,7 +720,19 @@ function attachSession(client, initialRoom) {
|
|
|
328
720
|
CAPABILITIES_REQUEST_MSG,
|
|
329
721
|
OBJECT_CONTROL_RESULT_MSG,
|
|
330
722
|
STATE_SNAP_MSG,
|
|
331
|
-
OBJECT_SNAP_MSG
|
|
723
|
+
OBJECT_SNAP_MSG,
|
|
724
|
+
// chat.on() has its own channel. Bridging these would re-register room.onMessage for
|
|
725
|
+
// the same type and clobber the chat listener, silently killing chat for that game.
|
|
726
|
+
CHAT_MSG,
|
|
727
|
+
CHAT_HISTORY_MSG,
|
|
728
|
+
CHAT_HISTORY_REQUEST_MSG,
|
|
729
|
+
// voice.* owns these; bridging them would clobber the mesh's own listeners.
|
|
730
|
+
VOICE_SIGNAL_MSG,
|
|
731
|
+
VOICE_REJECTED_MSG,
|
|
732
|
+
VOICE_JOIN_MSG,
|
|
733
|
+
VOICE_LEAVE_MSG,
|
|
734
|
+
VOICE_ICE_REQUEST_MSG,
|
|
735
|
+
VOICE_ICE_MSG
|
|
332
736
|
]);
|
|
333
737
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
334
738
|
const lastShared = /* @__PURE__ */ new Map();
|
|
@@ -344,6 +748,12 @@ function attachSession(client, initialRoom) {
|
|
|
344
748
|
}
|
|
345
749
|
return;
|
|
346
750
|
}
|
|
751
|
+
if (key === VOICE_KEY) {
|
|
752
|
+
const ids = parseValue(value);
|
|
753
|
+
voice.setRoster(Array.isArray(ids) ? ids.filter((v) => typeof v === "string") : []);
|
|
754
|
+
emit("voice", voice.participants);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
347
757
|
if (key.startsWith(OBJ_PREFIX)) {
|
|
348
758
|
const id = key.slice(OBJ_PREFIX.length);
|
|
349
759
|
const blob = parseValue(value);
|
|
@@ -544,6 +954,55 @@ function attachSession(client, initialRoom) {
|
|
|
544
954
|
}
|
|
545
955
|
if (countIfDropped) droppedWhileOffline++;
|
|
546
956
|
};
|
|
957
|
+
const voice = new VoiceMesh({
|
|
958
|
+
selfId: () => room.sessionId,
|
|
959
|
+
nameOf: (id) => room.state?.players?.get(id)?.name ?? "",
|
|
960
|
+
sendSignal: (to, data) => send0(VOICE_SIGNAL_MSG, { to, data }, true)
|
|
961
|
+
});
|
|
962
|
+
voice.onChange(() => emit("voice", voice.participants));
|
|
963
|
+
let iceRefreshTimer = null;
|
|
964
|
+
const ice = createIceRequester(() => send0(VOICE_ICE_REQUEST_MSG, {}, true), ICE_REQUEST_TIMEOUT_MS);
|
|
965
|
+
const clearIceRefresh = () => {
|
|
966
|
+
if (iceRefreshTimer) clearTimeout(iceRefreshTimer);
|
|
967
|
+
iceRefreshTimer = null;
|
|
968
|
+
};
|
|
969
|
+
const scheduleIceRefresh = (ttl) => {
|
|
970
|
+
clearIceRefresh();
|
|
971
|
+
if (typeof ttl !== "number" || !Number.isFinite(ttl) || ttl <= 0) return;
|
|
972
|
+
iceRefreshTimer = setTimeout(() => {
|
|
973
|
+
if (!voice.active) return;
|
|
974
|
+
void ice.request().then((cfg) => {
|
|
975
|
+
if (!voice.active || !cfg?.iceServers?.length) return;
|
|
976
|
+
voice.setIceServers(cfg.iceServers);
|
|
977
|
+
scheduleIceRefresh(cfg.ttl);
|
|
978
|
+
});
|
|
979
|
+
}, Math.max(6e4, Math.floor(ttl * 800)));
|
|
980
|
+
};
|
|
981
|
+
const chatHistory = [];
|
|
982
|
+
const chatSeen = /* @__PURE__ */ new Set();
|
|
983
|
+
const chatListeners = /* @__PURE__ */ new Set();
|
|
984
|
+
const chatMuted = /* @__PURE__ */ new Set();
|
|
985
|
+
const isChatMessage = (m) => !!m && typeof m === "object" && typeof m.id === "number" && typeof m.from === "string" && typeof m.name === "string" && typeof m.text === "string";
|
|
986
|
+
const acceptChat = (message) => {
|
|
987
|
+
if (!isChatMessage(message)) return;
|
|
988
|
+
if (chatSeen.has(message.id)) return;
|
|
989
|
+
if (chatMuted.has(message.from)) return;
|
|
990
|
+
chatSeen.add(message.id);
|
|
991
|
+
const last = chatHistory[chatHistory.length - 1];
|
|
992
|
+
if (!last || message.id > last.id) chatHistory.push(message);
|
|
993
|
+
else {
|
|
994
|
+
let i = chatHistory.length;
|
|
995
|
+
while (i > 0 && (chatHistory[i - 1]?.id ?? 0) > message.id) i--;
|
|
996
|
+
chatHistory.splice(i, 0, message);
|
|
997
|
+
}
|
|
998
|
+
for (const cb of [...chatListeners]) {
|
|
999
|
+
try {
|
|
1000
|
+
cb(message);
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
console.error("[multiplayer] a chat listener threw", err);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
547
1006
|
const wireRoom = (r) => {
|
|
548
1007
|
let hydrated;
|
|
549
1008
|
const $ = getStateCallbacks(r);
|
|
@@ -633,6 +1092,32 @@ function attachSession(client, initialRoom) {
|
|
|
633
1092
|
r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
|
|
634
1093
|
});
|
|
635
1094
|
send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
|
|
1095
|
+
r.onMessage(VOICE_SIGNAL_MSG, (msg) => {
|
|
1096
|
+
if (msg && typeof msg.from === "string") void voice.handleSignal(msg.from, msg.data);
|
|
1097
|
+
});
|
|
1098
|
+
r.onMessage(VOICE_REJECTED_MSG, (msg) => {
|
|
1099
|
+
clearIceRefresh();
|
|
1100
|
+
voice.stop();
|
|
1101
|
+
emit("voice:rejected", msg ?? { reason: "full" });
|
|
1102
|
+
});
|
|
1103
|
+
r.onMessage(VOICE_ICE_MSG, (msg) => {
|
|
1104
|
+
ice.deliver(msg ?? null);
|
|
1105
|
+
});
|
|
1106
|
+
for (const inert of [
|
|
1107
|
+
VOICE_JOIN_MSG,
|
|
1108
|
+
VOICE_LEAVE_MSG,
|
|
1109
|
+
VOICE_ICE_REQUEST_MSG,
|
|
1110
|
+
CHAT_HISTORY_REQUEST_MSG
|
|
1111
|
+
]) {
|
|
1112
|
+
r.onMessage(inert, () => {
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
r.onMessage(CHAT_MSG, (msg) => acceptChat(msg));
|
|
1116
|
+
r.onMessage(CHAT_HISTORY_MSG, (msg) => {
|
|
1117
|
+
if (!Array.isArray(msg?.messages)) return;
|
|
1118
|
+
for (const m of msg.messages) acceptChat(m);
|
|
1119
|
+
});
|
|
1120
|
+
send0(CHAT_HISTORY_REQUEST_MSG, {});
|
|
636
1121
|
r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
|
|
637
1122
|
if (!msg || typeof msg.requestId !== "string") return;
|
|
638
1123
|
const pending = pendingControls.get(msg.requestId);
|
|
@@ -914,11 +1399,82 @@ function attachSession(client, initialRoom) {
|
|
|
914
1399
|
}
|
|
915
1400
|
send0(type, payload, true);
|
|
916
1401
|
},
|
|
1402
|
+
chat: {
|
|
1403
|
+
send(text) {
|
|
1404
|
+
if (typeof text !== "string") return;
|
|
1405
|
+
if (!text.trim()) return;
|
|
1406
|
+
send0(CHAT_MSG, { text }, true);
|
|
1407
|
+
},
|
|
1408
|
+
on(cb) {
|
|
1409
|
+
chatListeners.add(cb);
|
|
1410
|
+
return () => chatListeners.delete(cb);
|
|
1411
|
+
},
|
|
1412
|
+
get history() {
|
|
1413
|
+
return [...chatHistory];
|
|
1414
|
+
},
|
|
1415
|
+
mute(playerId) {
|
|
1416
|
+
if (typeof playerId === "string" && playerId) chatMuted.add(playerId);
|
|
1417
|
+
},
|
|
1418
|
+
unmute(playerId) {
|
|
1419
|
+
chatMuted.delete(playerId);
|
|
1420
|
+
},
|
|
1421
|
+
get muted() {
|
|
1422
|
+
return [...chatMuted];
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
voice: {
|
|
1426
|
+
async join(options) {
|
|
1427
|
+
await voice.start(options, true);
|
|
1428
|
+
if (!options?.iceServers?.length) {
|
|
1429
|
+
const cfg = await ice.request();
|
|
1430
|
+
if (voice.active && cfg?.iceServers?.length) {
|
|
1431
|
+
voice.setIceServers(cfg.iceServers);
|
|
1432
|
+
scheduleIceRefresh(cfg.ttl);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (!voice.active) return;
|
|
1436
|
+
send0(VOICE_JOIN_MSG, {}, true);
|
|
1437
|
+
voice.releasePeering();
|
|
1438
|
+
},
|
|
1439
|
+
leave() {
|
|
1440
|
+
clearIceRefresh();
|
|
1441
|
+
send0(VOICE_LEAVE_MSG, {}, true);
|
|
1442
|
+
voice.stop();
|
|
1443
|
+
},
|
|
1444
|
+
get active() {
|
|
1445
|
+
return voice.active;
|
|
1446
|
+
},
|
|
1447
|
+
get participants() {
|
|
1448
|
+
return voice.participants;
|
|
1449
|
+
},
|
|
1450
|
+
setMicEnabled(on2) {
|
|
1451
|
+
voice.setMicEnabled(on2 === true);
|
|
1452
|
+
},
|
|
1453
|
+
get micEnabled() {
|
|
1454
|
+
return voice.micEnabled;
|
|
1455
|
+
},
|
|
1456
|
+
get speaking() {
|
|
1457
|
+
return voice.speaking;
|
|
1458
|
+
},
|
|
1459
|
+
mute(playerId) {
|
|
1460
|
+
voice.mute(playerId);
|
|
1461
|
+
},
|
|
1462
|
+
unmute(playerId) {
|
|
1463
|
+
voice.unmute(playerId);
|
|
1464
|
+
},
|
|
1465
|
+
setVolume(playerId, volume) {
|
|
1466
|
+
voice.setVolume(playerId, volume);
|
|
1467
|
+
},
|
|
1468
|
+
get muted() {
|
|
1469
|
+
return voice.participants.filter((p) => p.muted).map((p) => p.id);
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
917
1472
|
setRoomOpen(open) {
|
|
918
1473
|
room.send(ROOM_OPEN_MSG, { open });
|
|
919
1474
|
},
|
|
920
1475
|
leave() {
|
|
921
1476
|
intentionalLeave = true;
|
|
1477
|
+
voice.stop();
|
|
922
1478
|
connected = false;
|
|
923
1479
|
syncHostTicks();
|
|
924
1480
|
settleAllControls("aborted");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/multiplayer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "The multiplayer client wrapper + shared room schema for genex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsup",
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "node --test test/*.test.ts",
|
|
26
27
|
"check": "pnpm build && publint && attw --pack . --profile esm-only",
|
|
27
28
|
"prepack": "pnpm build && publint"
|
|
28
29
|
},
|
|
@@ -44,7 +45,8 @@
|
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@arethetypeswrong/cli": "^0.18.0",
|
|
46
47
|
"publint": "^0.3.0",
|
|
47
|
-
"tsup": "^8.0.0"
|
|
48
|
+
"tsup": "^8.0.0",
|
|
49
|
+
"typescript": "^5.8.0"
|
|
48
50
|
},
|
|
49
51
|
"repository": {
|
|
50
52
|
"type": "git",
|