littlejsengine 1.24.3 → 1.26.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 (53) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/EDITOR.md +2 -2
  3. package/FAQ.md +55 -11
  4. package/README.md +6 -2
  5. package/REFERENCE.md +203 -57
  6. package/dist/littlejs.d.ts +933 -313
  7. package/dist/littlejs.esm.js +5143 -2419
  8. package/dist/littlejs.esm.min.js +38 -24
  9. package/dist/littlejs.js +5132 -2416
  10. package/dist/littlejs.min.js +37 -23
  11. package/dist/littlejs.release.js +4905 -2232
  12. package/package.json +4 -4
  13. package/plugins/box2d.js +141 -56
  14. package/plugins/drawUtilities.js +374 -380
  15. package/plugins/gltf.js +383 -60
  16. package/plugins/lightSystem.js +412 -30
  17. package/plugins/math3d.js +1015 -1014
  18. package/plugins/medalSystem.js +19 -7
  19. package/plugins/newgrounds.js +5 -6
  20. package/plugins/parallax.js +1 -0
  21. package/plugins/particleEffects.js +47 -14
  22. package/plugins/pathFinder.js +60 -38
  23. package/plugins/pluginExport.js +11 -0
  24. package/plugins/postProcess.js +200 -67
  25. package/plugins/render3d.js +493 -2391
  26. package/plugins/render3dDebug.js +5 -8
  27. package/plugins/render3dEditor.js +84 -9
  28. package/plugins/render3dExtras.js +122 -33
  29. package/plugins/render3dLevel.js +67 -8
  30. package/plugins/render3dMesh.js +1158 -0
  31. package/plugins/render3dObject.js +1451 -0
  32. package/plugins/render3dVoxels.js +15 -3
  33. package/plugins/textureSheet.js +9 -8
  34. package/plugins/threejs.js +1 -1
  35. package/plugins/tweenSystem.js +117 -87
  36. package/plugins/uiSystem.js +62 -28
  37. package/plugins/wavedash.js +229 -0
  38. package/src/engine.js +192 -40
  39. package/src/engineAudio.js +47 -27
  40. package/src/engineBuild.mjs +6 -1
  41. package/src/engineDebug.js +23 -162
  42. package/src/engineDraw.js +148 -43
  43. package/src/engineEditor.js +153 -40
  44. package/src/engineExport.js +0 -3
  45. package/src/engineInput.js +31 -25
  46. package/src/engineMath.js +38 -20
  47. package/src/engineObject.js +68 -50
  48. package/src/engineParticles.js +66 -18
  49. package/src/engineRelease.js +18 -15
  50. package/src/engineSettings.js +48 -9
  51. package/src/engineTileLayer.js +44 -18
  52. package/src/engineUtilities.js +56 -11
  53. package/src/engineWebGL.js +68 -22
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.24.3';
35
+ const engineVersion = '1.26.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -179,7 +179,134 @@ function engineObjectsCollidePairAdd(asker, other, resolve=false)
179
179
  others || engineObjectsCollidePairs.set(asker, others = new Map);
180
180
  others.set(other, resolve);
181
181
  }
182
+
183
+ // with this many solids or more, a mover finds what is near it through a grid of cells rather than checking every
184
+ // solid, a big game's cost going from every pair to what is close; it resolves the same contacts in the same order,
185
+ // so a game plays the same either way, and a few solids are quicker checked all
186
+ let engineCollideGridMin = 64;
187
+ // the grid for this update, {list, built}: the list of solids, and the grid made from it the first time a 2D mover
188
+ // walks it, so a frame with no 2D mover, as 3D solids alone, makes none; built is each cell's solids, each solid's
189
+ // cells, and each solid's place in the list, which the contacts are taken in; solids too big for cells are near
190
+ // everything
191
+ let engineCollideGrid;
192
+
193
+ // the grid of a list of solids, its cells about twice a typical solid so most are in one to four
194
+ function engineCollideGridBuild(list)
195
+ {
196
+ let extent = 0;
197
+ for (const o of list)
198
+ extent += min(max(abs(o.size.x), abs(o.size.y)), 16) || 0; // a negative size is a mirrored one
199
+ const index = new Map;
200
+ const grid = {size: max(2 * extent / list.length, .5), cells: new Map, at: new Map, index, big: new Set,
201
+ seen: new Map, stamp: 0, byIndex: (a, b)=> index.get(a) - index.get(b)}; // what a query found, and its order
202
+ list.forEach((o, i)=> { index.set(o, i); engineCollideGridPlace(grid, o); });
203
+ return grid;
204
+ }
205
+
206
+ // the cells an object's box covers, first and last along x and y, in one kept array to read at once; a negative size
207
+ // is a mirrored sprite of that size
208
+ const engineCollideGridBox = [0, 0, 0, 0];
209
+ function engineCollideGridCells(grid, o)
210
+ {
211
+ const s = grid.size, w = abs(o.size.x) / 2, h = abs(o.size.y) / 2, box = engineCollideGridBox;
212
+ box[0] = floor((o.pos.x - w) / s), box[1] = floor((o.pos.y - h) / s);
213
+ box[2] = floor((o.pos.x + w) / s), box[3] = floor((o.pos.y + h) / s);
214
+ return box;
215
+ }
216
+
217
+ // whether a box is kept in its cells: not over 1024 of them, and within 2^31 cells of the origin, past which a cell
218
+ // loop would not end, as ++ stops changing a number past 2^53, and the keys would not be exact; one with no finite
219
+ // box does not fit either, and is near every mover
220
+ const engineCollideGridFits = (x0, y0, x1, y1)=> (x1 - x0 + 1) * (y1 - y0 + 1) <= 1024 &&
221
+ abs(x0) < 2**31 && abs(y0) < 2**31 && abs(x1) < 2**31 && abs(y1) < 2**31;
222
+
223
+ // put a solid in the cells its box covers now, out of those it was in; a box on a cell's edge is in both cells, so
224
+ // solids that touch share one
225
+ function engineCollideGridPlace(grid, o)
226
+ {
227
+ if (!grid.index.has(o)) return;
228
+ const [x0, y0, x1, y1] = engineCollideGridCells(grid, o);
229
+ const big = !engineCollideGridFits(x0, y0, x1, y1);
230
+ const was = grid.at.get(o);
231
+ if (was && was[0] === x0 && was[1] === y0 && was[2] === x1 && was[3] === y1 && was[4] === big) return;
232
+ if (was && !was[4])
233
+ for (let x = was[0]; x <= was[2]; ++x)
234
+ for (let y = was[1]; y <= was[3]; ++y)
235
+ {
236
+ const cell = grid.cells.get(x * 1048576 + y);
237
+ cell.splice(cell.indexOf(o), 1);
238
+ }
239
+ grid.big.delete(o);
240
+ if (big)
241
+ grid.big.add(o);
242
+ else
243
+ for (let x = x0; x <= x1; ++x)
244
+ for (let y = y0; y <= y1; ++y)
245
+ {
246
+ const key = x * 1048576 + y;
247
+ const cell = grid.cells.get(key);
248
+ cell ? cell.push(o) : grid.cells.set(key, [o]);
249
+ }
250
+ grid.at.set(o, [x0, y0, x1, y1, big]);
251
+ }
252
+
253
+ // the solids a mover is near, after a place in the list, in list order, filled into near; each found once, by the
254
+ // query's stamp
255
+ function engineCollideGridNear(grid, o, after, near)
256
+ {
257
+ const [x0, y0, x1, y1] = engineCollideGridCells(grid, o);
258
+ const stamp = ++grid.stamp;
259
+ near.length = 0;
260
+ for (const other of grid.big)
261
+ engineCollideGridAdd(grid, other, after, stamp, near);
262
+ if (engineCollideGridFits(x0, y0, x1, y1))
263
+ {
264
+ for (let x = x0; x <= x1; ++x)
265
+ for (let y = y0; y <= y1; ++y)
266
+ {
267
+ const cell = grid.cells.get(x * 1048576 + y);
268
+ if (cell)
269
+ for (const other of cell)
270
+ engineCollideGridAdd(grid, other, after, stamp, near);
271
+ }
272
+ }
273
+ else
274
+ for (const other of grid.index.keys()) // a mover too big for cells is near everything
275
+ engineCollideGridAdd(grid, other, after, stamp, near);
276
+ near.sort(grid.byIndex);
277
+ }
278
+
279
+ // add a solid to what a query found, once, when it is after the place in the list
280
+ function engineCollideGridAdd(grid, other, after, stamp, near)
281
+ {
282
+ if (grid.seen.get(other) === stamp) return;
283
+ grid.seen.set(other, stamp);
284
+ grid.index.get(other) > after && near.push(other);
285
+ }
286
+
287
+ // the solids a mover checks, as checking every one would reach them: in list order, and found again from where it is
288
+ // whenever its box has moved, as a push does, so a solid it is pushed into later in the list is still checked
289
+ function* engineCollideGridWalk(o)
290
+ {
291
+ const grid = engineCollideGrid.built ||= engineCollideGridBuild(engineCollideGrid.list);
292
+ let after = -1, x, y, w, h, k = 0;
293
+ const near = [];
294
+ for (;;)
295
+ {
296
+ if (o.pos.x !== x || o.pos.y !== y || o.size.x !== w || o.size.y !== h)
297
+ {
298
+ x = o.pos.x, y = o.pos.y, w = o.size.x, h = o.size.y;
299
+ engineCollideGridNear(grid, o, after, near);
300
+ k = 0;
301
+ }
302
+ if (k >= near.length) return;
303
+ const other = near[k++];
304
+ after = grid.index.get(other);
305
+ yield other;
306
+ }
307
+ }
182
308
  let engineInitialized = false; // engineInit ran, with or without a canvas
309
+ let engineInitDone; // the promise the first engineInit hands back, which a second call hands back too
183
310
  // the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
184
311
  // loop starts
185
312
  let engineLoads, engineLoadsDone = 0;
@@ -223,7 +350,7 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
223
350
  p.update === update && p.render === render &&
224
351
  p.glContextLost === glContextLost &&
225
352
  p.glContextRestored === glContextRestored &&
226
- p.preRender === preRender));
353
+ p.preRender === preRender), 'engineAddPlugin: this plugin was already added');
227
354
 
228
355
  const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored, preRender);
229
356
  pluginList.push(plugin);
@@ -305,7 +432,7 @@ function engineLoadingScreenDraw(elapsed)
305
432
  * @param {GameCallback} [gameUpdatePost] - Called after physics and objects are updated, even when paused, use for UI updates
306
433
  * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
307
434
  * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
308
- * @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
435
+ * @param {Array<string>|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path
309
436
  * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
310
437
  * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
311
438
  * so set canvasFixedSize or canvasMaxSize to fit a smaller element
@@ -319,16 +446,19 @@ function engineLoadingScreenDraw(elapsed)
319
446
  * ()=> { drawHUD(); }, // gameRenderPost
320
447
  * ['tiles.png', 'tilesLevel.png'] // images to load
321
448
  * );
322
- * @memberof Engine */
449
+ * @memberof Engine
450
+ * @return {Promise<void>} - Done when the images have loaded and gameInit has run */
323
451
  async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
324
452
  {
325
- showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
326
453
  ASSERT(!engineInitialized, 'engine already initialized');
327
454
  // runtime guard so release builds (where the assert is stripped) don't
328
- // double-register listeners / double-add canvases on a second call
329
- if (engineInitialized) return;
455
+ // double-register listeners / double-add canvases on a second call, which is done when the first is
456
+ if (engineInitialized) return engineInitDone;
330
457
  engineInitialized = true;
458
+ showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
331
459
  engineLoads = [], engineLoadsDone = 0;
460
+ if (typeof imageSources === 'string')
461
+ imageSources = [imageSources]; // one image given alone
332
462
  ASSERT(isArray(imageSources), 'pass in images as array');
333
463
 
334
464
  // allow passing in empty functions
@@ -470,8 +600,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
470
600
  debugUpdate();
471
601
  gameUpdatePost();
472
602
  inputUpdatePost();
473
- if (debugVideoCaptureIsActive())
474
- renderFrame();
475
603
  }
476
604
 
477
605
  // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
@@ -493,7 +621,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
493
621
 
494
622
  // render only when something changed, displays that refresh faster
495
623
  // than the fixed update rate would otherwise redraw identical frames
496
- if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
624
+ if (wasUpdated || windowChanged)
497
625
  renderFrame();
498
626
  engineManualStep || engineScheduleFrame();
499
627
 
@@ -525,14 +653,13 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
525
653
  inputRender();
526
654
  debugRender();
527
655
  glFlush();
528
- debugRenderPost();
529
656
  drawCount = 0;
530
657
  primitiveCount = 0;
531
658
  }
532
659
  }
533
660
 
534
661
  // skip setup if headless
535
- if (headlessMode) return startEngine([]);
662
+ if (headlessMode) return engineInitDone = startEngine([]);
536
663
 
537
664
  // ensure body exists for minimal HTML where the script runs before <body> is parsed
538
665
  if (!document.body)
@@ -610,21 +737,25 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
610
737
  promises.push(splash);
611
738
  }
612
739
 
613
- // the splash first, the images load under it, then the loading screen for the rest
614
- showSplashScreen && await promises[promises.length - 1];
615
- return startEngine(promises);
740
+ // kept before anything is awaited, so a second call has it
741
+ return engineInitDone = startEngine(promises);
616
742
 
617
- // gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
618
- // done, the loading screen showing in the meantime; an error in gameInit reaches the caller
743
+ // the splash first, the images load under it, then gameInit runs once they are in, and the game loop starts once
744
+ // it and everything loaded while it ran are done, the loading screen showing in the meantime; an error in gameInit
745
+ // reaches the caller
619
746
  async function startEngine(images)
620
747
  {
748
+ showSplashScreen && !headlessMode && await images[images.length - 1]; // headless has no splash
621
749
  const init = (async ()=> { await Promise.all(images); await gameInit(); })();
622
750
  engineAddLoad(init);
623
751
  await engineWaitForLoads();
624
752
  engineLoads = undefined;
625
753
  await init;
626
754
  engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
627
- engineManualStep || engineUpdate();
755
+ if (engineManualStep) return;
756
+ if (debug) return engineUpdate(); // a debug build stops at an error, the first frame's as any
757
+ try { engineUpdate(); }
758
+ catch (error) { engineFrameFailed(error); } // a release build goes on past it, as on any later frame
628
759
  }
629
760
  }
630
761
 
@@ -642,7 +773,7 @@ function engineUpdateCanvas()
642
773
  if (canvasFixedSize.x)
643
774
  {
644
775
  // set canvas fixed size
645
- mainCanvasSize = canvasFixedSize.copy();
776
+ mainCanvasSize.set(canvasFixedSize.x, canvasFixedSize.y);
646
777
 
647
778
  // fit to window using css width and height
648
779
  const innerAspect = innerWidth / innerHeight;
@@ -666,7 +797,8 @@ function engineUpdateCanvas()
666
797
 
667
798
  // responsive aspect ratio, of the size after canvasMaxSize, which can change its shape
668
799
  const innerAspect = mainCanvasSize.x / mainCanvasSize.y;
669
- ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect);
800
+ ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect,
801
+ 'canvasMinAspect must not be above canvasMaxAspect', canvasMinAspect, canvasMaxAspect);
670
802
  if (canvasMaxAspect && innerAspect > canvasMaxAspect)
671
803
  {
672
804
  // full height
@@ -702,9 +834,12 @@ function engineUpdateCanvas()
702
834
  }
703
835
  else
704
836
  {
705
- // setting the size also resets the context state, match that
837
+ // setting the size also resets the context state, match that, what a game may have left on it too
706
838
  mainContext.setTransform(1, 0, 0, 1, 0, 0);
707
839
  mainContext.globalCompositeOperation = 'source-over';
840
+ mainContext.globalAlpha = 1;
841
+ mainContext.filter = 'none';
842
+ mainContext.shadowColor = 'rgba(0,0,0,0)';
708
843
  mainContext.clearRect(0, 0, bufferSizeX, bufferSizeY);
709
844
  }
710
845
 
@@ -725,6 +860,18 @@ function engineUpdateCanvas()
725
860
  mainContext.lineCap = 'round';
726
861
  }
727
862
 
863
+ // a release build goes on past an error in a frame, a frozen game is the worst a player can get; an error is logged
864
+ // when it is not the last one again, one every frame would flood the console
865
+ function engineFrameFailed(error)
866
+ {
867
+ const text = String(error);
868
+ text === engineFrameErrorLast || console.error(error);
869
+ engineFrameErrorLast = text;
870
+ // the frame's input is cleared as its tick would have, or a key press that threw would be pressed again
871
+ inputUpdatePost();
872
+ engineScheduleFrame();
873
+ }
874
+
728
875
  // ask for the next frame of the loop, once however often it is called before that frame, and skip it if manual
729
876
  // step was turned on since, so turning it off and on again within a frame can not start a second loop
730
877
  function engineScheduleFrame()
@@ -737,17 +884,7 @@ function engineScheduleFrame()
737
884
  if (engineManualStep) return;
738
885
  if (debug) return engineUpdateInternal(frameTimeMS); // a debug build stops at an error, where it shows it
739
886
  try { engineUpdateInternal(frameTimeMS); }
740
- catch (error)
741
- {
742
- // a release build goes on past an error in a frame, a frozen game is the worst a player can get; an error
743
- // is logged when it is not the last one again, one every frame would flood the console
744
- const text = String(error);
745
- text === engineFrameErrorLast || console.error(error);
746
- engineFrameErrorLast = text;
747
- // the frame's input is cleared as its tick would have, or a key press that threw would be pressed again
748
- inputUpdatePost();
749
- engineScheduleFrame();
750
- }
887
+ catch (error) { engineFrameFailed(error); }
751
888
  };
752
889
  if (typeof requestAnimationFrame === 'function')
753
890
  requestAnimationFrame(next);
@@ -768,7 +905,7 @@ const engineStepMaxFrames = 36000;
768
905
  * setHeadlessMode(true);
769
906
  * setEngineManualStep(true);
770
907
  * await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost);
771
- * engineStep(600); // advance 10 seconds of game time
908
+ * engineStep(600); // 600 updates of 1/60 second; the first is at time 0, so time is then 599/60
772
909
  * @memberof Engine */
773
910
  function engineStep(frames=1)
774
911
  {
@@ -805,11 +942,23 @@ function engineObjectsUpdate()
805
942
  o.collideSolidObjects && (engineObjectsCollide.push(o), (o.mass ? engineObjectsCollideStaticLast : fixed).push(o));
806
943
  for (const o of fixed)
807
944
  engineObjectsCollideStaticLast.push(o);
945
+ // written only when there is a grid: V8 keeps a variable that is never written as a constant, and writing it
946
+ // every frame makes this function deoptimize again and again in a game with no grid
947
+ if (engineObjectsCollideStaticLast.length >= engineCollideGridMin)
948
+ engineCollideGrid = {list: engineObjectsCollideStaticLast, built: undefined};
808
949
 
809
- // update physics before object update
810
- for (const o of engineObjects)
811
- if (!o.parent && !o.destroyed)
812
- o.updatePhysics();
950
+ // update physics before object update, each solid put where it moved to in the grid, for the movers after it;
951
+ // the grid is let go even when a callback throws, so an updatePhysics called before the next update checks all
952
+ try
953
+ {
954
+ for (const o of engineObjects)
955
+ if (!o.parent && !o.destroyed)
956
+ {
957
+ o.updatePhysics();
958
+ engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
959
+ }
960
+ }
961
+ finally { engineCollideGrid && (engineCollideGrid = undefined); }
813
962
 
814
963
  // recursive object update: the children are walked from a copy on a shared stack, since a child that
815
964
  // destroys itself leaves its parent's list on the spot and the next child would slide past the loop
@@ -819,9 +968,12 @@ function engineObjectsUpdate()
819
968
  const start = engineChildStack.length;
820
969
  for (const child of children)
821
970
  engineChildStack.push(child);
822
- for (let i = start; i < engineChildStack.length; ++i)
823
- updateChildObject(engineChildStack[i]);
824
- engineChildStack.length = start;
971
+ try
972
+ {
973
+ for (let i = start; i < engineChildStack.length; ++i)
974
+ updateChildObject(engineChildStack[i]);
975
+ }
976
+ finally { engineChildStack.length = start; } // put back when an update throws too, or it keeps them
825
977
  }
826
978
  const pass = engineObjectsUpdateCount;
827
979
  function updateChildObject(o)
@@ -20,6 +20,9 @@
20
20
  * @memberof Audio */
21
21
  let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext;
22
22
 
23
+ // resume the audio, a refusal caught so it is not an uncaught error in the console, the next gesture tries again
24
+ function audioResume() { audioContext?.resume()?.catch?.(()=> {}); }
25
+
23
26
  /** Master gain node for all audio to pass through, made at load so effects can connect to it any time
24
27
  * @type {GainNode}
25
28
  * @memberof Audio */
@@ -35,7 +38,8 @@ if (audioMasterGain)
35
38
  // soundVolume can be set directly, so the master gain follows it each frame
36
39
  function audioUpdateVolume()
37
40
  {
38
- if (audioMasterGain && soundVolume !== audioMasterVolume)
41
+ // a volume that is not a number, from a slider's text say, is not handed on, where every frame it would throw
42
+ if (audioMasterGain && soundVolume !== audioMasterVolume && isFinite(soundVolume))
39
43
  audioMasterGain.gain.value = audioMasterVolume = soundVolume;
40
44
  }
41
45
 
@@ -76,6 +80,7 @@ function audioSetSession()
76
80
  let audioSuspendedWhenHidden = false;
77
81
  function audioVisibilityChange()
78
82
  {
83
+ if (!audioContext) return; // a browser with no audio has none to pause
79
84
  if (document.hidden)
80
85
  {
81
86
  if (!soundPauseWhenHidden || audioContext.state != 'running') return;
@@ -85,7 +90,7 @@ function audioVisibilityChange()
85
90
  else if (audioSuspendedWhenHidden)
86
91
  {
87
92
  audioSuspendedWhenHidden = false;
88
- audioContext.resume();
93
+ audioResume();
89
94
  }
90
95
  }
91
96
 
@@ -122,7 +127,6 @@ function audioStateChange()
122
127
  * - The output node is disconnected from everything else first, so it only feeds the speakers
123
128
  * - The two ends of a chain must already be connected to each other, like effectA.connect(effectB)
124
129
  * - Call with no arguments to remove the effect, an effect that was the master goes back to feeding the master gain
125
- * - Debug video capture records the end of the master chain, but loses its tap if the effect changes mid-capture
126
130
  * @param {AudioNode|AudioEffectNodes} [input] - Node or effect the master gain connects to
127
131
  * @param {AudioNode|AudioEffectNodes} [output] - Node or effect that connects to the audio destination, defaults to the input's output
128
132
  * @memberof Audio */
@@ -177,11 +181,16 @@ function audioEffectNode(effectOrNode, key)
177
181
  return /** @type {AudioNode} */ (effectOrNode);
178
182
  }
179
183
 
184
+ // a value for an audio param, which throws on one that is not finite, so a release build, with no asserts to stop it,
185
+ // takes NaN or Infinity as the value given in its place: silent, centered or the normal rate
186
+ const audioFinite = (value, otherwise)=> isFinite(value) ? value : otherwise;
187
+
180
188
  // ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight;
181
189
  // returns when the ramp ends
182
190
  function audioParamRamp(param, value, fadeTime=0)
183
191
  {
184
192
  ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
193
+ value = audioFinite(value, 0), fadeTime = audioFinite(fadeTime, 0);
185
194
  const startTime = audioContext.currentTime;
186
195
  param.cancelScheduledValues(startTime);
187
196
  if (fadeTime)
@@ -197,7 +206,7 @@ function audioParamRamp(param, value, fadeTime=0)
197
206
  ///////////////////////////////////////////////////////////////////////////////
198
207
 
199
208
  /**
200
- * @callback SoundLoadCallback - Function called when sound is loaded
209
+ * @callback SoundLoadCallback - Function called once a sound has loaded, or failed to, which sound.isLoaded() tells
201
210
  * @param {Sound} sound
202
211
  * @memberof Audio
203
212
  */
@@ -224,11 +233,12 @@ function audioParamRamp(param, value, fadeTime=0)
224
233
  class Sound
225
234
  {
226
235
  /** Create a sound object and cache the audio for later use
227
- * @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array
236
+ * @param {string|URL|Array<number|undefined>} [asset] - Filename or URL of an audio file, or a zzfx array
228
237
  * @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
229
238
  * @param {number} [range=soundDefaultRange] - World space max range of sound
230
239
  * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
231
- * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
240
+ * @param {SoundLoadCallback} [onloadCallback] - Called once the sound has loaded, or its file failed to, so a
241
+ * game counting its sounds goes on; isLoaded says which
232
242
  */
233
243
  constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
234
244
  {
@@ -287,7 +297,7 @@ class Sound
287
297
 
288
298
  // remove randomness so it can be applied on playback, a value passed in wins over the array's
289
299
  const randomnessIndex = 1;
290
- this.randomness = randomness ?? zzfxSound[randomnessIndex] ?? .05;
300
+ this.randomness = randomness ?? clamp(zzfxSound[randomnessIndex] ?? .05); // 0 to 1, as one passed in
291
301
  zzfxSound[randomnessIndex] = 0;
292
302
 
293
303
  // generate the zzfx samples, then hand them to an audio buffer so
@@ -303,7 +313,12 @@ class Sound
303
313
  // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
304
314
  const filename = asset + '';
305
315
  engineAddLoad(this.loadSound(filename).catch(e=> // startup waits for it, and a release build says why too
306
- console.warn('Sound load failed for', filename, '-', e?.message ?? e)));
316
+ {
317
+ console.warn('Sound load failed for', filename, '-', e?.message ?? e);
318
+ // failed, not loaded and its callback threw; one that throws here says so, a load is no place to stop
319
+ try { this.loadedPercent === 1 || this.onloadCallback?.(this); }
320
+ catch (callbackError) { console.warn('Sound onloadCallback failed for', filename, '-', callbackError); }
321
+ }));
307
322
  }
308
323
  }
309
324
 
@@ -386,8 +401,8 @@ class Sound
386
401
  {
387
402
  // apply range based fade
388
403
  const lengthSquared = cameraPos.distanceSquared(pos);
389
- if (lengthSquared > range*range)
390
- return; // out of range
404
+ if (lengthSquared >= range*range)
405
+ return; // out of range, at it too, where the fade is silent
391
406
 
392
407
  // attenuate volume by distance, full volume out to the taper and a fade past it,
393
408
  // so a taper of 1 plays at full volume right up to the range
@@ -463,14 +478,14 @@ class Sound
463
478
  */
464
479
  isLoaded() { return this.loadedPercent === 1; }
465
480
 
466
- /** Loads a sound from a URL and decodes it into sample data.
481
+ /** Loads a sound from a URL and decodes it into sample data
467
482
  * @param {string} filename
468
483
  * @return {Promise} */
469
484
  async loadSound(filename)
470
485
  {
471
- const response = await fetch(filename);
486
+ const response = await loadFetch(filename, 'loadSound'); // named, with the hint for a page opened from disk
472
487
  if (!response.ok)
473
- throw new Error(`Failed to load sound from ${filename}: ${response.status} ${response.statusText}`);
488
+ throw new Error(`loadSound: could not load ${filename}, HTTP ${response.status} ${response.statusText}`);
474
489
  const arrayBuffer = await response.arrayBuffer();
475
490
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
476
491
 
@@ -521,11 +536,11 @@ class SoundInstance
521
536
  /** @property {Sound} - The sound object */
522
537
  this.sound = sound;
523
538
  /** @property {number} - How much to scale volume by */
524
- this.volume = volume;
539
+ this.volume = audioFinite(volume, 0);
525
540
  /** @property {number} - The playback rate to use */
526
- this.rate = rate;
541
+ this.rate = audioFinite(rate, 1);
527
542
  /** @property {number} - How much to apply stereo panning */
528
- this.pan = pan;
543
+ this.pan = audioFinite(pan, 0);
529
544
  /** @property {boolean} - Should the sound loop */
530
545
  this.loop = loop;
531
546
  /** @property {number|undefined} - Where it is in the sound while not playing, in the sound's own seconds, undefined while playing
@@ -617,7 +632,7 @@ class SoundInstance
617
632
  {
618
633
  ASSERT(volume >= 0, 'Sound volume must be positive or zero');
619
634
  ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
620
- this.volume = volume;
635
+ this.volume = volume = audioFinite(volume, 0);
621
636
  this.gainNode && audioParamRamp(this.gainNode.gain, volume, fadeTime);
622
637
  }
623
638
 
@@ -627,7 +642,7 @@ class SoundInstance
627
642
  setPan(pan)
628
643
  {
629
644
  ASSERT(isNumber(pan), 'Sound pan must be a number');
630
- this.pan = pan;
645
+ this.pan = pan = audioFinite(pan, 0);
631
646
  if (this.pannerNode)
632
647
  this.pannerNode.pan.value = clamp(pan, -1, 1);
633
648
  }
@@ -645,7 +660,7 @@ class SoundInstance
645
660
  this.startOffset = this.getCurrentTime();
646
661
  this.startTime = audioContext.currentTime;
647
662
  }
648
- this.rate = rate;
663
+ this.rate = rate = audioFinite(rate, 1);
649
664
  if (this.source)
650
665
  this.source.playbackRate.value = rate;
651
666
  }
@@ -721,7 +736,9 @@ class SoundInstance
721
736
  if (!this.isPlaying()) return this.pausedTime;
722
737
  const duration = this.getDuration();
723
738
  const place = this.startOffset + (audioContext.currentTime - this.startTime) * this.rate;
724
- return duration ? mod(place, duration) : 0; // a sound still loading has no length yet
739
+ // a sound still loading has no length yet; a loop goes around, a one shot past its end, before its ended
740
+ // event, stays at its end, so a pause there does not play it again on resume
741
+ return !duration ? 0 : this.loop ? mod(place, duration) : min(place, duration);
725
742
  }
726
743
 
727
744
  /** 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
@@ -795,7 +812,7 @@ function getNoteFrequency(semitoneOffset, rootFrequency=220)
795
812
  * @param {number} [rate] - The playback rate to use
796
813
  * @param {number} [pan] - How much to apply stereo panning
797
814
  * @param {boolean} [loop] - True if the sound should loop when it reaches the end
798
- * @param {number} [sampleRate=44100] - Sample rate for the sound
815
+ * @param {number} [sampleRate=audioDefaultSampleRate] - Sample rate for the sound
799
816
  * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
800
817
  * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
801
818
  * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
@@ -812,7 +829,7 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
812
829
  // fix stalled audio, don't build a buffer that can't be played;
813
830
  // but a context suspended because the page is hidden stays suspended until it shows
814
831
  if (!audioSuspendedWhenHidden)
815
- audioContext.resume();
832
+ audioResume();
816
833
  return;
817
834
  }
818
835
 
@@ -822,7 +839,7 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
822
839
 
823
840
  /** Copy arrays of samples into a new audio buffer
824
841
  * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
825
- * @param {number} [sampleRate=44100] - Sample rate for the sound
842
+ * @param {number} [sampleRate=audioDefaultSampleRate] - Sample rate for the sound
826
843
  * @return {AudioBuffer} - The audio buffer holding the samples
827
844
  * @memberof Audio */
828
845
  function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
@@ -857,10 +874,12 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
857
874
  // fix stalled audio, this sound won't be able to play;
858
875
  // but a context suspended because the page is hidden stays suspended until it shows
859
876
  if (!audioSuspendedWhenHidden)
860
- audioContext.resume();
877
+ audioResume();
861
878
  return;
862
879
  }
863
880
 
881
+ volume = audioFinite(volume, 0), rate = audioFinite(rate, 1), pan = audioFinite(pan, 0);
882
+
864
883
  // setup source, many sources can share one buffer
865
884
  const source = audioContext.createBufferSource();
866
885
  source.buffer = buffer;
@@ -916,8 +935,8 @@ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
916
935
  * @param {number} [release] - Release time, how fast sound fades out (seconds)
917
936
  * @param {number} [shape] - Shape of the sound wave
918
937
  * @param {number} [shapeCurve] - Squareness of wave (0=square, 1=normal, 2=pointy)
919
- * @param {number} [slide] - How much to slide frequency (kHz/s)
920
- * @param {number} [deltaSlide] - How much to change slide (kHz/s/s)
938
+ * @param {number} [slide] - How much to slide frequency, 1 is about 500 Hz a second, as ZzFX has it
939
+ * @param {number} [deltaSlide] - How much to change slide each second, in the units of slide
921
940
  * @param {number} [pitchJump] - Frequency of pitch jump (Hz)
922
941
  * @param {number} [pitchJumpTime] - Time of pitch jump (seconds)
923
942
  * @param {number} [repeatTime] - Resets some parameters periodically (seconds)
@@ -928,7 +947,8 @@ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
928
947
  * @param {number} [sustainVolume] - Volume level for sustain (percent)
929
948
  * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
930
949
  * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
931
- * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
950
+ * @param {number} [filter] - Filter cutoff, positive for HPF, negative for LPF; the cutoff is about twice this in
951
+ * Hz, as ZzFX has it, and its resonance can take the samples past 1
932
952
  * @return {Float32Array} - The audio samples
933
953
  * @memberof Audio */
934
954
  function zzfxG
@@ -42,6 +42,7 @@ const enginePluginFiles =
42
42
  [
43
43
  `${PLUGIN_FOLDER}/medalSystem.js`,
44
44
  `${PLUGIN_FOLDER}/newgrounds.js`,
45
+ `${PLUGIN_FOLDER}/wavedash.js`,
45
46
  `${PLUGIN_FOLDER}/postProcess.js`,
46
47
  `${PLUGIN_FOLDER}/lightSystem.js`,
47
48
  `${PLUGIN_FOLDER}/audioEffects.js`,
@@ -55,6 +56,8 @@ const enginePluginFiles =
55
56
  `${PLUGIN_FOLDER}/pathFinder.js`,
56
57
  `${PLUGIN_FOLDER}/math3d.js`,
57
58
  `${PLUGIN_FOLDER}/render3d.js`,
59
+ `${PLUGIN_FOLDER}/render3dMesh.js`,
60
+ `${PLUGIN_FOLDER}/render3dObject.js`,
58
61
  `${PLUGIN_FOLDER}/render3dExtras.js`,
59
62
  `${PLUGIN_FOLDER}/render3dVoxels.js`,
60
63
  `${PLUGIN_FOLDER}/render3dLevel.js`,
@@ -292,7 +295,9 @@ function typeScriptBuildStep(filename)
292
295
  // strictNullChecks keeps undefined in the types, where tsc would drop it from accessors and object types
293
296
  // whatever the JSDoc says, so a game in strict mode can set render3D.shader = undefined and is told a
294
297
  // flare may be missing; a game not in strict mode reads the same types as before
295
- execSync(`npx -p typescript tsc "${filename}" --declaration --allowJs --emitDeclarationOnly --strictNullChecks --outFile "${tsFilename}"`);
298
+ // the target names the library tsc types with: without it tsc takes ES5's, where Map and Set do not resolve
299
+ // and come out as any, which went unseen while a dependency brought @types/node, which declares them
300
+ execSync(`npx -p typescript tsc "${filename}" --declaration --allowJs --emitDeclarationOnly --strictNullChecks --target es2022 --outFile "${tsFilename}"`);
296
301
 
297
302
  // Make declare module part use the package name littlejsengine
298
303
  let fileContent = fs.readFileSync(tsFilename, 'utf8');