dewdrop-sdk 0.4.1 → 0.5.1
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 +4 -61
- package/package.json +2 -2
- package/src/index.js +3 -1
- package/src/session.js +6 -5
- package/src/voice.js +135 -0
package/README.md
CHANGED
|
@@ -1,67 +1,10 @@
|
|
|
1
1
|
# dewdrop-sdk
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Build a three.js game for Dewdrop and run it locally: the Vite plugin, model
|
|
4
|
+
calls, multiplayer, saves and voice.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
In a Vite 6 or later project:
|
|
9
|
-
|
|
10
|
-
```sh
|
|
11
|
-
npm install -D dewdrop-sdk
|
|
12
|
-
npm install --save-exact three@0.185.1
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
```js
|
|
16
|
-
// vite.config.js
|
|
17
|
-
import dew from 'dewdrop-sdk/vite';
|
|
18
|
-
export default { plugins: [dew()] };
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
`npx dewctl init` sets up a new game this way, with the Dewdrop CLI beside it.
|
|
22
|
-
To update a project, run `npm install -D dewdrop-sdk@latest`; saves need 0.4.0
|
|
23
|
-
or later.
|
|
24
|
-
|
|
25
|
-
## Saves locally
|
|
26
|
-
|
|
27
|
-
On Dewdrop, `localStorage` and IndexedDB aren't local: nothing your game
|
|
28
|
-
writes to them is stored in the player's browser or on their device, and they
|
|
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
|
-
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`.
|
|
33
|
-
`npm run dev` does the same in your browser: every page load starts with
|
|
34
|
-
`localStorage` and IndexedDB empty, and saves are kept in the browser, in a
|
|
35
|
-
database named `dewdrop-saves` your game doesn't see, for the game
|
|
36
|
-
`dewdrop.json` names and the player `?player=` names, across your edits. Tabs
|
|
37
|
-
of one dev server share the browser's storage, so loading one empties the
|
|
38
|
-
others'.
|
|
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
|
-
## Docs
|
|
62
|
-
|
|
63
|
-
How to build your game, from the look to model calls, multiplayer, saves and
|
|
64
|
-
what works on Dewdrop, is in the docs: https://alpha.dewdrop.world/docs
|
|
6
|
+
How to set up a project and everything the SDK does is in the docs:
|
|
7
|
+
https://alpha.dewdrop.world/docs
|
|
65
8
|
|
|
66
9
|
## License
|
|
67
10
|
|
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.1",
|
|
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/index.js
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
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
7
|
// page loads; `session` says when a session ends ahead of time, which here
|
|
8
|
-
// none does
|
|
8
|
+
// none does; and `voice` reads the microphone through the browser's own
|
|
9
|
+
// audio worklet, into the chunks a game gets on Dewdrop.
|
|
9
10
|
export { llm } from './llm.js';
|
|
10
11
|
export { backend } from './backend.js';
|
|
11
12
|
export { clients } from './clients.js';
|
|
12
13
|
export { session } from './session.js';
|
|
14
|
+
export { voice } from './voice.js';
|
|
13
15
|
import { attachBackend, joining } from './backend.js';
|
|
14
16
|
import { load, save } from './saves.js';
|
|
15
17
|
|
package/src/session.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// `session` on a developer's machine. On Dewdrop a session can end ahead of
|
|
2
|
-
// time — for maintenance,
|
|
3
|
-
// hears of it first, up to fifteen
|
|
2
|
+
// time — for maintenance, a newer version of its game published, or its
|
|
3
|
+
// player idle for fifteen minutes — and hears of it first, up to fifteen
|
|
4
|
+
// minutes before, a minute for an idle player: `session.ending` is
|
|
4
5
|
// `{at, reason}`, `at` in milliseconds since the epoch as `Date.now()` counts
|
|
5
|
-
// and `reason` `'restart'` or `'
|
|
6
|
-
// time it changes, its withdrawal to null included, so a game can
|
|
7
|
-
// say so. `npm run dev` ends no session ahead of time, so here `ending`
|
|
6
|
+
// and `reason` `'restart'`, `'update'` or `'idle'`, and an `ending` event
|
|
7
|
+
// fires each time it changes, its withdrawal to null included, so a game can
|
|
8
|
+
// save and say so. `npm run dev` ends no session ahead of time, so here `ending`
|
|
8
9
|
// stays null and the event never fires; a game tries its handler by calling
|
|
9
10
|
// it.
|
|
10
11
|
import { defineHandlers } from './net.js';
|
package/src/voice.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// `voice` on a developer's machine: the player's microphone a chunk at a
|
|
2
|
+
// time, as a game gets it on Dewdrop, where Dewdrop carries no voice
|
|
3
|
+
// between players and plays none itself: a game sends what it captures
|
|
4
|
+
// to its backend through its messages, the backend sends it on to the
|
|
5
|
+
// players it chooses, and their games play it through their own audio
|
|
6
|
+
// graphs. Here the browser's own audio worklet reads the microphone and
|
|
7
|
+
// hands the game the same chunks, so a game captures in `npm run dev` as
|
|
8
|
+
// it captures on Dewdrop.
|
|
9
|
+
|
|
10
|
+
// The chunks as Dewdrop hands them to a game: the rate of their samples,
|
|
11
|
+
// the samples in one chunk, 1/60 s, and the chunks that wait for a game
|
|
12
|
+
// that reads late, the newest kept.
|
|
13
|
+
export const SAMPLE_RATE = 48000;
|
|
14
|
+
export const VOICE_CHUNK_SAMPLES = 800;
|
|
15
|
+
export const VOICE_CHUNKS_HELD = 8;
|
|
16
|
+
|
|
17
|
+
// The processor that reads the microphone in the browser's audio thread:
|
|
18
|
+
// it takes the microphone's frames at the context's rate, steps through
|
|
19
|
+
// them at SAMPLE_RATE, and posts each VOICE_CHUNK_SAMPLES of them as 16-bit
|
|
20
|
+
// samples, the buffer handed over rather than copied. Its output is
|
|
21
|
+
// silence, so it can hang off the destination, which is what has it run.
|
|
22
|
+
export const CAPTURE_PROCESSOR = 'dew-voice-capture';
|
|
23
|
+
export const CAPTURE_WORKLET = `
|
|
24
|
+
class DewVoiceCapture extends AudioWorkletProcessor {
|
|
25
|
+
constructor() {
|
|
26
|
+
super();
|
|
27
|
+
this.chunk = new Int16Array(${VOICE_CHUNK_SAMPLES});
|
|
28
|
+
this.at = 0;
|
|
29
|
+
// The microphone's frames per sample, and where in the block the next
|
|
30
|
+
// sample falls.
|
|
31
|
+
this.step = sampleRate / ${SAMPLE_RATE};
|
|
32
|
+
this.phase = 0;
|
|
33
|
+
}
|
|
34
|
+
process(inputs) {
|
|
35
|
+
const frames = inputs[0][0];
|
|
36
|
+
if (frames === undefined) return true;
|
|
37
|
+
while (this.phase < frames.length) {
|
|
38
|
+
const i = Math.floor(this.phase);
|
|
39
|
+
const next = i + 1 < frames.length ? frames[i + 1] : frames[i];
|
|
40
|
+
const sample = frames[i] + (next - frames[i]) * (this.phase - i);
|
|
41
|
+
const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample;
|
|
42
|
+
this.chunk[this.at++] = Math.round(clamped * 32767);
|
|
43
|
+
if (this.at === this.chunk.length) {
|
|
44
|
+
this.port.postMessage(this.chunk, [this.chunk.buffer]);
|
|
45
|
+
this.chunk = new Int16Array(${VOICE_CHUNK_SAMPLES});
|
|
46
|
+
this.at = 0;
|
|
47
|
+
}
|
|
48
|
+
this.phase += this.step;
|
|
49
|
+
}
|
|
50
|
+
this.phase -= frames.length;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
registerProcessor('${CAPTURE_PROCESSOR}', DewVoiceCapture);
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
// The capture under way, if any.
|
|
58
|
+
let capturing = null;
|
|
59
|
+
|
|
60
|
+
// A stream with a live audio track, as `getUserMedia` resolves one.
|
|
61
|
+
function liveMicrophone(stream) {
|
|
62
|
+
return stream !== null && typeof stream === 'object' && typeof stream.getAudioTracks === 'function'
|
|
63
|
+
&& stream.getAudioTracks().some((track) => track.readyState === 'live');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The player's microphone, a chunk at a time: given the stream
|
|
67
|
+
// `getUserMedia` resolved, while its track is live, hands back the
|
|
68
|
+
// capture, whose `read()` returns the chunks that arrived since the last
|
|
69
|
+
// read — each an Int16Array of VOICE_CHUNK_SAMPLES samples, mono, at
|
|
70
|
+
// SAMPLE_RATE, in order, the newest VOICE_CHUNKS_HELD kept when the game
|
|
71
|
+
// reads late — and whose `stop()` ends it, closing the context at once.
|
|
72
|
+
// One at a time. The browser's own graph reads the stream: a context at
|
|
73
|
+
// the sample rate where the browser allows one, the worklet between the
|
|
74
|
+
// stream and the destination.
|
|
75
|
+
function capture(stream) {
|
|
76
|
+
if (!liveMicrophone(stream)) {
|
|
77
|
+
throw new TypeError('voice.capture: stream is not a live microphone stream from navigator.mediaDevices.getUserMedia');
|
|
78
|
+
}
|
|
79
|
+
if (capturing !== null) throw new DOMException('voice.capture: the microphone is captured already; stop that first', 'InvalidStateError');
|
|
80
|
+
if (typeof AudioContext === 'undefined' || typeof AudioWorkletNode === 'undefined') {
|
|
81
|
+
throw new DOMException('voice.capture is unavailable here: the browser has no AudioWorklet', 'NotSupportedError');
|
|
82
|
+
}
|
|
83
|
+
let context;
|
|
84
|
+
try {
|
|
85
|
+
context = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
86
|
+
} catch {
|
|
87
|
+
context = new AudioContext();
|
|
88
|
+
}
|
|
89
|
+
let chunks = [];
|
|
90
|
+
let live = true;
|
|
91
|
+
const worklet = URL.createObjectURL(new Blob([CAPTURE_WORKLET], { type: 'text/javascript' }));
|
|
92
|
+
const ready = context.audioWorklet.addModule(worklet).then(() => {
|
|
93
|
+
URL.revokeObjectURL(worklet);
|
|
94
|
+
if (!live) return undefined;
|
|
95
|
+
const node = new AudioWorkletNode(context, CAPTURE_PROCESSOR, {
|
|
96
|
+
numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1], channelCount: 1, channelCountMode: 'explicit',
|
|
97
|
+
});
|
|
98
|
+
node.port.onmessage = ({ data }) => {
|
|
99
|
+
if (!live) return;
|
|
100
|
+
chunks.push(data);
|
|
101
|
+
if (chunks.length > VOICE_CHUNKS_HELD) chunks.splice(0, chunks.length - VOICE_CHUNKS_HELD);
|
|
102
|
+
};
|
|
103
|
+
context.createMediaStreamSource(stream).connect(node).connect(context.destination);
|
|
104
|
+
// A context the browser keeps suspended until the player's next
|
|
105
|
+
// gesture starts then; nothing here waits on it.
|
|
106
|
+
context.resume().catch(() => {});
|
|
107
|
+
});
|
|
108
|
+
// A worklet the browser refuses leaves the capture silent; the reason
|
|
109
|
+
// goes to the console, where the developer is.
|
|
110
|
+
ready.catch((error) => console.error('voice.capture: the microphone could not be read: ' + error.message));
|
|
111
|
+
const mine = Object.freeze({
|
|
112
|
+
read() {
|
|
113
|
+
if (capturing !== mine || chunks.length === 0) return [];
|
|
114
|
+
const read = chunks;
|
|
115
|
+
chunks = [];
|
|
116
|
+
return read;
|
|
117
|
+
},
|
|
118
|
+
stop() {
|
|
119
|
+
if (capturing !== mine) return;
|
|
120
|
+
capturing = null;
|
|
121
|
+
live = false;
|
|
122
|
+
chunks = [];
|
|
123
|
+
// Closed at once, whether or not the worklet has loaded or the
|
|
124
|
+
// context ever ran: a setup still to come finds `live` false and
|
|
125
|
+
// makes nothing.
|
|
126
|
+
context.close().catch(() => {});
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
capturing = mine;
|
|
130
|
+
return mine;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// SAMPLE_RATE is the chunks' rate, for the buffers a game plays them
|
|
134
|
+
// through.
|
|
135
|
+
export const voice = Object.freeze({ capture, SAMPLE_RATE });
|