littlejsengine 1.19.3 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +58 -17
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. package/plugins/box2d.wasm.wasm +0 -0
@@ -15,17 +15,29 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- /** Audio context used by the engine
18
+ /** Audio context used by the engine, undefined outside a browser, where the engine runs headless
19
19
  * @type {AudioContext}
20
20
  * @memberof Audio */
21
- let audioContext = new AudioContext;
21
+ let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext;
22
22
 
23
23
  /** Master gain node for all audio to pass through, made at load so effects can connect to it any time
24
24
  * @type {GainNode}
25
25
  * @memberof Audio */
26
- let audioMasterGain = audioContext.createGain();
27
- audioMasterGain.connect(audioContext.destination);
28
- audioMasterGain.gain.value = soundVolume; // set starting value
26
+ let audioMasterGain = audioContext?.createGain();
27
+ let audioMasterVolume; // the soundVolume the master gain was last set to
28
+ if (audioMasterGain)
29
+ {
30
+ audioMasterGain.connect(audioContext.destination);
31
+ audioMasterGain.gain.value = audioMasterVolume = soundVolume; // set starting value
32
+ audioContext.addEventListener?.('statechange', audioStateChange);
33
+ }
34
+
35
+ // soundVolume can be set directly, so the master gain follows it each frame
36
+ function audioUpdateVolume()
37
+ {
38
+ if (audioMasterGain && soundVolume !== audioMasterVolume)
39
+ audioMasterGain.gain.value = audioMasterVolume = soundVolume;
40
+ }
29
41
 
30
42
  // the current master effect, kept so setAudioMasterEffect can undo the route it made,
31
43
  // and whether its output came from an effect, which gets its default route back
@@ -37,10 +49,10 @@ let audioMasterEffectInput, audioMasterEffectOutput, audioMasterEffectOutputIsEf
37
49
  const audioDefaultSampleRate = 44100;
38
50
 
39
51
  /** Check if the audio context is running and available for playback
40
- * @return {boolean} - True if the audio context is running
52
+ * @return {boolean} - True if the audio context is running, false when there is none
41
53
  * @memberof Audio */
42
54
  function audioIsRunning()
43
- { return audioContext.state === 'running'; }
55
+ { return audioContext?.state === 'running'; }
44
56
 
45
57
  function audioInit()
46
58
  {
@@ -67,6 +79,29 @@ function audioVisibilityChange()
67
79
  }
68
80
  }
69
81
 
82
+ // sound instances whose start failed only because the context was not running, like music started in gameInit
83
+ // before the first input, each with the time it tried; they start once the context runs unless paused or stopped
84
+ // first, and a one shot drops out once it would have ended anyway, so a backlog of sounds can't all play at once
85
+ const audioWaitingInstances = new Map;
86
+ function audioWaitingPrune(now=performance.now())
87
+ {
88
+ for (const [instance, startTime] of audioWaitingInstances)
89
+ {
90
+ const remaining = (instance.getDuration() - instance.pausedTime) / instance.rate;
91
+ if (!instance.loop && now - startTime > remaining * 1e3)
92
+ audioWaitingInstances.delete(instance);
93
+ }
94
+ }
95
+ function audioStateChange()
96
+ {
97
+ if (!audioIsRunning()) return;
98
+ audioWaitingPrune();
99
+ const instances = [...audioWaitingInstances.keys()];
100
+ audioWaitingInstances.clear();
101
+ for (const instance of instances)
102
+ instance.resume();
103
+ }
104
+
70
105
  /** Anything with input and output audio nodes, like an effect from the audio effects plugin
71
106
  * @typedef {{input: AudioNode, output: AudioNode}} AudioEffectNodes
72
107
  * @memberof Audio */
@@ -91,14 +126,20 @@ function setAudioMasterEffect(input, output)
91
126
  input = audioEffectNode(input, 'input');
92
127
  ASSERT(!input || typeof input.connect === 'function', 'input must be an AudioNode or an effect with input and output nodes');
93
128
  ASSERT(!output || typeof output.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
129
+ if (!audioMasterGain) return; // no audio outside a browser, where the engine runs headless
94
130
 
95
- // undo the current route, the master gain selectively so other taps on it survive,
96
- // but the output node from everything since it only ever fed the speakers;
97
- // an effect's output then goes back to the master gain, its default, so it still works for sounds
131
+ // undo the current route selectively so other taps survive; an effect's output that still feeds the speakers
132
+ // goes back to the master gain, its default, so it still works for sounds, but one that connect() already moved
133
+ // on, like the head of a longer chain being set now, stays where it was sent
98
134
  audioMasterGain.disconnect(audioMasterEffectInput || audioContext.destination);
99
- audioMasterEffectOutput?.disconnect();
100
- if (audioMasterEffectOutputIsEffect)
101
- audioMasterEffectOutput.connect(audioMasterGain);
135
+ if (audioMasterEffectOutput)
136
+ {
137
+ let fedSpeakers = true;
138
+ try { audioMasterEffectOutput.disconnect(audioContext.destination); }
139
+ catch { fedSpeakers = false; }
140
+ if (audioMasterEffectOutputIsEffect && fedSpeakers)
141
+ audioMasterEffectOutput.connect(audioMasterGain);
142
+ }
102
143
  audioMasterEffectInput = input;
103
144
  audioMasterEffectOutput = output;
104
145
  audioMasterEffectOutputIsEffect = outputIsEffect;
@@ -117,7 +158,8 @@ function setAudioMasterEffect(input, output)
117
158
  // get one of an effect's nodes, or the thing itself when it is already a node
118
159
  /** @param {AudioNode|AudioEffectNodes|undefined} effectOrNode
119
160
  * @param {'input'|'output'} key
120
- * @return {AudioNode} */
161
+ * @return {AudioNode}
162
+ * @ignore */
121
163
  function audioEffectNode(effectOrNode, key)
122
164
  {
123
165
  if (effectOrNode && 'input' in effectOrNode)
@@ -125,8 +167,31 @@ function audioEffectNode(effectOrNode, key)
125
167
  return /** @type {AudioNode} */ (effectOrNode);
126
168
  }
127
169
 
170
+ // ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight;
171
+ // returns when the ramp ends
172
+ function audioParamRamp(param, value, fadeTime=0)
173
+ {
174
+ ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
175
+ const startTime = audioContext.currentTime;
176
+ param.cancelScheduledValues(startTime);
177
+ if (fadeTime)
178
+ {
179
+ param.setValueAtTime(param.value, startTime);
180
+ param.linearRampToValueAtTime(value, startTime + fadeTime);
181
+ }
182
+ else
183
+ param.value = value;
184
+ return startTime + fadeTime;
185
+ }
186
+
128
187
  ///////////////////////////////////////////////////////////////////////////////
129
188
 
189
+ /**
190
+ * @callback SoundLoadCallback - Function called when sound is loaded
191
+ * @param {Sound} sound
192
+ * @memberof Audio
193
+ */
194
+
130
195
  /**
131
196
  * Sound Object - Stores a sound for later
132
197
  * - this can be used to load and play wave, mp3, and ogg files
@@ -148,15 +213,9 @@ function audioEffectNode(effectOrNode, key)
148
213
  */
149
214
  class Sound
150
215
  {
151
- /**
152
- * @callback SoundLoadCallback - Function called when sound is loaded
153
- * @param {Sound} sound
154
- * @memberof Audio
155
- */
156
-
157
216
  /** Create a sound object and cache the audio for later use
158
- * @param {string|Array} [asset] - Filename of audio file or zzfx array
159
- * @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds the zzfx default is used if undefined
217
+ * @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array
218
+ * @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds it overrides the array's own randomness, which is used if undefined
160
219
  * @param {number} [range=soundDefaultRange] - World space max range of sound
161
220
  * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
162
221
  * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
@@ -175,26 +234,29 @@ class Sound
175
234
  this.range = range;
176
235
  /** @property {number} - At what percentage of range should it start tapering */
177
236
  this.taper = taper;
178
- /** @property {number} - How much to randomize frequency each time sound plays */
237
+ /** @property {number} - How much to randomize frequency each time sound plays
238
+ * @type {number} */
179
239
  this.randomness = randomness ?? 0;
180
240
  /** @property {number} - Sample rate for this sound */
181
241
  this.sampleRate = audioDefaultSampleRate;
182
242
  /** @property {number} - How many samples per channel this sound has */
183
243
  this.sampleLength = 0;
184
- /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
185
- * @type {AudioBuffer} */
244
+ /** @property {AudioBuffer|undefined} - Decoded audio shared by every play of this sound
245
+ * @type {AudioBuffer|undefined} */
186
246
  this.sampleBuffer = undefined;
187
- /** @private
188
- * @type {Array<Array<number>|Float32Array>} */
247
+ // internal, the 3D plugin reads it to know the sound has loaded
248
+ /** @ignore
249
+ * @type {Array<Array<number>|Float32Array>|undefined} */
189
250
  this._sampleChannels = undefined;
190
251
  /** @property {number} - Percentage of this sound currently loaded, sounds
191
252
  * fetched from a url stay at 0 until decoding completes */
192
253
  this.loadedPercent = 0;
193
- /** @property {SoundLoadCallback} - function to call when sound is loaded */
254
+ /** @property {SoundLoadCallback|undefined} - function to call when sound is loaded
255
+ * @type {SoundLoadCallback|undefined} */
194
256
  this.onloadCallback = onloadCallback;
195
- /** @property {AudioNode|AudioEffectNodes} - Node or effect to route every play of this sound through instead of the master gain
257
+ /** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route every play of this sound through instead of the master gain
196
258
  * - Where this sound's audio goes, unlike AudioEffect.output which is an effect's own node, effects chain with connect()
197
- * @type {AudioNode|AudioEffectNodes} */
259
+ * @type {AudioNode|AudioEffectNodes|undefined} */
198
260
  this.output = undefined;
199
261
 
200
262
  if (isArray(asset))
@@ -202,10 +264,9 @@ class Sound
202
264
  // generate zzfx sound — copy so we don't mutate the caller's array
203
265
  const zzfxSound = asset.slice();
204
266
 
205
- // remove randomness so it can be applied on playback
206
- const defaultRandomness = randomness ?? .05;
267
+ // remove randomness so it can be applied on playback, a value passed in wins over the array's
207
268
  const randomnessIndex = 1;
208
- this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
269
+ this.randomness = randomness ?? zzfxSound[randomnessIndex] ?? .05;
209
270
  zzfxSound[randomnessIndex] = 0;
210
271
 
211
272
  // generate the zzfx samples, then hand them to an audio buffer so
@@ -215,11 +276,11 @@ class Sound
215
276
  this.loadedPercent = 1;
216
277
  onloadCallback?.(this);
217
278
  }
218
- else if (typeof asset === 'string')
279
+ else if (asset)
219
280
  {
220
- // load the audio file, report failures rather than leaving an
221
- // unhandled rejection, the sound just stays unloaded and silent
222
- const filename = asset;
281
+ // load the audio file, a URL object as bundlers give works like its string;
282
+ // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
283
+ const filename = asset + '';
223
284
  this.loadSound(filename).catch(e=>
224
285
  LOG('Sound load failed for', filename, '-', e.message));
225
286
  }
@@ -229,7 +290,7 @@ class Sound
229
290
  * Sounds keep their samples in an audio buffer, so reading this rebuilds
230
291
  * the arrays from it and caches them. The copies are safe to hold onto,
231
292
  * playing a sound detaches the buffer's own channel arrays.
232
- * @type {Array<Array<number>|Float32Array>} */
293
+ * @return {Array<Array<number>|Float32Array>|undefined} */
233
294
  get sampleChannels()
234
295
  {
235
296
  const buffer = this.sampleBuffer;
@@ -265,14 +326,17 @@ class Sound
265
326
  }
266
327
 
267
328
  /** Play the sound
268
- * Sounds may not play until a user interaction occurs
329
+ * - Browsers hold audio until the first user input, a sound played before it returns a paused instance
330
+ * that starts on its own once audio runs, unless paused or stopped first; a one shot that would have
331
+ * ended by then is dropped, and only the newest play of each sound waits, so a sound played every frame
332
+ * starts once
269
333
  * @param {Vector2} [pos] - World space position to play the sound if any
270
334
  * @param {number} [volume] - How much to scale volume by
271
335
  * @param {number} [pitch] - How much to scale pitch by
272
336
  * @param {number} [randomnessScale] - How much to scale pitch randomness
273
337
  * @param {boolean} [loop] - Should the sound loop?
274
338
  * @param {boolean} [paused] - Should the sound start paused
275
- * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
339
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
276
340
  */
277
341
  play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
278
342
  {
@@ -295,8 +359,11 @@ class Sound
295
359
  if (lengthSquared > range*range)
296
360
  return; // out of range
297
361
 
298
- // attenuate volume by distance
299
- volume *= percent(lengthSquared**.5, range, range*this.taper);
362
+ // attenuate volume by distance, full volume out to the taper and a fade past it,
363
+ // so a taper of 1 plays at full volume right up to the range
364
+ const distance = lengthSquared**.5, taperRange = range*this.taper;
365
+ if (distance > taperRange)
366
+ volume *= percent(distance, range, taperRange);
300
367
  }
301
368
 
302
369
  // get pan from screen space coords
@@ -310,13 +377,13 @@ class Sound
310
377
  if (debug && debugSound && pos)
311
378
  {
312
379
  // visualize where positioned sounds play and their falloff range
313
- debugCircle(pos, .5, '#0ff', .5, true);
380
+ debugCircle(pos, .5, '#0ff', .5, true, false);
314
381
  if (this.range)
315
382
  {
316
- debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
317
- debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
383
+ debugCircle(pos, 2*this.range, '#0ff', .5, false, false); // silent radius
384
+ debugCircle(pos, 2*this.range*this.taper, '#0ff', .5, false, false); // full volume radius
318
385
  }
319
- debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
386
+ debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5, 0, 'monospace', false);
320
387
  }
321
388
 
322
389
  return instance;
@@ -328,7 +395,7 @@ class Sound
328
395
  * @param {number} [pitch] - How much to scale pitch by
329
396
  * @param {number} [randomnessScale] - How much to scale pitch randomness
330
397
  * @param {boolean} [paused] - Should the sound start paused
331
- * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode */
398
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */
332
399
  playLoop(pos, volume=1, pitch=1, randomnessScale=1, paused=false)
333
400
  { return this.play(pos, volume, pitch, randomnessScale, true, paused); }
334
401
 
@@ -336,7 +403,7 @@ class Sound
336
403
  * @param {number} [volume] - Volume to play the music at
337
404
  * @param {boolean} [loop] - Should the music loop?
338
405
  * @param {boolean} [paused] - Should the music start paused
339
- * @return {SoundInstance} - The sound instance
406
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
340
407
  */
341
408
  playMusic(volume=1, loop=true, paused=false)
342
409
  { return this.play(undefined, volume, 1, 0, loop, paused); }
@@ -346,7 +413,7 @@ class Sound
346
413
  * @param {number} [semitoneOffset] - How many semitones to offset pitch
347
414
  * @param {Vector2} [pos] - World space position to play the sound if any
348
415
  * @param {number} [volume=1] - How much to scale volume by
349
- * @return {SoundInstance} - The sound instance
416
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
350
417
  */
351
418
  playNote(semitoneOffset=0, pos, volume)
352
419
  {
@@ -382,6 +449,7 @@ class Sound
382
449
  this.sampleRate = audioBuffer.sampleRate;
383
450
  this.sampleLength = audioBuffer.length;
384
451
  this.sampleBuffer = audioBuffer;
452
+ this._sampleChannels = undefined; // samples read from a sound loaded before are that sound's, not this one's
385
453
  this.loadedPercent = 1;
386
454
  this.onloadCallback?.(this);
387
455
  }
@@ -430,33 +498,49 @@ class SoundInstance
430
498
  this.pan = pan;
431
499
  /** @property {boolean} - Should the sound loop */
432
500
  this.loop = loop;
433
- /** @property {number} - Timestamp for audio context when paused */
501
+ /** @property {number|undefined} - Where it is in the sound while not playing, in the sound's own seconds, undefined while playing
502
+ * @type {number|undefined} */
434
503
  this.pausedTime = 0;
435
- /** @property {number} - Timestamp for audio context when started */
504
+ /** @property {number} - Audio context time its place was last taken at, while playing
505
+ * @type {number|undefined} */
436
506
  this.startTime = undefined;
437
- /** @property {GainNode} - Gain node for the sound */
507
+ /** @property {number} - Where it was in the sound at startTime, in the sound's own seconds */
508
+ this.startOffset = 0;
509
+ /** @property {GainNode|undefined} - Gain node for the sound, undefined once it is stopped or paused
510
+ * @type {GainNode|undefined} */
438
511
  this.gainNode = undefined;
439
- /** @property {AudioBufferSourceNode} - Source node of the audio */
512
+ /** @property {StereoPannerNode|undefined} - Stereo panner for the sound, undefined once it is stopped or paused
513
+ * @type {StereoPannerNode|undefined} */
514
+ this.pannerNode = undefined;
515
+ /** @property {AudioBufferSourceNode|undefined} - Source node of the audio, undefined while not playing
516
+ * @type {AudioBufferSourceNode|undefined} */
440
517
  this.source = undefined;
441
- /** @property {AudioNode|AudioEffectNodes} - Node or effect to route this instance through, copied from the sound
442
- * @type {AudioNode|AudioEffectNodes} */
518
+ /** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route this instance through, copied from the sound
519
+ * @type {AudioNode|AudioEffectNodes|undefined} */
443
520
  this.output = sound.output;
444
- // setup end callback and start sound, a sound that ends is stopped, its time back at 0
445
- this.onendedCallback = (source)=>
521
+ /** @property {AudioEndedCallback|undefined} - Called when this instance plays to its end, not when it is stopped
522
+ * or paused; it is read when the sound ends, so it can be set at any time
523
+ * @type {AudioEndedCallback|undefined} */
524
+ this.onendedCallback = undefined;
525
+ /** A playback that ends on its own leaves the instance stopped, its time back at 0; the ended event of one
526
+ * stopped or replaced since is too late to change anything
527
+ * @private */
528
+ this.sourceEnded = (source)=>
446
529
  {
447
- if (source === this.source)
448
- {
449
- this.source = undefined;
450
- this.startTime = undefined;
451
- this.pausedTime = 0;
452
- }
530
+ if (source !== this.source) return;
531
+ this.source = this.gainNode = this.pannerNode = undefined;
532
+ this.startTime = undefined;
533
+ this.pausedTime = 0;
534
+ this.onendedCallback?.(source);
453
535
  };
536
+
537
+ // start sound
454
538
  if (!paused)
455
539
  this.start();
456
540
  }
457
541
 
458
- /** Start playing the sound instance from the offset time
459
- * @param {number} [offset] - Offset in seconds to start playback from
542
+ /** Start playing the sound instance from a place in the sound
543
+ * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
460
544
  */
461
545
  start(offset=0)
462
546
  {
@@ -464,22 +548,34 @@ class SoundInstance
464
548
  if (this.isPlaying())
465
549
  this.stop();
466
550
  this.gainNode = audioContext.createGain();
551
+ this.pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(this.pan, -1, 1)});
467
552
 
468
553
  // build the shared buffer if it was not made at load time, then play it
469
554
  this.sound.buildSampleBuffer();
470
555
  this.source = this.sound.sampleBuffer ?
471
- playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback, this.output) :
472
- playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback, this.output);
556
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode) :
557
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode);
558
+ audioWaitingInstances.delete(this);
473
559
  if (this.source)
474
560
  {
475
- this.startTime = audioContext.currentTime - offset;
561
+ this.startTime = audioContext.currentTime;
562
+ this.startOffset = offset;
476
563
  this.pausedTime = undefined;
477
564
  }
478
565
  else
479
566
  {
480
- // the sound could not start, keep the place so a later resume picks it up
481
- this.startTime = undefined;
567
+ // the sound could not start, keep the place so a later resume picks it up,
568
+ // which happens on its own when it failed only because audio is not running yet
569
+ this.startTime = this.gainNode = this.pannerNode = undefined;
482
570
  this.pausedTime = offset;
571
+ if (!audioIsRunning())
572
+ {
573
+ // only the newest of each sound waits, a loop a game plays again each frame is one loop
574
+ audioWaitingPrune();
575
+ for (const other of audioWaitingInstances.keys())
576
+ other.sound === this.sound && audioWaitingInstances.delete(other);
577
+ audioWaitingInstances.set(this, performance.now());
578
+ }
483
579
  }
484
580
  }
485
581
 
@@ -492,32 +588,33 @@ class SoundInstance
492
588
  ASSERT(volume >= 0, 'Sound volume must be positive or zero');
493
589
  ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
494
590
  this.volume = volume;
495
- if (!this.gainNode) return;
496
-
497
- // drop any fade still scheduled so stacked calls don't fight,
498
- // then ramp from wherever the gain is now or jump straight there
499
- const gain = this.gainNode.gain;
500
- const startFade = audioContext.currentTime;
501
- gain.cancelScheduledValues(startFade);
502
- if (fadeTime)
503
- {
504
- gain.setValueAtTime(gain.value, startFade);
505
- gain.linearRampToValueAtTime(volume, startFade + fadeTime);
506
- }
507
- else
508
- gain.value = volume;
591
+ this.gainNode && audioParamRamp(this.gainNode.gain, volume, fadeTime);
592
+ }
593
+
594
+ /** Set the stereo pan of this sound instance, while it plays too
595
+ * - A looping sound can follow its source across the screen this way
596
+ * @param {number} pan - -1 is left, 0 is center, 1 is right, clamped to that range */
597
+ setPan(pan)
598
+ {
599
+ ASSERT(isNumber(pan), 'Sound pan must be a number');
600
+ this.pan = pan;
601
+ if (this.pannerNode)
602
+ this.pannerNode.pan.value = clamp(pan, -1, 1);
509
603
  }
510
604
 
511
605
  /** Set the playback rate of this sound instance, its speed and pitch, while it plays
512
606
  * - A looping sound can follow something smoothly this way, like an engine with the speed
513
- * - A rate of 0 freezes the sound in place, its current time is not tracked until it moves again
607
+ * - A rate of 0 freezes the sound in place, and it carries on from there when the rate comes back
514
608
  * @param {number} rate - 1 is normal, 2 is twice as fast and an octave up */
515
609
  setRate(rate)
516
610
  {
517
611
  ASSERT(rate >= 0, 'Sound rate must be positive or zero');
518
- // keep the place in the sound, only the speed changes from here, so the current time stays true
519
- if (this.isPlaying() && rate)
520
- this.startTime = audioContext.currentTime - this.getCurrentTime() * this.rate / rate;
612
+ // keep the place in the sound, only the speed changes from here
613
+ if (this.isPlaying())
614
+ {
615
+ this.startOffset = this.getCurrentTime();
616
+ this.startTime = audioContext.currentTime;
617
+ }
521
618
  this.rate = rate;
522
619
  if (this.source)
523
620
  this.source.playbackRate.value = rate;
@@ -528,6 +625,7 @@ class SoundInstance
528
625
  stop(fadeTime=0)
529
626
  {
530
627
  ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
628
+ audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
531
629
  if (this.isPlaying())
532
630
  {
533
631
  if (fadeTime)
@@ -535,15 +633,8 @@ class SoundInstance
535
633
  // ramp off gain from where it is now (not 1, or low-volume
536
634
  // instances would jump back up before fading, and a volume
537
635
  // fade in flight carries on down from its current point);
538
- // cancel any prior scheduling so stacked stop calls don't
539
- // re-anchor partway through a previous fade
540
- const gain = this.gainNode.gain;
541
- const startFade = audioContext.currentTime;
542
- const endFade = startFade + fadeTime;
543
- gain.cancelScheduledValues(startFade);
544
- gain.setValueAtTime(gain.value, startFade);
545
- gain.linearRampToValueAtTime(0, endFade);
546
- this.source.stop(endFade);
636
+ // the ramp and the stop share one end time
637
+ this.source.stop(audioParamRamp(this.gainNode.gain, 0, fadeTime));
547
638
  }
548
639
  else
549
640
  this.source.stop();
@@ -551,11 +642,16 @@ class SoundInstance
551
642
  this.pausedTime = 0;
552
643
  this.source = undefined;
553
644
  this.startTime = undefined;
645
+ // let go of the gain node so a later setVolume can't cancel the fade out, the ended listener disconnects
646
+ // it, and start makes a new one
647
+ this.gainNode = undefined;
648
+ this.pannerNode = undefined;
554
649
  }
555
650
 
556
651
  /** Pause this sound instance */
557
652
  pause()
558
653
  {
654
+ audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
559
655
  if (this.isPaused()) return;
560
656
 
561
657
  // save current time and stop sound
@@ -563,6 +659,8 @@ class SoundInstance
563
659
  this.source.stop();
564
660
  this.source = undefined;
565
661
  this.startTime = undefined;
662
+ this.gainNode = undefined; // resume starts with a new one at the volume set meanwhile
663
+ this.pannerNode = undefined;
566
664
  }
567
665
 
568
666
  /** Resume this sound instance */
@@ -584,24 +682,25 @@ class SoundInstance
584
682
  */
585
683
  isPaused() { return !this.isPlaying(); }
586
684
 
587
- /** Get the current playback time in seconds
588
- * @return {number} - Current playback time
685
+ /** Get where it is in the sound, in the sound's own seconds: at a rate of 2 it moves two seconds for each one
686
+ * that passes, and at 0 it stays put
687
+ * @return {number} - Seconds into the sound
589
688
  */
590
689
  getCurrentTime()
591
690
  {
592
691
  if (!this.isPlaying()) return this.pausedTime;
593
692
  const duration = this.getDuration();
594
- // guard mod against 0 duration (rate=0 or sound not loaded)
595
- return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
693
+ const place = this.startOffset + (audioContext.currentTime - this.startTime) * this.rate;
694
+ return duration ? mod(place, duration) : 0; // a sound still loading has no length yet
596
695
  }
597
696
 
598
- /** Get the total duration of this sound
599
- * @return {number} - Total duration in seconds (0 if loading)
697
+ /** Get the length of the sound in its own seconds, the same at any rate; divide by the rate for how long it takes to play
698
+ * @return {number} - Length in seconds (0 if loading)
600
699
  */
601
- getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
700
+ getDuration() { return this.sound.getDuration(); }
602
701
 
603
702
  /** Get source of this sound instance
604
- * @return {AudioBufferSourceNode}
703
+ * @return {AudioBufferSourceNode|undefined} - The source, or undefined while not playing
605
704
  */
606
705
  getSource() { return this.source; }
607
706
  }
@@ -668,24 +767,27 @@ function getNoteFrequency(semitoneOffset, rootFrequency=220)
668
767
  * @param {boolean} [loop] - True if the sound should loop when it reaches the end
669
768
  * @param {number} [sampleRate=44100] - Sample rate for the sound
670
769
  * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
671
- * @param {number} [offset] - Offset in seconds to start playback from
770
+ * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
672
771
  * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
673
772
  * @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
674
- * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
773
+ * @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
774
+ * @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
675
775
  * @memberof Audio */
676
- function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output)
776
+ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output, pannerNode)
677
777
  {
678
778
  if (!soundEnable || headlessMode) return;
679
779
 
680
780
  if (!audioIsRunning())
681
781
  {
682
- // fix stalled audio, don't build a buffer that can't be played
683
- audioContext.resume();
782
+ // fix stalled audio, don't build a buffer that can't be played;
783
+ // but a context suspended because the page is hidden stays suspended until it shows
784
+ if (!audioSuspendedWhenHidden)
785
+ audioContext.resume();
684
786
  return;
685
787
  }
686
788
 
687
789
  const buffer = createAudioBuffer(sampleChannels, sampleRate);
688
- return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output);
790
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output, pannerNode);
689
791
  }
690
792
 
691
793
  /** Copy arrays of samples into a new audio buffer
@@ -710,19 +812,22 @@ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
710
812
  * @param {number} [pan] - How much to apply stereo panning
711
813
  * @param {boolean} [loop] - True if the sound should loop when it reaches the end
712
814
  * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
713
- * @param {number} [offset] - Offset in seconds to start playback from
815
+ * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
714
816
  * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
715
817
  * @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
716
- * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
818
+ * @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
819
+ * @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
717
820
  * @memberof Audio */
718
- function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output)
821
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output, pannerNode)
719
822
  {
720
823
  if (!soundEnable || headlessMode) return;
721
824
 
722
825
  if (!audioIsRunning())
723
826
  {
724
- // fix stalled audio, this sound won't be able to play
725
- audioContext.resume();
827
+ // fix stalled audio, this sound won't be able to play;
828
+ // but a context suspended because the page is hidden stays suspended until it shows
829
+ if (!audioSuspendedWhenHidden)
830
+ audioContext.resume();
726
831
  return;
727
832
  }
728
833
 
@@ -740,21 +845,20 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
740
845
  gainNode.connect(outputNode);
741
846
 
742
847
  // connect source to stereo panner and gain
743
- const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
744
- source.connect(pannerNode).connect(gainNode);
848
+ const panner = pannerNode || new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
849
+ source.connect(panner).connect(gainNode);
745
850
 
746
851
  // disconnect nodes when the sound ends so the audio graph doesn't grow
747
852
  // unbounded across many play() calls (source.stop() also fires 'ended')
748
853
  source.addEventListener('ended', ()=>
749
854
  {
750
855
  gainNode.disconnect();
751
- pannerNode.disconnect();
856
+ panner.disconnect();
752
857
  if (onended) onended(source);
753
858
  });
754
859
 
755
- // play and return sound
756
- const startOffset = offset * rate;
757
- source.start(0, startOffset);
860
+ // play and return sound, the offset is a place in the buffer whatever the rate
861
+ source.start(0, offset);
758
862
 
759
863
  if (debug && debugSound)
760
864
  LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
@@ -769,7 +873,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
769
873
  *
770
874
  * <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
771
875
  * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5]
772
- * @return {AudioBufferSourceNode} - The audio node of the sound played
876
+ * @return {AudioBufferSourceNode|undefined} - The audio node of the sound played, undefined if play fails
773
877
  * @memberof Audio */
774
878
  function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
775
879
 
@@ -840,7 +944,7 @@ function zzfxG
840
944
  f, // wave frequency
841
945
 
842
946
  // biquad LP/HP filter
843
- quality = 2, w = PI2 * abs(filter) * 2 / sampleRate,
947
+ quality = 2, w = PI2 * min(abs(filter), sampleRate/4 - 1) * 2 / sampleRate, // stable below a quarter rate
844
948
  cosw = cos(w), alpha = sin(w) / 2 / quality,
845
949
  a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
846
950
  b0 = (1 + sign(filter) * cosw) / 2 / a0,
@@ -890,7 +994,7 @@ function zzfxG
890
994
 
891
995
  s = delay ? s/2 + (delay > i ? 0 : // delay
892
996
  (i<length-delay? 1 : (length-i)/delay) * // release delay
893
- b[i-delay|0]/2/volume) : s; // sample delay
997
+ b[i-delay|0]/2/(volume||1)) : s; // sample delay, stored samples are 0 at volume 0
894
998
 
895
999
  if (filter) // apply filter
896
1000
  s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);