@umicat/three-sdk 0.8.5 → 0.9.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/dist/GameAudio.d.ts +93 -0
- package/dist/GameAudio.js +220 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound for a web game, through Web Audio.
|
|
3
|
+
*
|
|
4
|
+
* This is a platform capability rather than something each game writes,
|
|
5
|
+
* because getting it wrong is invisible in every place a creator would look.
|
|
6
|
+
* Four traps, all of them found the expensive way:
|
|
7
|
+
*
|
|
8
|
+
* **1. Never use `HTMLAudioElement` for game sound.** iOS gives each element a
|
|
9
|
+
* real audio pipeline, caps how many may exist, and charges for every
|
|
10
|
+
* `play()`. A game pooling about forty of them ran at 11fps on an iPhone and
|
|
11
|
+
* a locked 60 with the sound muted — while a desktop A/B showed no difference
|
|
12
|
+
* at all. Web Audio decodes each clip once into a buffer; playing one
|
|
13
|
+
* allocates a source node the browser throws away, and overlap is free.
|
|
14
|
+
*
|
|
15
|
+
* **2. The context starts suspended.** Browsers block audio until a gesture and
|
|
16
|
+
* iOS is strictest. A SYNTHETIC click does not count, which is how a
|
|
17
|
+
* measurement run can end up testing the muted case and reporting silence as
|
|
18
|
+
* success.
|
|
19
|
+
*
|
|
20
|
+
* **3. `resume()` is asynchronous.** Reading `ctx.state` on the next line finds
|
|
21
|
+
* 'suspended' and a cached "unlocked" flag stays false forever — the game is
|
|
22
|
+
* silent no matter how many times it is tapped. Readiness is derived here, not
|
|
23
|
+
* stored.
|
|
24
|
+
*
|
|
25
|
+
* **4. iOS suspends the context when the app goes away** and does not bring it
|
|
26
|
+
* back, so sound works until the first phone call and then never again.
|
|
27
|
+
*
|
|
28
|
+
* Music is the one exception to rule 1, and for the same reason rule 1 exists.
|
|
29
|
+
* A three-minute track decodes to something like seventy megabytes of PCM;
|
|
30
|
+
* forty short effects as elements was fatal, but ONE element playing one long
|
|
31
|
+
* thing is exactly what elements are for. So effects are buffers and music
|
|
32
|
+
* streams, routed through the same graph so ducking still works.
|
|
33
|
+
*
|
|
34
|
+
* The game brings its own clips; nothing here knows what a game sounds like.
|
|
35
|
+
*/
|
|
36
|
+
export interface AudioClipSpec {
|
|
37
|
+
/** 0-1, balanced by ear against the music. */
|
|
38
|
+
volume?: number;
|
|
39
|
+
/** Minimum gap between retriggers, in ms. Four towers reloading together
|
|
40
|
+
* turn one thwip into a buzz; a few tens of milliseconds fixes it and
|
|
41
|
+
* nobody notices a dropped shot. */
|
|
42
|
+
throttle?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface GameAudioOptions {
|
|
45
|
+
/** Clip name → how to play it. Names map to `${base}${name}${extension}`. */
|
|
46
|
+
clips: Record<string, AudioClipSpec>;
|
|
47
|
+
/** Where the files live, relative to the game. */
|
|
48
|
+
base?: string;
|
|
49
|
+
/** File extension, including the dot. */
|
|
50
|
+
extension?: string;
|
|
51
|
+
/** A track to loop as music. Streamed, not decoded — see the note above.
|
|
52
|
+
* A name containing a dot is used as-is, so `'theme.mp3'` works alongside
|
|
53
|
+
* `.ogg` effects. */
|
|
54
|
+
music?: string;
|
|
55
|
+
musicVolume?: number;
|
|
56
|
+
}
|
|
57
|
+
export declare class GameAudio {
|
|
58
|
+
private ctx;
|
|
59
|
+
private master;
|
|
60
|
+
private musicGain;
|
|
61
|
+
private musicEl;
|
|
62
|
+
private musicName;
|
|
63
|
+
private readonly buffers;
|
|
64
|
+
private readonly lastPlayed;
|
|
65
|
+
private readonly clips;
|
|
66
|
+
private readonly base;
|
|
67
|
+
private readonly ext;
|
|
68
|
+
private readonly musicVolume;
|
|
69
|
+
private muted;
|
|
70
|
+
private readonly cleanups;
|
|
71
|
+
constructor(opts: GameAudioOptions);
|
|
72
|
+
/** Whether sound can be heard right now. Asked of the context every time —
|
|
73
|
+
* a cached flag is exactly the bug described above. */
|
|
74
|
+
get ready(): boolean;
|
|
75
|
+
private start;
|
|
76
|
+
/** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
|
|
77
|
+
private urlFor;
|
|
78
|
+
private loadAll;
|
|
79
|
+
private startMusic;
|
|
80
|
+
/**
|
|
81
|
+
* Change the track. Pass `null` for silence.
|
|
82
|
+
*
|
|
83
|
+
* Each scene gets its own: a lobby that sounds like the fight is a lobby you
|
|
84
|
+
* do not linger in.
|
|
85
|
+
*/
|
|
86
|
+
setMusic(name: string | null): void;
|
|
87
|
+
play(name: string): void;
|
|
88
|
+
/** Duck the music for a moment — for an ending that should be heard over it. */
|
|
89
|
+
duck(seconds?: number): void;
|
|
90
|
+
setMuted(on: boolean): void;
|
|
91
|
+
get isMuted(): boolean;
|
|
92
|
+
dispose(): void;
|
|
93
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound for a web game, through Web Audio.
|
|
3
|
+
*
|
|
4
|
+
* This is a platform capability rather than something each game writes,
|
|
5
|
+
* because getting it wrong is invisible in every place a creator would look.
|
|
6
|
+
* Four traps, all of them found the expensive way:
|
|
7
|
+
*
|
|
8
|
+
* **1. Never use `HTMLAudioElement` for game sound.** iOS gives each element a
|
|
9
|
+
* real audio pipeline, caps how many may exist, and charges for every
|
|
10
|
+
* `play()`. A game pooling about forty of them ran at 11fps on an iPhone and
|
|
11
|
+
* a locked 60 with the sound muted — while a desktop A/B showed no difference
|
|
12
|
+
* at all. Web Audio decodes each clip once into a buffer; playing one
|
|
13
|
+
* allocates a source node the browser throws away, and overlap is free.
|
|
14
|
+
*
|
|
15
|
+
* **2. The context starts suspended.** Browsers block audio until a gesture and
|
|
16
|
+
* iOS is strictest. A SYNTHETIC click does not count, which is how a
|
|
17
|
+
* measurement run can end up testing the muted case and reporting silence as
|
|
18
|
+
* success.
|
|
19
|
+
*
|
|
20
|
+
* **3. `resume()` is asynchronous.** Reading `ctx.state` on the next line finds
|
|
21
|
+
* 'suspended' and a cached "unlocked" flag stays false forever — the game is
|
|
22
|
+
* silent no matter how many times it is tapped. Readiness is derived here, not
|
|
23
|
+
* stored.
|
|
24
|
+
*
|
|
25
|
+
* **4. iOS suspends the context when the app goes away** and does not bring it
|
|
26
|
+
* back, so sound works until the first phone call and then never again.
|
|
27
|
+
*
|
|
28
|
+
* Music is the one exception to rule 1, and for the same reason rule 1 exists.
|
|
29
|
+
* A three-minute track decodes to something like seventy megabytes of PCM;
|
|
30
|
+
* forty short effects as elements was fatal, but ONE element playing one long
|
|
31
|
+
* thing is exactly what elements are for. So effects are buffers and music
|
|
32
|
+
* streams, routed through the same graph so ducking still works.
|
|
33
|
+
*
|
|
34
|
+
* The game brings its own clips; nothing here knows what a game sounds like.
|
|
35
|
+
*/
|
|
36
|
+
export class GameAudio {
|
|
37
|
+
constructor(opts) {
|
|
38
|
+
this.ctx = null;
|
|
39
|
+
this.master = null;
|
|
40
|
+
this.musicGain = null;
|
|
41
|
+
this.musicEl = null;
|
|
42
|
+
this.musicName = null;
|
|
43
|
+
this.buffers = new Map();
|
|
44
|
+
this.lastPlayed = new Map();
|
|
45
|
+
this.muted = false;
|
|
46
|
+
this.cleanups = [];
|
|
47
|
+
this.clips = opts.clips;
|
|
48
|
+
this.base = opts.base ?? 'audio/';
|
|
49
|
+
this.ext = opts.extension ?? '.ogg';
|
|
50
|
+
this.musicName = opts.music ?? null;
|
|
51
|
+
this.musicVolume = opts.musicVolume ?? 0.28;
|
|
52
|
+
const unlock = () => {
|
|
53
|
+
void this.start().then(() => {
|
|
54
|
+
if (!this.ready)
|
|
55
|
+
return;
|
|
56
|
+
for (const ev of ['pointerdown', 'keydown', 'touchstart'])
|
|
57
|
+
window.removeEventListener(ev, unlock);
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
for (const ev of ['pointerdown', 'keydown', 'touchstart'])
|
|
61
|
+
window.addEventListener(ev, unlock);
|
|
62
|
+
this.cleanups.push(() => {
|
|
63
|
+
for (const ev of ['pointerdown', 'keydown', 'touchstart'])
|
|
64
|
+
window.removeEventListener(ev, unlock);
|
|
65
|
+
});
|
|
66
|
+
const onVisible = () => {
|
|
67
|
+
if (!document.hidden && this.ctx?.state === 'suspended')
|
|
68
|
+
void this.ctx.resume();
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
71
|
+
this.cleanups.push(() => document.removeEventListener('visibilitychange', onVisible));
|
|
72
|
+
}
|
|
73
|
+
/** Whether sound can be heard right now. Asked of the context every time —
|
|
74
|
+
* a cached flag is exactly the bug described above. */
|
|
75
|
+
get ready() { return this.ctx?.state === 'running'; }
|
|
76
|
+
async start() {
|
|
77
|
+
if (this.ready)
|
|
78
|
+
return;
|
|
79
|
+
const AC = window.AudioContext
|
|
80
|
+
?? window.webkitAudioContext;
|
|
81
|
+
if (!AC)
|
|
82
|
+
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
|
+
// Called inside the gesture's call stack, and awaited before anything asks
|
|
94
|
+
// whether it worked.
|
|
95
|
+
try {
|
|
96
|
+
await this.ctx.resume();
|
|
97
|
+
}
|
|
98
|
+
catch { /* a blocked context is not fatal */ }
|
|
99
|
+
if (this.ready)
|
|
100
|
+
this.startMusic();
|
|
101
|
+
}
|
|
102
|
+
/** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
|
|
103
|
+
urlFor(name) {
|
|
104
|
+
return `${this.base}${name}${name.includes('.') ? '' : this.ext}`;
|
|
105
|
+
}
|
|
106
|
+
async loadAll() {
|
|
107
|
+
const load = async (name) => {
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(this.urlFor(name));
|
|
110
|
+
const bytes = await res.arrayBuffer();
|
|
111
|
+
const buf = await this.ctx.decodeAudioData(bytes);
|
|
112
|
+
// Tagged so a test can see WHICH clip played — a buffer has no name,
|
|
113
|
+
// and "some audio happened" is not a check.
|
|
114
|
+
buf.__name = name;
|
|
115
|
+
this.buffers.set(name, buf);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* a clip that will not decode is not worth taking the game down for */
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// All at once. One at a time left the first actions silent — caution about
|
|
122
|
+
// decode cost, for work that is not on the main thread.
|
|
123
|
+
await Promise.all(Object.keys(this.clips).map(load));
|
|
124
|
+
}
|
|
125
|
+
startMusic() {
|
|
126
|
+
if (!this.ctx || !this.musicGain || this.muted || !this.musicName)
|
|
127
|
+
return;
|
|
128
|
+
if (!this.musicEl) {
|
|
129
|
+
const el = new Audio(this.urlFor(this.musicName));
|
|
130
|
+
el.loop = true;
|
|
131
|
+
el.preload = 'auto';
|
|
132
|
+
el.crossOrigin = 'anonymous';
|
|
133
|
+
// Routed through the graph, not played on its own, so `duck()` and the
|
|
134
|
+
// master gain reach it like everything else.
|
|
135
|
+
try {
|
|
136
|
+
this.ctx.createMediaElementSource(el).connect(this.musicGain);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Some engines refuse to route an element they consider tainted. Fall
|
|
140
|
+
// back to playing it directly rather than losing the music entirely.
|
|
141
|
+
el.volume = this.musicVolume;
|
|
142
|
+
}
|
|
143
|
+
this.musicEl = el;
|
|
144
|
+
}
|
|
145
|
+
void this.musicEl.play().catch(() => { });
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Change the track. Pass `null` for silence.
|
|
149
|
+
*
|
|
150
|
+
* Each scene gets its own: a lobby that sounds like the fight is a lobby you
|
|
151
|
+
* do not linger in.
|
|
152
|
+
*/
|
|
153
|
+
setMusic(name) {
|
|
154
|
+
if (name === this.musicName)
|
|
155
|
+
return;
|
|
156
|
+
this.musicName = name;
|
|
157
|
+
if (this.musicEl) {
|
|
158
|
+
this.musicEl.pause();
|
|
159
|
+
this.musicEl.src = '';
|
|
160
|
+
this.musicEl = null; // a new element: the old one's graph node is spent
|
|
161
|
+
}
|
|
162
|
+
if (name && this.ready && !this.muted)
|
|
163
|
+
this.startMusic();
|
|
164
|
+
}
|
|
165
|
+
play(name) {
|
|
166
|
+
const ctx = this.ctx;
|
|
167
|
+
if (this.muted || !this.ready || !ctx || !this.master)
|
|
168
|
+
return;
|
|
169
|
+
const buf = this.buffers.get(name);
|
|
170
|
+
if (!buf)
|
|
171
|
+
return;
|
|
172
|
+
const spec = this.clips[name];
|
|
173
|
+
const now = performance.now();
|
|
174
|
+
const gap = spec?.throttle ?? 0;
|
|
175
|
+
if (gap && now - (this.lastPlayed.get(name) ?? -1e9) < gap)
|
|
176
|
+
return;
|
|
177
|
+
this.lastPlayed.set(name, now);
|
|
178
|
+
const src = ctx.createBufferSource();
|
|
179
|
+
src.buffer = buf;
|
|
180
|
+
const g = ctx.createGain();
|
|
181
|
+
g.gain.value = spec?.volume ?? 0.5;
|
|
182
|
+
src.connect(g);
|
|
183
|
+
g.connect(this.master);
|
|
184
|
+
src.start();
|
|
185
|
+
// Nodes disconnect themselves when they end; without this they pile up as
|
|
186
|
+
// garbage the collector has to chase during play.
|
|
187
|
+
src.onended = () => { src.disconnect(); g.disconnect(); };
|
|
188
|
+
}
|
|
189
|
+
/** Duck the music for a moment — for an ending that should be heard over it. */
|
|
190
|
+
duck(seconds = 3) {
|
|
191
|
+
if (!this.ctx || !this.musicGain)
|
|
192
|
+
return;
|
|
193
|
+
const t = this.ctx.currentTime;
|
|
194
|
+
this.musicGain.gain.cancelScheduledValues(t);
|
|
195
|
+
this.musicGain.gain.setValueAtTime(this.musicGain.gain.value, t);
|
|
196
|
+
this.musicGain.gain.linearRampToValueAtTime(this.musicVolume * 0.25, t + 0.2);
|
|
197
|
+
this.musicGain.gain.linearRampToValueAtTime(this.musicVolume, t + seconds);
|
|
198
|
+
}
|
|
199
|
+
setMuted(on) {
|
|
200
|
+
this.muted = on;
|
|
201
|
+
if (this.master && this.ctx) {
|
|
202
|
+
this.master.gain.setTargetAtTime(on ? 0 : 1, this.ctx.currentTime, 0.02);
|
|
203
|
+
}
|
|
204
|
+
// Pause the stream as well as silencing it: a muted track still costs a
|
|
205
|
+
// decoder and a download for something nobody can hear.
|
|
206
|
+
if (on)
|
|
207
|
+
this.musicEl?.pause();
|
|
208
|
+
else if (this.ready)
|
|
209
|
+
this.startMusic();
|
|
210
|
+
}
|
|
211
|
+
get isMuted() { return this.muted; }
|
|
212
|
+
dispose() {
|
|
213
|
+
for (const c of this.cleanups)
|
|
214
|
+
c();
|
|
215
|
+
this.cleanups.length = 0;
|
|
216
|
+
this.musicEl?.pause();
|
|
217
|
+
void this.ctx?.close();
|
|
218
|
+
this.ctx = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { Input3D } from './Input3D.js';
|
|
|
10
10
|
export type { Input3DOptions, Input3DAction } from './Input3D.js';
|
|
11
11
|
export { attachToSocket, findBone, boneNames } from './Sockets.js';
|
|
12
12
|
export { flashTint, updateTints, isTinted } from './Tint.js';
|
|
13
|
+
export { GameAudio } from './GameAudio.js';
|
|
14
|
+
export type { GameAudioOptions, AudioClipSpec } from './GameAudio.js';
|
|
13
15
|
export type { Attachment } from './Sockets.js';
|
|
14
16
|
export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
|
|
15
17
|
export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { CharacterAnimator } from './CharacterAnimator.js';
|
|
|
11
11
|
export { Input3D } from './Input3D.js';
|
|
12
12
|
export { attachToSocket, findBone, boneNames } from './Sockets.js';
|
|
13
13
|
export { flashTint, updateTints, isTinted } from './Tint.js';
|
|
14
|
+
export { GameAudio } from './GameAudio.js';
|
|
14
15
|
// Re-exported so a game imports one package for the common case. A game should
|
|
15
16
|
// not have to know that identity and saves come from a different package than
|
|
16
17
|
// the renderer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|