@genex-ai/multiplayer 0.16.0 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -6
- package/dist/index.js +37 -6
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -263,10 +263,20 @@ can't starve it and a chat flood can't starve movement.
|
|
|
263
263
|
## `voice` — mesh voice chat
|
|
264
264
|
|
|
265
265
|
```ts
|
|
266
|
+
import { getShell } from '@genex-ai/embed-sdk'; // 0.32.0+
|
|
267
|
+
|
|
268
|
+
// Voice is not available inside the Genex app yet — do not draw the button there.
|
|
269
|
+
joinBtn.hidden = getShell() === 'app';
|
|
270
|
+
|
|
266
271
|
// MUST be inside a click/tap handler — browsers gate the mic prompt AND playback on a gesture.
|
|
267
272
|
joinBtn.onclick = async () => {
|
|
268
|
-
try { await room.voice.join(); }
|
|
269
|
-
catch (err) {
|
|
273
|
+
try { await room.voice.join(); }
|
|
274
|
+
catch (err) {
|
|
275
|
+
// The page around the game withheld the microphone and the player was never asked:
|
|
276
|
+
// this build did not declare it (see below). Nothing the player can allow.
|
|
277
|
+
if ((err as Error).name === 'VoiceNotDelegatedError') showNotice('Voice chat is not available here');
|
|
278
|
+
else showNotice('Microphone needed for voice'); // the player said no, or has no microphone
|
|
279
|
+
}
|
|
270
280
|
};
|
|
271
281
|
|
|
272
282
|
room.on('voice', (participants) => renderVoiceHud(participants)); // joined/left/speaking/connected
|
|
@@ -277,6 +287,36 @@ room.voice.mute(playerId); // local only
|
|
|
277
287
|
room.voice.leave(); // releases the mic (turns the recording indicator off)
|
|
278
288
|
```
|
|
279
289
|
|
|
290
|
+
**The microphone is declared per build.** On its Genex page a game runs in a frame, and the
|
|
291
|
+
page gives that frame a microphone only when the build said it needs one. A build that did
|
|
292
|
+
not is refused at once, with no prompt — while the very same build works on the game's own
|
|
293
|
+
link and on a dev server, which is why it is easy to ship without noticing. The declaration
|
|
294
|
+
is made at `genex preview` and `genex publish` (CLI **1.36.6+**), which read the game's own
|
|
295
|
+
source and declare the microphone when they find `.voice.join()` called with nothing or
|
|
296
|
+
with an options object written at the call — on any `.voice`, so `mm.session.voice.join()`
|
|
297
|
+
counts. Both print what the build is given on its Genex page, so a missing declaration
|
|
298
|
+
shows before a player meets it. A call the CLI cannot see (`voice.join(opts)` with the
|
|
299
|
+
options in a variable, `const { voice } = room; voice.join()`, or code outside the game's
|
|
300
|
+
`src/`) is declared by hand, in the game's `package.json`:
|
|
301
|
+
|
|
302
|
+
```jsonc
|
|
303
|
+
"genex": {
|
|
304
|
+
"capabilities": ["microphone"]
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
The declaration belongs to the build, so preview or publish again after adding it. The
|
|
309
|
+
spatial pack (`npx genex controller voice`) declares the microphone by itself.
|
|
310
|
+
|
|
311
|
+
The refusal names itself: since **0.16.2**, `join()` rejects with an error whose `name` is
|
|
312
|
+
`VoiceNotDelegatedError` when it was the page, not the player, that said no (the browser's
|
|
313
|
+
own error is its `cause`). Chromium browsers can make that distinction; in the others the
|
|
314
|
+
page's refusal arrives as the browser's own error, the same as a player's.
|
|
315
|
+
|
|
316
|
+
**Not in the Genex app yet.** Inside the Genex mobile app the microphone is refused for
|
|
317
|
+
every game, declared or not, and `join()` rejects as if the player had said no. Hide the
|
|
318
|
+
voice button there, as above.
|
|
319
|
+
|
|
280
320
|
**Party-sized, not room-sized — max 6.** Audio goes peer-to-peer in a full mesh: everyone
|
|
281
321
|
uploads to everyone. The relay carries no audio at all, only the roster and the WebRTC
|
|
282
322
|
handshake. Past a small party what breaks first is not bandwidth but reliability — a joiner
|
|
@@ -429,17 +469,51 @@ For `preset: 'private'` games, skip the queue: a host mints an invite **code**,
|
|
|
429
469
|
|
|
430
470
|
```ts
|
|
431
471
|
import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
|
|
472
|
+
import { share, getEntry } from "@genex-ai/embed-sdk"; // 0.32.0+
|
|
473
|
+
|
|
474
|
+
const opts = { urls, room: slug, auth: () => getColyseusAuth() };
|
|
432
475
|
|
|
433
476
|
// host
|
|
434
|
-
const lobby = await createPrivate<State>(
|
|
435
|
-
|
|
477
|
+
const lobby = await createPrivate<State>(opts);
|
|
478
|
+
showCode(lobby.code); // show the code as well: the way in when a link will not do
|
|
479
|
+
|
|
480
|
+
// host's Invite button. share() has to start inside the click, before any await.
|
|
481
|
+
inviteBtn.onclick = async () => {
|
|
482
|
+
const { status, url } = await share({ text: "Join my lobby", params: { lobby: lobby.code } });
|
|
483
|
+
if (status === "copied") toast("Invite link copied");
|
|
484
|
+
if (status === "unavailable") showLink(url); // no share sheet and no clipboard: show the link
|
|
485
|
+
};
|
|
436
486
|
|
|
437
|
-
// friend
|
|
438
|
-
|
|
487
|
+
// friend: one way in, for the code from a link and for a code typed by hand
|
|
488
|
+
async function enter(code: string) {
|
|
489
|
+
if (!/^[\w-]{1,32}$/.test(code)) return null; // anyone can write a link: take a code, nothing else
|
|
490
|
+
try {
|
|
491
|
+
return await joinPrivate<State>(code, opts);
|
|
492
|
+
} catch {
|
|
493
|
+
showNotice("That lobby is no longer open"); // closed, or out of this player's reach
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// at boot, after waitForPlayer(): were they sent an invite link?
|
|
499
|
+
const invited = getEntry().params.get("lobby");
|
|
500
|
+
const joined = invited ? await enter(invited) : null; // null: the menu, with its "Enter code" field
|
|
439
501
|
|
|
440
502
|
// then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()
|
|
441
503
|
```
|
|
442
504
|
|
|
505
|
+
`share()` builds the link itself: the game's page on Genex plus `?lobby=<code>` (on a
|
|
506
|
+
creator's own domain, that address). `getEntry()` gives the parameter back to whoever opens
|
|
507
|
+
it, on the game page, on the game's own link and across the sign-in round trip. Do not
|
|
508
|
+
build the link from `location.href`: inside the game page that is the frame's bare address,
|
|
509
|
+
not the page a friend should land on. Any parameter name works except the platform's own
|
|
510
|
+
(`embed`, `ref`, `s`, `from`, `remix`, `slug`, and everything starting `genex_`).
|
|
511
|
+
|
|
512
|
+
`joinPrivate` rejects when the code does not lead to a lobby this player can reach: it has
|
|
513
|
+
closed, or its host is on another region's relay than this player's nearest one. Catch it
|
|
514
|
+
and show the menu. Keep the typed code as the second way in: a shell that does not pass the
|
|
515
|
+
link's parameter on yet opens the game without it.
|
|
516
|
+
|
|
443
517
|
## Rules of thumb
|
|
444
518
|
|
|
445
519
|
- **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,13 @@ function createIceRequester(send, timeoutMs) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// src/voice.ts
|
|
36
|
+
var NOT_DELEGATED = "The microphone was refused by the page that frames this game, not by the player: the frame was not given it, so the browser never asked. On its Genex page a build is given the microphone only when it declares it. An up-to-date genex CLI declares it at `genex preview` and `genex publish` when the game's own source calls voice.join(); to declare it by hand, add \"microphone\" to genex.capabilities in the game's package.json. Then preview or publish again.";
|
|
37
|
+
var VoiceNotDelegatedError = class extends Error {
|
|
38
|
+
constructor(cause) {
|
|
39
|
+
super(NOT_DELEGATED, { cause });
|
|
40
|
+
this.name = "VoiceNotDelegatedError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
36
43
|
var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
37
44
|
var SPEAKING_POLL_MS = 120;
|
|
38
45
|
var DEFAULT_SPEAKING_THRESHOLD = 0.02;
|
|
@@ -87,8 +94,10 @@ var VoiceMesh = class {
|
|
|
87
94
|
return () => this.listeners.delete(cb);
|
|
88
95
|
}
|
|
89
96
|
/** 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.
|
|
91
|
-
*
|
|
97
|
+
* surfaces that; it is a normal outcome, not a bug. The one refusal that IS a bug throws a
|
|
98
|
+
* VoiceNotDelegatedError instead: the page framing the game withheld the microphone, because
|
|
99
|
+
* the build never declared it. Must be called from a user gesture: browsers gate both the
|
|
100
|
+
* permission prompt and audio playback on one. */
|
|
92
101
|
async start(options = {}, holdPeering = false) {
|
|
93
102
|
if (this.running) return;
|
|
94
103
|
this.peeringHeld = holdPeering;
|
|
@@ -96,10 +105,14 @@ var VoiceMesh = class {
|
|
|
96
105
|
this.spatial = options.spatial === true;
|
|
97
106
|
this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
|
|
98
107
|
this.micOn = options.startMuted !== true;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
try {
|
|
109
|
+
this.local = await navigator.mediaDevices.getUserMedia({
|
|
110
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
111
|
+
video: false
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw micRefusal(err);
|
|
115
|
+
}
|
|
103
116
|
for (const t of this.local.getAudioTracks()) t.enabled = this.micOn;
|
|
104
117
|
this.audio = new AudioContext();
|
|
105
118
|
this.localAnalyser = this.makeAnalyser(this.local);
|
|
@@ -360,6 +373,24 @@ var VoiceMesh = class {
|
|
|
360
373
|
}
|
|
361
374
|
}
|
|
362
375
|
};
|
|
376
|
+
function isFramed() {
|
|
377
|
+
try {
|
|
378
|
+
return window.self !== window.top;
|
|
379
|
+
} catch {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function micRefusal(err) {
|
|
384
|
+
if (err?.name !== "NotAllowedError") return err;
|
|
385
|
+
try {
|
|
386
|
+
if (!isFramed()) return err;
|
|
387
|
+
const policy = document.featurePolicy;
|
|
388
|
+
if (policy?.allowsFeature?.("microphone") !== false) return err;
|
|
389
|
+
} catch {
|
|
390
|
+
return err;
|
|
391
|
+
}
|
|
392
|
+
return new VoiceNotDelegatedError(err);
|
|
393
|
+
}
|
|
363
394
|
function hasTurnUrl(server) {
|
|
364
395
|
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
365
396
|
return urls.some((u) => typeof u === "string" && u.startsWith("turn"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/multiplayer",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"description": "The multiplayer client wrapper + shared room schema for genex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -49,9 +49,8 @@
|
|
|
49
49
|
"typescript": "^5.8.0"
|
|
50
50
|
},
|
|
51
51
|
"repository": {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"directory": "packages/multiplayer"
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/genex-games/genex.git"
|
|
55
54
|
},
|
|
56
55
|
"license": "MIT"
|
|
57
56
|
}
|