minimojs 1.0.0-alpha.30 → 1.0.0-alpha.32
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 +3 -0
- package/dist/internal/AssetSystem.d.ts +6 -1
- package/dist/internal/AssetSystem.js +122 -27
- package/dist/internal/SoundSystem.d.ts +39 -1
- package/dist/internal/SoundSystem.js +239 -2
- package/dist/minimo-format.d.ts +25 -3
- package/dist/minimo-format.js +5 -4
- package/dist/minimo.d.ts +95 -14
- package/dist/minimo.js +121 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,6 +91,8 @@ Note: text defaults to `monospace`, so a game that names no font downloads none.
|
|
|
91
91
|
|
|
92
92
|
Load images in `game.onPreload`, once, at boot. Only a game with many scenes that each carry many images of their own, such as one with many levels, should give scenes an `onPreload()` so each loads its images when the player reaches it; see `examples/level-loading/`.
|
|
93
93
|
|
|
94
|
+
Sounds come from files too. Declare them as `sound` entries in an asset file, or load them with `game.loadSound(key, src)`, and play them with `game.playSound(key)`, which returns a playback you can `pause()`, `resume()`, and `stop()`; pass `{ loop: true }` for music. MP3 plays in every browser. `game.sound` and `game.soundSequence` still synthesize tones with no file at all; see `examples/sound-files/`.
|
|
95
|
+
|
|
94
96
|
## Examples
|
|
95
97
|
|
|
96
98
|
- `examples/chickens-eggs-basket/`
|
|
@@ -105,6 +107,7 @@ Load images in `game.onPreload`, once, at boot. Only a game with many scenes tha
|
|
|
105
107
|
- `examples/pseudo-3d-racer/`
|
|
106
108
|
- `examples/animations/`
|
|
107
109
|
- `examples/level-loading/`
|
|
110
|
+
- `examples/sound-files/`
|
|
108
111
|
|
|
109
112
|
Run locally from the `minimojs` directory:
|
|
110
113
|
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Fetches and decodes one audio file. The engine passes one backed by its
|
|
3
|
+
* `AudioContext`; the asset system only stores what it returns, so it needs no
|
|
4
|
+
* audio API of its own and runs in tests without a browser.
|
|
5
|
+
*/
|
|
6
|
+
export type SoundLoader = (src: string) => Promise<unknown>;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Images and sounds, loaded by key. Both kinds share one queue, one progress
|
|
3
|
+
* count, one failure report, and one namespace of keys, so a game's loading
|
|
4
|
+
* screen and its retry cover every file it loads.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
2
8
|
export class AssetSystem {
|
|
3
|
-
constructor() {
|
|
9
|
+
constructor(_soundLoader = null) {
|
|
10
|
+
this._soundLoader = _soundLoader;
|
|
4
11
|
/** @internal */
|
|
5
12
|
this._queuedImages = new Map();
|
|
6
13
|
/** @internal */
|
|
@@ -19,6 +26,12 @@ export class AssetSystem {
|
|
|
19
26
|
this._assetFileByKey = new Map();
|
|
20
27
|
/** @internal */
|
|
21
28
|
this._registeredAssetFiles = new WeakSet();
|
|
29
|
+
/** @internal */
|
|
30
|
+
this._queuedSounds = new Map();
|
|
31
|
+
/** @internal */
|
|
32
|
+
this._loadedSounds = new Map();
|
|
33
|
+
/** @internal */
|
|
34
|
+
this._soundSources = new Map();
|
|
22
35
|
}
|
|
23
36
|
queueImage(key, src) {
|
|
24
37
|
const safeKey = key.trim();
|
|
@@ -30,8 +43,24 @@ export class AssetSystem {
|
|
|
30
43
|
throw new Error("MinimoJS: Image sources must be non-empty strings.");
|
|
31
44
|
}
|
|
32
45
|
this.refuseAssetFileKey(safeKey, "loadImage()");
|
|
46
|
+
this.refuseSoundKey(safeKey, "loadImage()");
|
|
33
47
|
this.enqueueImage(safeKey, safeSrc);
|
|
34
48
|
}
|
|
49
|
+
queueSound(key, src) {
|
|
50
|
+
const safeKey = key.trim();
|
|
51
|
+
const safeSrc = src.trim();
|
|
52
|
+
if (safeKey.length === 0) {
|
|
53
|
+
throw new Error("MinimoJS: Sound keys must be non-empty strings.");
|
|
54
|
+
}
|
|
55
|
+
if (safeSrc.length === 0) {
|
|
56
|
+
throw new Error("MinimoJS: Sound sources must be non-empty strings.");
|
|
57
|
+
}
|
|
58
|
+
this.refuseAssetFileKey(safeKey, "loadSound()");
|
|
59
|
+
if (this._imageSources.has(safeKey) || this._loadedImages.has(safeKey)) {
|
|
60
|
+
throw new Error(`MinimoJS: loadSound() cannot use the key "${safeKey}", which an image uses. Keys must be unique in the whole game.`);
|
|
61
|
+
}
|
|
62
|
+
this.enqueueSound(safeKey, safeSrc);
|
|
63
|
+
}
|
|
35
64
|
/**
|
|
36
65
|
* Registers a parsed asset file and queues its images. `source` is the
|
|
37
66
|
* object the game passed, which identifies the file, so loading the same
|
|
@@ -48,6 +77,9 @@ export class AssetSystem {
|
|
|
48
77
|
if (this._imageSources.has(entry.key) || this._loadedImages.has(entry.key)) {
|
|
49
78
|
throw new Error(`MinimoJS: Asset key "${entry.key}" is already registered with loadImage() or createTexture(). Keys must be unique in the whole game.`);
|
|
50
79
|
}
|
|
80
|
+
if (this._soundSources.has(entry.key)) {
|
|
81
|
+
throw new Error(`MinimoJS: Asset key "${entry.key}" is already registered with loadSound(). Keys must be unique in the whole game.`);
|
|
82
|
+
}
|
|
51
83
|
}
|
|
52
84
|
else if (owner !== source) {
|
|
53
85
|
throw new Error(`MinimoJS: Asset key "${entry.key}" is declared by two asset files. Keys must be unique in the whole game.`);
|
|
@@ -58,6 +90,9 @@ export class AssetSystem {
|
|
|
58
90
|
if (entry.type === "image") {
|
|
59
91
|
this.enqueueImage(entry.key, entry.src.trim());
|
|
60
92
|
}
|
|
93
|
+
else if (entry.type === "sound") {
|
|
94
|
+
this.enqueueSound(entry.key, entry.src.trim());
|
|
95
|
+
}
|
|
61
96
|
}
|
|
62
97
|
const isFirstRegistration = !this._registeredAssetFiles.has(source);
|
|
63
98
|
this._registeredAssetFiles.add(source);
|
|
@@ -72,6 +107,17 @@ export class AssetSystem {
|
|
|
72
107
|
this._imageSources.set(safeKey, safeSrc);
|
|
73
108
|
this._queuedImages.set(safeKey, safeSrc);
|
|
74
109
|
}
|
|
110
|
+
/** @internal */
|
|
111
|
+
enqueueSound(safeKey, safeSrc) {
|
|
112
|
+
const existingSrc = this._soundSources.get(safeKey);
|
|
113
|
+
if (existingSrc !== undefined && existingSrc !== safeSrc) {
|
|
114
|
+
throw new Error(`MinimoJS: Sound key "${safeKey}" is already registered with a different source.`);
|
|
115
|
+
}
|
|
116
|
+
this._soundSources.set(safeKey, safeSrc);
|
|
117
|
+
if (!this._loadedSounds.has(safeKey)) {
|
|
118
|
+
this._queuedSounds.set(safeKey, safeSrc);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
75
121
|
createTexture(key, width, height, painter) {
|
|
76
122
|
const safeKey = key.trim();
|
|
77
123
|
if (safeKey.length === 0) {
|
|
@@ -81,6 +127,7 @@ export class AssetSystem {
|
|
|
81
127
|
throw new Error("MinimoJS: createTexture() requires a painter function.");
|
|
82
128
|
}
|
|
83
129
|
this.refuseAssetFileKey(safeKey, "createTexture()");
|
|
130
|
+
this.refuseSoundKey(safeKey, "createTexture()");
|
|
84
131
|
const canvas = document.createElement("canvas");
|
|
85
132
|
canvas.width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
|
|
86
133
|
canvas.height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
|
|
@@ -105,45 +152,78 @@ export class AssetSystem {
|
|
|
105
152
|
hasImage(key) {
|
|
106
153
|
return this._loadedImages.has(key);
|
|
107
154
|
}
|
|
108
|
-
|
|
155
|
+
/** Returns a decoded sound, as the sound loader returned it, if it is loaded. */
|
|
156
|
+
getSound(key) {
|
|
157
|
+
return this._loadedSounds.get(key);
|
|
158
|
+
}
|
|
159
|
+
hasSound(key) {
|
|
160
|
+
return this._loadedSounds.has(key);
|
|
161
|
+
}
|
|
162
|
+
/** Counts the queued images and sounds that are not loaded yet. */
|
|
163
|
+
getQueuedAssetCount() {
|
|
109
164
|
let total = 0;
|
|
110
165
|
for (const key of this._queuedImages.keys()) {
|
|
111
166
|
if (!this._loadedImages.has(key)) {
|
|
112
167
|
total++;
|
|
113
168
|
}
|
|
114
169
|
}
|
|
170
|
+
for (const key of this._queuedSounds.keys()) {
|
|
171
|
+
if (!this._loadedSounds.has(key)) {
|
|
172
|
+
total++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
115
175
|
return total;
|
|
116
176
|
}
|
|
117
177
|
/**
|
|
118
|
-
* Loads every queued image that is not loaded yet, settling all
|
|
119
|
-
* before returning instead of stopping at the first failure.
|
|
178
|
+
* Loads every queued image and sound that is not loaded yet, settling all
|
|
179
|
+
* of them before returning instead of stopping at the first failure.
|
|
120
180
|
*
|
|
121
|
-
* Loaded
|
|
181
|
+
* Loaded files leave the queue; failed ones stay in it, so a retry loads
|
|
122
182
|
* only them. Returns the keys that failed, empty when all loaded.
|
|
123
183
|
*/
|
|
124
|
-
async
|
|
125
|
-
const
|
|
126
|
-
const
|
|
184
|
+
async loadQueuedAssets(onProgress) {
|
|
185
|
+
const images = [...this._queuedImages.entries()].filter(([key]) => !this._loadedImages.has(key));
|
|
186
|
+
const sounds = [...this._queuedSounds.entries()].filter(([key]) => !this._loadedSounds.has(key));
|
|
187
|
+
const total = images.length + sounds.length;
|
|
127
188
|
let settled = 0;
|
|
128
189
|
const failed = [];
|
|
129
190
|
onProgress?.(0, total, null);
|
|
130
191
|
if (total === 0) {
|
|
131
192
|
this._queuedImages.clear();
|
|
193
|
+
this._queuedSounds.clear();
|
|
132
194
|
return failed;
|
|
133
195
|
}
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
const image = await this.loadImage(src);
|
|
137
|
-
this._loadedImages.set(key, image);
|
|
138
|
-
this.bumpImageVersion(key);
|
|
139
|
-
this._queuedImages.delete(key);
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
failed.push(key);
|
|
143
|
-
}
|
|
196
|
+
const settle = (key) => {
|
|
144
197
|
settled += 1;
|
|
145
198
|
onProgress?.(settled, total, key);
|
|
146
|
-
}
|
|
199
|
+
};
|
|
200
|
+
await Promise.all([
|
|
201
|
+
...images.map(async ([key, src]) => {
|
|
202
|
+
try {
|
|
203
|
+
const image = await this.loadImage(src);
|
|
204
|
+
this._loadedImages.set(key, image);
|
|
205
|
+
this.bumpImageVersion(key);
|
|
206
|
+
this._queuedImages.delete(key);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
failed.push(key);
|
|
210
|
+
}
|
|
211
|
+
settle(key);
|
|
212
|
+
}),
|
|
213
|
+
...sounds.map(async ([key, src]) => {
|
|
214
|
+
try {
|
|
215
|
+
if (!this._soundLoader) {
|
|
216
|
+
throw new Error("MinimoJS: This game cannot load sounds.");
|
|
217
|
+
}
|
|
218
|
+
this._loadedSounds.set(key, await this._soundLoader(src));
|
|
219
|
+
this._queuedSounds.delete(key);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
failed.push(key);
|
|
223
|
+
}
|
|
224
|
+
settle(key);
|
|
225
|
+
}),
|
|
226
|
+
]);
|
|
147
227
|
for (const key of [...this._queuedImages.keys()]) {
|
|
148
228
|
if (this._loadedImages.has(key)) {
|
|
149
229
|
this._queuedImages.delete(key);
|
|
@@ -151,16 +231,23 @@ export class AssetSystem {
|
|
|
151
231
|
}
|
|
152
232
|
return failed;
|
|
153
233
|
}
|
|
154
|
-
/** Returns the source registered for an image key, if any. */
|
|
155
|
-
|
|
156
|
-
return this._imageSources.get(key);
|
|
234
|
+
/** Returns the source registered for an image or sound key, if any. */
|
|
235
|
+
getAssetSource(key) {
|
|
236
|
+
return this._imageSources.get(key) ?? this._soundSources.get(key);
|
|
157
237
|
}
|
|
158
238
|
/**
|
|
159
|
-
* Drops queued images that were not loaded, so a later load does
|
|
160
|
-
* them up. Their keys may be registered again.
|
|
239
|
+
* Drops queued images and sounds that were not loaded, so a later load does
|
|
240
|
+
* not pick them up. Their keys may be registered again.
|
|
161
241
|
*/
|
|
162
|
-
|
|
242
|
+
discardQueuedAssets() {
|
|
163
243
|
this._queuedImages.clear();
|
|
244
|
+
this._queuedSounds.clear();
|
|
245
|
+
}
|
|
246
|
+
/** @internal */
|
|
247
|
+
refuseSoundKey(safeKey, caller) {
|
|
248
|
+
if (this._soundSources.has(safeKey)) {
|
|
249
|
+
throw new Error(`MinimoJS: ${caller} cannot use the key "${safeKey}", which a sound uses. Keys must be unique in the whole game.`);
|
|
250
|
+
}
|
|
164
251
|
}
|
|
165
252
|
/** @internal */
|
|
166
253
|
refuseAssetFileKey(safeKey, caller) {
|
|
@@ -168,10 +255,18 @@ export class AssetSystem {
|
|
|
168
255
|
throw new Error(`MinimoJS: ${caller} cannot use the key "${safeKey}", which an asset file declares. Keys must be unique in the whole game.`);
|
|
169
256
|
}
|
|
170
257
|
}
|
|
171
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* Loads one image with a CORS request, as `fetch` loads sounds, so an image
|
|
260
|
+
* from another origin never taints the canvas and the game can still read
|
|
261
|
+
* its pixels or export it. A host that does not answer with CORS fails the
|
|
262
|
+
* image instead.
|
|
263
|
+
*
|
|
264
|
+
* @internal
|
|
265
|
+
*/
|
|
172
266
|
loadImage(src) {
|
|
173
267
|
return new Promise((resolve, reject) => {
|
|
174
268
|
const image = new Image();
|
|
269
|
+
image.crossOrigin = "anonymous";
|
|
175
270
|
image.onload = () => resolve(image);
|
|
176
271
|
image.onerror = () => {
|
|
177
272
|
reject(new Error(`MinimoJS: Failed to load image "${src}".`));
|
|
@@ -1 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
/** Options for one play of a loaded sound. */
|
|
2
|
+
export interface PlaySoundOptions {
|
|
3
|
+
/** Repeat the sound until it is stopped. Default `false`. */
|
|
4
|
+
loop?: boolean;
|
|
5
|
+
/** Volume from `0`, silent, to `1`, as recorded. Default `1`. */
|
|
6
|
+
volume?: number;
|
|
7
|
+
}
|
|
8
|
+
/** Where a {@link SoundPlayback} is: playing, paused, or stopped for good. */
|
|
9
|
+
export type SoundPlaybackState = "playing" | "paused" | "stopped";
|
|
10
|
+
/**
|
|
11
|
+
* One play of a loaded sound, returned by `game.playSound`. Keep it to pause,
|
|
12
|
+
* resume, or stop that play, such as the game's music:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const music = game.playSound("theme", { loop: true, volume: 0.6 });
|
|
16
|
+
* music.pause();
|
|
17
|
+
* music.resume();
|
|
18
|
+
* music.stop();
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* A stopped play cannot be resumed; call `game.playSound` again.
|
|
22
|
+
*/
|
|
23
|
+
export declare class SoundPlayback {
|
|
24
|
+
/** The key the sound was loaded under. */
|
|
25
|
+
readonly key: string;
|
|
26
|
+
/** Whether this play repeats until stopped. */
|
|
27
|
+
readonly loop: boolean;
|
|
28
|
+
/** `"playing"`, `"paused"`, or `"stopped"`. A one-shot play stops by itself when it ends. */
|
|
29
|
+
get state(): SoundPlaybackState;
|
|
30
|
+
/** This play's volume, from `0` to `1`. Takes effect immediately. */
|
|
31
|
+
get volume(): number;
|
|
32
|
+
set volume(value: number);
|
|
33
|
+
/** Pauses the play where it is. Does nothing unless it is playing. */
|
|
34
|
+
pause(): void;
|
|
35
|
+
/** Continues a paused play from where it paused. Does nothing unless it is paused. */
|
|
36
|
+
resume(): void;
|
|
37
|
+
/** Stops the play for good. */
|
|
38
|
+
stop(): void;
|
|
39
|
+
}
|
|
@@ -1,8 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One play of a loaded sound, returned by `game.playSound`. Keep it to pause,
|
|
3
|
+
* resume, or stop that play, such as the game's music:
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const music = game.playSound("theme", { loop: true, volume: 0.6 });
|
|
7
|
+
* music.pause();
|
|
8
|
+
* music.resume();
|
|
9
|
+
* music.stop();
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* A stopped play cannot be resumed; call `game.playSound` again.
|
|
13
|
+
*/
|
|
14
|
+
export class SoundPlayback {
|
|
15
|
+
/** @internal */
|
|
16
|
+
constructor(
|
|
17
|
+
/** The key the sound was loaded under. */
|
|
18
|
+
key,
|
|
19
|
+
/** Whether this play repeats until stopped. */
|
|
20
|
+
loop, volume,
|
|
21
|
+
/** @internal */
|
|
22
|
+
_system,
|
|
23
|
+
/** @internal */
|
|
24
|
+
_buffer) {
|
|
25
|
+
this.key = key;
|
|
26
|
+
this.loop = loop;
|
|
27
|
+
this._system = _system;
|
|
28
|
+
this._buffer = _buffer;
|
|
29
|
+
/** @internal */
|
|
30
|
+
this._state = "playing";
|
|
31
|
+
/** @internal */
|
|
32
|
+
this._source = null;
|
|
33
|
+
/** @internal */
|
|
34
|
+
this._gain = null;
|
|
35
|
+
/** Seconds into the sound where the current source started, or where it was paused. @internal */
|
|
36
|
+
this._offset = 0;
|
|
37
|
+
/** The context time at which the current source started. @internal */
|
|
38
|
+
this._startedAt = 0;
|
|
39
|
+
this._volume = clampVolume(volume);
|
|
40
|
+
if (!_system || !_buffer) {
|
|
41
|
+
this._state = "stopped";
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this._startSource();
|
|
45
|
+
}
|
|
46
|
+
/** `"playing"`, `"paused"`, or `"stopped"`. A one-shot play stops by itself when it ends. */
|
|
47
|
+
get state() {
|
|
48
|
+
return this._state;
|
|
49
|
+
}
|
|
50
|
+
/** This play's volume, from `0` to `1`. Takes effect immediately. */
|
|
51
|
+
get volume() {
|
|
52
|
+
return this._volume;
|
|
53
|
+
}
|
|
54
|
+
set volume(value) {
|
|
55
|
+
this._volume = clampVolume(value);
|
|
56
|
+
if (this._gain) {
|
|
57
|
+
this._gain.gain.value = this._volume;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Pauses the play where it is. Does nothing unless it is playing. */
|
|
61
|
+
pause() {
|
|
62
|
+
if (this._state !== "playing" || !this._system || !this._buffer)
|
|
63
|
+
return;
|
|
64
|
+
const elapsed = this._system.context.currentTime - this._startedAt;
|
|
65
|
+
const position = this._offset + Math.max(0, elapsed);
|
|
66
|
+
this._offset = this.loop
|
|
67
|
+
? position % this._buffer.duration
|
|
68
|
+
: Math.min(position, this._buffer.duration);
|
|
69
|
+
this._stopSource();
|
|
70
|
+
this._state = "paused";
|
|
71
|
+
}
|
|
72
|
+
/** Continues a paused play from where it paused. Does nothing unless it is paused. */
|
|
73
|
+
resume() {
|
|
74
|
+
if (this._state !== "paused")
|
|
75
|
+
return;
|
|
76
|
+
this._state = "playing";
|
|
77
|
+
this._startSource();
|
|
78
|
+
}
|
|
79
|
+
/** Stops the play for good. */
|
|
80
|
+
stop() {
|
|
81
|
+
if (this._state === "stopped")
|
|
82
|
+
return;
|
|
83
|
+
this._stopSource();
|
|
84
|
+
this._state = "stopped";
|
|
85
|
+
this._system?.forget(this);
|
|
86
|
+
}
|
|
87
|
+
/** @internal */
|
|
88
|
+
_startSource() {
|
|
89
|
+
const system = this._system;
|
|
90
|
+
const buffer = this._buffer;
|
|
91
|
+
if (!system || !buffer)
|
|
92
|
+
return;
|
|
93
|
+
const ctx = system.context;
|
|
94
|
+
const gain = ctx.createGain();
|
|
95
|
+
gain.gain.value = this._volume;
|
|
96
|
+
gain.connect(ctx.destination);
|
|
97
|
+
const source = ctx.createBufferSource();
|
|
98
|
+
source.buffer = buffer;
|
|
99
|
+
source.loop = this.loop;
|
|
100
|
+
source.connect(gain);
|
|
101
|
+
source.onended = () => {
|
|
102
|
+
if (this._source !== source)
|
|
103
|
+
return;
|
|
104
|
+
this._source = null;
|
|
105
|
+
this._gain = null;
|
|
106
|
+
gain.disconnect();
|
|
107
|
+
this._state = "stopped";
|
|
108
|
+
system.forget(this);
|
|
109
|
+
};
|
|
110
|
+
this._source = source;
|
|
111
|
+
this._gain = gain;
|
|
112
|
+
this._startedAt = ctx.currentTime;
|
|
113
|
+
source.start(0, this._offset);
|
|
114
|
+
system.remember(this);
|
|
115
|
+
}
|
|
116
|
+
/** @internal */
|
|
117
|
+
_stopSource() {
|
|
118
|
+
const source = this._source;
|
|
119
|
+
const gain = this._gain;
|
|
120
|
+
this._source = null;
|
|
121
|
+
this._gain = null;
|
|
122
|
+
if (source) {
|
|
123
|
+
source.onended = null;
|
|
124
|
+
try {
|
|
125
|
+
source.stop();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// A source that already ended throws on some browsers.
|
|
129
|
+
}
|
|
130
|
+
source.disconnect();
|
|
131
|
+
}
|
|
132
|
+
gain?.disconnect();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function clampVolume(value) {
|
|
136
|
+
if (!Number.isFinite(value))
|
|
137
|
+
return 1;
|
|
138
|
+
return Math.min(1, Math.max(0, value));
|
|
139
|
+
}
|
|
140
|
+
/** The events that count as the player's first gesture, which lets a page play sound. */
|
|
141
|
+
const UNLOCK_EVENTS = ["pointerdown", "touchend", "mousedown", "keydown"];
|
|
1
142
|
/** @internal */
|
|
2
143
|
export class SoundSystem {
|
|
3
144
|
constructor() {
|
|
4
145
|
/** @internal */
|
|
5
146
|
this._audioCtx = null;
|
|
147
|
+
/** @internal */
|
|
148
|
+
this._playing = new Set();
|
|
149
|
+
/** Whether the page hid while sound could play, so showing it again resumes. @internal */
|
|
150
|
+
this._suspendedByHiding = false;
|
|
6
151
|
}
|
|
7
152
|
sound(freq, durationMs) {
|
|
8
153
|
const ctx = this.getAudioContext();
|
|
@@ -41,15 +186,107 @@ export class SoundSystem {
|
|
|
41
186
|
startTime += durationSec;
|
|
42
187
|
}
|
|
43
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Fetches and decodes an audio file. Used by the asset system to load the
|
|
191
|
+
* sounds a game queues, so a file the browser cannot decode fails while the
|
|
192
|
+
* game loads, with every other file that failed.
|
|
193
|
+
*/
|
|
194
|
+
async decode(src) {
|
|
195
|
+
const response = await fetch(src);
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(`MinimoJS: Failed to load sound "${src}" (HTTP ${response.status}).`);
|
|
198
|
+
}
|
|
199
|
+
const data = await response.arrayBuffer();
|
|
200
|
+
const ctx = this.getAudioContext();
|
|
201
|
+
// The callback form, because older Safari returns no promise.
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
const result = ctx.decodeAudioData(data, resolve, reject);
|
|
204
|
+
result?.catch?.(reject);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Plays a decoded sound. Before the player's first gesture the page cannot
|
|
209
|
+
* play sound: a looping play waits and starts with that gesture, while a
|
|
210
|
+
* one-shot play is dropped, so effects queued meanwhile do not all sound at
|
|
211
|
+
* once. A dropped play is returned already stopped.
|
|
212
|
+
*/
|
|
213
|
+
play(key, buffer, options = {}) {
|
|
214
|
+
const loop = options.loop === true;
|
|
215
|
+
const volume = options.volume ?? 1;
|
|
216
|
+
const ctx = this.getAudioContext();
|
|
217
|
+
if (!loop && ctx.state !== "running") {
|
|
218
|
+
return new SoundPlayback(key, loop, volume, null, null);
|
|
219
|
+
}
|
|
220
|
+
return new SoundPlayback(key, loop, volume, this, buffer);
|
|
221
|
+
}
|
|
222
|
+
/** Stops every play of a loaded sound. Synthesized tones are not affected. */
|
|
223
|
+
stopAll() {
|
|
224
|
+
for (const playback of [...this._playing]) {
|
|
225
|
+
playback.stop();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** @internal */
|
|
229
|
+
get context() {
|
|
230
|
+
return this.getAudioContext();
|
|
231
|
+
}
|
|
232
|
+
/** @internal */
|
|
233
|
+
remember(playback) {
|
|
234
|
+
this._playing.add(playback);
|
|
235
|
+
}
|
|
236
|
+
/** @internal */
|
|
237
|
+
forget(playback) {
|
|
238
|
+
this._playing.delete(playback);
|
|
239
|
+
}
|
|
44
240
|
/** @internal */
|
|
45
241
|
getAudioContext() {
|
|
46
242
|
if (!this._audioCtx) {
|
|
47
243
|
this._audioCtx = new AudioContext();
|
|
244
|
+
this.listenForUnlockAndVisibility();
|
|
48
245
|
}
|
|
49
246
|
const ctx = this._audioCtx;
|
|
50
|
-
if (ctx.state === "suspended") {
|
|
51
|
-
ctx.resume();
|
|
247
|
+
if (ctx.state === "suspended" && !this._suspendedByHiding) {
|
|
248
|
+
void ctx.resume().catch(() => { });
|
|
52
249
|
}
|
|
53
250
|
return ctx;
|
|
54
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Resumes the context on the player's first gesture, which is what lets a
|
|
254
|
+
* browser play sound, and pauses all sound while the page is hidden.
|
|
255
|
+
*
|
|
256
|
+
* @internal
|
|
257
|
+
*/
|
|
258
|
+
listenForUnlockAndVisibility() {
|
|
259
|
+
if (typeof window === "undefined")
|
|
260
|
+
return;
|
|
261
|
+
const unlock = () => {
|
|
262
|
+
const ctx = this._audioCtx;
|
|
263
|
+
if (!ctx || this._suspendedByHiding)
|
|
264
|
+
return;
|
|
265
|
+
if (ctx.state === "running") {
|
|
266
|
+
for (const type of UNLOCK_EVENTS)
|
|
267
|
+
window.removeEventListener(type, unlock, true);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
void ctx.resume().catch(() => { });
|
|
271
|
+
};
|
|
272
|
+
for (const type of UNLOCK_EVENTS)
|
|
273
|
+
window.addEventListener(type, unlock, true);
|
|
274
|
+
if (typeof document === "undefined")
|
|
275
|
+
return;
|
|
276
|
+
document.addEventListener("visibilitychange", () => {
|
|
277
|
+
const ctx = this._audioCtx;
|
|
278
|
+
if (!ctx)
|
|
279
|
+
return;
|
|
280
|
+
if (document.visibilityState === "hidden") {
|
|
281
|
+
if (ctx.state === "running") {
|
|
282
|
+
this._suspendedByHiding = true;
|
|
283
|
+
void ctx.suspend().catch(() => { });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else if (this._suspendedByHiding) {
|
|
287
|
+
this._suspendedByHiding = false;
|
|
288
|
+
void ctx.resume().catch(() => { });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
55
292
|
}
|
package/dist/minimo-format.d.ts
CHANGED
|
@@ -30,6 +30,27 @@ export interface ImageAsset {
|
|
|
30
30
|
/** The image's URL or path, as `game.loadImage(key, src)` takes it. Non-empty. */
|
|
31
31
|
src: string;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* A sound declared in an asset file, loaded as
|
|
35
|
+
* `game.loadSound(key, src)` would load it, and played with
|
|
36
|
+
* `game.playSound(key)`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```json
|
|
40
|
+
* { "key": "jump", "type": "sound", "src": "assets/jump.mp3" }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export interface SoundAsset {
|
|
44
|
+
/**
|
|
45
|
+
* The key `game.playSound` uses for this sound. Non-empty, with no leading
|
|
46
|
+
* or trailing spaces, and unique in the whole game, images included.
|
|
47
|
+
*/
|
|
48
|
+
key: string;
|
|
49
|
+
/** Always `"sound"`. */
|
|
50
|
+
type: "sound";
|
|
51
|
+
/** The audio file's URL or path, as `game.loadSound(key, src)` takes it. Non-empty. */
|
|
52
|
+
src: string;
|
|
53
|
+
}
|
|
33
54
|
/**
|
|
34
55
|
* An entry whose `type` this engine does not support, such as one written for
|
|
35
56
|
* a newer engine. It is kept as written, so a tool that saves the file does
|
|
@@ -58,7 +79,8 @@ export interface UnknownAsset {
|
|
|
58
79
|
* "version": 1,
|
|
59
80
|
* "assets": [
|
|
60
81
|
* { "key": "player", "type": "image", "src": "assets/player.png" },
|
|
61
|
-
* { "key": "bat", "type": "image", "src": "assets/bat.png" }
|
|
82
|
+
* { "key": "bat", "type": "image", "src": "assets/bat.png" },
|
|
83
|
+
* { "key": "jump", "type": "sound", "src": "assets/jump.mp3" }
|
|
62
84
|
* ]
|
|
63
85
|
* }
|
|
64
86
|
* ```
|
|
@@ -69,7 +91,7 @@ export interface AssetFile {
|
|
|
69
91
|
/** The version of the file's shape. Version `1` is the only one so far. */
|
|
70
92
|
version: 1;
|
|
71
93
|
/** The declared assets, in file order. */
|
|
72
|
-
assets: (ImageAsset | UnknownAsset)[];
|
|
94
|
+
assets: (ImageAsset | SoundAsset | UnknownAsset)[];
|
|
73
95
|
}
|
|
74
96
|
/**
|
|
75
97
|
* Validates the content of an asset file and returns a copy of it.
|
|
@@ -81,7 +103,7 @@ export interface AssetFile {
|
|
|
81
103
|
* - an entry without a string `key` and `type`;
|
|
82
104
|
* - an empty key, or one with leading or trailing spaces;
|
|
83
105
|
* - a key repeated in the file, whatever the types of its entries;
|
|
84
|
-
* - an `image` entry without a non-empty string `src`.
|
|
106
|
+
* - an `image` or `sound` entry without a non-empty string `src`.
|
|
85
107
|
*
|
|
86
108
|
* An entry of a type this engine does not support is kept unchanged, without
|
|
87
109
|
* checking its other fields, so nothing is lost when a tool saves the file.
|
package/dist/minimo-format.js
CHANGED
|
@@ -13,7 +13,8 @@ const ASSET_FILE_FORMAT = "minimo-assets";
|
|
|
13
13
|
/** The newest asset file `version` this engine reads. */
|
|
14
14
|
const ASSET_FILE_VERSION = 1;
|
|
15
15
|
const ASSET_FILE_FIELDS = new Set(["format", "version", "assets"]);
|
|
16
|
-
|
|
16
|
+
/** The fields of each supported entry type; `image` and `sound` share theirs. */
|
|
17
|
+
const SRC_ASSET_FIELDS = new Set(["key", "type", "src"]);
|
|
17
18
|
/**
|
|
18
19
|
* Validates the content of an asset file and returns a copy of it.
|
|
19
20
|
*
|
|
@@ -24,7 +25,7 @@ const IMAGE_ASSET_FIELDS = new Set(["key", "type", "src"]);
|
|
|
24
25
|
* - an entry without a string `key` and `type`;
|
|
25
26
|
* - an empty key, or one with leading or trailing spaces;
|
|
26
27
|
* - a key repeated in the file, whatever the types of its entries;
|
|
27
|
-
* - an `image` entry without a non-empty string `src`.
|
|
28
|
+
* - an `image` or `sound` entry without a non-empty string `src`.
|
|
28
29
|
*
|
|
29
30
|
* An entry of a type this engine does not support is kept unchanged, without
|
|
30
31
|
* checking its other fields, so nothing is lost when a tool saves the file.
|
|
@@ -81,8 +82,8 @@ export function parseAssetFile(json) {
|
|
|
81
82
|
if (typeof type !== "string" || type.length === 0) {
|
|
82
83
|
throw assetError(`${where} ("${key}") must have a string "type", found ${describe(type)}.`);
|
|
83
84
|
}
|
|
84
|
-
if (type === "image") {
|
|
85
|
-
rejectUnknownFields(entry,
|
|
85
|
+
if (type === "image" || type === "sound") {
|
|
86
|
+
rejectUnknownFields(entry, SRC_ASSET_FIELDS, `${where} ("${key}")`);
|
|
86
87
|
const src = entry.src;
|
|
87
88
|
if (typeof src !== "string" || src.trim().length === 0) {
|
|
88
89
|
throw assetError(`${where} ("${key}") must have a non-empty string "src", found ${describe(src)}.`);
|
package/dist/minimo.d.ts
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
* ALL ROTATIONS ARE IN DEGREES.
|
|
8
8
|
* THE ENGINE LOOP USES requestAnimationFrame ONLY.
|
|
9
9
|
*/
|
|
10
|
+
import { SoundPlayback } from "./internal/SoundSystem.js";
|
|
11
|
+
import type { PlaySoundOptions } from "./internal/SoundSystem.js";
|
|
10
12
|
import type { DesignObject } from "./minimo-format.js";
|
|
11
13
|
export { Design, parseAssetFile } from "./minimo-format.js";
|
|
12
|
-
export
|
|
14
|
+
export { SoundPlayback } from "./internal/SoundSystem.js";
|
|
15
|
+
export type { PlaySoundOptions, SoundPlaybackState } from "./internal/SoundSystem.js";
|
|
16
|
+
export type { AssetFile, DesignBackground, DesignEmoji, DesignFile, DesignImage, DesignObject, DesignObjectBase, DesignObjectType, DesignSpriteBase, DesignText, ImageAsset, SoundAsset, UnknownAsset, } from "./minimo-format.js";
|
|
13
17
|
/**
|
|
14
18
|
* Optional tuning values for {@link Game.animateExplode}.
|
|
15
19
|
*
|
|
@@ -200,8 +204,8 @@ export interface IScene {
|
|
|
200
204
|
*
|
|
201
205
|
* Called whenever the scene is entered — by {@link Game.start},
|
|
202
206
|
* {@link Game.reset}, or {@link Game.transitionTo} — before
|
|
203
|
-
* {@link IScene.onCreate}. Call {@link Game.loadAssets}
|
|
204
|
-
* {@link Game.loadImage} inside it, as inside `game.onPreload`. Images that
|
|
207
|
+
* {@link IScene.onCreate}. Call {@link Game.loadAssets},
|
|
208
|
+
* {@link Game.loadImage}, or {@link Game.loadSound} inside it, as inside `game.onPreload`. Images that
|
|
205
209
|
* are already loaded are skipped, so replaying a scene loads nothing.
|
|
206
210
|
*
|
|
207
211
|
* When images are pending, the scene being left freezes, dimmed, under a
|
|
@@ -1306,15 +1310,15 @@ export declare class Game {
|
|
|
1306
1310
|
/**
|
|
1307
1311
|
* Asset registration callback invoked once before the first scene is created.
|
|
1308
1312
|
*
|
|
1309
|
-
* This is the normal place to load a game's images. Use it to register them
|
|
1310
|
-
* with {@link Game.loadAssets} or {@link Game.
|
|
1313
|
+
* This is the normal place to load a game's images and sounds. Use it to register them
|
|
1314
|
+
* with {@link Game.loadAssets}, {@link Game.loadImage}, or {@link Game.loadSound}. The callback is synchronous: it should only
|
|
1311
1315
|
* queue assets, not await network work.
|
|
1312
1316
|
*
|
|
1313
|
-
* After `onPreload` returns, MinimoJS loads the queued images, together with
|
|
1317
|
+
* After `onPreload` returns, MinimoJS loads the queued images and sounds, together with
|
|
1314
1318
|
* those of the first scene's {@link IScene.onPreload} if it has one, and
|
|
1315
|
-
* shows a loading line until they are ready. If any
|
|
1319
|
+
* shows a loading line until they are ready. If any file fails, it asks the
|
|
1316
1320
|
* player with `window.confirm` whether to retry them; declining leaves the
|
|
1317
|
-
* game on the loading screen and reports the failed
|
|
1321
|
+
* game on the loading screen and reports the failed files on the console.
|
|
1318
1322
|
*
|
|
1319
1323
|
* @example
|
|
1320
1324
|
* ```ts
|
|
@@ -1530,6 +1534,10 @@ export declare class Game {
|
|
|
1530
1534
|
* `loadImage` may only be called from within {@link Game.onPreload} or a
|
|
1531
1535
|
* scene's {@link IScene.onPreload}.
|
|
1532
1536
|
*
|
|
1537
|
+
* Images are requested with CORS, as sounds are, so the canvas stays
|
|
1538
|
+
* readable and exportable. An image on another origin loads only when its
|
|
1539
|
+
* host answers with CORS headers.
|
|
1540
|
+
*
|
|
1533
1541
|
* @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
|
|
1534
1542
|
* @param src - Image URL or relative path.
|
|
1535
1543
|
*
|
|
@@ -1541,9 +1549,37 @@ export declare class Game {
|
|
|
1541
1549
|
* ```
|
|
1542
1550
|
*/
|
|
1543
1551
|
loadImage(key: string, src: string): void;
|
|
1552
|
+
/**
|
|
1553
|
+
* Queues an audio file to be loaded during {@link Game.onPreload}, to play
|
|
1554
|
+
* later with {@link Game.playSound}.
|
|
1555
|
+
*
|
|
1556
|
+
* Like {@link Game.loadImage}, it only registers the sound, and may only be
|
|
1557
|
+
* called from within {@link Game.onPreload} or a scene's
|
|
1558
|
+
* {@link IScene.onPreload}. The file loads and decodes with the images,
|
|
1559
|
+
* under the same loading line, and a file that fails is retried with them.
|
|
1560
|
+
* Keys are unique in the whole game: a sound cannot share its key with an
|
|
1561
|
+
* image. Prefer declaring sounds in an asset file, as `sound` entries
|
|
1562
|
+
* loaded with {@link Game.loadAssets}.
|
|
1563
|
+
*
|
|
1564
|
+
* MP3 plays in every browser; WAV does too, but its files are large. OGG
|
|
1565
|
+
* does not play in every Safari.
|
|
1566
|
+
*
|
|
1567
|
+
* @param key - Stable key used later by {@link Game.playSound}.
|
|
1568
|
+
* @param src - Audio file URL or relative path.
|
|
1569
|
+
*
|
|
1570
|
+
* @example
|
|
1571
|
+
* ```ts
|
|
1572
|
+
* game.onPreload = () => {
|
|
1573
|
+
* game.loadSound("jump", "assets/jump.mp3");
|
|
1574
|
+
* game.loadSound("theme", "assets/theme.mp3");
|
|
1575
|
+
* };
|
|
1576
|
+
* ```
|
|
1577
|
+
*/
|
|
1578
|
+
loadSound(key: string, src: string): void;
|
|
1544
1579
|
/**
|
|
1545
1580
|
* Queues every asset an asset file declares, as one {@link Game.loadImage}
|
|
1546
|
-
* call per `image` entry
|
|
1581
|
+
* call per `image` entry and one {@link Game.loadSound} call per `sound`
|
|
1582
|
+
* entry would.
|
|
1547
1583
|
*
|
|
1548
1584
|
* Pass the content of a `*.assets.json` file, obtained by JSON import. The
|
|
1549
1585
|
* file is checked with {@link parseAssetFile}, and an invalid one throws an
|
|
@@ -1552,11 +1588,11 @@ export declare class Game {
|
|
|
1552
1588
|
* {@link IScene.onPreload}.
|
|
1553
1589
|
*
|
|
1554
1590
|
* - Keys are unique in the whole game. A key that another asset file
|
|
1555
|
-
* declares, or that {@link Game.loadImage}
|
|
1556
|
-
* registered, throws an error naming it, and so does a later
|
|
1591
|
+
* declares, or that {@link Game.loadImage}, {@link Game.loadSound}, or
|
|
1592
|
+
* {@link Game.createTexture} registered, throws an error naming it, and so does a later
|
|
1557
1593
|
* `loadImage` or `createTexture` of a key an asset file declares.
|
|
1558
1594
|
* - Loading the same file again, as a scene's `onPreload()` does each time
|
|
1559
|
-
* the scene is entered, queues only its
|
|
1595
|
+
* the scene is entered, queues only its files that are not loaded.
|
|
1560
1596
|
* - An entry of a type this engine does not support is skipped, with a
|
|
1561
1597
|
* console warning naming it, so the game still loads every asset it can.
|
|
1562
1598
|
*
|
|
@@ -1989,11 +2025,56 @@ export declare class Game {
|
|
|
1989
2025
|
* ```
|
|
1990
2026
|
*/
|
|
1991
2027
|
isMobileDevice(): boolean;
|
|
2028
|
+
/**
|
|
2029
|
+
* Plays a sound loaded with {@link Game.loadSound} or an asset file's
|
|
2030
|
+
* `sound` entry, and returns its {@link SoundPlayback}.
|
|
2031
|
+
*
|
|
2032
|
+
* Plays overlap: playing a sound that is already playing starts another
|
|
2033
|
+
* play of it. Keep the returned playback to pause, resume, or stop it, such
|
|
2034
|
+
* as the game's music. A one-shot play stops by itself when it ends; a
|
|
2035
|
+
* looping one plays until stopped.
|
|
2036
|
+
*
|
|
2037
|
+
* - **Before the first tap.** Browsers keep a page silent until the player
|
|
2038
|
+
* taps, clicks, or presses a key. A looping sound started earlier, such as
|
|
2039
|
+
* music started in a scene's `onCreate()`, waits and begins with that
|
|
2040
|
+
* first gesture. A one-shot sound played earlier is dropped, and its
|
|
2041
|
+
* playback is returned already stopped.
|
|
2042
|
+
* - **Scenes.** {@link Game.reset} and {@link Game.transitionTo} do not stop
|
|
2043
|
+
* sounds. Stop the music yourself when a scene should change it.
|
|
2044
|
+
* - **Hidden page.** Every sound pauses while the page is hidden, and
|
|
2045
|
+
* continues when it is shown again.
|
|
2046
|
+
*
|
|
2047
|
+
* Throws an error naming the key when no sound is loaded under it.
|
|
2048
|
+
*
|
|
2049
|
+
* @param key - A key loaded with {@link Game.loadSound} or an asset file.
|
|
2050
|
+
* @param options - `loop` to repeat until stopped, and `volume` from `0` to `1`.
|
|
2051
|
+
*
|
|
2052
|
+
* @example
|
|
2053
|
+
* ```ts
|
|
2054
|
+
* game.playSound("jump");
|
|
2055
|
+
* const music = game.playSound("theme", { loop: true, volume: 0.6 });
|
|
2056
|
+
* music.pause();
|
|
2057
|
+
* music.resume();
|
|
2058
|
+
* music.stop();
|
|
2059
|
+
* ```
|
|
2060
|
+
*/
|
|
2061
|
+
playSound(key: string, options?: PlaySoundOptions): SoundPlayback;
|
|
2062
|
+
/**
|
|
2063
|
+
* Stops every sound playing from a file, such as music and effects started
|
|
2064
|
+
* with {@link Game.playSound}. Synthesized tones are not affected.
|
|
2065
|
+
*
|
|
2066
|
+
* @example
|
|
2067
|
+
* ```ts
|
|
2068
|
+
* game.stopAllSounds();
|
|
2069
|
+
* ```
|
|
2070
|
+
*/
|
|
2071
|
+
stopAllSounds(): void;
|
|
1992
2072
|
/**
|
|
1993
2073
|
* Plays a square-wave beep using the Web Audio API.
|
|
1994
2074
|
*
|
|
1995
|
-
* **Square wave only.**
|
|
1996
|
-
*
|
|
2075
|
+
* **Square wave only.** This synthesizes a tone and needs no file. To play
|
|
2076
|
+
* a recorded sound, load it with {@link Game.loadSound} and play it with
|
|
2077
|
+
* {@link Game.playSound}.
|
|
1997
2078
|
*
|
|
1998
2079
|
* The AudioContext is created lazily on first call. Browsers require a user
|
|
1999
2080
|
* gesture (click, keypress) before audio can play — always call `sound()` in
|
package/dist/minimo.js
CHANGED
|
@@ -24,6 +24,7 @@ import { TransitionSystem } from "./internal/TransitionSystem.js";
|
|
|
24
24
|
import { TimerSystem } from "./internal/TimerSystem.js";
|
|
25
25
|
import { Design, findFirst, parseAssetFile, parseDesignObject } from "./minimo-format.js";
|
|
26
26
|
export { Design, parseAssetFile } from "./minimo-format.js";
|
|
27
|
+
export { SoundPlayback } from "./internal/SoundSystem.js";
|
|
27
28
|
/** How long a scene may load before it dims and its progress ring appears, so a fast load shows neither. */
|
|
28
29
|
const SCENE_LOADING_INDICATOR_DELAY_MS = 250;
|
|
29
30
|
/** How long the dimming and the ring take to fade in once they appear. */
|
|
@@ -1448,15 +1449,15 @@ export class Game {
|
|
|
1448
1449
|
/**
|
|
1449
1450
|
* Asset registration callback invoked once before the first scene is created.
|
|
1450
1451
|
*
|
|
1451
|
-
* This is the normal place to load a game's images. Use it to register them
|
|
1452
|
-
* with {@link Game.loadAssets} or {@link Game.
|
|
1452
|
+
* This is the normal place to load a game's images and sounds. Use it to register them
|
|
1453
|
+
* with {@link Game.loadAssets}, {@link Game.loadImage}, or {@link Game.loadSound}. The callback is synchronous: it should only
|
|
1453
1454
|
* queue assets, not await network work.
|
|
1454
1455
|
*
|
|
1455
|
-
* After `onPreload` returns, MinimoJS loads the queued images, together with
|
|
1456
|
+
* After `onPreload` returns, MinimoJS loads the queued images and sounds, together with
|
|
1456
1457
|
* those of the first scene's {@link IScene.onPreload} if it has one, and
|
|
1457
|
-
* shows a loading line until they are ready. If any
|
|
1458
|
+
* shows a loading line until they are ready. If any file fails, it asks the
|
|
1458
1459
|
* player with `window.confirm` whether to retry them; declining leaves the
|
|
1459
|
-
* game on the loading screen and reports the failed
|
|
1460
|
+
* game on the loading screen and reports the failed files on the console.
|
|
1460
1461
|
*
|
|
1461
1462
|
* @example
|
|
1462
1463
|
* ```ts
|
|
@@ -1504,9 +1505,9 @@ export class Game {
|
|
|
1504
1505
|
this._physicsSystem = new PhysicsSystem();
|
|
1505
1506
|
this._spriteSystem = new SpriteSystem(this._animationSystem, this);
|
|
1506
1507
|
this._backgroundSystem = new BackgroundSystem();
|
|
1507
|
-
this._assetSystem = new AssetSystem();
|
|
1508
|
-
this._inputSystem = new InputSystem(this, this._canvas);
|
|
1509
1508
|
this._soundSystem = new SoundSystem();
|
|
1509
|
+
this._assetSystem = new AssetSystem((src) => this._soundSystem.decode(src));
|
|
1510
|
+
this._inputSystem = new InputSystem(this, this._canvas);
|
|
1510
1511
|
this._textSystem = new TextSystem();
|
|
1511
1512
|
this._renderSystem = new RenderSystem();
|
|
1512
1513
|
this._explosionSystem = new ExplosionSystem();
|
|
@@ -1681,6 +1682,10 @@ export class Game {
|
|
|
1681
1682
|
* `loadImage` may only be called from within {@link Game.onPreload} or a
|
|
1682
1683
|
* scene's {@link IScene.onPreload}.
|
|
1683
1684
|
*
|
|
1685
|
+
* Images are requested with CORS, as sounds are, so the canvas stays
|
|
1686
|
+
* readable and exportable. An image on another origin loads only when its
|
|
1687
|
+
* host answers with CORS headers.
|
|
1688
|
+
*
|
|
1684
1689
|
* @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
|
|
1685
1690
|
* @param src - Image URL or relative path.
|
|
1686
1691
|
*
|
|
@@ -1697,9 +1702,42 @@ export class Game {
|
|
|
1697
1702
|
}
|
|
1698
1703
|
this._assetSystem.queueImage(key, src);
|
|
1699
1704
|
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Queues an audio file to be loaded during {@link Game.onPreload}, to play
|
|
1707
|
+
* later with {@link Game.playSound}.
|
|
1708
|
+
*
|
|
1709
|
+
* Like {@link Game.loadImage}, it only registers the sound, and may only be
|
|
1710
|
+
* called from within {@link Game.onPreload} or a scene's
|
|
1711
|
+
* {@link IScene.onPreload}. The file loads and decodes with the images,
|
|
1712
|
+
* under the same loading line, and a file that fails is retried with them.
|
|
1713
|
+
* Keys are unique in the whole game: a sound cannot share its key with an
|
|
1714
|
+
* image. Prefer declaring sounds in an asset file, as `sound` entries
|
|
1715
|
+
* loaded with {@link Game.loadAssets}.
|
|
1716
|
+
*
|
|
1717
|
+
* MP3 plays in every browser; WAV does too, but its files are large. OGG
|
|
1718
|
+
* does not play in every Safari.
|
|
1719
|
+
*
|
|
1720
|
+
* @param key - Stable key used later by {@link Game.playSound}.
|
|
1721
|
+
* @param src - Audio file URL or relative path.
|
|
1722
|
+
*
|
|
1723
|
+
* @example
|
|
1724
|
+
* ```ts
|
|
1725
|
+
* game.onPreload = () => {
|
|
1726
|
+
* game.loadSound("jump", "assets/jump.mp3");
|
|
1727
|
+
* game.loadSound("theme", "assets/theme.mp3");
|
|
1728
|
+
* };
|
|
1729
|
+
* ```
|
|
1730
|
+
*/
|
|
1731
|
+
loadSound(key, src) {
|
|
1732
|
+
if (!this._isRegisteringPreloadAssets) {
|
|
1733
|
+
throw new Error("MinimoJS: loadSound() may only be called inside game.onPreload or a scene's onPreload().");
|
|
1734
|
+
}
|
|
1735
|
+
this._assetSystem.queueSound(key, src);
|
|
1736
|
+
}
|
|
1700
1737
|
/**
|
|
1701
1738
|
* Queues every asset an asset file declares, as one {@link Game.loadImage}
|
|
1702
|
-
* call per `image` entry
|
|
1739
|
+
* call per `image` entry and one {@link Game.loadSound} call per `sound`
|
|
1740
|
+
* entry would.
|
|
1703
1741
|
*
|
|
1704
1742
|
* Pass the content of a `*.assets.json` file, obtained by JSON import. The
|
|
1705
1743
|
* file is checked with {@link parseAssetFile}, and an invalid one throws an
|
|
@@ -1708,11 +1746,11 @@ export class Game {
|
|
|
1708
1746
|
* {@link IScene.onPreload}.
|
|
1709
1747
|
*
|
|
1710
1748
|
* - Keys are unique in the whole game. A key that another asset file
|
|
1711
|
-
* declares, or that {@link Game.loadImage}
|
|
1712
|
-
* registered, throws an error naming it, and so does a later
|
|
1749
|
+
* declares, or that {@link Game.loadImage}, {@link Game.loadSound}, or
|
|
1750
|
+
* {@link Game.createTexture} registered, throws an error naming it, and so does a later
|
|
1713
1751
|
* `loadImage` or `createTexture` of a key an asset file declares.
|
|
1714
1752
|
* - Loading the same file again, as a scene's `onPreload()` does each time
|
|
1715
|
-
* the scene is entered, queues only its
|
|
1753
|
+
* the scene is entered, queues only its files that are not loaded.
|
|
1716
1754
|
* - An entry of a type this engine does not support is skipped, with a
|
|
1717
1755
|
* console warning naming it, so the game still loads every asset it can.
|
|
1718
1756
|
*
|
|
@@ -1734,7 +1772,7 @@ export class Game {
|
|
|
1734
1772
|
if (!isFirstLoad)
|
|
1735
1773
|
return;
|
|
1736
1774
|
for (const entry of file.assets) {
|
|
1737
|
-
if (entry.type !== "image") {
|
|
1775
|
+
if (entry.type !== "image" && entry.type !== "sound") {
|
|
1738
1776
|
console.warn(`MinimoJS: Skipped the asset "${entry.key}", whose type "${entry.type}" this version of MinimoJS does not support.`);
|
|
1739
1777
|
}
|
|
1740
1778
|
}
|
|
@@ -2307,11 +2345,64 @@ export class Game {
|
|
|
2307
2345
|
// -------------------------------------------------------------------------
|
|
2308
2346
|
// Sound
|
|
2309
2347
|
// -------------------------------------------------------------------------
|
|
2348
|
+
/**
|
|
2349
|
+
* Plays a sound loaded with {@link Game.loadSound} or an asset file's
|
|
2350
|
+
* `sound` entry, and returns its {@link SoundPlayback}.
|
|
2351
|
+
*
|
|
2352
|
+
* Plays overlap: playing a sound that is already playing starts another
|
|
2353
|
+
* play of it. Keep the returned playback to pause, resume, or stop it, such
|
|
2354
|
+
* as the game's music. A one-shot play stops by itself when it ends; a
|
|
2355
|
+
* looping one plays until stopped.
|
|
2356
|
+
*
|
|
2357
|
+
* - **Before the first tap.** Browsers keep a page silent until the player
|
|
2358
|
+
* taps, clicks, or presses a key. A looping sound started earlier, such as
|
|
2359
|
+
* music started in a scene's `onCreate()`, waits and begins with that
|
|
2360
|
+
* first gesture. A one-shot sound played earlier is dropped, and its
|
|
2361
|
+
* playback is returned already stopped.
|
|
2362
|
+
* - **Scenes.** {@link Game.reset} and {@link Game.transitionTo} do not stop
|
|
2363
|
+
* sounds. Stop the music yourself when a scene should change it.
|
|
2364
|
+
* - **Hidden page.** Every sound pauses while the page is hidden, and
|
|
2365
|
+
* continues when it is shown again.
|
|
2366
|
+
*
|
|
2367
|
+
* Throws an error naming the key when no sound is loaded under it.
|
|
2368
|
+
*
|
|
2369
|
+
* @param key - A key loaded with {@link Game.loadSound} or an asset file.
|
|
2370
|
+
* @param options - `loop` to repeat until stopped, and `volume` from `0` to `1`.
|
|
2371
|
+
*
|
|
2372
|
+
* @example
|
|
2373
|
+
* ```ts
|
|
2374
|
+
* game.playSound("jump");
|
|
2375
|
+
* const music = game.playSound("theme", { loop: true, volume: 0.6 });
|
|
2376
|
+
* music.pause();
|
|
2377
|
+
* music.resume();
|
|
2378
|
+
* music.stop();
|
|
2379
|
+
* ```
|
|
2380
|
+
*/
|
|
2381
|
+
playSound(key, options) {
|
|
2382
|
+
const buffer = this._assetSystem.getSound(key);
|
|
2383
|
+
if (buffer === undefined) {
|
|
2384
|
+
throw new Error(`MinimoJS: No sound is loaded under the key "${key}". Load it with game.loadSound() or an asset file's "sound" entry in an onPreload hook.`);
|
|
2385
|
+
}
|
|
2386
|
+
return this._soundSystem.play(key, buffer, options);
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Stops every sound playing from a file, such as music and effects started
|
|
2390
|
+
* with {@link Game.playSound}. Synthesized tones are not affected.
|
|
2391
|
+
*
|
|
2392
|
+
* @example
|
|
2393
|
+
* ```ts
|
|
2394
|
+
* game.stopAllSounds();
|
|
2395
|
+
* ```
|
|
2396
|
+
*/
|
|
2397
|
+
stopAllSounds() {
|
|
2398
|
+
this._soundSystem.stopAll();
|
|
2399
|
+
}
|
|
2310
2400
|
/**
|
|
2311
2401
|
* Plays a square-wave beep using the Web Audio API.
|
|
2312
2402
|
*
|
|
2313
|
-
* **Square wave only.**
|
|
2314
|
-
*
|
|
2403
|
+
* **Square wave only.** This synthesizes a tone and needs no file. To play
|
|
2404
|
+
* a recorded sound, load it with {@link Game.loadSound} and play it with
|
|
2405
|
+
* {@link Game.playSound}.
|
|
2315
2406
|
*
|
|
2316
2407
|
* The AudioContext is created lazily on first call. Browsers require a user
|
|
2317
2408
|
* gesture (click, keypress) before audio can play — always call `sound()` in
|
|
@@ -2982,7 +3073,7 @@ export class Game {
|
|
|
2982
3073
|
return;
|
|
2983
3074
|
}
|
|
2984
3075
|
this._registerSceneAssets(scene ?? this._currentScene);
|
|
2985
|
-
if (this._assetSystem.
|
|
3076
|
+
if (this._assetSystem.getQueuedAssetCount() === 0) {
|
|
2986
3077
|
this._resetNow(scene);
|
|
2987
3078
|
return;
|
|
2988
3079
|
}
|
|
@@ -3045,7 +3136,7 @@ export class Game {
|
|
|
3045
3136
|
return;
|
|
3046
3137
|
}
|
|
3047
3138
|
this._registerSceneAssets(scene);
|
|
3048
|
-
if (this._assetSystem.
|
|
3139
|
+
if (this._assetSystem.getQueuedAssetCount() === 0) {
|
|
3049
3140
|
this._transitionNow(scene, options, onComplete);
|
|
3050
3141
|
return;
|
|
3051
3142
|
}
|
|
@@ -3149,14 +3240,14 @@ export class Game {
|
|
|
3149
3240
|
registeredScene = this._currentScene;
|
|
3150
3241
|
this._registerSceneAssets(registeredScene);
|
|
3151
3242
|
}
|
|
3152
|
-
const failed = await this._assetSystem.
|
|
3243
|
+
const failed = await this._assetSystem.loadQueuedAssets((settled, total) => {
|
|
3153
3244
|
if (total > 0)
|
|
3154
3245
|
this._renderLoadingScreen(settled / total);
|
|
3155
3246
|
});
|
|
3156
3247
|
if (failed.length > 0) {
|
|
3157
|
-
this.
|
|
3158
|
-
if (!this.
|
|
3159
|
-
this._assetSystem.
|
|
3248
|
+
this._reportFailedAssets(failed);
|
|
3249
|
+
if (!this._confirmAssetRetry(failed.length)) {
|
|
3250
|
+
this._assetSystem.discardQueuedAssets();
|
|
3160
3251
|
return false;
|
|
3161
3252
|
}
|
|
3162
3253
|
continue;
|
|
@@ -3291,14 +3382,14 @@ export class Game {
|
|
|
3291
3382
|
/** @internal */
|
|
3292
3383
|
async _loadSceneImages(load, finish) {
|
|
3293
3384
|
for (;;) {
|
|
3294
|
-
const failed = await this._assetSystem.
|
|
3385
|
+
const failed = await this._assetSystem.loadQueuedAssets((settled, total) => {
|
|
3295
3386
|
load.progress = total === 0 ? 1 : settled / total;
|
|
3296
3387
|
});
|
|
3297
3388
|
if (failed.length === 0)
|
|
3298
3389
|
break;
|
|
3299
|
-
this.
|
|
3300
|
-
if (!this.
|
|
3301
|
-
this._assetSystem.
|
|
3390
|
+
this._reportFailedAssets(failed);
|
|
3391
|
+
if (!this._confirmAssetRetry(failed.length)) {
|
|
3392
|
+
this._assetSystem.discardQueuedAssets();
|
|
3302
3393
|
this._sceneLoad = null;
|
|
3303
3394
|
return;
|
|
3304
3395
|
}
|
|
@@ -3308,23 +3399,23 @@ export class Game {
|
|
|
3308
3399
|
finish();
|
|
3309
3400
|
}
|
|
3310
3401
|
/** @internal */
|
|
3311
|
-
|
|
3402
|
+
_reportFailedAssets(keys) {
|
|
3312
3403
|
const list = keys
|
|
3313
|
-
.map((key) => `"${key}" (${this._assetSystem.
|
|
3404
|
+
.map((key) => `"${key}" (${this._assetSystem.getAssetSource(key) ?? "no source"})`)
|
|
3314
3405
|
.join(", ");
|
|
3315
|
-
console.error(`MinimoJS: ${keys.length}
|
|
3406
|
+
console.error(`MinimoJS: ${keys.length} file${keys.length === 1 ? "" : "s"} failed to load: ${list}.`);
|
|
3316
3407
|
}
|
|
3317
3408
|
/**
|
|
3318
|
-
* Asks the player whether to retry failed images, with the browser's own
|
|
3409
|
+
* Asks the player whether to retry failed images and sounds, with the browser's own
|
|
3319
3410
|
* confirmation dialog. Returns `false` where no dialog can be shown.
|
|
3320
3411
|
*
|
|
3321
3412
|
* @internal
|
|
3322
3413
|
*/
|
|
3323
|
-
|
|
3414
|
+
_confirmAssetRetry(failedCount) {
|
|
3324
3415
|
if (typeof window === "undefined" || typeof window.confirm !== "function") {
|
|
3325
3416
|
return false;
|
|
3326
3417
|
}
|
|
3327
|
-
const noun = failedCount === 1 ? "
|
|
3418
|
+
const noun = failedCount === 1 ? "file" : "files";
|
|
3328
3419
|
try {
|
|
3329
3420
|
return window.confirm(`${failedCount} ${noun} could not be loaded. Try again?`);
|
|
3330
3421
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimojs",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.32",
|
|
4
4
|
"description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Image-first sprites with emoji as a zero-asset alternative, rAF loop, TypeScript-first, LLM-friendly.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/minimo.js",
|