@umicat/three-sdk 0.8.5 → 0.9.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/dist/GameAudio.d.ts +77 -0
- package/dist/GameAudio.js +183 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
* The game brings its own clips; nothing here knows what a game sounds like.
|
|
29
|
+
*/
|
|
30
|
+
export interface AudioClipSpec {
|
|
31
|
+
/** 0-1, balanced by ear against the music. */
|
|
32
|
+
volume?: number;
|
|
33
|
+
/** Minimum gap between retriggers, in ms. Four towers reloading together
|
|
34
|
+
* turn one thwip into a buzz; a few tens of milliseconds fixes it and
|
|
35
|
+
* nobody notices a dropped shot. */
|
|
36
|
+
throttle?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface GameAudioOptions {
|
|
39
|
+
/** Clip name → how to play it. Names map to `${base}${name}${extension}`. */
|
|
40
|
+
clips: Record<string, AudioClipSpec>;
|
|
41
|
+
/** Where the files live, relative to the game. */
|
|
42
|
+
base?: string;
|
|
43
|
+
/** File extension, including the dot. */
|
|
44
|
+
extension?: string;
|
|
45
|
+
/** A clip to loop as music. Loaded first, so the theme is not queued behind
|
|
46
|
+
* every sound effect. */
|
|
47
|
+
music?: string;
|
|
48
|
+
musicVolume?: number;
|
|
49
|
+
}
|
|
50
|
+
export declare class GameAudio {
|
|
51
|
+
private ctx;
|
|
52
|
+
private master;
|
|
53
|
+
private musicGain;
|
|
54
|
+
private musicSource;
|
|
55
|
+
private readonly buffers;
|
|
56
|
+
private readonly lastPlayed;
|
|
57
|
+
private readonly clips;
|
|
58
|
+
private readonly base;
|
|
59
|
+
private readonly ext;
|
|
60
|
+
private readonly music?;
|
|
61
|
+
private readonly musicVolume;
|
|
62
|
+
private muted;
|
|
63
|
+
private readonly cleanups;
|
|
64
|
+
constructor(opts: GameAudioOptions);
|
|
65
|
+
/** Whether sound can be heard right now. Asked of the context every time —
|
|
66
|
+
* a cached flag is exactly the bug described above. */
|
|
67
|
+
get ready(): boolean;
|
|
68
|
+
private start;
|
|
69
|
+
private loadAll;
|
|
70
|
+
private startMusic;
|
|
71
|
+
play(name: string): void;
|
|
72
|
+
/** Duck the music for a moment — for an ending that should be heard over it. */
|
|
73
|
+
duck(seconds?: number): void;
|
|
74
|
+
setMuted(on: boolean): void;
|
|
75
|
+
get isMuted(): boolean;
|
|
76
|
+
dispose(): void;
|
|
77
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
* The game brings its own clips; nothing here knows what a game sounds like.
|
|
29
|
+
*/
|
|
30
|
+
export class GameAudio {
|
|
31
|
+
constructor(opts) {
|
|
32
|
+
this.ctx = null;
|
|
33
|
+
this.master = null;
|
|
34
|
+
this.musicGain = null;
|
|
35
|
+
this.musicSource = null;
|
|
36
|
+
this.buffers = new Map();
|
|
37
|
+
this.lastPlayed = new Map();
|
|
38
|
+
this.muted = false;
|
|
39
|
+
this.cleanups = [];
|
|
40
|
+
this.clips = opts.clips;
|
|
41
|
+
this.base = opts.base ?? 'audio/';
|
|
42
|
+
this.ext = opts.extension ?? '.ogg';
|
|
43
|
+
this.music = opts.music;
|
|
44
|
+
this.musicVolume = opts.musicVolume ?? 0.28;
|
|
45
|
+
const unlock = () => {
|
|
46
|
+
void this.start().then(() => {
|
|
47
|
+
if (!this.ready)
|
|
48
|
+
return;
|
|
49
|
+
for (const ev of ['pointerdown', 'keydown', 'touchstart'])
|
|
50
|
+
window.removeEventListener(ev, unlock);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
for (const ev of ['pointerdown', 'keydown', 'touchstart'])
|
|
54
|
+
window.addEventListener(ev, unlock);
|
|
55
|
+
this.cleanups.push(() => {
|
|
56
|
+
for (const ev of ['pointerdown', 'keydown', 'touchstart'])
|
|
57
|
+
window.removeEventListener(ev, unlock);
|
|
58
|
+
});
|
|
59
|
+
const onVisible = () => {
|
|
60
|
+
if (!document.hidden && this.ctx?.state === 'suspended')
|
|
61
|
+
void this.ctx.resume();
|
|
62
|
+
};
|
|
63
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
64
|
+
this.cleanups.push(() => document.removeEventListener('visibilitychange', onVisible));
|
|
65
|
+
}
|
|
66
|
+
/** Whether sound can be heard right now. Asked of the context every time —
|
|
67
|
+
* a cached flag is exactly the bug described above. */
|
|
68
|
+
get ready() { return this.ctx?.state === 'running'; }
|
|
69
|
+
async start() {
|
|
70
|
+
if (this.ready)
|
|
71
|
+
return;
|
|
72
|
+
const AC = window.AudioContext
|
|
73
|
+
?? window.webkitAudioContext;
|
|
74
|
+
if (!AC)
|
|
75
|
+
return;
|
|
76
|
+
if (!this.ctx) {
|
|
77
|
+
this.ctx = new AC();
|
|
78
|
+
this.master = this.ctx.createGain();
|
|
79
|
+
this.master.gain.value = this.muted ? 0 : 1;
|
|
80
|
+
this.master.connect(this.ctx.destination);
|
|
81
|
+
this.musicGain = this.ctx.createGain();
|
|
82
|
+
this.musicGain.gain.value = this.musicVolume;
|
|
83
|
+
this.musicGain.connect(this.master);
|
|
84
|
+
void this.loadAll();
|
|
85
|
+
}
|
|
86
|
+
// Called inside the gesture's call stack, and awaited before anything asks
|
|
87
|
+
// whether it worked.
|
|
88
|
+
try {
|
|
89
|
+
await this.ctx.resume();
|
|
90
|
+
}
|
|
91
|
+
catch { /* a blocked context is not fatal */ }
|
|
92
|
+
if (this.ready)
|
|
93
|
+
this.startMusic();
|
|
94
|
+
}
|
|
95
|
+
async loadAll() {
|
|
96
|
+
const load = async (name) => {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(`${this.base}${name}${this.ext}`);
|
|
99
|
+
const bytes = await res.arrayBuffer();
|
|
100
|
+
const buf = await this.ctx.decodeAudioData(bytes);
|
|
101
|
+
// Tagged so a test can see WHICH clip played — a buffer has no name,
|
|
102
|
+
// and "some audio happened" is not a check.
|
|
103
|
+
buf.__name = name;
|
|
104
|
+
this.buffers.set(name, buf);
|
|
105
|
+
if (name === this.music && this.ready && !this.musicSource)
|
|
106
|
+
this.startMusic();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* a clip that will not decode is not worth taking the game down for */
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
// Music first, then everything else at once. One at a time queued the theme
|
|
113
|
+
// behind every effect and left the first actions silent — caution about
|
|
114
|
+
// decode cost, for work that is not on the main thread.
|
|
115
|
+
if (this.music)
|
|
116
|
+
await load(this.music);
|
|
117
|
+
await Promise.all(Object.keys(this.clips).map(load));
|
|
118
|
+
}
|
|
119
|
+
startMusic() {
|
|
120
|
+
if (!this.ctx || !this.musicGain || this.musicSource || this.muted || !this.music)
|
|
121
|
+
return;
|
|
122
|
+
const buf = this.buffers.get(this.music);
|
|
123
|
+
if (!buf)
|
|
124
|
+
return; // still decoding; loadAll calls back
|
|
125
|
+
const src = this.ctx.createBufferSource();
|
|
126
|
+
src.buffer = buf;
|
|
127
|
+
src.loop = true;
|
|
128
|
+
src.connect(this.musicGain);
|
|
129
|
+
src.start();
|
|
130
|
+
this.musicSource = src;
|
|
131
|
+
}
|
|
132
|
+
play(name) {
|
|
133
|
+
const ctx = this.ctx;
|
|
134
|
+
if (this.muted || !this.ready || !ctx || !this.master)
|
|
135
|
+
return;
|
|
136
|
+
const buf = this.buffers.get(name);
|
|
137
|
+
if (!buf)
|
|
138
|
+
return;
|
|
139
|
+
const spec = this.clips[name];
|
|
140
|
+
const now = performance.now();
|
|
141
|
+
const gap = spec?.throttle ?? 0;
|
|
142
|
+
if (gap && now - (this.lastPlayed.get(name) ?? -1e9) < gap)
|
|
143
|
+
return;
|
|
144
|
+
this.lastPlayed.set(name, now);
|
|
145
|
+
const src = ctx.createBufferSource();
|
|
146
|
+
src.buffer = buf;
|
|
147
|
+
const g = ctx.createGain();
|
|
148
|
+
g.gain.value = spec?.volume ?? 0.5;
|
|
149
|
+
src.connect(g);
|
|
150
|
+
g.connect(this.master);
|
|
151
|
+
src.start();
|
|
152
|
+
// Nodes disconnect themselves when they end; without this they pile up as
|
|
153
|
+
// garbage the collector has to chase during play.
|
|
154
|
+
src.onended = () => { src.disconnect(); g.disconnect(); };
|
|
155
|
+
}
|
|
156
|
+
/** Duck the music for a moment — for an ending that should be heard over it. */
|
|
157
|
+
duck(seconds = 3) {
|
|
158
|
+
if (!this.ctx || !this.musicGain)
|
|
159
|
+
return;
|
|
160
|
+
const t = this.ctx.currentTime;
|
|
161
|
+
this.musicGain.gain.cancelScheduledValues(t);
|
|
162
|
+
this.musicGain.gain.setValueAtTime(this.musicGain.gain.value, t);
|
|
163
|
+
this.musicGain.gain.linearRampToValueAtTime(this.musicVolume * 0.25, t + 0.2);
|
|
164
|
+
this.musicGain.gain.linearRampToValueAtTime(this.musicVolume, t + seconds);
|
|
165
|
+
}
|
|
166
|
+
setMuted(on) {
|
|
167
|
+
this.muted = on;
|
|
168
|
+
if (this.master && this.ctx) {
|
|
169
|
+
this.master.gain.setTargetAtTime(on ? 0 : 1, this.ctx.currentTime, 0.02);
|
|
170
|
+
}
|
|
171
|
+
if (!on && this.ready)
|
|
172
|
+
this.startMusic();
|
|
173
|
+
}
|
|
174
|
+
get isMuted() { return this.muted; }
|
|
175
|
+
dispose() {
|
|
176
|
+
for (const c of this.cleanups)
|
|
177
|
+
c();
|
|
178
|
+
this.cleanups.length = 0;
|
|
179
|
+
this.musicSource?.stop();
|
|
180
|
+
void this.ctx?.close();
|
|
181
|
+
this.ctx = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
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.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",
|