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.d.ts +61 -2
- package/dist/littlejs.esm.js +175 -45
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +169 -45
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +169 -45
- package/package.json +2 -1
- package/plugins/threejs.js +3 -0
- package/src/engine.js +1 -1
- package/src/engineAudio.js +103 -39
- package/src/engineDraw.js +15 -0
- package/src/engineExport.js +6 -0
- package/src/engineInput.js +34 -5
- package/src/engineSettings.js +13 -0
package/dist/littlejs.release.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.
|
|
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() > .
|
|
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 >
|
|
5510
|
-
v < -
|
|
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(
|
|
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} -
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
6362
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/plugins/threejs.js
CHANGED
|
@@ -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
package/src/engineAudio.js
CHANGED
|
@@ -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} -
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
234
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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);
|