dewdrop-sdk 0.4.0 → 0.5.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 +70 -3
- package/package.json +2 -2
- package/src/backend-host.js +1 -1
- package/src/backend.js +25 -9
- package/src/clients.js +15 -6
- package/src/index.js +6 -1
- package/src/net.js +3 -0
- package/src/saves.js +5 -4
- package/src/serve.js +35 -12
- package/src/session.js +17 -0
- package/src/voice.js +192 -0
package/README.md
CHANGED
|
@@ -20,15 +20,16 @@ export default { plugins: [dew()] };
|
|
|
20
20
|
|
|
21
21
|
`npx dewctl init` sets up a new game this way, with the Dewdrop CLI beside it.
|
|
22
22
|
To update a project, run `npm install -D dewdrop-sdk@latest`; saves need 0.4.0
|
|
23
|
-
or later.
|
|
23
|
+
or later, voice 0.5.0.
|
|
24
24
|
|
|
25
25
|
## Saves locally
|
|
26
26
|
|
|
27
27
|
On Dewdrop, `localStorage` and IndexedDB aren't local: nothing your game
|
|
28
28
|
writes to them is stored in the player's browser or on their device, and they
|
|
29
29
|
start empty each time a player opens your game. `saves.save()` keeps what they
|
|
30
|
-
hold in Dewdrop's storage for that player, and `saves.load()` puts it back
|
|
31
|
-
|
|
30
|
+
hold in Dewdrop's storage for that player, and `saves.load()` puts it back,
|
|
31
|
+
in later uploads of your game too. A game with a `backend.js` keeps no saves:
|
|
32
|
+
its state is its rooms', and both calls refuse with `NotSupportedError`.
|
|
32
33
|
`npm run dev` does the same in your browser: every page load starts with
|
|
33
34
|
`localStorage` and IndexedDB empty, and saves are kept in the browser, in a
|
|
34
35
|
database named `dewdrop-saves` your game doesn't see, for the game
|
|
@@ -36,6 +37,72 @@ database named `dewdrop-saves` your game doesn't see, for the game
|
|
|
36
37
|
of one dev server share the browser's storage, so loading one empties the
|
|
37
38
|
others'.
|
|
38
39
|
|
|
40
|
+
## Rooms locally
|
|
41
|
+
|
|
42
|
+
`npm run dev` runs your `backend.js` for each room the tabs join: `?room=`
|
|
43
|
+
names the room, `?player=` the player, and `?players=` how many players a
|
|
44
|
+
room the tab opens seats, 100 unless it says 1 to 100, as Dewdrop's rooms
|
|
45
|
+
do; `?players=1` is singleplayer. A player is counted once however many tabs
|
|
46
|
+
they play from, and a tab past a full room is refused, with the reason in
|
|
47
|
+
the console. In the backend, `clients.capacity` is the room's size.
|
|
48
|
+
|
|
49
|
+
## Sessions that end
|
|
50
|
+
|
|
51
|
+
On Dewdrop a session can end ahead of time: for maintenance, or a newer
|
|
52
|
+
version of your game published. Its player sees a countdown and reconnects
|
|
53
|
+
on their own, and your game hears of it first, up to fifteen minutes
|
|
54
|
+
before: `session.ending` from `dewdrop-sdk` is `{at, reason}`, `at` in
|
|
55
|
+
milliseconds as `Date.now()` counts and `reason` `'restart'` or `'update'`,
|
|
56
|
+
and an `ending` event fires each time it changes, its withdrawal to `null`
|
|
57
|
+
included. Call `saves.save()` then to keep what the player has done. `npm
|
|
58
|
+
run dev` ends no session ahead of time, so `session.ending` stays `null`
|
|
59
|
+
there.
|
|
60
|
+
|
|
61
|
+
## Voice
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { backend, voice } from 'dewdrop-sdk';
|
|
65
|
+
|
|
66
|
+
// This player's microphone, a chunk at a time, sent to the backend each frame.
|
|
67
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
68
|
+
const capture = voice.capture(stream); // capture.stop() to go quiet
|
|
69
|
+
function frame() {
|
|
70
|
+
for (const chunk of capture.read()) backend.send(chunk); // Int16Array, 800 samples at 48 kHz
|
|
71
|
+
requestAnimationFrame(frame);
|
|
72
|
+
}
|
|
73
|
+
requestAnimationFrame(frame);
|
|
74
|
+
|
|
75
|
+
// Another player's voice, as the backend sends it on: from their figure,
|
|
76
|
+
// loud up close, fading with distance.
|
|
77
|
+
const source = voice.source(listener.context);
|
|
78
|
+
const sound = new PositionalAudio(listener); // on their avatar
|
|
79
|
+
sound.setNodeSource(source.node); // or source.node.connect(anywhere)
|
|
80
|
+
backend.onmessage = ({ data }) => source.write(new Int16Array(data, 4)); // behind the backend's own header
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Dewdrop carries no voice between players itself: your `backend.js`
|
|
84
|
+
decides who hears whom — those within earshot, a team, everyone — and
|
|
85
|
+
sends each chunk on through its ordinary messages, so a game can mix,
|
|
86
|
+
mute, record or transcribe as it sees fit. `voice.capture(stream)` takes
|
|
87
|
+
the live microphone stream from `getUserMedia` and hands back the
|
|
88
|
+
capture: `read()` returns the chunks that arrived since the last read,
|
|
89
|
+
each an `Int16Array` of 800 samples, mono, at `voice.SAMPLE_RATE`
|
|
90
|
+
(48 kHz), and `stop()` ends it; one capture at a time, a second throws
|
|
91
|
+
an `InvalidStateError`. A read is at most eight chunks of 1,600 bytes,
|
|
92
|
+
which a client's frame sends to its backend whole within its 16 KiB
|
|
93
|
+
with room beside, so a game may send every chunk it reads; a backend's
|
|
94
|
+
frame sends some 160 copies on within its 256 KiB, so a crowd all
|
|
95
|
+
speaking at once is more than one frame carries and a backend sheds the
|
|
96
|
+
rest — voice is late or nothing — or compresses first. `voice.source(context)`
|
|
97
|
+
is plain Web Audio: `node` is a node of `context` for `connect` or a
|
|
98
|
+
three.js `PositionalAudio`'s `setNodeSource`, `write(samples)` plays an
|
|
99
|
+
`Int16Array` or `Float32Array` right after those written before it —
|
|
100
|
+
the first two chunks ahead of the clock, so a chunk a little early or
|
|
101
|
+
late plays without a gap — dropping what runs eight chunks ahead, and
|
|
102
|
+
`close()` leaves the graph. `npm run dev` reads your browser's
|
|
103
|
+
microphone through an audio worklet and hands your game the same chunks,
|
|
104
|
+
so voice works between tabs locally as it does on Dewdrop.
|
|
105
|
+
|
|
39
106
|
## Docs
|
|
40
107
|
|
|
41
108
|
How to build your game, from the look to model calls, multiplayer, saves and
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dewdrop-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Build a three.js game for Dewdrop: the Vite plugin, model calls, multiplayer and
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Build a three.js game for Dewdrop: the Vite plugin, model calls, multiplayer, saves and voice.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
package/src/backend-host.js
CHANGED
|
@@ -11,7 +11,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
11
11
|
import { attachClients } from './clients.js';
|
|
12
12
|
|
|
13
13
|
process.env.DEW_DEV_ORIGIN = workerData.origin;
|
|
14
|
-
const start = attachClients(parentPort);
|
|
14
|
+
const start = attachClients(parentPort, { capacity: workerData.capacity });
|
|
15
15
|
await import(pathToFileURL(workerData.entry).href);
|
|
16
16
|
process.on('uncaughtException', (error) => console.error(`uncaught ${error?.stack ?? error}`));
|
|
17
17
|
start();
|
package/src/backend.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// past its cap. The page's URL says which room the tab joins and under
|
|
8
8
|
// what name; on Dewdrop the game's page says which room, and the name is
|
|
9
9
|
// the player's username.
|
|
10
|
-
import { MAX_NET_CLIENT_FRAME_BYTES, createInbox, createOutbox, defineHandlers, label, side, unpack } from './net.js';
|
|
10
|
+
import { MAX_NET_CLIENTS, MAX_NET_CLIENT_FRAME_BYTES, createInbox, createOutbox, defineHandlers, label, side, unpack } from './net.js';
|
|
11
11
|
import { NO_CHANNEL } from './route.js';
|
|
12
12
|
|
|
13
13
|
let channel = null;
|
|
@@ -58,20 +58,35 @@ function name(text) {
|
|
|
58
58
|
return label(text.replace(/\p{Cc}/gu, '').trim());
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
61
|
+
// The players a room seats as `?players=` names them: a whole number from
|
|
62
|
+
// 1 to MAX_NET_CLIENTS, or the most without one or with any other.
|
|
63
|
+
function players(text) {
|
|
64
|
+
return /^[0-9]{1,3}$/.test(text) && Number(text) >= 1 && Number(text) <= MAX_NET_CLIENTS ? Number(text) : MAX_NET_CLIENTS;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Where a page's URL joins its tab: the room `?room=` names, the name
|
|
68
|
+
// `?player=` names, and the players `?players=` says the room seats should
|
|
69
|
+
// the tab make it, each held to Dewdrop's rule — the empty room without
|
|
63
70
|
// one, or with one that names no room; unnamed without a name, or with one
|
|
64
71
|
// that is nothing but space and control characters.
|
|
65
72
|
export function joining(search) {
|
|
66
73
|
const params = new URLSearchParams(search);
|
|
67
|
-
return { room: room(params.get('room') ?? ''), player: name(params.get('player') ?? '') };
|
|
74
|
+
return { room: room(params.get('room') ?? ''), player: name(params.get('player') ?? ''), players: players(params.get('players') ?? '') };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Whether the page's game has a backend, as the plugin said; false until
|
|
78
|
+
// it has.
|
|
79
|
+
export function gameHasBackend() {
|
|
80
|
+
return hasBackend;
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
// Joins the backend of `room` under `player`'s name over `hot`, the page's
|
|
71
|
-
// hot context, once the plugin has said whether the game has one
|
|
72
|
-
// `present` is that, `schedule` runs a
|
|
73
|
-
// frame. Without a plugin there is no
|
|
74
|
-
|
|
84
|
+
// hot context, once the plugin has said whether the game has one, a room
|
|
85
|
+
// it makes seating `players`: `present` is that, `schedule` runs a
|
|
86
|
+
// function at the next animation frame. Without a plugin there is no
|
|
87
|
+
// channel, and `send` says so. A room that seats no more says so in the
|
|
88
|
+
// console, as Dewdrop refuses the connect, and the tab's sends reach no one.
|
|
89
|
+
export function attachBackend(hot, { channel: routed, hasBackend: present, frame: schedule, room = '', player = '', players = MAX_NET_CLIENTS }) {
|
|
75
90
|
if (!routed) return;
|
|
76
91
|
channel = hot;
|
|
77
92
|
hasBackend = present;
|
|
@@ -83,5 +98,6 @@ export function attachBackend(hot, { channel: routed, hasBackend: present, frame
|
|
|
83
98
|
draining = true;
|
|
84
99
|
frame(deliver);
|
|
85
100
|
});
|
|
86
|
-
hot.
|
|
101
|
+
hot.on('dew:full', ({ capacity }) => console.error(`[dewdrop] this room is full: it seats ${capacity === 1 ? '1 player' : `${capacity} players`}`));
|
|
102
|
+
hot.send('dew:join', { room, player, players });
|
|
87
103
|
}
|
package/src/clients.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// `clients` on a developer's machine: the backend's side of the messages a
|
|
2
|
-
// game exchanges with its clients,
|
|
3
|
-
//
|
|
4
|
-
// the socket to the tab it names. The thread ticks sixty times a second:
|
|
2
|
+
// game exchanges with its clients, and the most players its room seats,
|
|
3
|
+
// run by the plugin in a thread of the dev server with a port to the
|
|
4
|
+
// plugin, which carries each message over the socket to the tab it names. The thread ticks sixty times a second:
|
|
5
5
|
// each tick delivers what the clients sent as `join`, `leave`, `message`,
|
|
6
6
|
// and `gap` events — a join naming the player's label — runs the
|
|
7
7
|
// animation-frame callbacks, and sends the frame's messages.
|
|
8
|
-
import { MAX_NET_BACKEND_FRAME_BYTES, NET_TICK_MS, ClientEvent, createInbox, createOutbox, defineHandlers, side, unpack } from './net.js';
|
|
8
|
+
import { MAX_NET_BACKEND_FRAME_BYTES, MAX_NET_CLIENTS, NET_TICK_MS, ClientEvent, createInbox, createOutbox, defineHandlers, side, unpack } from './net.js';
|
|
9
9
|
|
|
10
10
|
const outbox = createOutbox(MAX_NET_BACKEND_FRAME_BYTES);
|
|
11
11
|
const inbox = createInbox();
|
|
12
12
|
let port = null;
|
|
13
|
+
let seats = 0;
|
|
13
14
|
|
|
14
15
|
// A client number as the backend was told it: a whole number from one.
|
|
15
16
|
function number(op, value) {
|
|
@@ -28,6 +29,12 @@ class Clients extends EventTarget {
|
|
|
28
29
|
if (port === null) throw new DOMException('clients is unavailable in a client', 'NotSupportedError');
|
|
29
30
|
outbox.push('clients.broadcast', 0, data);
|
|
30
31
|
}
|
|
32
|
+
// The most players the room seats, as it was made: 1 for a room of one,
|
|
33
|
+
// a player counted once however many clients they play from.
|
|
34
|
+
get capacity() {
|
|
35
|
+
if (port === null) throw new DOMException('clients is unavailable in a client', 'NotSupportedError');
|
|
36
|
+
return seats;
|
|
37
|
+
}
|
|
31
38
|
}
|
|
32
39
|
defineHandlers(Clients.prototype, ['join', 'leave', 'message', 'gap']);
|
|
33
40
|
|
|
@@ -76,9 +83,11 @@ function frame() {
|
|
|
76
83
|
// held until the returned function starts the ticks, which the host calls
|
|
77
84
|
// once the backend has evaluated, so the first tick delivers what arrived
|
|
78
85
|
// meanwhile, the first join included, to the handlers the module
|
|
79
|
-
// registered. `tick` runs the frame once per tick from then on
|
|
80
|
-
|
|
86
|
+
// registered. `tick` runs the frame once per tick from then on, and
|
|
87
|
+
// `capacity` is the most players the room seats.
|
|
88
|
+
export function attachClients(channel, { tick = (fn) => setInterval(fn, NET_TICK_MS), capacity = MAX_NET_CLIENTS } = {}) {
|
|
81
89
|
port = channel;
|
|
90
|
+
seats = capacity;
|
|
82
91
|
side.backend = true;
|
|
83
92
|
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
|
84
93
|
globalThis.requestAnimationFrame = requestAnimationFrame;
|
package/src/index.js
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
// over its route, `backend` over its socket to the backend the plugin
|
|
5
5
|
// runs, `clients`, in that backend, over its port back, and `saves` into
|
|
6
6
|
// the browser's own storage, which the plugin's script empties as each
|
|
7
|
-
// page loads
|
|
7
|
+
// page loads; `session` says when a session ends ahead of time, which here
|
|
8
|
+
// none does; and `voice` reads the microphone through the browser's own
|
|
9
|
+
// audio worklet, the chunks Dewdrop's server would decode.
|
|
8
10
|
export { llm } from './llm.js';
|
|
9
11
|
export { backend } from './backend.js';
|
|
10
12
|
export { clients } from './clients.js';
|
|
13
|
+
export { session } from './session.js';
|
|
14
|
+
export { voice } from './voice.js';
|
|
11
15
|
import { attachBackend, joining } from './backend.js';
|
|
12
16
|
import { load, save } from './saves.js';
|
|
13
17
|
|
|
@@ -30,5 +34,6 @@ if (typeof document !== 'undefined' && import.meta.hot) {
|
|
|
30
34
|
frame: (fn) => requestAnimationFrame(fn),
|
|
31
35
|
room: joined.room,
|
|
32
36
|
player: joined.player,
|
|
37
|
+
players: joined.players,
|
|
33
38
|
});
|
|
34
39
|
}
|
package/src/net.js
CHANGED
|
@@ -15,6 +15,9 @@ export const NET_RECORD_HEADER_BYTES = 16;
|
|
|
15
15
|
// The most bytes a player's label takes: the name the player joined
|
|
16
16
|
// under, which the backend hears on the join.
|
|
17
17
|
export const MAX_NET_PLAYER_BYTES = 64;
|
|
18
|
+
// The most players a room seats, and the most clients it holds: a room's
|
|
19
|
+
// size is 1 to this.
|
|
20
|
+
export const MAX_NET_CLIENTS = 100;
|
|
18
21
|
// How often the local backend ticks: sixty times a second.
|
|
19
22
|
export const NET_TICK_MS = 1000 / 60;
|
|
20
23
|
|
package/src/saves.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// names and the player the page's URL names, and `load` puts it back.
|
|
7
7
|
// Calls run in the order they are made, and the checks and their messages
|
|
8
8
|
// are Dewdrop's, so a call that fails here fails there the same way.
|
|
9
|
-
import { joining } from './backend.js';
|
|
9
|
+
import { gameHasBackend, joining } from './backend.js';
|
|
10
10
|
import { SAVES_STATE } from './fresh.js';
|
|
11
11
|
import { side } from './net.js';
|
|
12
12
|
import { NO_SAVES } from './route.js';
|
|
@@ -24,10 +24,11 @@ let last = Promise.resolve();
|
|
|
24
24
|
let loads = 0;
|
|
25
25
|
|
|
26
26
|
// Runs `run` once every call made before has settled and the page's
|
|
27
|
-
// storage has started empty; refused at once in a
|
|
28
|
-
//
|
|
27
|
+
// storage has started empty; refused at once in a game with a backend —
|
|
28
|
+
// its backend and its tabs alike, as on Dewdrop, where such a game keeps
|
|
29
|
+
// no saves — and in a page without the plugin's script.
|
|
29
30
|
function inOrder(op, run) {
|
|
30
|
-
if (side.backend) return Promise.reject(new DOMException('saves is unavailable in a backend', 'NotSupportedError'));
|
|
31
|
+
if (side.backend || gameHasBackend()) return Promise.reject(new DOMException('saves is unavailable in a game with a backend', 'NotSupportedError'));
|
|
31
32
|
const state = globalThis[Symbol.for(SAVES_STATE)];
|
|
32
33
|
if (state === undefined) return Promise.reject(new TypeError(`${op}: ${NO_SAVES}`));
|
|
33
34
|
const call = last.then(() => state.ready).then(() => run(state));
|
package/src/serve.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// The dev server's backend host, its stand-in for Dewdrop. A thread runs
|
|
2
2
|
// the project's backend.js for each room the tabs join, from the room's
|
|
3
3
|
// first tab's join until five minutes after its last tab's leave, as on
|
|
4
|
-
// Dewdrop, so a tab that comes back in that time rejoins it;
|
|
4
|
+
// Dewdrop, so a tab that comes back in that time rejoins it; a room seats
|
|
5
|
+
// the players its first tab's page said, a player counted once however
|
|
6
|
+
// many tabs they play from and an unnamed tab as one each, and refuses a
|
|
7
|
+
// tab past them as Dewdrop refuses its connect; the tabs are
|
|
5
8
|
// numbered from one per room, never twice while its thread runs; each
|
|
6
9
|
// tab's frame of sends reaches its room's thread over the port
|
|
7
10
|
// with the tab's number, and each of the thread's sends reaches the tab it
|
|
@@ -14,7 +17,7 @@
|
|
|
14
17
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
18
|
import { dirname, resolve } from 'node:path';
|
|
16
19
|
import { Worker } from 'node:worker_threads';
|
|
17
|
-
import { label } from './net.js';
|
|
20
|
+
import { MAX_NET_CLIENTS, label } from './net.js';
|
|
18
21
|
|
|
19
22
|
// How long a room's backend runs on after its last tab's leave, as on
|
|
20
23
|
// Dewdrop.
|
|
@@ -47,12 +50,14 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
47
50
|
const rooms = new Map();
|
|
48
51
|
const tabs = new Map();
|
|
49
52
|
|
|
50
|
-
// One room's backend: its thread from the
|
|
51
|
-
// EMPTY_ROOM_MS after its last tab's leave
|
|
52
|
-
// tabs by number from one
|
|
53
|
-
|
|
53
|
+
// One room's backend, seating `capacity` players: its thread from the
|
|
54
|
+
// room's first tab's join until EMPTY_ROOM_MS after its last tab's leave
|
|
55
|
+
// with none joining since, its tabs by number from one with their
|
|
56
|
+
// players' names.
|
|
57
|
+
function createRoom(key, capacity) {
|
|
54
58
|
const name = key === '' ? 'the backend' : `the backend of room ${JSON.stringify(key)}`;
|
|
55
59
|
const members = new Map();
|
|
60
|
+
const names = new Map();
|
|
56
61
|
let worker = null;
|
|
57
62
|
let next = 1;
|
|
58
63
|
let broken = false;
|
|
@@ -68,7 +73,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
68
73
|
|
|
69
74
|
function start() {
|
|
70
75
|
files = backendFiles(entry);
|
|
71
|
-
worker = new Worker(new URL('./backend-host.js', import.meta.url), { workerData: { entry, origin: origin() } });
|
|
76
|
+
worker = new Worker(new URL('./backend-host.js', import.meta.url), { workerData: { entry, origin: origin(), capacity } });
|
|
72
77
|
const started = worker;
|
|
73
78
|
started.on('message', (message) => {
|
|
74
79
|
if (message.kind === 'send' && worker === started) deliver(message.sends);
|
|
@@ -89,6 +94,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
89
94
|
idle = null;
|
|
90
95
|
for (const client of members.keys()) tabs.delete(client);
|
|
91
96
|
members.clear();
|
|
97
|
+
names.clear();
|
|
92
98
|
next = 1;
|
|
93
99
|
if (ended !== null) ended.terminate();
|
|
94
100
|
}
|
|
@@ -101,13 +107,26 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
101
107
|
broken = true;
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
// The room's players, each once: its tabs' names, and each unnamed tab.
|
|
111
|
+
function seated() {
|
|
112
|
+
const players = [...names.values()];
|
|
113
|
+
return players.filter((player, at) => player === '' || players.indexOf(player) === at).length;
|
|
114
|
+
}
|
|
115
|
+
|
|
104
116
|
const room = {
|
|
105
117
|
join(client, player) {
|
|
106
118
|
if (broken) return;
|
|
119
|
+
const returning = player !== '' && [...names.values()].includes(player);
|
|
120
|
+
if (members.size >= MAX_NET_CLIENTS || (!returning && seated() >= capacity)) {
|
|
121
|
+
log.error(`[dewdrop] a tab is refused ${name}: it seats ${capacity === 1 ? '1 player' : `${capacity} players`}`);
|
|
122
|
+
client.send('dew:full', { capacity });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
107
125
|
clearTimeout(idle);
|
|
108
126
|
idle = null;
|
|
109
127
|
const number = next++;
|
|
110
128
|
members.set(client, number);
|
|
129
|
+
names.set(client, player);
|
|
111
130
|
tabs.set(client, room);
|
|
112
131
|
if (worker === null) start();
|
|
113
132
|
worker.postMessage([{ kind: 'join', client: number, player }]);
|
|
@@ -117,6 +136,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
117
136
|
const number = members.get(client);
|
|
118
137
|
if (number === undefined) return;
|
|
119
138
|
members.delete(client);
|
|
139
|
+
names.delete(client);
|
|
120
140
|
tabs.delete(client);
|
|
121
141
|
worker.postMessage([{ kind: 'leave', client: number }]);
|
|
122
142
|
if (members.size === 0) {
|
|
@@ -151,14 +171,17 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
const host = {
|
|
154
|
-
// A tab joins the room its page named under the name it gave
|
|
155
|
-
//
|
|
156
|
-
|
|
174
|
+
// A tab joins the room its page named under the name it gave, a room
|
|
175
|
+
// it makes seating the players its page said; a page that named
|
|
176
|
+
// neither joins the empty room unnamed, and one that named no size a
|
|
177
|
+
// room seating the most.
|
|
178
|
+
join(client, { room = '', player = '', players = MAX_NET_CLIENTS } = {}) {
|
|
157
179
|
if (tabs.has(client)) return;
|
|
158
180
|
const key = typeof room === 'string' ? room : '';
|
|
159
181
|
let it = rooms.get(key);
|
|
160
182
|
if (it === undefined) {
|
|
161
|
-
|
|
183
|
+
const capacity = Number.isInteger(players) && players >= 1 && players <= MAX_NET_CLIENTS ? players : MAX_NET_CLIENTS;
|
|
184
|
+
it = createRoom(key, capacity);
|
|
162
185
|
rooms.set(key, it);
|
|
163
186
|
}
|
|
164
187
|
it.join(client, label(player));
|
|
@@ -171,7 +194,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
171
194
|
},
|
|
172
195
|
close,
|
|
173
196
|
};
|
|
174
|
-
ws.on('dew:join', (data, client) => host.join(client, { room: data?.room, player: data?.player }));
|
|
197
|
+
ws.on('dew:join', (data, client) => host.join(client, { room: data?.room, player: data?.player, players: data?.players }));
|
|
175
198
|
ws.on('dew:send', (data, client) => host.receive(client, data.items ?? []));
|
|
176
199
|
watcher.on('change', (file) => {
|
|
177
200
|
if (files.has(resolve(file))) restart(`${file} changed`);
|
package/src/session.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// `session` on a developer's machine. On Dewdrop a session can end ahead of
|
|
2
|
+
// time — for maintenance, or a newer version of its game published — and
|
|
3
|
+
// hears of it first, up to fifteen minutes before: `session.ending` is
|
|
4
|
+
// `{at, reason}`, `at` in milliseconds since the epoch as `Date.now()` counts
|
|
5
|
+
// and `reason` `'restart'` or `'update'`, and an `ending` event fires each
|
|
6
|
+
// time it changes, its withdrawal to null included, so a game can save and
|
|
7
|
+
// say so. `npm run dev` ends no session ahead of time, so here `ending`
|
|
8
|
+
// stays null and the event never fires; a game tries its handler by calling
|
|
9
|
+
// it.
|
|
10
|
+
import { defineHandlers } from './net.js';
|
|
11
|
+
|
|
12
|
+
class Session extends EventTarget {
|
|
13
|
+
get ending() { return null; }
|
|
14
|
+
}
|
|
15
|
+
defineHandlers(Session.prototype, ['ending']);
|
|
16
|
+
|
|
17
|
+
export const session = new Session();
|
package/src/voice.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// `voice` on a developer's machine: the runtime's surface — the player's
|
|
2
|
+
// microphone a chunk at a time, and a source that plays such chunks
|
|
3
|
+
// through the game's own audio graph — where Dewdrop carries no voice
|
|
4
|
+
// between players itself: a game sends what it captures to its backend
|
|
5
|
+
// through its messages, and the backend sends it on to the players it
|
|
6
|
+
// chooses. On Dewdrop the server decodes the microphone the browser
|
|
7
|
+
// streams onto the game's voice stream; here the browser's own audio
|
|
8
|
+
// worklet reads the microphone and hands the game the same chunks, so a
|
|
9
|
+
// game captures in `npm run dev` as it captures on Dewdrop. `source` is
|
|
10
|
+
// plain Web Audio, the same function as Dewdrop's, and plays here as
|
|
11
|
+
// there.
|
|
12
|
+
|
|
13
|
+
// Hand-copied from the runtime, which tests/sync.test.mjs pins: the rate
|
|
14
|
+
// of the samples a capture reads and a source plays, the samples in one
|
|
15
|
+
// of a capture's chunks, 1/60 s, and the chunks that wait for a game that
|
|
16
|
+
// reads late, the newest kept.
|
|
17
|
+
export const SAMPLE_RATE = 48000;
|
|
18
|
+
export const VOICE_CHUNK_SAMPLES = 800;
|
|
19
|
+
export const VOICE_CHUNKS_HELD = 8;
|
|
20
|
+
// How far ahead of the context's clock a source begins what is written
|
|
21
|
+
// to it — the first chunk, and the first after it ran dry — in seconds:
|
|
22
|
+
// two chunks, which a later chunk may come late by and still follow the
|
|
23
|
+
// last without a gap; and how far ahead writes may run before the newest
|
|
24
|
+
// is dropped instead, so a voice falls no further behind.
|
|
25
|
+
const SOURCE_LEAD = 2 * VOICE_CHUNK_SAMPLES / SAMPLE_RATE;
|
|
26
|
+
const MAX_SOURCE_LEAD = 8 * VOICE_CHUNK_SAMPLES / SAMPLE_RATE;
|
|
27
|
+
|
|
28
|
+
// The processor that reads the microphone in the browser's audio thread:
|
|
29
|
+
// it takes the microphone's frames at the context's rate, steps through
|
|
30
|
+
// them at SAMPLE_RATE, and posts each VOICE_CHUNK_SAMPLES of them as 16-bit
|
|
31
|
+
// samples, the buffer handed over rather than copied. Its output is
|
|
32
|
+
// silence, so it can hang off the destination, which is what has it run.
|
|
33
|
+
export const CAPTURE_PROCESSOR = 'dew-voice-capture';
|
|
34
|
+
export const CAPTURE_WORKLET = `
|
|
35
|
+
class DewVoiceCapture extends AudioWorkletProcessor {
|
|
36
|
+
constructor() {
|
|
37
|
+
super();
|
|
38
|
+
this.chunk = new Int16Array(${VOICE_CHUNK_SAMPLES});
|
|
39
|
+
this.at = 0;
|
|
40
|
+
// The microphone's frames per sample, and where in the block the next
|
|
41
|
+
// sample falls.
|
|
42
|
+
this.step = sampleRate / ${SAMPLE_RATE};
|
|
43
|
+
this.phase = 0;
|
|
44
|
+
}
|
|
45
|
+
process(inputs) {
|
|
46
|
+
const frames = inputs[0][0];
|
|
47
|
+
if (frames === undefined) return true;
|
|
48
|
+
while (this.phase < frames.length) {
|
|
49
|
+
const i = Math.floor(this.phase);
|
|
50
|
+
const next = i + 1 < frames.length ? frames[i + 1] : frames[i];
|
|
51
|
+
const sample = frames[i] + (next - frames[i]) * (this.phase - i);
|
|
52
|
+
const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample;
|
|
53
|
+
this.chunk[this.at++] = Math.round(clamped * 32767);
|
|
54
|
+
if (this.at === this.chunk.length) {
|
|
55
|
+
this.port.postMessage(this.chunk, [this.chunk.buffer]);
|
|
56
|
+
this.chunk = new Int16Array(${VOICE_CHUNK_SAMPLES});
|
|
57
|
+
this.at = 0;
|
|
58
|
+
}
|
|
59
|
+
this.phase += this.step;
|
|
60
|
+
}
|
|
61
|
+
this.phase -= frames.length;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
registerProcessor('${CAPTURE_PROCESSOR}', DewVoiceCapture);
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
// The capture under way, if any.
|
|
69
|
+
let capturing = null;
|
|
70
|
+
|
|
71
|
+
// A stream with a live audio track, as `getUserMedia` resolves one.
|
|
72
|
+
function liveMicrophone(stream) {
|
|
73
|
+
return stream !== null && typeof stream === 'object' && typeof stream.getAudioTracks === 'function'
|
|
74
|
+
&& stream.getAudioTracks().some((track) => track.readyState === 'live');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The player's microphone, a chunk at a time: given the stream
|
|
78
|
+
// `getUserMedia` resolved, while its track is live, hands back the
|
|
79
|
+
// capture, whose `read()` returns the chunks that arrived since the last
|
|
80
|
+
// read — each an Int16Array of VOICE_CHUNK_SAMPLES samples, mono, at
|
|
81
|
+
// SAMPLE_RATE, in order, the newest VOICE_CHUNKS_HELD kept when the game
|
|
82
|
+
// reads late — and whose `stop()` ends it, closing the context at once.
|
|
83
|
+
// One at a time. The browser's own graph reads the stream: a context at
|
|
84
|
+
// the sample rate where the browser allows one, the worklet between the
|
|
85
|
+
// stream and the destination.
|
|
86
|
+
function capture(stream) {
|
|
87
|
+
if (!liveMicrophone(stream)) {
|
|
88
|
+
throw new TypeError('voice.capture: stream is not a live microphone stream from navigator.mediaDevices.getUserMedia');
|
|
89
|
+
}
|
|
90
|
+
if (capturing !== null) throw new DOMException('voice.capture: the microphone is captured already; stop that first', 'InvalidStateError');
|
|
91
|
+
if (typeof AudioContext === 'undefined' || typeof AudioWorkletNode === 'undefined') {
|
|
92
|
+
throw new DOMException('voice.capture is unavailable here: the browser has no AudioWorklet', 'NotSupportedError');
|
|
93
|
+
}
|
|
94
|
+
let context;
|
|
95
|
+
try {
|
|
96
|
+
context = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
97
|
+
} catch {
|
|
98
|
+
context = new AudioContext();
|
|
99
|
+
}
|
|
100
|
+
let chunks = [];
|
|
101
|
+
let live = true;
|
|
102
|
+
const worklet = URL.createObjectURL(new Blob([CAPTURE_WORKLET], { type: 'text/javascript' }));
|
|
103
|
+
const ready = context.audioWorklet.addModule(worklet).then(() => {
|
|
104
|
+
URL.revokeObjectURL(worklet);
|
|
105
|
+
if (!live) return undefined;
|
|
106
|
+
const node = new AudioWorkletNode(context, CAPTURE_PROCESSOR, {
|
|
107
|
+
numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1], channelCount: 1, channelCountMode: 'explicit',
|
|
108
|
+
});
|
|
109
|
+
node.port.onmessage = ({ data }) => {
|
|
110
|
+
if (!live) return;
|
|
111
|
+
chunks.push(data);
|
|
112
|
+
if (chunks.length > VOICE_CHUNKS_HELD) chunks.splice(0, chunks.length - VOICE_CHUNKS_HELD);
|
|
113
|
+
};
|
|
114
|
+
context.createMediaStreamSource(stream).connect(node).connect(context.destination);
|
|
115
|
+
// A context the browser keeps suspended until the player's next
|
|
116
|
+
// gesture starts then; nothing here waits on it.
|
|
117
|
+
context.resume().catch(() => {});
|
|
118
|
+
});
|
|
119
|
+
// A worklet the browser refuses leaves the capture silent; the reason
|
|
120
|
+
// goes to the console, where the developer is.
|
|
121
|
+
ready.catch((error) => console.error('voice.capture: the microphone could not be read: ' + error.message));
|
|
122
|
+
const mine = Object.freeze({
|
|
123
|
+
read() {
|
|
124
|
+
if (capturing !== mine || chunks.length === 0) return [];
|
|
125
|
+
const read = chunks;
|
|
126
|
+
chunks = [];
|
|
127
|
+
return read;
|
|
128
|
+
},
|
|
129
|
+
stop() {
|
|
130
|
+
if (capturing !== mine) return;
|
|
131
|
+
capturing = null;
|
|
132
|
+
live = false;
|
|
133
|
+
chunks = [];
|
|
134
|
+
// Closed at once, whether or not the worklet has loaded or the
|
|
135
|
+
// context ever ran: a setup still to come finds `live` false and
|
|
136
|
+
// makes nothing.
|
|
137
|
+
context.close().catch(() => {});
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
capturing = mine;
|
|
141
|
+
return mine;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isObjectLike(value) { return value !== null && (typeof value === 'object' || typeof value === 'function'); }
|
|
145
|
+
|
|
146
|
+
// A source for `context`'s graph that plays the chunks written to it as
|
|
147
|
+
// they come — Int16Array or Float32Array samples at SAMPLE_RATE, mono,
|
|
148
|
+
// as `capture` reads them or as another player's reached this game —
|
|
149
|
+
// each right after the last: `node` is the source, for `connect`
|
|
150
|
+
// or a three.js PositionalAudio's `setNodeSource`; `write` plays a chunk
|
|
151
|
+
// after those before it; `close` leaves the graph. Plain Web Audio — a
|
|
152
|
+
// buffer source a write, begun SOURCE_LEAD ahead of the context's clock
|
|
153
|
+
// and following the last, and nothing of Dewdrop's — so a game may play a
|
|
154
|
+
// voice its own way instead. The runtime's web/voice.js carries this same
|
|
155
|
+
// function, which tests/sync.test.mjs pins line for line.
|
|
156
|
+
function source(context) {
|
|
157
|
+
if (!isObjectLike(context) || typeof context.createBufferSource !== 'function') {
|
|
158
|
+
throw new TypeError('voice.source: context is not an AudioContext');
|
|
159
|
+
}
|
|
160
|
+
const node = context.createGain();
|
|
161
|
+
// When the next chunk written plays, on the context's clock.
|
|
162
|
+
let at = 0;
|
|
163
|
+
return Object.freeze({
|
|
164
|
+
node,
|
|
165
|
+
write(samples) {
|
|
166
|
+
const tag = Object.prototype.toString.call(samples);
|
|
167
|
+
if (tag !== '[object Int16Array]' && tag !== '[object Float32Array]') {
|
|
168
|
+
throw new TypeError('voice.source: write takes an Int16Array or a Float32Array of samples');
|
|
169
|
+
}
|
|
170
|
+
if (samples.length === 0) return;
|
|
171
|
+
const now = context.currentTime;
|
|
172
|
+
// The first write, or one after the chunks before it played out:
|
|
173
|
+
// SOURCE_LEAD ahead of the clock. While they still play, right
|
|
174
|
+
// after them, however early or late this one came, which is what
|
|
175
|
+
// rides out the jitter; too far ahead of the clock: dropped.
|
|
176
|
+
if (at <= now) at = now + SOURCE_LEAD;
|
|
177
|
+
else if (at > now + MAX_SOURCE_LEAD) return;
|
|
178
|
+
const buffer = context.createBuffer(1, samples.length, SAMPLE_RATE);
|
|
179
|
+
const channel = buffer.getChannelData(0);
|
|
180
|
+
if (tag === '[object Float32Array]') channel.set(samples);
|
|
181
|
+
else for (let i = 0; i < samples.length; i++) channel[i] = samples[i] / 32768;
|
|
182
|
+
const player = context.createBufferSource();
|
|
183
|
+
player.buffer = buffer;
|
|
184
|
+
player.connect(node);
|
|
185
|
+
player.start(at);
|
|
186
|
+
at += buffer.duration;
|
|
187
|
+
},
|
|
188
|
+
close() { node.disconnect(); },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const voice = Object.freeze({ capture, source, SAMPLE_RATE });
|