@genex-ai/multiplayer 0.12.1 → 0.15.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 +68 -1
- package/dist/index.d.ts +139 -1
- package/dist/index.js +441 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -223,12 +223,79 @@ 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
|
+
**Configure TURN for real games.** Roughly 10–20% of players sit behind symmetric NAT and
|
|
287
|
+
cannot connect peer-to-peer at all. Without a TURN server they hear nothing and are heard
|
|
288
|
+
by nobody, while everyone else works fine — which is a miserable bug to diagnose:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
await room.voice.join({ iceServers: [{ urls: 'turn:…', username: '…', credential: '…' }] });
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Spatial voice** (`npx genex controller voice`) is the version worth shipping in a 3D game:
|
|
295
|
+
join with `{ spatial: true }` and the SDK stops playing audio flat, handing you `p.stream`
|
|
296
|
+
per participant to drive a `THREE.PositionalAudio`. Distance falloff also softens the cap —
|
|
297
|
+
six audible neighbours read as a populated world rather than as a limit.
|
|
298
|
+
|
|
232
299
|
## `leave`
|
|
233
300
|
|
|
234
301
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
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
|
+
* Extra ICE servers — in practice a TURN entry. Roughly 10–20% of players sit behind
|
|
22
|
+
* symmetric NAT and cannot go peer-to-peer at all; without TURN, voice simply never
|
|
23
|
+
* connects for them. STUN-only is the default because it needs no infrastructure.
|
|
24
|
+
*/
|
|
25
|
+
iceServers?: RTCIceServer[];
|
|
26
|
+
/** Join with the mic gated off — the right default for push-to-talk games. */
|
|
27
|
+
startMuted?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Spatial mode: the SDK does NOT play remote audio, it only hands you `p.stream` so you
|
|
30
|
+
* can drive a THREE.PositionalAudio yourself (`genex controller voice` does this). A
|
|
31
|
+
* muted sink element is still created per peer — without one attached, Chrome does not
|
|
32
|
+
* flow a remote WebRTC stream into WebAudio at all, which reads as "spatial voice is
|
|
33
|
+
* silent" and is the single most confusing bug in this lane.
|
|
34
|
+
*/
|
|
35
|
+
spatial?: boolean;
|
|
36
|
+
/** RMS threshold for the speaking flag, 0..1. Default 0.02. */
|
|
37
|
+
speakingThreshold?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
4
40
|
type ColyseusAuth = {
|
|
5
41
|
embedToken: string;
|
|
6
42
|
};
|
|
@@ -181,6 +217,15 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
181
217
|
on(event: 'object', cb: (id: string) => void): () => void;
|
|
182
218
|
/** The room host changed (fires with the new host's id, or `undefined`). */
|
|
183
219
|
on(event: 'host', cb: (host: string | undefined) => void): () => void;
|
|
220
|
+
/** The voice roster or a participant's state changed (joined, left, started/stopped
|
|
221
|
+
* speaking, connected, muted). Fires with the full participant list — re-render from it. */
|
|
222
|
+
on(event: 'voice', cb: (participants: VoiceParticipant[]) => void): () => void;
|
|
223
|
+
/** Your `voice.join()` was refused by the relay — the room is at the 6-participant cap.
|
|
224
|
+
* The mic is already released; show the reason rather than retrying in a loop. */
|
|
225
|
+
on(event: 'voice:rejected', cb: (info: {
|
|
226
|
+
reason: string;
|
|
227
|
+
limit?: number;
|
|
228
|
+
}) => void): () => void;
|
|
184
229
|
/**
|
|
185
230
|
* The connection dropped unexpectedly and auto-reconnect is underway (attempts run
|
|
186
231
|
* ~0.5s→8s backoff inside the server's grace window). Show a "reconnecting…" overlay;
|
|
@@ -204,9 +249,102 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
204
249
|
* by the relay itself (`state`, `shared`, `claim`, `obj`, `release`, `destroy`, and the
|
|
205
250
|
* `match:`/`__` prefixes) are refused with a console warning — pick another name. */
|
|
206
251
|
send(type: string, payload?: unknown): void;
|
|
252
|
+
/** Text chat for this room. See {@link ChatApi}. */
|
|
253
|
+
chat: ChatApi;
|
|
254
|
+
/** Mesh voice chat for this room. See {@link VoiceApi}. */
|
|
255
|
+
voice: VoiceApi;
|
|
256
|
+
/**
|
|
257
|
+
* HOST ONLY. Stop (or resume) the matchmaker placing NEW players into THIS room.
|
|
258
|
+
*
|
|
259
|
+
* For a lobby game: call `setRoomOpen(false)` when your match starts, so arrivals
|
|
260
|
+
* form the NEXT room instead of dropping into a fight already in progress, and
|
|
261
|
+
* `setRoomOpen(true)` when you return to the lobby. Everyone already in the room
|
|
262
|
+
* keeps playing, and a reconnecting player still gets back in (reconnection uses a
|
|
263
|
+
* reserved seat, not matchmaking).
|
|
264
|
+
*
|
|
265
|
+
* Ignored (with no error) when called by a non-host — read `isHost` first. All
|
|
266
|
+
* clients are told either way, so you can render "closed" from the `room:open` event.
|
|
267
|
+
*/
|
|
268
|
+
setRoomOpen(open: boolean): void;
|
|
207
269
|
/** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
|
|
208
270
|
leave(): void;
|
|
209
271
|
};
|
|
272
|
+
/** One chat line, as the relay stamped it. Only `text` came from the sender's client —
|
|
273
|
+
* `from` and `name` are filled in server-side from the verified session, so a player
|
|
274
|
+
* cannot post under someone else's name. */
|
|
275
|
+
type ChatMessage = {
|
|
276
|
+
/** Monotonic per room, ascending. Stable across a reconnect — use it as a render key. */
|
|
277
|
+
id: number;
|
|
278
|
+
/** Sender's player id (`room.id` for your own lines). */
|
|
279
|
+
from: string;
|
|
280
|
+
/** Sender's verified display name. */
|
|
281
|
+
name: string;
|
|
282
|
+
/** Sanitized text: ≤500 code points, no control or bidi characters, single-spaced. */
|
|
283
|
+
text: string;
|
|
284
|
+
/** Server timestamp (epoch ms). */
|
|
285
|
+
at: number;
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Room text chat. The relay keeps the last ~50 lines and replays them on connect, so a
|
|
289
|
+
* player joining mid-game sees the recent conversation.
|
|
290
|
+
*
|
|
291
|
+
* IMPORTANT — chat is NOT `send()`: your own lines come back to you, in server order
|
|
292
|
+
* with the server's timestamp. Render only what arrives here; do not also append your
|
|
293
|
+
* own line locally on submit, or it will appear twice.
|
|
294
|
+
*/
|
|
295
|
+
type ChatApi = {
|
|
296
|
+
/** Send a line. Empty/whitespace-only text is ignored; the relay trims, strips control
|
|
297
|
+
* and bidi characters, and clamps to 500 code points. Over ~2 lines/second the relay
|
|
298
|
+
* drops the excess (and warns via `rate:drop`). */
|
|
299
|
+
send(text: string): void;
|
|
300
|
+
/**
|
|
301
|
+
* Fires once per line you had not seen — live messages, plus any the replayed history
|
|
302
|
+
* fills in on connect or after a reconnect blip. Append-only and de-duplicated by `id`,
|
|
303
|
+
* so a handler that just appends stays correct across reconnects. Returns a disposer.
|
|
304
|
+
*/
|
|
305
|
+
on(cb: (message: ChatMessage) => void): () => void;
|
|
306
|
+
/** Every line known to this client, oldest first, de-duplicated. Safe to render on mount. */
|
|
307
|
+
readonly history: ChatMessage[];
|
|
308
|
+
/** Hide a player's lines locally (client-side only — nothing is sent to the server and
|
|
309
|
+
* other players are unaffected). Applies to messages received from now on. */
|
|
310
|
+
mute(playerId: string): void;
|
|
311
|
+
unmute(playerId: string): void;
|
|
312
|
+
/** Currently muted player ids. */
|
|
313
|
+
readonly muted: string[];
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* Mesh voice chat — real-time talking between players. Audio travels peer-to-peer; the
|
|
317
|
+
* relay only keeps the roster and couriers the WebRTC handshake.
|
|
318
|
+
*
|
|
319
|
+
* **Party-sized, not room-sized.** The relay caps voice at 6 participants because a mesh
|
|
320
|
+
* makes every participant upload to every other one, and a joiner has to negotiate with
|
|
321
|
+
* everyone already present — reliability, not bandwidth, is what breaks first. A 7th
|
|
322
|
+
* `join()` is refused with `voice:rejected`; the room itself still holds up to 64 players.
|
|
323
|
+
*
|
|
324
|
+
* `join()` must be called from a click or tap: browsers gate both the microphone prompt
|
|
325
|
+
* and audio playback on a user gesture. It rejects if the player denies the mic — that is
|
|
326
|
+
* an ordinary outcome to show in the UI, not an error to swallow.
|
|
327
|
+
*/
|
|
328
|
+
type VoiceApi = {
|
|
329
|
+
/** Ask for the mic and enter the room's voice roster. Rejects if permission is denied. */
|
|
330
|
+
join(options?: VoiceJoinOptions): Promise<void>;
|
|
331
|
+
/** Leave voice and release the microphone (this is what turns the recording indicator off). */
|
|
332
|
+
leave(): void;
|
|
333
|
+
readonly active: boolean;
|
|
334
|
+
/** Everyone else in voice, with their stream, speaking flag and connection state. */
|
|
335
|
+
readonly participants: VoiceParticipant[];
|
|
336
|
+
/** Push-to-talk: gate your outgoing audio instantly, without renegotiating. */
|
|
337
|
+
setMicEnabled(on: boolean): void;
|
|
338
|
+
readonly micEnabled: boolean;
|
|
339
|
+
/** True while YOU are above the speaking threshold and un-gated. */
|
|
340
|
+
readonly speaking: boolean;
|
|
341
|
+
/** Silence a player locally. Nothing is sent to them, to the relay, or to anyone else. */
|
|
342
|
+
mute(playerId: string): void;
|
|
343
|
+
unmute(playerId: string): void;
|
|
344
|
+
/** 0..1 playback volume for one player (ignored in `spatial` mode — you own the audio then). */
|
|
345
|
+
setVolume(playerId: string, volume: number): void;
|
|
346
|
+
readonly muted: string[];
|
|
347
|
+
};
|
|
210
348
|
/**
|
|
211
349
|
* Connect to a genex multiplayer room. You must supply your player-state type:
|
|
212
350
|
*
|
|
@@ -330,4 +468,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
|
|
|
330
468
|
*/
|
|
331
469
|
declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
|
|
332
470
|
|
|
333
|
-
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 };
|
|
471
|
+
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,304 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Client, getStateCallbacks } from "@colyseus/sdk";
|
|
3
|
+
|
|
4
|
+
// src/voice.ts
|
|
5
|
+
var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
6
|
+
var SPEAKING_POLL_MS = 120;
|
|
7
|
+
var DEFAULT_SPEAKING_THRESHOLD = 0.02;
|
|
8
|
+
var SPEAKING_RELEASE_RATIO = 0.6;
|
|
9
|
+
var VoiceMesh = class {
|
|
10
|
+
transport;
|
|
11
|
+
peers = /* @__PURE__ */ new Map();
|
|
12
|
+
listeners = /* @__PURE__ */ new Set();
|
|
13
|
+
roster = [];
|
|
14
|
+
local = null;
|
|
15
|
+
audio = null;
|
|
16
|
+
localAnalyser = null;
|
|
17
|
+
levelTimer = null;
|
|
18
|
+
iceServers = DEFAULT_ICE;
|
|
19
|
+
spatial = false;
|
|
20
|
+
threshold = DEFAULT_SPEAKING_THRESHOLD;
|
|
21
|
+
micOn = true;
|
|
22
|
+
selfSpeaking = false;
|
|
23
|
+
running = false;
|
|
24
|
+
constructor(transport) {
|
|
25
|
+
this.transport = transport;
|
|
26
|
+
}
|
|
27
|
+
get active() {
|
|
28
|
+
return this.running;
|
|
29
|
+
}
|
|
30
|
+
get micEnabled() {
|
|
31
|
+
return this.micOn;
|
|
32
|
+
}
|
|
33
|
+
get speaking() {
|
|
34
|
+
return this.selfSpeaking;
|
|
35
|
+
}
|
|
36
|
+
get participants() {
|
|
37
|
+
return [...this.peers.values()].map((p) => ({
|
|
38
|
+
id: p.id,
|
|
39
|
+
name: this.transport.nameOf(p.id),
|
|
40
|
+
stream: p.stream,
|
|
41
|
+
speaking: p.speaking,
|
|
42
|
+
muted: p.muted,
|
|
43
|
+
volume: p.volume,
|
|
44
|
+
connection: p.connection
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
onChange(cb) {
|
|
48
|
+
this.listeners.add(cb);
|
|
49
|
+
return () => this.listeners.delete(cb);
|
|
50
|
+
}
|
|
51
|
+
/** Acquire the microphone. Throws if the user denies it or no device exists — the caller
|
|
52
|
+
* surfaces that; it is a normal outcome, not a bug. Must be called from a user gesture:
|
|
53
|
+
* browsers gate both the permission prompt and audio playback on one. */
|
|
54
|
+
async start(options = {}) {
|
|
55
|
+
if (this.running) return;
|
|
56
|
+
this.iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE;
|
|
57
|
+
this.spatial = options.spatial === true;
|
|
58
|
+
this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
|
|
59
|
+
this.micOn = options.startMuted !== true;
|
|
60
|
+
this.local = await navigator.mediaDevices.getUserMedia({
|
|
61
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
62
|
+
video: false
|
|
63
|
+
});
|
|
64
|
+
for (const t of this.local.getAudioTracks()) t.enabled = this.micOn;
|
|
65
|
+
this.audio = new AudioContext();
|
|
66
|
+
this.localAnalyser = this.makeAnalyser(this.local);
|
|
67
|
+
this.running = true;
|
|
68
|
+
this.levelTimer = setInterval(() => this.pollLevels(), SPEAKING_POLL_MS);
|
|
69
|
+
}
|
|
70
|
+
/** Tear everything down. Safe to call when not running. */
|
|
71
|
+
stop() {
|
|
72
|
+
this.running = false;
|
|
73
|
+
if (this.levelTimer) {
|
|
74
|
+
clearInterval(this.levelTimer);
|
|
75
|
+
this.levelTimer = null;
|
|
76
|
+
}
|
|
77
|
+
for (const id of [...this.peers.keys()]) this.destroyPeer(id);
|
|
78
|
+
for (const t of this.local?.getTracks() ?? []) t.stop();
|
|
79
|
+
this.local = null;
|
|
80
|
+
this.localAnalyser = null;
|
|
81
|
+
void this.audio?.close().catch(() => {
|
|
82
|
+
});
|
|
83
|
+
this.audio = null;
|
|
84
|
+
this.roster = [];
|
|
85
|
+
this.selfSpeaking = false;
|
|
86
|
+
this.emit();
|
|
87
|
+
}
|
|
88
|
+
/** The relay's authoritative roster changed: open connections to newcomers, drop leavers. */
|
|
89
|
+
setRoster(ids) {
|
|
90
|
+
this.roster = ids;
|
|
91
|
+
if (!this.running) return;
|
|
92
|
+
const self = this.transport.selfId();
|
|
93
|
+
const wanted = new Set(ids.filter((id) => id !== self));
|
|
94
|
+
for (const id of wanted) if (!this.peers.has(id)) this.createPeer(id);
|
|
95
|
+
for (const id of [...this.peers.keys()]) if (!wanted.has(id)) this.destroyPeer(id);
|
|
96
|
+
this.emit();
|
|
97
|
+
}
|
|
98
|
+
/** Push-to-talk. Gates the track rather than renegotiating — instant, and it keeps the
|
|
99
|
+
* peer connections warm so the first syllable after a key press is not clipped. */
|
|
100
|
+
setMicEnabled(on) {
|
|
101
|
+
this.micOn = on;
|
|
102
|
+
for (const t of this.local?.getAudioTracks() ?? []) t.enabled = on;
|
|
103
|
+
if (!on && this.selfSpeaking) {
|
|
104
|
+
this.selfSpeaking = false;
|
|
105
|
+
this.emit();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
mute(id) {
|
|
109
|
+
this.setMuted(id, true);
|
|
110
|
+
}
|
|
111
|
+
unmute(id) {
|
|
112
|
+
this.setMuted(id, false);
|
|
113
|
+
}
|
|
114
|
+
setVolume(id, volume) {
|
|
115
|
+
const p = this.peers.get(id);
|
|
116
|
+
if (!p) return;
|
|
117
|
+
p.volume = Math.max(0, Math.min(1, volume));
|
|
118
|
+
if (p.el) p.el.volume = p.volume;
|
|
119
|
+
this.emit();
|
|
120
|
+
}
|
|
121
|
+
/** A signal arrived from `from` (the relay stamped it — it cannot be spoofed). */
|
|
122
|
+
async handleSignal(from, raw) {
|
|
123
|
+
if (!this.running) return;
|
|
124
|
+
const signal = raw;
|
|
125
|
+
if (!signal || typeof signal !== "object" || typeof signal.t !== "string") return;
|
|
126
|
+
const peer = this.peers.get(from) ?? (this.roster.includes(from) ? this.createPeer(from) : void 0);
|
|
127
|
+
if (!peer) return;
|
|
128
|
+
try {
|
|
129
|
+
if (signal.t === "offer") {
|
|
130
|
+
await peer.pc.setRemoteDescription(signal.sdp);
|
|
131
|
+
await this.flushIce(peer);
|
|
132
|
+
const answer = await peer.pc.createAnswer();
|
|
133
|
+
await peer.pc.setLocalDescription(answer);
|
|
134
|
+
this.transport.sendSignal(from, { t: "answer", sdp: answer });
|
|
135
|
+
} else if (signal.t === "answer") {
|
|
136
|
+
if (peer.pc.signalingState !== "have-local-offer") return;
|
|
137
|
+
await peer.pc.setRemoteDescription(signal.sdp);
|
|
138
|
+
await this.flushIce(peer);
|
|
139
|
+
} else if (signal.t === "ice") {
|
|
140
|
+
if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.c);
|
|
141
|
+
else peer.pendingIce.push(signal.c);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.warn(`[multiplayer] voice signaling failed with ${from}`, err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ---- internals ----
|
|
148
|
+
setMuted(id, muted) {
|
|
149
|
+
const p = this.peers.get(id);
|
|
150
|
+
if (!p || p.muted === muted) return;
|
|
151
|
+
p.muted = muted;
|
|
152
|
+
if (p.el) p.el.muted = muted || this.spatial;
|
|
153
|
+
if (muted && p.speaking) p.speaking = false;
|
|
154
|
+
this.emit();
|
|
155
|
+
}
|
|
156
|
+
createPeer(id) {
|
|
157
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
158
|
+
const peer = {
|
|
159
|
+
id,
|
|
160
|
+
pc,
|
|
161
|
+
el: null,
|
|
162
|
+
stream: null,
|
|
163
|
+
analyser: null,
|
|
164
|
+
pendingIce: [],
|
|
165
|
+
speaking: false,
|
|
166
|
+
muted: false,
|
|
167
|
+
volume: 1,
|
|
168
|
+
connection: "connecting"
|
|
169
|
+
};
|
|
170
|
+
this.peers.set(id, peer);
|
|
171
|
+
for (const track of this.local?.getAudioTracks() ?? []) {
|
|
172
|
+
if (this.local) pc.addTrack(track, this.local);
|
|
173
|
+
}
|
|
174
|
+
pc.onicecandidate = (e) => {
|
|
175
|
+
if (e.candidate) this.transport.sendSignal(id, { t: "ice", c: e.candidate.toJSON() });
|
|
176
|
+
};
|
|
177
|
+
pc.ontrack = (e) => {
|
|
178
|
+
const stream = e.streams[0] ?? new MediaStream([e.track]);
|
|
179
|
+
peer.stream = stream;
|
|
180
|
+
peer.el = this.attachSink(stream, peer);
|
|
181
|
+
peer.analyser = this.makeAnalyser(stream);
|
|
182
|
+
this.emit();
|
|
183
|
+
};
|
|
184
|
+
pc.onconnectionstatechange = () => {
|
|
185
|
+
const s = pc.connectionState;
|
|
186
|
+
const next = s === "connected" ? "connected" : s === "failed" || s === "closed" ? "failed" : "connecting";
|
|
187
|
+
if (next !== peer.connection) {
|
|
188
|
+
peer.connection = next;
|
|
189
|
+
if (next === "failed") {
|
|
190
|
+
console.warn(`[multiplayer] voice connection to ${id} failed (no TURN configured?)`);
|
|
191
|
+
}
|
|
192
|
+
this.emit();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
if (this.transport.selfId() < id) void this.offer(peer);
|
|
196
|
+
return peer;
|
|
197
|
+
}
|
|
198
|
+
async offer(peer) {
|
|
199
|
+
try {
|
|
200
|
+
const offer = await peer.pc.createOffer();
|
|
201
|
+
await peer.pc.setLocalDescription(offer);
|
|
202
|
+
this.transport.sendSignal(peer.id, { t: "offer", sdp: offer });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.warn(`[multiplayer] voice offer to ${peer.id} failed`, err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async flushIce(peer) {
|
|
208
|
+
const queued = peer.pendingIce.splice(0);
|
|
209
|
+
for (const c of queued) {
|
|
210
|
+
try {
|
|
211
|
+
await peer.pc.addIceCandidate(c);
|
|
212
|
+
} catch {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
destroyPeer(id) {
|
|
217
|
+
const peer = this.peers.get(id);
|
|
218
|
+
if (!peer) return;
|
|
219
|
+
this.peers.delete(id);
|
|
220
|
+
peer.pc.onicecandidate = null;
|
|
221
|
+
peer.pc.ontrack = null;
|
|
222
|
+
peer.pc.onconnectionstatechange = null;
|
|
223
|
+
try {
|
|
224
|
+
peer.pc.close();
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
if (peer.el) {
|
|
228
|
+
peer.el.srcObject = null;
|
|
229
|
+
peer.el.remove();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Attach the stream to an element. Required even in spatial mode (see VoiceJoinOptions). */
|
|
233
|
+
attachSink(stream, peer) {
|
|
234
|
+
const el = document.createElement("audio");
|
|
235
|
+
el.autoplay = true;
|
|
236
|
+
el.srcObject = stream;
|
|
237
|
+
el.muted = this.spatial || peer.muted;
|
|
238
|
+
el.volume = peer.volume;
|
|
239
|
+
el.style.display = "none";
|
|
240
|
+
document.body.appendChild(el);
|
|
241
|
+
void el.play().catch((err) => {
|
|
242
|
+
console.warn("[multiplayer] voice playback was blocked \u2014 join voice from a click/tap", err);
|
|
243
|
+
});
|
|
244
|
+
return el;
|
|
245
|
+
}
|
|
246
|
+
makeAnalyser(stream) {
|
|
247
|
+
if (!this.audio) return null;
|
|
248
|
+
try {
|
|
249
|
+
const src = this.audio.createMediaStreamSource(stream);
|
|
250
|
+
const analyser = this.audio.createAnalyser();
|
|
251
|
+
analyser.fftSize = 512;
|
|
252
|
+
src.connect(analyser);
|
|
253
|
+
return analyser;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
pollLevels() {
|
|
259
|
+
let changed = false;
|
|
260
|
+
const localLevel = this.micOn ? rms(this.localAnalyser) : 0;
|
|
261
|
+
const nextSelf = this.crossed(localLevel, this.selfSpeaking);
|
|
262
|
+
if (nextSelf !== this.selfSpeaking) {
|
|
263
|
+
this.selfSpeaking = nextSelf;
|
|
264
|
+
changed = true;
|
|
265
|
+
}
|
|
266
|
+
for (const peer of this.peers.values()) {
|
|
267
|
+
const next = peer.muted ? false : this.crossed(rms(peer.analyser), peer.speaking);
|
|
268
|
+
if (next !== peer.speaking) {
|
|
269
|
+
peer.speaking = next;
|
|
270
|
+
changed = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (changed) this.emit();
|
|
274
|
+
}
|
|
275
|
+
/** Threshold with hysteresis so a name tag does not strobe between syllables. */
|
|
276
|
+
crossed(level, was) {
|
|
277
|
+
return level >= (was ? this.threshold * SPEAKING_RELEASE_RATIO : this.threshold);
|
|
278
|
+
}
|
|
279
|
+
emit() {
|
|
280
|
+
for (const cb of [...this.listeners]) {
|
|
281
|
+
try {
|
|
282
|
+
cb();
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.error("[multiplayer] a voice listener threw", err);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
function rms(analyser) {
|
|
290
|
+
if (!analyser) return 0;
|
|
291
|
+
const buf = new Uint8Array(analyser.fftSize);
|
|
292
|
+
analyser.getByteTimeDomainData(buf);
|
|
293
|
+
let sum = 0;
|
|
294
|
+
for (let i = 0; i < buf.length; i++) {
|
|
295
|
+
const v = ((buf[i] ?? 128) - 128) / 128;
|
|
296
|
+
sum += v * v;
|
|
297
|
+
}
|
|
298
|
+
return Math.sqrt(sum / buf.length);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/index.ts
|
|
3
302
|
var RELAY_ROOM_TYPE = "play";
|
|
4
303
|
var QUEUE_ROOM_TYPE = "queue";
|
|
5
304
|
var MATCH_ROOM_TYPE = "match";
|
|
@@ -22,9 +321,18 @@ var MATCH_KEY = "__match";
|
|
|
22
321
|
var INPUT_MSG = "__input";
|
|
23
322
|
var CAPABILITIES_MSG = "__capabilities";
|
|
24
323
|
var CAPABILITIES_REQUEST_MSG = "__capabilities:request";
|
|
324
|
+
var CHAT_MSG = "__chat";
|
|
325
|
+
var CHAT_HISTORY_MSG = "__chat:history";
|
|
326
|
+
var CHAT_HISTORY_REQUEST_MSG = "__chat:history:request";
|
|
327
|
+
var VOICE_KEY = "__voice";
|
|
328
|
+
var VOICE_JOIN_MSG = "__voice:join";
|
|
329
|
+
var VOICE_LEAVE_MSG = "__voice:leave";
|
|
330
|
+
var VOICE_SIGNAL_MSG = "__voice:signal";
|
|
331
|
+
var VOICE_REJECTED_MSG = "__voice:rejected";
|
|
25
332
|
var OBJECT_CONTROL_RESULT_MSG = "__object:result";
|
|
26
333
|
var OBJECT_CONTROL_VERSION = 2;
|
|
27
334
|
var STATE_SNAP_MSG = "__state:snap";
|
|
335
|
+
var ROOM_OPEN_MSG = "__room:open";
|
|
28
336
|
var OBJECT_SNAP_MSG = "__object:snap";
|
|
29
337
|
var MP_PROTOCOL = 2;
|
|
30
338
|
async function relayClient(opts) {
|
|
@@ -319,6 +627,10 @@ function attachSession(client, initialRoom) {
|
|
|
319
627
|
"reconnecting",
|
|
320
628
|
"reconnected",
|
|
321
629
|
"disconnect",
|
|
630
|
+
// Locally emitted by the voice mesh — never relay message types. Without these here,
|
|
631
|
+
// room.on('voice', …) would register a stray room.onMessage('voice') as well.
|
|
632
|
+
"voice",
|
|
633
|
+
"voice:rejected",
|
|
322
634
|
INPUT_MSG,
|
|
323
635
|
// inputs.on() has its own channel — never bridged as a custom event
|
|
324
636
|
"rate:drop",
|
|
@@ -327,7 +639,17 @@ function attachSession(client, initialRoom) {
|
|
|
327
639
|
CAPABILITIES_REQUEST_MSG,
|
|
328
640
|
OBJECT_CONTROL_RESULT_MSG,
|
|
329
641
|
STATE_SNAP_MSG,
|
|
330
|
-
OBJECT_SNAP_MSG
|
|
642
|
+
OBJECT_SNAP_MSG,
|
|
643
|
+
// chat.on() has its own channel. Bridging these would re-register room.onMessage for
|
|
644
|
+
// the same type and clobber the chat listener, silently killing chat for that game.
|
|
645
|
+
CHAT_MSG,
|
|
646
|
+
CHAT_HISTORY_MSG,
|
|
647
|
+
CHAT_HISTORY_REQUEST_MSG,
|
|
648
|
+
// voice.* owns these; bridging them would clobber the mesh's own listeners.
|
|
649
|
+
VOICE_SIGNAL_MSG,
|
|
650
|
+
VOICE_REJECTED_MSG,
|
|
651
|
+
VOICE_JOIN_MSG,
|
|
652
|
+
VOICE_LEAVE_MSG
|
|
331
653
|
]);
|
|
332
654
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
333
655
|
const lastShared = /* @__PURE__ */ new Map();
|
|
@@ -343,6 +665,12 @@ function attachSession(client, initialRoom) {
|
|
|
343
665
|
}
|
|
344
666
|
return;
|
|
345
667
|
}
|
|
668
|
+
if (key === VOICE_KEY) {
|
|
669
|
+
const ids = parseValue(value);
|
|
670
|
+
voice.setRoster(Array.isArray(ids) ? ids.filter((v) => typeof v === "string") : []);
|
|
671
|
+
emit("voice", voice.participants);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
346
674
|
if (key.startsWith(OBJ_PREFIX)) {
|
|
347
675
|
const id = key.slice(OBJ_PREFIX.length);
|
|
348
676
|
const blob = parseValue(value);
|
|
@@ -543,6 +871,37 @@ function attachSession(client, initialRoom) {
|
|
|
543
871
|
}
|
|
544
872
|
if (countIfDropped) droppedWhileOffline++;
|
|
545
873
|
};
|
|
874
|
+
const voice = new VoiceMesh({
|
|
875
|
+
selfId: () => room.sessionId,
|
|
876
|
+
nameOf: (id) => room.state?.players?.get(id)?.name ?? "",
|
|
877
|
+
sendSignal: (to, data) => send0(VOICE_SIGNAL_MSG, { to, data }, true)
|
|
878
|
+
});
|
|
879
|
+
voice.onChange(() => emit("voice", voice.participants));
|
|
880
|
+
const chatHistory = [];
|
|
881
|
+
const chatSeen = /* @__PURE__ */ new Set();
|
|
882
|
+
const chatListeners = /* @__PURE__ */ new Set();
|
|
883
|
+
const chatMuted = /* @__PURE__ */ new Set();
|
|
884
|
+
const isChatMessage = (m) => !!m && typeof m === "object" && typeof m.id === "number" && typeof m.from === "string" && typeof m.name === "string" && typeof m.text === "string";
|
|
885
|
+
const acceptChat = (message) => {
|
|
886
|
+
if (!isChatMessage(message)) return;
|
|
887
|
+
if (chatSeen.has(message.id)) return;
|
|
888
|
+
if (chatMuted.has(message.from)) return;
|
|
889
|
+
chatSeen.add(message.id);
|
|
890
|
+
const last = chatHistory[chatHistory.length - 1];
|
|
891
|
+
if (!last || message.id > last.id) chatHistory.push(message);
|
|
892
|
+
else {
|
|
893
|
+
let i = chatHistory.length;
|
|
894
|
+
while (i > 0 && (chatHistory[i - 1]?.id ?? 0) > message.id) i--;
|
|
895
|
+
chatHistory.splice(i, 0, message);
|
|
896
|
+
}
|
|
897
|
+
for (const cb of [...chatListeners]) {
|
|
898
|
+
try {
|
|
899
|
+
cb(message);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
console.error("[multiplayer] a chat listener threw", err);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
};
|
|
546
905
|
const wireRoom = (r) => {
|
|
547
906
|
let hydrated;
|
|
548
907
|
const $ = getStateCallbacks(r);
|
|
@@ -632,6 +991,23 @@ function attachSession(client, initialRoom) {
|
|
|
632
991
|
r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
|
|
633
992
|
});
|
|
634
993
|
send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
|
|
994
|
+
r.onMessage(VOICE_SIGNAL_MSG, (msg) => {
|
|
995
|
+
if (msg && typeof msg.from === "string") void voice.handleSignal(msg.from, msg.data);
|
|
996
|
+
});
|
|
997
|
+
r.onMessage(VOICE_REJECTED_MSG, (msg) => {
|
|
998
|
+
voice.stop();
|
|
999
|
+
emit("voice:rejected", msg ?? { reason: "full" });
|
|
1000
|
+
});
|
|
1001
|
+
for (const inert of [VOICE_JOIN_MSG, VOICE_LEAVE_MSG, CHAT_HISTORY_REQUEST_MSG]) {
|
|
1002
|
+
r.onMessage(inert, () => {
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
r.onMessage(CHAT_MSG, (msg) => acceptChat(msg));
|
|
1006
|
+
r.onMessage(CHAT_HISTORY_MSG, (msg) => {
|
|
1007
|
+
if (!Array.isArray(msg?.messages)) return;
|
|
1008
|
+
for (const m of msg.messages) acceptChat(m);
|
|
1009
|
+
});
|
|
1010
|
+
send0(CHAT_HISTORY_REQUEST_MSG, {});
|
|
635
1011
|
r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
|
|
636
1012
|
if (!msg || typeof msg.requestId !== "string") return;
|
|
637
1013
|
const pending = pendingControls.get(msg.requestId);
|
|
@@ -913,8 +1289,72 @@ function attachSession(client, initialRoom) {
|
|
|
913
1289
|
}
|
|
914
1290
|
send0(type, payload, true);
|
|
915
1291
|
},
|
|
1292
|
+
chat: {
|
|
1293
|
+
send(text) {
|
|
1294
|
+
if (typeof text !== "string") return;
|
|
1295
|
+
if (!text.trim()) return;
|
|
1296
|
+
send0(CHAT_MSG, { text }, true);
|
|
1297
|
+
},
|
|
1298
|
+
on(cb) {
|
|
1299
|
+
chatListeners.add(cb);
|
|
1300
|
+
return () => chatListeners.delete(cb);
|
|
1301
|
+
},
|
|
1302
|
+
get history() {
|
|
1303
|
+
return [...chatHistory];
|
|
1304
|
+
},
|
|
1305
|
+
mute(playerId) {
|
|
1306
|
+
if (typeof playerId === "string" && playerId) chatMuted.add(playerId);
|
|
1307
|
+
},
|
|
1308
|
+
unmute(playerId) {
|
|
1309
|
+
chatMuted.delete(playerId);
|
|
1310
|
+
},
|
|
1311
|
+
get muted() {
|
|
1312
|
+
return [...chatMuted];
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
voice: {
|
|
1316
|
+
async join(options) {
|
|
1317
|
+
await voice.start(options);
|
|
1318
|
+
send0(VOICE_JOIN_MSG, {}, true);
|
|
1319
|
+
},
|
|
1320
|
+
leave() {
|
|
1321
|
+
send0(VOICE_LEAVE_MSG, {}, true);
|
|
1322
|
+
voice.stop();
|
|
1323
|
+
},
|
|
1324
|
+
get active() {
|
|
1325
|
+
return voice.active;
|
|
1326
|
+
},
|
|
1327
|
+
get participants() {
|
|
1328
|
+
return voice.participants;
|
|
1329
|
+
},
|
|
1330
|
+
setMicEnabled(on2) {
|
|
1331
|
+
voice.setMicEnabled(on2 === true);
|
|
1332
|
+
},
|
|
1333
|
+
get micEnabled() {
|
|
1334
|
+
return voice.micEnabled;
|
|
1335
|
+
},
|
|
1336
|
+
get speaking() {
|
|
1337
|
+
return voice.speaking;
|
|
1338
|
+
},
|
|
1339
|
+
mute(playerId) {
|
|
1340
|
+
voice.mute(playerId);
|
|
1341
|
+
},
|
|
1342
|
+
unmute(playerId) {
|
|
1343
|
+
voice.unmute(playerId);
|
|
1344
|
+
},
|
|
1345
|
+
setVolume(playerId, volume) {
|
|
1346
|
+
voice.setVolume(playerId, volume);
|
|
1347
|
+
},
|
|
1348
|
+
get muted() {
|
|
1349
|
+
return voice.participants.filter((p) => p.muted).map((p) => p.id);
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
setRoomOpen(open) {
|
|
1353
|
+
room.send(ROOM_OPEN_MSG, { open });
|
|
1354
|
+
},
|
|
916
1355
|
leave() {
|
|
917
1356
|
intentionalLeave = true;
|
|
1357
|
+
voice.stop();
|
|
918
1358
|
connected = false;
|
|
919
1359
|
syncHostTicks();
|
|
920
1360
|
settleAllControls("aborted");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/multiplayer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "The multiplayer client wrapper + shared room schema for genex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@arethetypeswrong/cli": "^0.18.0",
|
|
46
46
|
"publint": "^0.3.0",
|
|
47
|
-
"tsup": "^8.0.0"
|
|
47
|
+
"tsup": "^8.0.0",
|
|
48
|
+
"typescript": "^5.8.0"
|
|
48
49
|
},
|
|
49
50
|
"repository": {
|
|
50
51
|
"type": "git",
|