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.
- package/CHANGELOG.md +123 -0
- package/EDITOR.md +2 -2
- package/FAQ.md +55 -11
- package/README.md +6 -2
- package/REFERENCE.md +203 -57
- package/dist/littlejs.d.ts +933 -313
- package/dist/littlejs.esm.js +5143 -2419
- package/dist/littlejs.esm.min.js +38 -24
- package/dist/littlejs.js +5132 -2416
- package/dist/littlejs.min.js +37 -23
- package/dist/littlejs.release.js +4905 -2232
- package/package.json +4 -4
- package/plugins/box2d.js +141 -56
- package/plugins/drawUtilities.js +374 -380
- package/plugins/gltf.js +383 -60
- package/plugins/lightSystem.js +412 -30
- package/plugins/math3d.js +1015 -1014
- package/plugins/medalSystem.js +19 -7
- package/plugins/newgrounds.js +5 -6
- package/plugins/parallax.js +1 -0
- package/plugins/particleEffects.js +47 -14
- package/plugins/pathFinder.js +60 -38
- package/plugins/pluginExport.js +11 -0
- package/plugins/postProcess.js +200 -67
- package/plugins/render3d.js +493 -2391
- package/plugins/render3dDebug.js +5 -8
- package/plugins/render3dEditor.js +84 -9
- package/plugins/render3dExtras.js +122 -33
- package/plugins/render3dLevel.js +67 -8
- package/plugins/render3dMesh.js +1158 -0
- package/plugins/render3dObject.js +1451 -0
- package/plugins/render3dVoxels.js +15 -3
- package/plugins/textureSheet.js +9 -8
- package/plugins/threejs.js +1 -1
- package/plugins/tweenSystem.js +117 -87
- package/plugins/uiSystem.js +62 -28
- package/plugins/wavedash.js +229 -0
- package/src/engine.js +192 -40
- package/src/engineAudio.js +47 -27
- package/src/engineBuild.mjs +6 -1
- package/src/engineDebug.js +23 -162
- package/src/engineDraw.js +148 -43
- package/src/engineEditor.js +153 -40
- package/src/engineExport.js +0 -3
- package/src/engineInput.js +31 -25
- package/src/engineMath.js +38 -20
- package/src/engineObject.js +68 -50
- package/src/engineParticles.js +66 -18
- package/src/engineRelease.js +18 -15
- package/src/engineSettings.js +48 -9
- package/src/engineTileLayer.js +44 -18
- package/src/engineUtilities.js +56 -11
- 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.
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
614
|
-
|
|
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
|
|
618
|
-
// done, the loading screen showing in the meantime; an error in gameInit
|
|
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
|
-
|
|
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
|
|
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); //
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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)
|
package/src/engineAudio.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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] -
|
|
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
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
920
|
-
* @param {number} [deltaSlide] - How much to change slide
|
|
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
|
|
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
|
package/src/engineBuild.mjs
CHANGED
|
@@ -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
|
-
|
|
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');
|