littlejsengine 1.18.26 → 1.18.28

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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.26';
38
+ const engineVersion = '1.18.28';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -2346,6 +2346,14 @@ let gamepadsEnable = true;
2346
2346
  * @memberof Settings */
2347
2347
  let gamepadDirectionEmulateStick = true;
2348
2348
 
2349
+ /** If true, axes that do not rest near center are ignored on gamepads without
2350
+ * standard mapping. Steering wheels and flight sticks report pedal and throttle
2351
+ * axes that rest at full deflection, which otherwise reads as a stick held down.
2352
+ * @type {boolean}
2353
+ * @default
2354
+ * @memberof Settings */
2355
+ let gamepadAxisFilterEnable = true;
2356
+
2349
2357
  /** If true the WASD keys are also routed to the direction keys (for better accessibility)
2350
2358
  * @type {boolean}
2351
2359
  * @default
@@ -2684,6 +2692,11 @@ function setGamepadsEnable(enable) { gamepadsEnable = enable; }
2684
2692
  * @memberof Settings */
2685
2693
  function setGamepadDirectionEmulateStick(enable) { gamepadDirectionEmulateStick = enable; }
2686
2694
 
2695
+ /** Set if axes that do not rest near center are ignored on non-standard gamepads
2696
+ * @param {boolean} enable
2697
+ * @memberof Settings */
2698
+ function setGamepadAxisFilterEnable(enable) { gamepadAxisFilterEnable = enable; }
2699
+
2687
2700
  /** Set if true the WASD keys are also routed to the direction keys
2688
2701
  * @param {boolean} enable
2689
2702
  * @memberof Settings */
@@ -3428,6 +3441,12 @@ let workReadCanvas;
3428
3441
  * @memberof Draw */
3429
3442
  let workReadContext;
3430
3443
 
3444
+ /** Extra canvas to composite behind the engine canvases when combining canvases
3445
+ * Set by plugins that render to their own canvas below the LittleJS canvases
3446
+ * @type {HTMLCanvasElement}
3447
+ * @memberof Draw */
3448
+ let backgroundCanvas;
3449
+
3431
3450
  /** The size of the main canvas (and other secondary canvases)
3432
3451
  * @type {Vector2}
3433
3452
  * @memberof Draw */
@@ -4579,6 +4598,13 @@ function setAdditiveBlendMode(additive=true)
4579
4598
  drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
4580
4599
  }
4581
4600
 
4601
+ /** Set an extra canvas to composite behind the engine canvases when combining
4602
+ * Plugins that insert their own canvas below the LittleJS canvases should set
4603
+ * this so it appears in screenshots and video capture
4604
+ * @param {HTMLCanvasElement} [canvas]
4605
+ * @memberof Draw */
4606
+ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
4607
+
4582
4608
  /** Combines LittleJS canvases onto the main canvas
4583
4609
  * This is necessary for things like screenshots and video
4584
4610
  * @memberof Draw */
@@ -4591,6 +4617,8 @@ function combineCanvases()
4591
4617
  // leaving workContext.fillStyle transparent can't silently no-op this
4592
4618
  workContext.fillStyle = '#000';
4593
4619
  workContext.fillRect(0,0,w,h);
4620
+ if (backgroundCanvas)
4621
+ workContext.drawImage(backgroundCanvas, 0, 0, w, h);
4594
4622
  glCopyToContext(workContext);
4595
4623
  workContext.drawImage(mainCanvas, 0, 0);
4596
4624
  mainContext.drawImage(workCanvas, 0, 0);
@@ -4989,6 +5017,7 @@ function inputClear()
4989
5017
  touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
4990
5018
  gamepadStickData.length = 0;
4991
5019
  gamepadDpadData.length = 0;
5020
+ gamepadAxisCentered.length = 0;
4992
5021
  }
4993
5022
 
4994
5023
  ///////////////////////////////////////////////////////////////////////////////
@@ -5226,6 +5255,11 @@ const inputData = [[]];
5226
5255
 
5227
5256
  // gamepad internal variables
5228
5257
  const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5258
+ // per gamepad, how many consecutive frames each axis has rested inside the
5259
+ // dead zone, used to tell stick axes from axes that rest at full deflection
5260
+ const gamepadAxisCentered = [];
5261
+ // how long an axis must rest inside the dead zone before it counts as a stick
5262
+ const gamepadAxisCenteredFrames = 15;
5229
5263
 
5230
5264
  // touch gamepad internal variables
5231
5265
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
@@ -5474,7 +5508,7 @@ function inputUpdate()
5474
5508
  // gamepad: any button held or stick moved
5475
5509
  let gamepadActive = false;
5476
5510
  for (let s = gamepadStickCount(); s-- && !gamepadActive;)
5477
- gamepadActive = gamepadStick(s).lengthSquared() > .04;
5511
+ gamepadActive = gamepadStick(s).lengthSquared() > .2;
5478
5512
  for (let b = 17; b-- && !gamepadActive;)
5479
5513
  gamepadActive = gamepadIsDown(b);
5480
5514
 
@@ -5502,12 +5536,12 @@ function inputUpdate()
5502
5536
  // gamepads are updated by engine every frame automatically
5503
5537
  function gamepadsUpdate()
5504
5538
  {
5539
+ const deadZoneMin=.3, deadZoneMax=.8;
5505
5540
  const applyDeadZones = (v)=>
5506
5541
  {
5507
- const min=.3, max=.8;
5508
5542
  const deadZone = (v)=>
5509
- v > min ? percent(v, min, max) :
5510
- v < -min ? -percent(-v, min, max) : 0;
5543
+ v > deadZoneMin ? percent(v, deadZoneMin, deadZoneMax) :
5544
+ v < -deadZoneMin ? -percent(-v, deadZoneMin, deadZoneMax) : 0;
5511
5545
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5512
5546
  };
5513
5547
 
@@ -5591,6 +5625,7 @@ function inputUpdate()
5591
5625
  gamepadStickData[i] = undefined;
5592
5626
  gamepadDpadData[i] = undefined;
5593
5627
  gamepadHadInput[i] = undefined;
5628
+ gamepadAxisCentered[i] = undefined;
5594
5629
  continue;
5595
5630
  }
5596
5631
 
@@ -5599,8 +5634,30 @@ function inputUpdate()
5599
5634
  const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
5600
5635
 
5601
5636
  // read analog sticks
5637
+ // gamepads without standard mapping (steering wheels, flight sticks)
5638
+ // can report axes that rest at full deflection instead of center,
5639
+ // which would otherwise read as a stick held down forever, so only
5640
+ // trust an axis once it has rested inside the dead zone for a moment
5641
+ const isStandard = gamepad.mapping === 'standard';
5642
+ const centered = gamepadAxisCentered[i] ?? (gamepadAxisCentered[i] = []);
5643
+ const readAxis = (j)=>
5644
+ {
5645
+ const v = gamepad.axes[j];
5646
+ if (isStandard && j < 4)
5647
+ return v; // spec guarantees axes 0-3 are the two sticks
5648
+ if (!gamepadAxisFilterEnable)
5649
+ return v;
5650
+
5651
+ // once an axis has proven it rests at center it stays trusted,
5652
+ // otherwise moving it would immediately disqualify it again
5653
+ const frames = centered[j] | 0;
5654
+ if (frames > gamepadAxisCenteredFrames)
5655
+ return v;
5656
+ centered[j] = abs(v) < deadZoneMin ? frames + 1 : 0;
5657
+ return 0;
5658
+ };
5602
5659
  for (let j = 0; j < gamepad.axes.length-1; j+=2)
5603
- sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
5660
+ sticks[j>>1] = applyDeadZones(vec2(readAxis(j), readAxis(j+1)));
5604
5661
 
5605
5662
  // read buttons
5606
5663
  let hadInput = false;
@@ -6226,7 +6283,15 @@ class Sound
6226
6283
  this.randomness = randomness ?? 0;
6227
6284
  /** @property {number} - Sample rate for this sound */
6228
6285
  this.sampleRate = audioDefaultSampleRate;
6229
- /** @property {number} - Percentage of this sound currently loaded */
6286
+ /** @property {number} - How many samples per channel this sound has */
6287
+ this.sampleLength = 0;
6288
+ /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
6289
+ * @type {AudioBuffer} */
6290
+ this.sampleBuffer = undefined;
6291
+ /** @private @type {Array<Array<number>|Float32Array>} */
6292
+ this._sampleChannels = undefined;
6293
+ /** @property {number} - Percentage of this sound currently loaded, sounds
6294
+ * fetched from a url stay at 0 until decoding completes */
6230
6295
  this.loadedPercent = 0;
6231
6296
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
6232
6297
  this.onloadCallback = onloadCallback;
@@ -6242,8 +6307,10 @@ class Sound
6242
6307
  this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
6243
6308
  zzfxSound[randomnessIndex] = 0;
6244
6309
 
6245
- // generate the zzfx samples
6310
+ // generate the zzfx samples, then hand them to an audio buffer so
6311
+ // the plain arrays can be released and every play shares the buffer
6246
6312
  this.sampleChannels = [zzfxG(...zzfxSound)];
6313
+ this.buildSampleBuffer();
6247
6314
  this.loadedPercent = 1;
6248
6315
  onloadCallback?.(this);
6249
6316
  }
@@ -6255,6 +6322,45 @@ class Sound
6255
6322
  }
6256
6323
  }
6257
6324
 
6325
+ /** Sample data for each channel
6326
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
6327
+ * the arrays from it and caches them. The copies are safe to hold onto,
6328
+ * playing a sound detaches the buffer's own channel arrays.
6329
+ * @type {Array<Array<number>|Float32Array>} */
6330
+ get sampleChannels()
6331
+ {
6332
+ const buffer = this.sampleBuffer;
6333
+ if (!this._sampleChannels && buffer)
6334
+ {
6335
+ const channels = [];
6336
+ for (let i = 0; i < buffer.numberOfChannels; i++)
6337
+ channels.push(buffer.getChannelData(i).slice());
6338
+ this._sampleChannels = channels;
6339
+ }
6340
+ return this._sampleChannels;
6341
+ }
6342
+
6343
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
6344
+ set sampleChannels(sampleChannels)
6345
+ {
6346
+ // new samples invalidate the buffer built from the old ones
6347
+ this._sampleChannels = sampleChannels;
6348
+ this.sampleBuffer = undefined;
6349
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
6350
+ }
6351
+
6352
+ /** Move this sound's samples into an audio buffer that every play can share
6353
+ * Does nothing if there is already a buffer or no samples to build one from */
6354
+ buildSampleBuffer()
6355
+ {
6356
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
6357
+
6358
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
6359
+
6360
+ // the buffer owns the samples now, release the arrays we built it from
6361
+ this._sampleChannels = undefined;
6362
+ }
6363
+
6258
6364
  /** Play the sound
6259
6365
  * Sounds may not play until a user interaction occurs
6260
6366
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -6273,7 +6379,7 @@ class Sound
6273
6379
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
6274
6380
 
6275
6381
  if (!soundEnable || headlessMode) return;
6276
- if (!this.sampleChannels) return;
6382
+ if (!this.sampleBuffer && !this._sampleChannels) return;
6277
6383
 
6278
6384
  let pan;
6279
6385
  if (pos)
@@ -6340,7 +6446,7 @@ class Sound
6340
6446
  * @return {number} - How long the sound is in seconds (0 if loading)
6341
6447
  */
6342
6448
  getDuration()
6343
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
6449
+ { return this.sampleLength / this.sampleRate || 0; }
6344
6450
 
6345
6451
  /** Check if sound is loaded, for sounds fetched from a url
6346
6452
  * @return {boolean} - True if sound is loaded and ready to play
@@ -6358,36 +6464,11 @@ class Sound
6358
6464
  const arrayBuffer = await response.arrayBuffer();
6359
6465
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
6360
6466
 
6361
- // convert audio buffer to sample channels across multiple frames
6362
- const channelCount = audioBuffer.numberOfChannels;
6363
- const samplesPerFrame = 1e5;
6364
- const sampleChannels = [];
6365
- for (let channel = 0; channel < channelCount; channel++)
6366
- {
6367
- const channelData = audioBuffer.getChannelData(channel);
6368
- const channelLength = channelData.length;
6369
- sampleChannels[channel] = new Array(channelLength);
6370
- let sampleIndex = 0;
6371
- while (sampleIndex < channelLength)
6372
- {
6373
- // yield to next frame
6374
- await new Promise(resolve => setTimeout(resolve, 0));
6375
-
6376
- // copy chunk of samples
6377
- const endIndex = min(sampleIndex + samplesPerFrame, channelLength);
6378
- for (; sampleIndex < endIndex; sampleIndex++)
6379
- sampleChannels[channel][sampleIndex] = channelData[sampleIndex];
6380
-
6381
- // update loaded percent
6382
- const samplesTotal = channelCount * channelLength;
6383
- const samplesProcessed = channel * channelLength + sampleIndex;
6384
- this.loadedPercent = samplesProcessed / samplesTotal;
6385
- }
6386
- }
6387
-
6388
- // setup the sound to be played
6467
+ // keep the decoded buffer as is, it is exactly what playback needs and
6468
+ // every play shares it, no channel data is read or copied
6389
6469
  this.sampleRate = audioBuffer.sampleRate;
6390
- this.sampleChannels = sampleChannels;
6470
+ this.sampleLength = audioBuffer.length;
6471
+ this.sampleBuffer = audioBuffer;
6391
6472
  this.loadedPercent = 1;
6392
6473
  this.onloadCallback?.(this);
6393
6474
  }
@@ -6463,7 +6544,12 @@ class SoundInstance
6463
6544
  if (this.isPlaying())
6464
6545
  this.stop();
6465
6546
  this.gainNode = audioContext.createGain();
6466
- this.source = playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
6547
+
6548
+ // build the shared buffer if it was not made at load time, then play it
6549
+ this.sound.buildSampleBuffer();
6550
+ this.source = this.sound.sampleBuffer ?
6551
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
6552
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
6467
6553
  if (this.source)
6468
6554
  {
6469
6555
  this.startTime = audioContext.currentTime - offset;
@@ -6638,19 +6724,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
6638
6724
 
6639
6725
  if (!audioIsRunning())
6640
6726
  {
6641
- // fix stalled audio, this sound won't be able to play
6727
+ // fix stalled audio, don't build a buffer that can't be played
6642
6728
  audioContext.resume();
6643
6729
  return;
6644
6730
  }
6645
6731
 
6646
- // create buffer and source
6732
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
6733
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
6734
+ }
6735
+
6736
+ /** Copy arrays of samples into a new audio buffer
6737
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
6738
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
6739
+ * @return {AudioBuffer} - The audio buffer holding the samples
6740
+ * @memberof Audio */
6741
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
6742
+ {
6647
6743
  const channelCount = sampleChannels.length;
6648
6744
  const sampleLength = sampleChannels[0].length;
6649
6745
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
6650
- const source = audioContext.createBufferSource();
6651
-
6652
- // copy samples to buffer and setup source
6653
6746
  sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
6747
+ return buffer;
6748
+ }
6749
+
6750
+ /** Play an audio buffer with given settings
6751
+ * The buffer can be shared by any number of sounds playing at once
6752
+ * @param {AudioBuffer} buffer - The audio buffer to play
6753
+ * @param {number} [volume] - How much to scale volume by
6754
+ * @param {number} [rate] - The playback rate to use
6755
+ * @param {number} [pan] - How much to apply stereo panning
6756
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
6757
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
6758
+ * @param {number} [offset] - Offset in seconds to start playback from
6759
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
6760
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
6761
+ * @memberof Audio */
6762
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
6763
+ {
6764
+ if (!soundEnable || headlessMode) return;
6765
+
6766
+ if (!audioIsRunning())
6767
+ {
6768
+ // fix stalled audio, this sound won't be able to play
6769
+ audioContext.resume();
6770
+ return;
6771
+ }
6772
+
6773
+ // setup source, many sources can share one buffer
6774
+ const source = audioContext.createBufferSource();
6654
6775
  source.buffer = buffer;
6655
6776
  source.playbackRate.value = rate;
6656
6777
  source.loop = loop;
@@ -16117,6 +16238,9 @@ class ThreeJSPlugin
16117
16238
  rootElement.insertBefore(threeCanvas, rootElement.firstChild);
16118
16239
  threeCanvas.style.cssText = mainCanvas.style.cssText;
16119
16240
 
16241
+ // composite the 3D canvas into screenshots and video capture
16242
+ setBackgroundCanvas(threeCanvas);
16243
+
16120
16244
  // render automatically each frame after the engine renders
16121
16245
  engineAddPlugin(undefined, ()=> this.render());
16122
16246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.18.26",
3
+ "version": "1.18.28",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -39,6 +39,7 @@
39
39
  "scripts": {
40
40
  "build": "node src/engineBuild.mjs",
41
41
  "build-docs": "node tools/buildDocs.mjs",
42
+ "build-release": "node tools/buildRelease.mjs",
42
43
  "test": "node --test --import ./test/setup.mjs \"test/**/*.test.mjs\""
43
44
  },
44
45
  "engines": {
@@ -57,6 +57,9 @@ class ThreeJSPlugin
57
57
  rootElement.insertBefore(threeCanvas, rootElement.firstChild);
58
58
  threeCanvas.style.cssText = mainCanvas.style.cssText;
59
59
 
60
+ // composite the 3D canvas into screenshots and video capture
61
+ setBackgroundCanvas(threeCanvas);
62
+
60
63
  // render automatically each frame after the engine renders
61
64
  engineAddPlugin(undefined, ()=> this.render());
62
65
  }
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.18.26';
35
+ const engineVersion = '1.18.28';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -98,7 +98,15 @@ class Sound
98
98
  this.randomness = randomness ?? 0;
99
99
  /** @property {number} - Sample rate for this sound */
100
100
  this.sampleRate = audioDefaultSampleRate;
101
- /** @property {number} - Percentage of this sound currently loaded */
101
+ /** @property {number} - How many samples per channel this sound has */
102
+ this.sampleLength = 0;
103
+ /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
104
+ * @type {AudioBuffer} */
105
+ this.sampleBuffer = undefined;
106
+ /** @private @type {Array<Array<number>|Float32Array>} */
107
+ this._sampleChannels = undefined;
108
+ /** @property {number} - Percentage of this sound currently loaded, sounds
109
+ * fetched from a url stay at 0 until decoding completes */
102
110
  this.loadedPercent = 0;
103
111
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
104
112
  this.onloadCallback = onloadCallback;
@@ -114,8 +122,10 @@ class Sound
114
122
  this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
115
123
  zzfxSound[randomnessIndex] = 0;
116
124
 
117
- // generate the zzfx samples
125
+ // generate the zzfx samples, then hand them to an audio buffer so
126
+ // the plain arrays can be released and every play shares the buffer
118
127
  this.sampleChannels = [zzfxG(...zzfxSound)];
128
+ this.buildSampleBuffer();
119
129
  this.loadedPercent = 1;
120
130
  onloadCallback?.(this);
121
131
  }
@@ -127,6 +137,45 @@ class Sound
127
137
  }
128
138
  }
129
139
 
140
+ /** Sample data for each channel
141
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
142
+ * the arrays from it and caches them. The copies are safe to hold onto,
143
+ * playing a sound detaches the buffer's own channel arrays.
144
+ * @type {Array<Array<number>|Float32Array>} */
145
+ get sampleChannels()
146
+ {
147
+ const buffer = this.sampleBuffer;
148
+ if (!this._sampleChannels && buffer)
149
+ {
150
+ const channels = [];
151
+ for (let i = 0; i < buffer.numberOfChannels; i++)
152
+ channels.push(buffer.getChannelData(i).slice());
153
+ this._sampleChannels = channels;
154
+ }
155
+ return this._sampleChannels;
156
+ }
157
+
158
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
159
+ set sampleChannels(sampleChannels)
160
+ {
161
+ // new samples invalidate the buffer built from the old ones
162
+ this._sampleChannels = sampleChannels;
163
+ this.sampleBuffer = undefined;
164
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
165
+ }
166
+
167
+ /** Move this sound's samples into an audio buffer that every play can share
168
+ * Does nothing if there is already a buffer or no samples to build one from */
169
+ buildSampleBuffer()
170
+ {
171
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
172
+
173
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
174
+
175
+ // the buffer owns the samples now, release the arrays we built it from
176
+ this._sampleChannels = undefined;
177
+ }
178
+
130
179
  /** Play the sound
131
180
  * Sounds may not play until a user interaction occurs
132
181
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -145,7 +194,7 @@ class Sound
145
194
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
146
195
 
147
196
  if (!soundEnable || headlessMode) return;
148
- if (!this.sampleChannels) return;
197
+ if (!this.sampleBuffer && !this._sampleChannels) return;
149
198
 
150
199
  let pan;
151
200
  if (pos)
@@ -212,7 +261,7 @@ class Sound
212
261
  * @return {number} - How long the sound is in seconds (0 if loading)
213
262
  */
214
263
  getDuration()
215
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
264
+ { return this.sampleLength / this.sampleRate || 0; }
216
265
 
217
266
  /** Check if sound is loaded, for sounds fetched from a url
218
267
  * @return {boolean} - True if sound is loaded and ready to play
@@ -230,36 +279,11 @@ class Sound
230
279
  const arrayBuffer = await response.arrayBuffer();
231
280
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
232
281
 
233
- // convert audio buffer to sample channels across multiple frames
234
- const channelCount = audioBuffer.numberOfChannels;
235
- const samplesPerFrame = 1e5;
236
- const sampleChannels = [];
237
- for (let channel = 0; channel < channelCount; channel++)
238
- {
239
- const channelData = audioBuffer.getChannelData(channel);
240
- const channelLength = channelData.length;
241
- sampleChannels[channel] = new Array(channelLength);
242
- let sampleIndex = 0;
243
- while (sampleIndex < channelLength)
244
- {
245
- // yield to next frame
246
- await new Promise(resolve => setTimeout(resolve, 0));
247
-
248
- // copy chunk of samples
249
- const endIndex = min(sampleIndex + samplesPerFrame, channelLength);
250
- for (; sampleIndex < endIndex; sampleIndex++)
251
- sampleChannels[channel][sampleIndex] = channelData[sampleIndex];
252
-
253
- // update loaded percent
254
- const samplesTotal = channelCount * channelLength;
255
- const samplesProcessed = channel * channelLength + sampleIndex;
256
- this.loadedPercent = samplesProcessed / samplesTotal;
257
- }
258
- }
259
-
260
- // setup the sound to be played
282
+ // keep the decoded buffer as is, it is exactly what playback needs and
283
+ // every play shares it, no channel data is read or copied
261
284
  this.sampleRate = audioBuffer.sampleRate;
262
- this.sampleChannels = sampleChannels;
285
+ this.sampleLength = audioBuffer.length;
286
+ this.sampleBuffer = audioBuffer;
263
287
  this.loadedPercent = 1;
264
288
  this.onloadCallback?.(this);
265
289
  }
@@ -335,7 +359,12 @@ class SoundInstance
335
359
  if (this.isPlaying())
336
360
  this.stop();
337
361
  this.gainNode = audioContext.createGain();
338
- this.source = playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
362
+
363
+ // build the shared buffer if it was not made at load time, then play it
364
+ this.sound.buildSampleBuffer();
365
+ this.source = this.sound.sampleBuffer ?
366
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
367
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
339
368
  if (this.source)
340
369
  {
341
370
  this.startTime = audioContext.currentTime - offset;
@@ -510,19 +539,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
510
539
 
511
540
  if (!audioIsRunning())
512
541
  {
513
- // fix stalled audio, this sound won't be able to play
542
+ // fix stalled audio, don't build a buffer that can't be played
514
543
  audioContext.resume();
515
544
  return;
516
545
  }
517
546
 
518
- // create buffer and source
547
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
548
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
549
+ }
550
+
551
+ /** Copy arrays of samples into a new audio buffer
552
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
553
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
554
+ * @return {AudioBuffer} - The audio buffer holding the samples
555
+ * @memberof Audio */
556
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
557
+ {
519
558
  const channelCount = sampleChannels.length;
520
559
  const sampleLength = sampleChannels[0].length;
521
560
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
522
- const source = audioContext.createBufferSource();
523
-
524
- // copy samples to buffer and setup source
525
561
  sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
562
+ return buffer;
563
+ }
564
+
565
+ /** Play an audio buffer with given settings
566
+ * The buffer can be shared by any number of sounds playing at once
567
+ * @param {AudioBuffer} buffer - The audio buffer to play
568
+ * @param {number} [volume] - How much to scale volume by
569
+ * @param {number} [rate] - The playback rate to use
570
+ * @param {number} [pan] - How much to apply stereo panning
571
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
572
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
573
+ * @param {number} [offset] - Offset in seconds to start playback from
574
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
575
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
576
+ * @memberof Audio */
577
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
578
+ {
579
+ if (!soundEnable || headlessMode) return;
580
+
581
+ if (!audioIsRunning())
582
+ {
583
+ // fix stalled audio, this sound won't be able to play
584
+ audioContext.resume();
585
+ return;
586
+ }
587
+
588
+ // setup source, many sources can share one buffer
589
+ const source = audioContext.createBufferSource();
526
590
  source.buffer = buffer;
527
591
  source.playbackRate.value = rate;
528
592
  source.loop = loop;
package/src/engineDraw.js CHANGED
@@ -57,6 +57,12 @@ let workReadCanvas;
57
57
  * @memberof Draw */
58
58
  let workReadContext;
59
59
 
60
+ /** Extra canvas to composite behind the engine canvases when combining canvases
61
+ * Set by plugins that render to their own canvas below the LittleJS canvases
62
+ * @type {HTMLCanvasElement}
63
+ * @memberof Draw */
64
+ let backgroundCanvas;
65
+
60
66
  /** The size of the main canvas (and other secondary canvases)
61
67
  * @type {Vector2}
62
68
  * @memberof Draw */
@@ -1208,6 +1214,13 @@ function setAdditiveBlendMode(additive=true)
1208
1214
  drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
1209
1215
  }
1210
1216
 
1217
+ /** Set an extra canvas to composite behind the engine canvases when combining
1218
+ * Plugins that insert their own canvas below the LittleJS canvases should set
1219
+ * this so it appears in screenshots and video capture
1220
+ * @param {HTMLCanvasElement} [canvas]
1221
+ * @memberof Draw */
1222
+ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
1223
+
1211
1224
  /** Combines LittleJS canvases onto the main canvas
1212
1225
  * This is necessary for things like screenshots and video
1213
1226
  * @memberof Draw */
@@ -1220,6 +1233,8 @@ function combineCanvases()
1220
1233
  // leaving workContext.fillStyle transparent can't silently no-op this
1221
1234
  workContext.fillStyle = '#000';
1222
1235
  workContext.fillRect(0,0,w,h);
1236
+ if (backgroundCanvas)
1237
+ workContext.drawImage(backgroundCanvas, 0, 0, w, h);
1223
1238
  glCopyToContext(workContext);
1224
1239
  workContext.drawImage(mainCanvas, 0, 0);
1225
1240
  mainContext.drawImage(workCanvas, 0, 0);