@umicat/three-sdk 0.10.0 → 0.12.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 +21 -4
- package/dist/GameAudio.d.ts +64 -1
- package/dist/GameAudio.js +125 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,15 +3,27 @@
|
|
|
3
3
|
The three.js runtime for Umicat games, per ADR-033. Platform services come from
|
|
4
4
|
`@umicat/platform-sdk` untouched; what lives here is the engine layer we own.
|
|
5
5
|
|
|
6
|
-
**Status:
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
**Status: published and in use.** `@umicat/three-sdk` is on npm; Balaboo (a
|
|
7
|
+
tower defence that ships from `woodland/`) is built on it, and `umicat-template-3d`
|
|
8
|
+
is what new 3D projects start from.
|
|
9
|
+
|
|
10
|
+
What it has: the scene3d format and loader, physics wiring, a character
|
|
11
|
+
controller and animator, an input layer that mounts on-screen controls on touch
|
|
12
|
+
devices and binds keys everywhere, bone sockets, hit tints, and audio. What it
|
|
13
|
+
still does not have: an editor.
|
|
14
|
+
|
|
15
|
+
> This paragraph said "seed, not a product — no input system, no character
|
|
16
|
+
> controller, no audio, and no published package" for ten minor versions after
|
|
17
|
+
> each of those arrived. A status line is the first thing anyone reads and the
|
|
18
|
+
> last thing anyone updates.
|
|
9
19
|
|
|
10
20
|
```
|
|
11
21
|
@umicat/platform-sdk identity · saves · gameData · rooms · ai · voice · dialogue
|
|
12
22
|
▲ (shared with @umicat/phaser-sdk)
|
|
13
23
|
│
|
|
14
|
-
@umicat/three-sdk ThreeUmicat · scene3d
|
|
24
|
+
@umicat/three-sdk ThreeUmicat · scene3d · loadScene3D · physics · Input3D
|
|
25
|
+
CharacterController3D · CharacterAnimator · GameAudio
|
|
26
|
+
sockets · tints
|
|
15
27
|
▲
|
|
16
28
|
your game gameplay
|
|
17
29
|
```
|
|
@@ -31,6 +43,11 @@ mocked — every line of SDK is the shipped code.**
|
|
|
31
43
|
| physics runs | a crate dropped from y=6 settles on the ground and does not fall through |
|
|
32
44
|
| design mode is render-only | same entities, zero mixers, zero bodies |
|
|
33
45
|
| authoring mistakes fail loudly | duplicate ids and missing clips throw at load, naming the clips that exist |
|
|
46
|
+
| music and effects are two volumes | the clip's gain hangs off `sfxGain`, not the master — `audio-volume.test.mjs` |
|
|
47
|
+
|
|
48
|
+
Eight suites, not one: the table above is the platform seam. The others cover
|
|
49
|
+
the character controller, the animator, sockets, tints, input icons, the
|
|
50
|
+
courtyard sample and the audio graph.
|
|
34
51
|
|
|
35
52
|
`slice.png` is the scene those assertions describe, rendered.
|
|
36
53
|
|
package/dist/GameAudio.d.ts
CHANGED
|
@@ -55,9 +55,20 @@ export interface GameAudioOptions {
|
|
|
55
55
|
musicVolume?: number;
|
|
56
56
|
}
|
|
57
57
|
export declare class GameAudio {
|
|
58
|
+
/** How long a sound asked for during the unlock may wait for the context. */
|
|
59
|
+
private static readonly PENDING_MS;
|
|
60
|
+
private loading;
|
|
61
|
+
private pending;
|
|
58
62
|
private ctx;
|
|
59
63
|
private master;
|
|
60
64
|
private musicGain;
|
|
65
|
+
/** Everything that is not music, on its own knob.
|
|
66
|
+
*
|
|
67
|
+
* Effects used to connect straight to the master, which made "quieter
|
|
68
|
+
* music" and "quieter sound" the same lever — and they are the two things
|
|
69
|
+
* players most want to set separately. Someone who wants the track down
|
|
70
|
+
* wants to keep hearing what is shooting at them. */
|
|
71
|
+
private sfxGain;
|
|
61
72
|
private musicEl;
|
|
62
73
|
private musicName;
|
|
63
74
|
private readonly buffers;
|
|
@@ -65,14 +76,53 @@ export declare class GameAudio {
|
|
|
65
76
|
private readonly clips;
|
|
66
77
|
private readonly base;
|
|
67
78
|
private readonly ext;
|
|
68
|
-
|
|
79
|
+
/** Not readonly any more: it is the top of the music's range and the level
|
|
80
|
+
* `duck()` returns to, and both move when the player moves the slider. */
|
|
81
|
+
private musicVolume;
|
|
82
|
+
private sfxVolume;
|
|
69
83
|
private muted;
|
|
70
84
|
private readonly cleanups;
|
|
71
85
|
constructor(opts: GameAudioOptions);
|
|
72
86
|
/** Whether sound can be heard right now. Asked of the context every time —
|
|
73
87
|
* a cached flag is exactly the bug described above. */
|
|
74
88
|
get ready(): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Build the graph and start fetching, WITHOUT asking to be heard.
|
|
91
|
+
*
|
|
92
|
+
* A context may be constructed and a buffer decoded while suspended; only
|
|
93
|
+
* `resume()` needs a gesture. Separating the two is what lets `preload()`
|
|
94
|
+
* do the slow half early.
|
|
95
|
+
*/
|
|
96
|
+
private ensureContext;
|
|
97
|
+
/**
|
|
98
|
+
* Fetch and decode every clip now, before anything has been pressed.
|
|
99
|
+
*
|
|
100
|
+
* Without this the first gesture of a session does BOTH jobs — it creates
|
|
101
|
+
* the context and it starts the downloads — so a sound asked for on that
|
|
102
|
+
* gesture has no buffer to play and is silently dropped. The gesture in
|
|
103
|
+
* question is usually a title screen's only button, which is the one press
|
|
104
|
+
* every player makes.
|
|
105
|
+
*
|
|
106
|
+
* Safe to call at boot: nothing here needs permission, and on a browser that
|
|
107
|
+
* has not been touched yet the context simply stays suspended until it is.
|
|
108
|
+
* Awaiting it is optional — the point is to have started.
|
|
109
|
+
*/
|
|
110
|
+
preload(): Promise<void>;
|
|
75
111
|
private start;
|
|
112
|
+
/**
|
|
113
|
+
* A sound asked for DURING the unlocking gesture, played the moment the
|
|
114
|
+
* context comes up.
|
|
115
|
+
*
|
|
116
|
+
* `resume()` is asynchronous, so a `play()` made inside the gesture that
|
|
117
|
+
* unlocks audio always finds `ready` false — the press that turns the sound
|
|
118
|
+
* on is the one press that cannot make one. Holding the request for a beat
|
|
119
|
+
* and firing it on `ready` costs nothing and covers exactly that case.
|
|
120
|
+
*
|
|
121
|
+
* One request, not a queue, and only if it is still FRESH. A button's click
|
|
122
|
+
* arriving a second late is worse than no click at all, and replaying a
|
|
123
|
+
* backlog the moment audio comes up is how a game greets you with a chord.
|
|
124
|
+
*/
|
|
125
|
+
private flushPending;
|
|
76
126
|
/** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
|
|
77
127
|
private urlFor;
|
|
78
128
|
private loadAll;
|
|
@@ -87,6 +137,19 @@ export declare class GameAudio {
|
|
|
87
137
|
play(name: string): void;
|
|
88
138
|
/** Duck the music for a moment — for an ending that should be heard over it. */
|
|
89
139
|
duck(seconds?: number): void;
|
|
140
|
+
/**
|
|
141
|
+
* How loud the music is, 0 to 1, as a fraction of the mix it was given.
|
|
142
|
+
*
|
|
143
|
+
* Separate from mute, and both are kept: mute is a switch you flip on the way
|
|
144
|
+
* into a room and back on the way out, and losing the level you had set to
|
|
145
|
+
* find it back at 1 is a small betrayal. Muted, this still records what the
|
|
146
|
+
* player chose — unmuting restores it.
|
|
147
|
+
*/
|
|
148
|
+
setMusicVolume(v: number): void;
|
|
149
|
+
get musicLevel(): number;
|
|
150
|
+
/** How loud everything that is not music is, 0 to 1. */
|
|
151
|
+
setSfxVolume(v: number): void;
|
|
152
|
+
get sfxLevel(): number;
|
|
90
153
|
setMuted(on: boolean): void;
|
|
91
154
|
get isMuted(): boolean;
|
|
92
155
|
dispose(): void;
|
package/dist/GameAudio.js
CHANGED
|
@@ -35,13 +35,23 @@
|
|
|
35
35
|
*/
|
|
36
36
|
export class GameAudio {
|
|
37
37
|
constructor(opts) {
|
|
38
|
+
this.loading = null;
|
|
39
|
+
this.pending = null;
|
|
38
40
|
this.ctx = null;
|
|
39
41
|
this.master = null;
|
|
40
42
|
this.musicGain = null;
|
|
43
|
+
/** Everything that is not music, on its own knob.
|
|
44
|
+
*
|
|
45
|
+
* Effects used to connect straight to the master, which made "quieter
|
|
46
|
+
* music" and "quieter sound" the same lever — and they are the two things
|
|
47
|
+
* players most want to set separately. Someone who wants the track down
|
|
48
|
+
* wants to keep hearing what is shooting at them. */
|
|
49
|
+
this.sfxGain = null;
|
|
41
50
|
this.musicEl = null;
|
|
42
51
|
this.musicName = null;
|
|
43
52
|
this.buffers = new Map();
|
|
44
53
|
this.lastPlayed = new Map();
|
|
54
|
+
this.sfxVolume = 1;
|
|
45
55
|
this.muted = false;
|
|
46
56
|
this.cleanups = [];
|
|
47
57
|
this.clips = opts.clips;
|
|
@@ -73,31 +83,85 @@ export class GameAudio {
|
|
|
73
83
|
/** Whether sound can be heard right now. Asked of the context every time —
|
|
74
84
|
* a cached flag is exactly the bug described above. */
|
|
75
85
|
get ready() { return this.ctx?.state === 'running'; }
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Build the graph and start fetching, WITHOUT asking to be heard.
|
|
88
|
+
*
|
|
89
|
+
* A context may be constructed and a buffer decoded while suspended; only
|
|
90
|
+
* `resume()` needs a gesture. Separating the two is what lets `preload()`
|
|
91
|
+
* do the slow half early.
|
|
92
|
+
*/
|
|
93
|
+
ensureContext() {
|
|
94
|
+
if (this.ctx)
|
|
95
|
+
return true;
|
|
79
96
|
const AC = window.AudioContext
|
|
80
97
|
?? window.webkitAudioContext;
|
|
81
98
|
if (!AC)
|
|
99
|
+
return false;
|
|
100
|
+
this.ctx = new AC();
|
|
101
|
+
this.master = this.ctx.createGain();
|
|
102
|
+
this.master.gain.value = this.muted ? 0 : 1;
|
|
103
|
+
this.master.connect(this.ctx.destination);
|
|
104
|
+
this.musicGain = this.ctx.createGain();
|
|
105
|
+
this.musicGain.gain.value = this.musicVolume;
|
|
106
|
+
this.musicGain.connect(this.master);
|
|
107
|
+
this.sfxGain = this.ctx.createGain();
|
|
108
|
+
this.sfxGain.gain.value = this.sfxVolume;
|
|
109
|
+
this.sfxGain.connect(this.master);
|
|
110
|
+
this.loading ?? (this.loading = this.loadAll());
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Fetch and decode every clip now, before anything has been pressed.
|
|
115
|
+
*
|
|
116
|
+
* Without this the first gesture of a session does BOTH jobs — it creates
|
|
117
|
+
* the context and it starts the downloads — so a sound asked for on that
|
|
118
|
+
* gesture has no buffer to play and is silently dropped. The gesture in
|
|
119
|
+
* question is usually a title screen's only button, which is the one press
|
|
120
|
+
* every player makes.
|
|
121
|
+
*
|
|
122
|
+
* Safe to call at boot: nothing here needs permission, and on a browser that
|
|
123
|
+
* has not been touched yet the context simply stays suspended until it is.
|
|
124
|
+
* Awaiting it is optional — the point is to have started.
|
|
125
|
+
*/
|
|
126
|
+
async preload() {
|
|
127
|
+
if (!this.ensureContext())
|
|
128
|
+
return;
|
|
129
|
+
await (this.loading ?? (this.loading = this.loadAll()));
|
|
130
|
+
}
|
|
131
|
+
async start() {
|
|
132
|
+
if (this.ready)
|
|
133
|
+
return;
|
|
134
|
+
if (!this.ensureContext())
|
|
82
135
|
return;
|
|
83
|
-
if (!this.ctx) {
|
|
84
|
-
this.ctx = new AC();
|
|
85
|
-
this.master = this.ctx.createGain();
|
|
86
|
-
this.master.gain.value = this.muted ? 0 : 1;
|
|
87
|
-
this.master.connect(this.ctx.destination);
|
|
88
|
-
this.musicGain = this.ctx.createGain();
|
|
89
|
-
this.musicGain.gain.value = this.musicVolume;
|
|
90
|
-
this.musicGain.connect(this.master);
|
|
91
|
-
void this.loadAll();
|
|
92
|
-
}
|
|
93
136
|
// Called inside the gesture's call stack, and awaited before anything asks
|
|
94
137
|
// whether it worked.
|
|
95
138
|
try {
|
|
96
139
|
await this.ctx.resume();
|
|
97
140
|
}
|
|
98
141
|
catch { /* a blocked context is not fatal */ }
|
|
99
|
-
if (this.ready)
|
|
142
|
+
if (this.ready) {
|
|
100
143
|
this.startMusic();
|
|
144
|
+
this.flushPending();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A sound asked for DURING the unlocking gesture, played the moment the
|
|
149
|
+
* context comes up.
|
|
150
|
+
*
|
|
151
|
+
* `resume()` is asynchronous, so a `play()` made inside the gesture that
|
|
152
|
+
* unlocks audio always finds `ready` false — the press that turns the sound
|
|
153
|
+
* on is the one press that cannot make one. Holding the request for a beat
|
|
154
|
+
* and firing it on `ready` costs nothing and covers exactly that case.
|
|
155
|
+
*
|
|
156
|
+
* One request, not a queue, and only if it is still FRESH. A button's click
|
|
157
|
+
* arriving a second late is worse than no click at all, and replaying a
|
|
158
|
+
* backlog the moment audio comes up is how a game greets you with a chord.
|
|
159
|
+
*/
|
|
160
|
+
flushPending() {
|
|
161
|
+
const p = this.pending;
|
|
162
|
+
this.pending = null;
|
|
163
|
+
if (p && performance.now() - p.at < GameAudio.PENDING_MS)
|
|
164
|
+
this.play(p.name);
|
|
101
165
|
}
|
|
102
166
|
/** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
|
|
103
167
|
urlFor(name) {
|
|
@@ -164,8 +228,14 @@ export class GameAudio {
|
|
|
164
228
|
}
|
|
165
229
|
play(name) {
|
|
166
230
|
const ctx = this.ctx;
|
|
167
|
-
if (this.muted
|
|
231
|
+
if (this.muted)
|
|
232
|
+
return;
|
|
233
|
+
if (!this.ready || !ctx || !this.master) {
|
|
234
|
+
// Not "drop it": this is the unlocking gesture, and the sound it asked
|
|
235
|
+
// for is the one the player is waiting to hear. `start()` plays it.
|
|
236
|
+
this.pending = { name, at: performance.now() };
|
|
168
237
|
return;
|
|
238
|
+
}
|
|
169
239
|
const buf = this.buffers.get(name);
|
|
170
240
|
if (!buf)
|
|
171
241
|
return;
|
|
@@ -180,7 +250,10 @@ export class GameAudio {
|
|
|
180
250
|
const g = ctx.createGain();
|
|
181
251
|
g.gain.value = spec?.volume ?? 0.5;
|
|
182
252
|
src.connect(g);
|
|
183
|
-
|
|
253
|
+
// Through the effects knob, not straight to the master. The clip's own
|
|
254
|
+
// `volume` is still its balance against the others; this is the player's
|
|
255
|
+
// opinion about all of them at once.
|
|
256
|
+
g.connect(this.sfxGain ?? this.master);
|
|
184
257
|
src.start();
|
|
185
258
|
// Nodes disconnect themselves when they end; without this they pile up as
|
|
186
259
|
// garbage the collector has to chase during play.
|
|
@@ -196,6 +269,40 @@ export class GameAudio {
|
|
|
196
269
|
this.musicGain.gain.linearRampToValueAtTime(this.musicVolume * 0.25, t + 0.2);
|
|
197
270
|
this.musicGain.gain.linearRampToValueAtTime(this.musicVolume, t + seconds);
|
|
198
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* How loud the music is, 0 to 1, as a fraction of the mix it was given.
|
|
274
|
+
*
|
|
275
|
+
* Separate from mute, and both are kept: mute is a switch you flip on the way
|
|
276
|
+
* into a room and back on the way out, and losing the level you had set to
|
|
277
|
+
* find it back at 1 is a small betrayal. Muted, this still records what the
|
|
278
|
+
* player chose — unmuting restores it.
|
|
279
|
+
*/
|
|
280
|
+
setMusicVolume(v) {
|
|
281
|
+
this.musicVolume = Math.max(0, Math.min(1, v));
|
|
282
|
+
if (this.musicGain && this.ctx) {
|
|
283
|
+
// Ramped, not assigned. A gain that jumps clicks, audibly, and a slider
|
|
284
|
+
// is a stream of tiny jumps.
|
|
285
|
+
this.musicGain.gain.setTargetAtTime(this.musicVolume, this.ctx.currentTime, 0.02);
|
|
286
|
+
}
|
|
287
|
+
// The fallback path, where the element could not be routed through the
|
|
288
|
+
// graph. Without this, the slider does nothing at all on those engines —
|
|
289
|
+
// and they are the ones nobody tests on.
|
|
290
|
+
if (this.musicEl && this.musicEl.volume !== undefined) {
|
|
291
|
+
try {
|
|
292
|
+
this.musicEl.volume = this.musicVolume;
|
|
293
|
+
}
|
|
294
|
+
catch { /* not fatal */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
get musicLevel() { return this.musicVolume; }
|
|
298
|
+
/** How loud everything that is not music is, 0 to 1. */
|
|
299
|
+
setSfxVolume(v) {
|
|
300
|
+
this.sfxVolume = Math.max(0, Math.min(1, v));
|
|
301
|
+
if (this.sfxGain && this.ctx) {
|
|
302
|
+
this.sfxGain.gain.setTargetAtTime(this.sfxVolume, this.ctx.currentTime, 0.02);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
get sfxLevel() { return this.sfxVolume; }
|
|
199
306
|
setMuted(on) {
|
|
200
307
|
this.muted = on;
|
|
201
308
|
if (this.master && this.ctx) {
|
|
@@ -218,3 +325,5 @@ export class GameAudio {
|
|
|
218
325
|
this.ctx = null;
|
|
219
326
|
}
|
|
220
327
|
}
|
|
328
|
+
/** How long a sound asked for during the unlock may wait for the context. */
|
|
329
|
+
GameAudio.PENDING_MS = 400;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|