minimojs 1.0.0-alpha.30 → 1.0.0-alpha.31

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 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
- export {};
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
- /** @internal */
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
- getQueuedImageCount() {
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 of them
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 images leave the queue; failed ones stay in it, so a retry loads
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 loadQueuedImages(onProgress) {
125
- const entries = [...this._queuedImages.entries()].filter(([key]) => !this._loadedImages.has(key));
126
- const total = entries.length;
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
- await Promise.all(entries.map(async ([key, src]) => {
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
- getImageSource(key) {
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 not pick
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
- discardQueuedImages() {
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) {
@@ -1 +1,39 @@
1
- export {};
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
  }
@@ -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.
@@ -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
- const IMAGE_ASSET_FIELDS = new Set(["key", "type", "src"]);
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, IMAGE_ASSET_FIELDS, `${where} ("${key}")`);
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 type { AssetFile, DesignBackground, DesignEmoji, DesignFile, DesignImage, DesignObject, DesignObjectBase, DesignObjectType, DesignSpriteBase, DesignText, ImageAsset, UnknownAsset, } from "./minimo-format.js";
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} or
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.loadImage}. The callback is synchronous: it should only
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 image fails, it asks the
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 images on the console.
1321
+ * game on the loading screen and reports the failed files on the console.
1318
1322
  *
1319
1323
  * @example
1320
1324
  * ```ts
@@ -1541,9 +1545,37 @@ export declare class Game {
1541
1545
  * ```
1542
1546
  */
1543
1547
  loadImage(key: string, src: string): void;
1548
+ /**
1549
+ * Queues an audio file to be loaded during {@link Game.onPreload}, to play
1550
+ * later with {@link Game.playSound}.
1551
+ *
1552
+ * Like {@link Game.loadImage}, it only registers the sound, and may only be
1553
+ * called from within {@link Game.onPreload} or a scene's
1554
+ * {@link IScene.onPreload}. The file loads and decodes with the images,
1555
+ * under the same loading line, and a file that fails is retried with them.
1556
+ * Keys are unique in the whole game: a sound cannot share its key with an
1557
+ * image. Prefer declaring sounds in an asset file, as `sound` entries
1558
+ * loaded with {@link Game.loadAssets}.
1559
+ *
1560
+ * MP3 plays in every browser; WAV does too, but its files are large. OGG
1561
+ * does not play in every Safari.
1562
+ *
1563
+ * @param key - Stable key used later by {@link Game.playSound}.
1564
+ * @param src - Audio file URL or relative path.
1565
+ *
1566
+ * @example
1567
+ * ```ts
1568
+ * game.onPreload = () => {
1569
+ * game.loadSound("jump", "assets/jump.mp3");
1570
+ * game.loadSound("theme", "assets/theme.mp3");
1571
+ * };
1572
+ * ```
1573
+ */
1574
+ loadSound(key: string, src: string): void;
1544
1575
  /**
1545
1576
  * Queues every asset an asset file declares, as one {@link Game.loadImage}
1546
- * call per `image` entry would.
1577
+ * call per `image` entry and one {@link Game.loadSound} call per `sound`
1578
+ * entry would.
1547
1579
  *
1548
1580
  * Pass the content of a `*.assets.json` file, obtained by JSON import. The
1549
1581
  * file is checked with {@link parseAssetFile}, and an invalid one throws an
@@ -1552,11 +1584,11 @@ export declare class Game {
1552
1584
  * {@link IScene.onPreload}.
1553
1585
  *
1554
1586
  * - Keys are unique in the whole game. A key that another asset file
1555
- * declares, or that {@link Game.loadImage} or {@link Game.createTexture}
1556
- * registered, throws an error naming it, and so does a later
1587
+ * declares, or that {@link Game.loadImage}, {@link Game.loadSound}, or
1588
+ * {@link Game.createTexture} registered, throws an error naming it, and so does a later
1557
1589
  * `loadImage` or `createTexture` of a key an asset file declares.
1558
1590
  * - Loading the same file again, as a scene's `onPreload()` does each time
1559
- * the scene is entered, queues only its images that are not loaded.
1591
+ * the scene is entered, queues only its files that are not loaded.
1560
1592
  * - An entry of a type this engine does not support is skipped, with a
1561
1593
  * console warning naming it, so the game still loads every asset it can.
1562
1594
  *
@@ -1989,11 +2021,56 @@ export declare class Game {
1989
2021
  * ```
1990
2022
  */
1991
2023
  isMobileDevice(): boolean;
2024
+ /**
2025
+ * Plays a sound loaded with {@link Game.loadSound} or an asset file's
2026
+ * `sound` entry, and returns its {@link SoundPlayback}.
2027
+ *
2028
+ * Plays overlap: playing a sound that is already playing starts another
2029
+ * play of it. Keep the returned playback to pause, resume, or stop it, such
2030
+ * as the game's music. A one-shot play stops by itself when it ends; a
2031
+ * looping one plays until stopped.
2032
+ *
2033
+ * - **Before the first tap.** Browsers keep a page silent until the player
2034
+ * taps, clicks, or presses a key. A looping sound started earlier, such as
2035
+ * music started in a scene's `onCreate()`, waits and begins with that
2036
+ * first gesture. A one-shot sound played earlier is dropped, and its
2037
+ * playback is returned already stopped.
2038
+ * - **Scenes.** {@link Game.reset} and {@link Game.transitionTo} do not stop
2039
+ * sounds. Stop the music yourself when a scene should change it.
2040
+ * - **Hidden page.** Every sound pauses while the page is hidden, and
2041
+ * continues when it is shown again.
2042
+ *
2043
+ * Throws an error naming the key when no sound is loaded under it.
2044
+ *
2045
+ * @param key - A key loaded with {@link Game.loadSound} or an asset file.
2046
+ * @param options - `loop` to repeat until stopped, and `volume` from `0` to `1`.
2047
+ *
2048
+ * @example
2049
+ * ```ts
2050
+ * game.playSound("jump");
2051
+ * const music = game.playSound("theme", { loop: true, volume: 0.6 });
2052
+ * music.pause();
2053
+ * music.resume();
2054
+ * music.stop();
2055
+ * ```
2056
+ */
2057
+ playSound(key: string, options?: PlaySoundOptions): SoundPlayback;
2058
+ /**
2059
+ * Stops every sound playing from a file, such as music and effects started
2060
+ * with {@link Game.playSound}. Synthesized tones are not affected.
2061
+ *
2062
+ * @example
2063
+ * ```ts
2064
+ * game.stopAllSounds();
2065
+ * ```
2066
+ */
2067
+ stopAllSounds(): void;
1992
2068
  /**
1993
2069
  * Plays a square-wave beep using the Web Audio API.
1994
2070
  *
1995
- * **Square wave only.** MinimoJS does not support audio files, samples,
1996
- * or other waveforms. Only procedural square-wave tones.
2071
+ * **Square wave only.** This synthesizes a tone and needs no file. To play
2072
+ * a recorded sound, load it with {@link Game.loadSound} and play it with
2073
+ * {@link Game.playSound}.
1997
2074
  *
1998
2075
  * The AudioContext is created lazily on first call. Browsers require a user
1999
2076
  * 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.loadImage}. The callback is synchronous: it should only
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 image fails, it asks the
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 images on the console.
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();
@@ -1697,9 +1698,42 @@ export class Game {
1697
1698
  }
1698
1699
  this._assetSystem.queueImage(key, src);
1699
1700
  }
1701
+ /**
1702
+ * Queues an audio file to be loaded during {@link Game.onPreload}, to play
1703
+ * later with {@link Game.playSound}.
1704
+ *
1705
+ * Like {@link Game.loadImage}, it only registers the sound, and may only be
1706
+ * called from within {@link Game.onPreload} or a scene's
1707
+ * {@link IScene.onPreload}. The file loads and decodes with the images,
1708
+ * under the same loading line, and a file that fails is retried with them.
1709
+ * Keys are unique in the whole game: a sound cannot share its key with an
1710
+ * image. Prefer declaring sounds in an asset file, as `sound` entries
1711
+ * loaded with {@link Game.loadAssets}.
1712
+ *
1713
+ * MP3 plays in every browser; WAV does too, but its files are large. OGG
1714
+ * does not play in every Safari.
1715
+ *
1716
+ * @param key - Stable key used later by {@link Game.playSound}.
1717
+ * @param src - Audio file URL or relative path.
1718
+ *
1719
+ * @example
1720
+ * ```ts
1721
+ * game.onPreload = () => {
1722
+ * game.loadSound("jump", "assets/jump.mp3");
1723
+ * game.loadSound("theme", "assets/theme.mp3");
1724
+ * };
1725
+ * ```
1726
+ */
1727
+ loadSound(key, src) {
1728
+ if (!this._isRegisteringPreloadAssets) {
1729
+ throw new Error("MinimoJS: loadSound() may only be called inside game.onPreload or a scene's onPreload().");
1730
+ }
1731
+ this._assetSystem.queueSound(key, src);
1732
+ }
1700
1733
  /**
1701
1734
  * Queues every asset an asset file declares, as one {@link Game.loadImage}
1702
- * call per `image` entry would.
1735
+ * call per `image` entry and one {@link Game.loadSound} call per `sound`
1736
+ * entry would.
1703
1737
  *
1704
1738
  * Pass the content of a `*.assets.json` file, obtained by JSON import. The
1705
1739
  * file is checked with {@link parseAssetFile}, and an invalid one throws an
@@ -1708,11 +1742,11 @@ export class Game {
1708
1742
  * {@link IScene.onPreload}.
1709
1743
  *
1710
1744
  * - Keys are unique in the whole game. A key that another asset file
1711
- * declares, or that {@link Game.loadImage} or {@link Game.createTexture}
1712
- * registered, throws an error naming it, and so does a later
1745
+ * declares, or that {@link Game.loadImage}, {@link Game.loadSound}, or
1746
+ * {@link Game.createTexture} registered, throws an error naming it, and so does a later
1713
1747
  * `loadImage` or `createTexture` of a key an asset file declares.
1714
1748
  * - Loading the same file again, as a scene's `onPreload()` does each time
1715
- * the scene is entered, queues only its images that are not loaded.
1749
+ * the scene is entered, queues only its files that are not loaded.
1716
1750
  * - An entry of a type this engine does not support is skipped, with a
1717
1751
  * console warning naming it, so the game still loads every asset it can.
1718
1752
  *
@@ -1734,7 +1768,7 @@ export class Game {
1734
1768
  if (!isFirstLoad)
1735
1769
  return;
1736
1770
  for (const entry of file.assets) {
1737
- if (entry.type !== "image") {
1771
+ if (entry.type !== "image" && entry.type !== "sound") {
1738
1772
  console.warn(`MinimoJS: Skipped the asset "${entry.key}", whose type "${entry.type}" this version of MinimoJS does not support.`);
1739
1773
  }
1740
1774
  }
@@ -2307,11 +2341,64 @@ export class Game {
2307
2341
  // -------------------------------------------------------------------------
2308
2342
  // Sound
2309
2343
  // -------------------------------------------------------------------------
2344
+ /**
2345
+ * Plays a sound loaded with {@link Game.loadSound} or an asset file's
2346
+ * `sound` entry, and returns its {@link SoundPlayback}.
2347
+ *
2348
+ * Plays overlap: playing a sound that is already playing starts another
2349
+ * play of it. Keep the returned playback to pause, resume, or stop it, such
2350
+ * as the game's music. A one-shot play stops by itself when it ends; a
2351
+ * looping one plays until stopped.
2352
+ *
2353
+ * - **Before the first tap.** Browsers keep a page silent until the player
2354
+ * taps, clicks, or presses a key. A looping sound started earlier, such as
2355
+ * music started in a scene's `onCreate()`, waits and begins with that
2356
+ * first gesture. A one-shot sound played earlier is dropped, and its
2357
+ * playback is returned already stopped.
2358
+ * - **Scenes.** {@link Game.reset} and {@link Game.transitionTo} do not stop
2359
+ * sounds. Stop the music yourself when a scene should change it.
2360
+ * - **Hidden page.** Every sound pauses while the page is hidden, and
2361
+ * continues when it is shown again.
2362
+ *
2363
+ * Throws an error naming the key when no sound is loaded under it.
2364
+ *
2365
+ * @param key - A key loaded with {@link Game.loadSound} or an asset file.
2366
+ * @param options - `loop` to repeat until stopped, and `volume` from `0` to `1`.
2367
+ *
2368
+ * @example
2369
+ * ```ts
2370
+ * game.playSound("jump");
2371
+ * const music = game.playSound("theme", { loop: true, volume: 0.6 });
2372
+ * music.pause();
2373
+ * music.resume();
2374
+ * music.stop();
2375
+ * ```
2376
+ */
2377
+ playSound(key, options) {
2378
+ const buffer = this._assetSystem.getSound(key);
2379
+ if (buffer === undefined) {
2380
+ 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.`);
2381
+ }
2382
+ return this._soundSystem.play(key, buffer, options);
2383
+ }
2384
+ /**
2385
+ * Stops every sound playing from a file, such as music and effects started
2386
+ * with {@link Game.playSound}. Synthesized tones are not affected.
2387
+ *
2388
+ * @example
2389
+ * ```ts
2390
+ * game.stopAllSounds();
2391
+ * ```
2392
+ */
2393
+ stopAllSounds() {
2394
+ this._soundSystem.stopAll();
2395
+ }
2310
2396
  /**
2311
2397
  * Plays a square-wave beep using the Web Audio API.
2312
2398
  *
2313
- * **Square wave only.** MinimoJS does not support audio files, samples,
2314
- * or other waveforms. Only procedural square-wave tones.
2399
+ * **Square wave only.** This synthesizes a tone and needs no file. To play
2400
+ * a recorded sound, load it with {@link Game.loadSound} and play it with
2401
+ * {@link Game.playSound}.
2315
2402
  *
2316
2403
  * The AudioContext is created lazily on first call. Browsers require a user
2317
2404
  * gesture (click, keypress) before audio can play — always call `sound()` in
@@ -2982,7 +3069,7 @@ export class Game {
2982
3069
  return;
2983
3070
  }
2984
3071
  this._registerSceneAssets(scene ?? this._currentScene);
2985
- if (this._assetSystem.getQueuedImageCount() === 0) {
3072
+ if (this._assetSystem.getQueuedAssetCount() === 0) {
2986
3073
  this._resetNow(scene);
2987
3074
  return;
2988
3075
  }
@@ -3045,7 +3132,7 @@ export class Game {
3045
3132
  return;
3046
3133
  }
3047
3134
  this._registerSceneAssets(scene);
3048
- if (this._assetSystem.getQueuedImageCount() === 0) {
3135
+ if (this._assetSystem.getQueuedAssetCount() === 0) {
3049
3136
  this._transitionNow(scene, options, onComplete);
3050
3137
  return;
3051
3138
  }
@@ -3149,14 +3236,14 @@ export class Game {
3149
3236
  registeredScene = this._currentScene;
3150
3237
  this._registerSceneAssets(registeredScene);
3151
3238
  }
3152
- const failed = await this._assetSystem.loadQueuedImages((settled, total) => {
3239
+ const failed = await this._assetSystem.loadQueuedAssets((settled, total) => {
3153
3240
  if (total > 0)
3154
3241
  this._renderLoadingScreen(settled / total);
3155
3242
  });
3156
3243
  if (failed.length > 0) {
3157
- this._reportFailedImages(failed);
3158
- if (!this._confirmImageRetry(failed.length)) {
3159
- this._assetSystem.discardQueuedImages();
3244
+ this._reportFailedAssets(failed);
3245
+ if (!this._confirmAssetRetry(failed.length)) {
3246
+ this._assetSystem.discardQueuedAssets();
3160
3247
  return false;
3161
3248
  }
3162
3249
  continue;
@@ -3291,14 +3378,14 @@ export class Game {
3291
3378
  /** @internal */
3292
3379
  async _loadSceneImages(load, finish) {
3293
3380
  for (;;) {
3294
- const failed = await this._assetSystem.loadQueuedImages((settled, total) => {
3381
+ const failed = await this._assetSystem.loadQueuedAssets((settled, total) => {
3295
3382
  load.progress = total === 0 ? 1 : settled / total;
3296
3383
  });
3297
3384
  if (failed.length === 0)
3298
3385
  break;
3299
- this._reportFailedImages(failed);
3300
- if (!this._confirmImageRetry(failed.length)) {
3301
- this._assetSystem.discardQueuedImages();
3386
+ this._reportFailedAssets(failed);
3387
+ if (!this._confirmAssetRetry(failed.length)) {
3388
+ this._assetSystem.discardQueuedAssets();
3302
3389
  this._sceneLoad = null;
3303
3390
  return;
3304
3391
  }
@@ -3308,23 +3395,23 @@ export class Game {
3308
3395
  finish();
3309
3396
  }
3310
3397
  /** @internal */
3311
- _reportFailedImages(keys) {
3398
+ _reportFailedAssets(keys) {
3312
3399
  const list = keys
3313
- .map((key) => `"${key}" (${this._assetSystem.getImageSource(key) ?? "no source"})`)
3400
+ .map((key) => `"${key}" (${this._assetSystem.getAssetSource(key) ?? "no source"})`)
3314
3401
  .join(", ");
3315
- console.error(`MinimoJS: ${keys.length} image${keys.length === 1 ? "" : "s"} failed to load: ${list}.`);
3402
+ console.error(`MinimoJS: ${keys.length} file${keys.length === 1 ? "" : "s"} failed to load: ${list}.`);
3316
3403
  }
3317
3404
  /**
3318
- * Asks the player whether to retry failed images, with the browser's own
3405
+ * Asks the player whether to retry failed images and sounds, with the browser's own
3319
3406
  * confirmation dialog. Returns `false` where no dialog can be shown.
3320
3407
  *
3321
3408
  * @internal
3322
3409
  */
3323
- _confirmImageRetry(failedCount) {
3410
+ _confirmAssetRetry(failedCount) {
3324
3411
  if (typeof window === "undefined" || typeof window.confirm !== "function") {
3325
3412
  return false;
3326
3413
  }
3327
- const noun = failedCount === 1 ? "image" : "images";
3414
+ const noun = failedCount === 1 ? "file" : "files";
3328
3415
  try {
3329
3416
  return window.confirm(`${failedCount} ${noun} could not be loaded. Try again?`);
3330
3417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.30",
3
+ "version": "1.0.0-alpha.31",
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",