@umicat/three-sdk 0.8.4 → 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/SceneLoader3D.js +9 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/scene3d.d.ts +9 -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/SceneLoader3D.js
CHANGED
|
@@ -132,7 +132,7 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
132
132
|
const mesh = o;
|
|
133
133
|
if (!mesh.isMesh)
|
|
134
134
|
return;
|
|
135
|
-
mesh.castShadow =
|
|
135
|
+
mesh.castShadow = e.castShadow !== false;
|
|
136
136
|
mesh.receiveShadow = true;
|
|
137
137
|
// A SkinnedMesh's bounding sphere is computed from the BIND pose and
|
|
138
138
|
// does not follow the bones. Once the object moves, three.js culls it
|
|
@@ -242,7 +242,14 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
242
242
|
// would otherwise be lit from the side of a box centred somewhere else.
|
|
243
243
|
d.target.position.copy(centre);
|
|
244
244
|
scene.add(d.target);
|
|
245
|
-
|
|
245
|
+
// Sized for the DEVICE, not for the desktop it was written on. A
|
|
246
|
+
// 2048 map is four megapixels of extra render target every frame, and
|
|
247
|
+
// a phone is already drawing at 2x for its screen — that combination
|
|
248
|
+
// took a board from smooth on a laptop to visibly dropping frames on
|
|
249
|
+
// an iPhone while looking identical in a screenshot.
|
|
250
|
+
const dense = typeof window !== 'undefined' && (window.devicePixelRatio ?? 1) > 2;
|
|
251
|
+
const map = dense ? 512 : 1024;
|
|
252
|
+
d.shadow.mapSize.set(map, map);
|
|
246
253
|
d.shadow.bias = -0.0012;
|
|
247
254
|
}
|
|
248
255
|
}
|
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/dist/scene3d.d.ts
CHANGED
|
@@ -83,6 +83,15 @@ export interface Entity3D {
|
|
|
83
83
|
/** Free-form, read by game code. The 2D SDK's `properties` equivalent. */
|
|
84
84
|
properties?: Record<string, unknown>;
|
|
85
85
|
visible?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Whether this entity casts a shadow. Defaults to true.
|
|
88
|
+
*
|
|
89
|
+
* Set it false for ground: a flat tile casting onto the flat tile beside it
|
|
90
|
+
* produces nothing anyone can see and costs a full extra draw of that mesh
|
|
91
|
+
* every frame. A board made of 144 tiles was drawing 288 times for 144
|
|
92
|
+
* tiles' worth of picture.
|
|
93
|
+
*/
|
|
94
|
+
castShadow?: boolean;
|
|
86
95
|
}
|
|
87
96
|
/**
|
|
88
97
|
* Semantic clip name → the clip actually inside the model.
|
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",
|