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.
package/dist/littlejs.js CHANGED
@@ -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}
@@ -3037,6 +3037,14 @@ let gamepadsEnable = true;
3037
3037
  * @memberof Settings */
3038
3038
  let gamepadDirectionEmulateStick = true;
3039
3039
 
3040
+ /** If true, axes that do not rest near center are ignored on gamepads without
3041
+ * standard mapping. Steering wheels and flight sticks report pedal and throttle
3042
+ * axes that rest at full deflection, which otherwise reads as a stick held down.
3043
+ * @type {boolean}
3044
+ * @default
3045
+ * @memberof Settings */
3046
+ let gamepadAxisFilterEnable = true;
3047
+
3040
3048
  /** If true the WASD keys are also routed to the direction keys (for better accessibility)
3041
3049
  * @type {boolean}
3042
3050
  * @default
@@ -3375,6 +3383,11 @@ function setGamepadsEnable(enable) { gamepadsEnable = enable; }
3375
3383
  * @memberof Settings */
3376
3384
  function setGamepadDirectionEmulateStick(enable) { gamepadDirectionEmulateStick = enable; }
3377
3385
 
3386
+ /** Set if axes that do not rest near center are ignored on non-standard gamepads
3387
+ * @param {boolean} enable
3388
+ * @memberof Settings */
3389
+ function setGamepadAxisFilterEnable(enable) { gamepadAxisFilterEnable = enable; }
3390
+
3378
3391
  /** Set if true the WASD keys are also routed to the direction keys
3379
3392
  * @param {boolean} enable
3380
3393
  * @memberof Settings */
@@ -4119,6 +4132,12 @@ let workReadCanvas;
4119
4132
  * @memberof Draw */
4120
4133
  let workReadContext;
4121
4134
 
4135
+ /** Extra canvas to composite behind the engine canvases when combining canvases
4136
+ * Set by plugins that render to their own canvas below the LittleJS canvases
4137
+ * @type {HTMLCanvasElement}
4138
+ * @memberof Draw */
4139
+ let backgroundCanvas;
4140
+
4122
4141
  /** The size of the main canvas (and other secondary canvases)
4123
4142
  * @type {Vector2}
4124
4143
  * @memberof Draw */
@@ -5270,6 +5289,13 @@ function setAdditiveBlendMode(additive=true)
5270
5289
  drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5271
5290
  }
5272
5291
 
5292
+ /** Set an extra canvas to composite behind the engine canvases when combining
5293
+ * Plugins that insert their own canvas below the LittleJS canvases should set
5294
+ * this so it appears in screenshots and video capture
5295
+ * @param {HTMLCanvasElement} [canvas]
5296
+ * @memberof Draw */
5297
+ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
5298
+
5273
5299
  /** Combines LittleJS canvases onto the main canvas
5274
5300
  * This is necessary for things like screenshots and video
5275
5301
  * @memberof Draw */
@@ -5282,6 +5308,8 @@ function combineCanvases()
5282
5308
  // leaving workContext.fillStyle transparent can't silently no-op this
5283
5309
  workContext.fillStyle = '#000';
5284
5310
  workContext.fillRect(0,0,w,h);
5311
+ if (backgroundCanvas)
5312
+ workContext.drawImage(backgroundCanvas, 0, 0, w, h);
5285
5313
  glCopyToContext(workContext);
5286
5314
  workContext.drawImage(mainCanvas, 0, 0);
5287
5315
  mainContext.drawImage(workCanvas, 0, 0);
@@ -5680,6 +5708,7 @@ function inputClear()
5680
5708
  touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
5681
5709
  gamepadStickData.length = 0;
5682
5710
  gamepadDpadData.length = 0;
5711
+ gamepadAxisCentered.length = 0;
5683
5712
  }
5684
5713
 
5685
5714
  ///////////////////////////////////////////////////////////////////////////////
@@ -5917,6 +5946,11 @@ const inputData = [[]];
5917
5946
 
5918
5947
  // gamepad internal variables
5919
5948
  const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5949
+ // per gamepad, how many consecutive frames each axis has rested inside the
5950
+ // dead zone, used to tell stick axes from axes that rest at full deflection
5951
+ const gamepadAxisCentered = [];
5952
+ // how long an axis must rest inside the dead zone before it counts as a stick
5953
+ const gamepadAxisCenteredFrames = 15;
5920
5954
 
5921
5955
  // touch gamepad internal variables
5922
5956
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
@@ -6165,7 +6199,7 @@ function inputUpdate()
6165
6199
  // gamepad: any button held or stick moved
6166
6200
  let gamepadActive = false;
6167
6201
  for (let s = gamepadStickCount(); s-- && !gamepadActive;)
6168
- gamepadActive = gamepadStick(s).lengthSquared() > .04;
6202
+ gamepadActive = gamepadStick(s).lengthSquared() > .2;
6169
6203
  for (let b = 17; b-- && !gamepadActive;)
6170
6204
  gamepadActive = gamepadIsDown(b);
6171
6205
 
@@ -6193,12 +6227,12 @@ function inputUpdate()
6193
6227
  // gamepads are updated by engine every frame automatically
6194
6228
  function gamepadsUpdate()
6195
6229
  {
6230
+ const deadZoneMin=.3, deadZoneMax=.8;
6196
6231
  const applyDeadZones = (v)=>
6197
6232
  {
6198
- const min=.3, max=.8;
6199
6233
  const deadZone = (v)=>
6200
- v > min ? percent(v, min, max) :
6201
- v < -min ? -percent(-v, min, max) : 0;
6234
+ v > deadZoneMin ? percent(v, deadZoneMin, deadZoneMax) :
6235
+ v < -deadZoneMin ? -percent(-v, deadZoneMin, deadZoneMax) : 0;
6202
6236
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
6203
6237
  };
6204
6238
 
@@ -6282,6 +6316,7 @@ function inputUpdate()
6282
6316
  gamepadStickData[i] = undefined;
6283
6317
  gamepadDpadData[i] = undefined;
6284
6318
  gamepadHadInput[i] = undefined;
6319
+ gamepadAxisCentered[i] = undefined;
6285
6320
  continue;
6286
6321
  }
6287
6322
 
@@ -6290,8 +6325,30 @@ function inputUpdate()
6290
6325
  const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
6291
6326
 
6292
6327
  // read analog sticks
6328
+ // gamepads without standard mapping (steering wheels, flight sticks)
6329
+ // can report axes that rest at full deflection instead of center,
6330
+ // which would otherwise read as a stick held down forever, so only
6331
+ // trust an axis once it has rested inside the dead zone for a moment
6332
+ const isStandard = gamepad.mapping === 'standard';
6333
+ const centered = gamepadAxisCentered[i] ?? (gamepadAxisCentered[i] = []);
6334
+ const readAxis = (j)=>
6335
+ {
6336
+ const v = gamepad.axes[j];
6337
+ if (isStandard && j < 4)
6338
+ return v; // spec guarantees axes 0-3 are the two sticks
6339
+ if (!gamepadAxisFilterEnable)
6340
+ return v;
6341
+
6342
+ // once an axis has proven it rests at center it stays trusted,
6343
+ // otherwise moving it would immediately disqualify it again
6344
+ const frames = centered[j] | 0;
6345
+ if (frames > gamepadAxisCenteredFrames)
6346
+ return v;
6347
+ centered[j] = abs(v) < deadZoneMin ? frames + 1 : 0;
6348
+ return 0;
6349
+ };
6293
6350
  for (let j = 0; j < gamepad.axes.length-1; j+=2)
6294
- sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
6351
+ sticks[j>>1] = applyDeadZones(vec2(readAxis(j), readAxis(j+1)));
6295
6352
 
6296
6353
  // read buttons
6297
6354
  let hadInput = false;
@@ -6917,7 +6974,15 @@ class Sound
6917
6974
  this.randomness = randomness ?? 0;
6918
6975
  /** @property {number} - Sample rate for this sound */
6919
6976
  this.sampleRate = audioDefaultSampleRate;
6920
- /** @property {number} - Percentage of this sound currently loaded */
6977
+ /** @property {number} - How many samples per channel this sound has */
6978
+ this.sampleLength = 0;
6979
+ /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
6980
+ * @type {AudioBuffer} */
6981
+ this.sampleBuffer = undefined;
6982
+ /** @private @type {Array<Array<number>|Float32Array>} */
6983
+ this._sampleChannels = undefined;
6984
+ /** @property {number} - Percentage of this sound currently loaded, sounds
6985
+ * fetched from a url stay at 0 until decoding completes */
6921
6986
  this.loadedPercent = 0;
6922
6987
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
6923
6988
  this.onloadCallback = onloadCallback;
@@ -6933,8 +6998,10 @@ class Sound
6933
6998
  this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
6934
6999
  zzfxSound[randomnessIndex] = 0;
6935
7000
 
6936
- // generate the zzfx samples
7001
+ // generate the zzfx samples, then hand them to an audio buffer so
7002
+ // the plain arrays can be released and every play shares the buffer
6937
7003
  this.sampleChannels = [zzfxG(...zzfxSound)];
7004
+ this.buildSampleBuffer();
6938
7005
  this.loadedPercent = 1;
6939
7006
  onloadCallback?.(this);
6940
7007
  }
@@ -6946,6 +7013,45 @@ class Sound
6946
7013
  }
6947
7014
  }
6948
7015
 
7016
+ /** Sample data for each channel
7017
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
7018
+ * the arrays from it and caches them. The copies are safe to hold onto,
7019
+ * playing a sound detaches the buffer's own channel arrays.
7020
+ * @type {Array<Array<number>|Float32Array>} */
7021
+ get sampleChannels()
7022
+ {
7023
+ const buffer = this.sampleBuffer;
7024
+ if (!this._sampleChannels && buffer)
7025
+ {
7026
+ const channels = [];
7027
+ for (let i = 0; i < buffer.numberOfChannels; i++)
7028
+ channels.push(buffer.getChannelData(i).slice());
7029
+ this._sampleChannels = channels;
7030
+ }
7031
+ return this._sampleChannels;
7032
+ }
7033
+
7034
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
7035
+ set sampleChannels(sampleChannels)
7036
+ {
7037
+ // new samples invalidate the buffer built from the old ones
7038
+ this._sampleChannels = sampleChannels;
7039
+ this.sampleBuffer = undefined;
7040
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
7041
+ }
7042
+
7043
+ /** Move this sound's samples into an audio buffer that every play can share
7044
+ * Does nothing if there is already a buffer or no samples to build one from */
7045
+ buildSampleBuffer()
7046
+ {
7047
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
7048
+
7049
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
7050
+
7051
+ // the buffer owns the samples now, release the arrays we built it from
7052
+ this._sampleChannels = undefined;
7053
+ }
7054
+
6949
7055
  /** Play the sound
6950
7056
  * Sounds may not play until a user interaction occurs
6951
7057
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -6964,7 +7070,7 @@ class Sound
6964
7070
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
6965
7071
 
6966
7072
  if (!soundEnable || headlessMode) return;
6967
- if (!this.sampleChannels) return;
7073
+ if (!this.sampleBuffer && !this._sampleChannels) return;
6968
7074
 
6969
7075
  let pan;
6970
7076
  if (pos)
@@ -7031,7 +7137,7 @@ class Sound
7031
7137
  * @return {number} - How long the sound is in seconds (0 if loading)
7032
7138
  */
7033
7139
  getDuration()
7034
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
7140
+ { return this.sampleLength / this.sampleRate || 0; }
7035
7141
 
7036
7142
  /** Check if sound is loaded, for sounds fetched from a url
7037
7143
  * @return {boolean} - True if sound is loaded and ready to play
@@ -7049,36 +7155,11 @@ class Sound
7049
7155
  const arrayBuffer = await response.arrayBuffer();
7050
7156
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
7051
7157
 
7052
- // convert audio buffer to sample channels across multiple frames
7053
- const channelCount = audioBuffer.numberOfChannels;
7054
- const samplesPerFrame = 1e5;
7055
- const sampleChannels = [];
7056
- for (let channel = 0; channel < channelCount; channel++)
7057
- {
7058
- const channelData = audioBuffer.getChannelData(channel);
7059
- const channelLength = channelData.length;
7060
- sampleChannels[channel] = new Array(channelLength);
7061
- let sampleIndex = 0;
7062
- while (sampleIndex < channelLength)
7063
- {
7064
- // yield to next frame
7065
- await new Promise(resolve => setTimeout(resolve, 0));
7066
-
7067
- // copy chunk of samples
7068
- const endIndex = min(sampleIndex + samplesPerFrame, channelLength);
7069
- for (; sampleIndex < endIndex; sampleIndex++)
7070
- sampleChannels[channel][sampleIndex] = channelData[sampleIndex];
7071
-
7072
- // update loaded percent
7073
- const samplesTotal = channelCount * channelLength;
7074
- const samplesProcessed = channel * channelLength + sampleIndex;
7075
- this.loadedPercent = samplesProcessed / samplesTotal;
7076
- }
7077
- }
7078
-
7079
- // setup the sound to be played
7158
+ // keep the decoded buffer as is, it is exactly what playback needs and
7159
+ // every play shares it, no channel data is read or copied
7080
7160
  this.sampleRate = audioBuffer.sampleRate;
7081
- this.sampleChannels = sampleChannels;
7161
+ this.sampleLength = audioBuffer.length;
7162
+ this.sampleBuffer = audioBuffer;
7082
7163
  this.loadedPercent = 1;
7083
7164
  this.onloadCallback?.(this);
7084
7165
  }
@@ -7154,7 +7235,12 @@ class SoundInstance
7154
7235
  if (this.isPlaying())
7155
7236
  this.stop();
7156
7237
  this.gainNode = audioContext.createGain();
7157
- this.source = playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
7238
+
7239
+ // build the shared buffer if it was not made at load time, then play it
7240
+ this.sound.buildSampleBuffer();
7241
+ this.source = this.sound.sampleBuffer ?
7242
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
7243
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
7158
7244
  if (this.source)
7159
7245
  {
7160
7246
  this.startTime = audioContext.currentTime - offset;
@@ -7329,19 +7415,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
7329
7415
 
7330
7416
  if (!audioIsRunning())
7331
7417
  {
7332
- // fix stalled audio, this sound won't be able to play
7418
+ // fix stalled audio, don't build a buffer that can't be played
7333
7419
  audioContext.resume();
7334
7420
  return;
7335
7421
  }
7336
7422
 
7337
- // create buffer and source
7423
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
7424
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
7425
+ }
7426
+
7427
+ /** Copy arrays of samples into a new audio buffer
7428
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
7429
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
7430
+ * @return {AudioBuffer} - The audio buffer holding the samples
7431
+ * @memberof Audio */
7432
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
7433
+ {
7338
7434
  const channelCount = sampleChannels.length;
7339
7435
  const sampleLength = sampleChannels[0].length;
7340
7436
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
7341
- const source = audioContext.createBufferSource();
7342
-
7343
- // copy samples to buffer and setup source
7344
7437
  sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
7438
+ return buffer;
7439
+ }
7440
+
7441
+ /** Play an audio buffer with given settings
7442
+ * The buffer can be shared by any number of sounds playing at once
7443
+ * @param {AudioBuffer} buffer - The audio buffer to play
7444
+ * @param {number} [volume] - How much to scale volume by
7445
+ * @param {number} [rate] - The playback rate to use
7446
+ * @param {number} [pan] - How much to apply stereo panning
7447
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
7448
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
7449
+ * @param {number} [offset] - Offset in seconds to start playback from
7450
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
7451
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
7452
+ * @memberof Audio */
7453
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
7454
+ {
7455
+ if (!soundEnable || headlessMode) return;
7456
+
7457
+ if (!audioIsRunning())
7458
+ {
7459
+ // fix stalled audio, this sound won't be able to play
7460
+ audioContext.resume();
7461
+ return;
7462
+ }
7463
+
7464
+ // setup source, many sources can share one buffer
7465
+ const source = audioContext.createBufferSource();
7345
7466
  source.buffer = buffer;
7346
7467
  source.playbackRate.value = rate;
7347
7468
  source.loop = loop;
@@ -16808,6 +16929,9 @@ class ThreeJSPlugin
16808
16929
  rootElement.insertBefore(threeCanvas, rootElement.firstChild);
16809
16930
  threeCanvas.style.cssText = mainCanvas.style.cssText;
16810
16931
 
16932
+ // composite the 3D canvas into screenshots and video capture
16933
+ setBackgroundCanvas(threeCanvas);
16934
+
16811
16935
  // render automatically each frame after the engine renders
16812
16936
  engineAddPlugin(undefined, ()=> this.render());
16813
16937
  }