littlejsengine 1.19.3 → 1.21.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 (54) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2857 -955
  4. package/dist/littlejs.esm.js +33428 -20995
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +33386 -20996
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +27841 -20422
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +786 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +148 -27
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +25 -0
  21. package/plugins/postProcess.js +96 -24
  22. package/plugins/render3d.js +2067 -1632
  23. package/plugins/render3dDebug.js +110 -0
  24. package/plugins/render3dEditor.js +1812 -0
  25. package/plugins/render3dExtras.js +1948 -0
  26. package/plugins/render3dLevel.js +189 -0
  27. package/plugins/render3dVoxels.js +514 -0
  28. package/plugins/sceneSystem.js +58 -0
  29. package/plugins/textureSheet.js +496 -458
  30. package/plugins/threejs.js +183 -163
  31. package/plugins/tweakables.js +411 -0
  32. package/plugins/tweenSystem.js +265 -145
  33. package/plugins/uiSystem.js +2024 -1687
  34. package/plugins/zzfxm.js +159 -159
  35. package/src/engine.js +378 -97
  36. package/src/engineAudio.js +231 -127
  37. package/src/engineBuild.mjs +292 -256
  38. package/src/engineDebug.js +967 -743
  39. package/src/engineDraw.js +391 -198
  40. package/src/engineEditor.js +2452 -0
  41. package/src/engineExport.js +18 -0
  42. package/src/engineInput.js +1466 -1293
  43. package/src/engineLogo.js +19 -0
  44. package/src/engineMath.js +1274 -1252
  45. package/src/engineObject.js +197 -82
  46. package/src/engineParticles.js +583 -532
  47. package/src/engineRelease.js +79 -45
  48. package/src/engineSettings.js +115 -29
  49. package/src/engineTileLayer.js +995 -741
  50. package/src/engineUtilities.js +19 -13
  51. package/src/engineWebGL.js +239 -82
  52. package/src/jsconfig.json +17 -13
  53. package/plugins/box2d.wasm.js +0 -630
  54. package/plugins/box2d.wasm.wasm +0 -0
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.19.3';
35
+ const engineVersion = '1.21.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -40,11 +40,12 @@ const engineVersion = '1.19.3';
40
40
  * @memberof Engine */
41
41
  const frameRate = 60;
42
42
 
43
- /** How many seconds each frame lasts, engine uses a fixed time step
43
+ /** How many seconds the update covers: 1/60 with the fixed time step, or with engineVariableStep the time of the
44
+ * display frame it runs on, times timeScale; while paused it keeps the last update's
44
45
  * @type {number}
45
46
  * @default 1/60
46
47
  * @memberof Engine */
47
- const timeDelta = 1/frameRate;
48
+ let timeDelta = 1/frameRate;
48
49
 
49
50
  /** Array containing all engine objects
50
51
  * @type {Array<EngineObject>}
@@ -56,6 +57,9 @@ let engineObjects = [];
56
57
  * @memberof Engine */
57
58
  let engineObjectsCollide = [];
58
59
 
60
+ // the same objects with the static ones last, the order 2D physics checks them in
61
+ let engineObjectsCollideStaticLast = [];
62
+
59
63
  /** Current update frame, used to calculate time
60
64
  * @type {number}
61
65
  * @memberof Engine */
@@ -66,7 +70,7 @@ let frame = 0;
66
70
  * @memberof Engine */
67
71
  let time = 0;
68
72
 
69
- /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
73
+ /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping; the debug speed keys scale it in debug builds)
70
74
  * @type {number}
71
75
  * @memberof Engine */
72
76
  let timeReal = 0;
@@ -89,8 +93,97 @@ function setPaused(isPaused=true) { paused = isPaused; }
89
93
 
90
94
  // Engine internal variables
91
95
  let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
96
+
97
+ // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
98
+ // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
99
+ // length is estimated from recent frames and kept internal, since the browser does not say what it is
100
+ const frameDeltaHistory = [];
101
+ let frameIntervalMS = 1e3 / 60, frameDeltaCarryMS = 0, frameDeltaCarryAverageMS = 0, frameDeltaSmoothing = true;
102
+ function engineSmoothDelta(deltaMS)
103
+ {
104
+ if (!(deltaMS > 0)) return 0;
105
+ if (deltaMS < 250) // a gap from a hidden tab is not a display frame
106
+ {
107
+ frameDeltaHistory.push(deltaMS);
108
+ frameDeltaHistory.length > 64 && frameDeltaHistory.shift();
109
+
110
+ // the frames each delta held are counted in the last estimate or in the lower quartile of the last 16,
111
+ // whichever the deltas fit better: the quartile is one frame even when a busy game misses most of them, and
112
+ // it takes over when the window moves to a display with another refresh rate, or the estimate went wrong
113
+ const recent = frameDeltaHistory.slice(-16).sort((a, b)=> a - b);
114
+ const [lastMS, lastOff] = engineFrameFit(frameIntervalMS);
115
+ const [quartileMS, quartileOff] = engineFrameFit(recent[recent.length >> 2]);
116
+ frameIntervalMS = lastOff <= quartileOff ? lastMS : quartileMS;
117
+
118
+ // deltas far from whole frames mean the display has no fixed refresh, with a margin so it does not flip
119
+ frameDeltaSmoothing = min(lastOff, quartileOff) < (frameDeltaSmoothing ? .2 : .12);
120
+ }
121
+
122
+ if (!frameDeltaSmoothing)
123
+ {
124
+ // deltas that are not whole frames of one length, like a variable refresh display, are the frame times
125
+ // as they are, with any carry paid, and smoothing starts over once they fit again
126
+ const rawMS = deltaMS + frameDeltaCarryMS;
127
+ frameDeltaCarryMS = min(rawMS, 0);
128
+ frameDeltaCarryAverageMS = 0;
129
+ return max(rawMS, 0);
130
+ }
131
+
132
+ // whole frames, 0 for one that came early or 2 after one was missed, the carry stays within half a frame; a
133
+ // little of its slow average is paid each frame, so jitter can not hold it at half a frame and flip every frame
134
+ const pullMS = frameDeltaCarryAverageMS / 32;
135
+ frameDeltaCarryMS += deltaMS;
136
+ const frames = round((frameDeltaCarryMS - pullMS) / frameIntervalMS);
137
+ if (frames < 1)
138
+ return 0; // the carry keeps it
139
+ const smoothMS = frames * frameIntervalMS + pullMS;
140
+ frameDeltaCarryMS -= smoothMS;
141
+ frameDeltaCarryAverageMS += (frameDeltaCarryMS - frameDeltaCarryAverageMS) / 32;
142
+ return smoothMS;
143
+ }
144
+
145
+ // fit the delta history as whole frames of about unitMS: the frame length is the least squares slope of time over
146
+ // frames held, so jitter and whole ms timestamps average out, with how far the deltas are from whole frames
147
+ function engineFrameFit(unitMS)
148
+ {
149
+ let timeMS = 0, frames = 0, offMS = 0, sumF = 0, sumT = 0, sumFF = 0, sumFT = 0;
150
+ const count = frameDeltaHistory.length;
151
+ for (const d of frameDeltaHistory)
152
+ {
153
+ const held = max(1, round(d / unitMS));
154
+ timeMS += d;
155
+ frames += held;
156
+ offMS += abs(d - held * unitMS);
157
+ sumF += frames, sumT += timeMS, sumFF += frames * frames, sumFT += frames * timeMS;
158
+ }
159
+ const spread = count * sumFF - sumF * sumF;
160
+ return [spread ? (count * sumFT - sumF * sumT) / spread : unitMS, offMS / count / unitMS];
161
+ }
162
+
163
+ // where the fixed step's time counts from, moved when the variable step hands back so time goes on from there
164
+ let timeFixedStart = 0, frameFixedStart = 0;
92
165
  let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
93
166
  let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
167
+ let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
168
+
169
+ // the pairs of objects asked about a collision this update, so the other's own physics does not ask again: each
170
+ // asker's others, with true for a pair both said to resolve, and false for one left overlapping, ignored or only
171
+ // nudged apart; a map for each asker so the lookup stays quick when many objects pile up on one spot
172
+ const engineObjectsCollidePairs = new Map;
173
+ function engineObjectsCollidePairAnswer(asker, other)
174
+ { return engineObjectsCollidePairs.get(asker)?.get(other); }
175
+ function engineObjectsCollidePairAdd(asker, other, resolve=false)
176
+ {
177
+ let others = engineObjectsCollidePairs.get(asker);
178
+ others || engineObjectsCollidePairs.set(asker, others = new Map);
179
+ others.set(other, resolve);
180
+ }
181
+ let engineInitialized = false; // engineInit ran, with or without a canvas
182
+ // the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
183
+ // loop starts
184
+ let engineLoads, engineLoadsDone = 0;
185
+ let engineObjectsUpdateCount = 0; // passes of engineObjectsUpdate so far, how a child knows it moved this pass
186
+ const engineChildStack = []; // the children being updated, taken off the live lists so one leaving does not skip the next
94
187
  let showEngineVersion = true;
95
188
 
96
189
  ///////////////////////////////////////////////////////////////////////////////
@@ -115,6 +208,7 @@ class EnginePlugin
115
208
  */
116
209
 
117
210
  /** Add a new update function for a plugin
211
+ * - update runs on every fixed tick, paused and timeScale 0 included; a plugin that simulates should skip those
118
212
  * @param {PluginCallback} [update]
119
213
  * @param {PluginCallback} [render]
120
214
  * @param {PluginCallback} [glContextLost]
@@ -137,24 +231,83 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
137
231
  ///////////////////////////////////////////////////////////////////////////////
138
232
  // Main Engine Functions
139
233
 
234
+ /** Add something the game loads to what startup waits for: while engineInit and gameInit run, the game loop starts
235
+ * once it is done, and the loading screen counts it; images from loadTexture and sounds from files are added on
236
+ * their own, and a load that fails counts as done; after startup it does nothing
237
+ * @param {Promise<any>} promise
238
+ * @return {Promise<any>} - The same promise
239
+ * @example
240
+ * async function gameInit() { level = await engineAddLoad(fetchJSON('level.json')); }
241
+ * @memberof Engine */
242
+ function engineAddLoad(promise)
243
+ {
244
+ if (engineLoads)
245
+ {
246
+ engineLoads.push(promise);
247
+ const done = ()=> { ++engineLoadsDone; };
248
+ promise.then(done, done);
249
+ }
250
+ return promise;
251
+ }
252
+
253
+ // wait for every load, the ones added while waiting too, drawing the loading screen each frame meanwhile
254
+ async function engineWaitForLoads()
255
+ {
256
+ let waiting = true;
257
+ const start = performance.now();
258
+ const drawFrame = ()=>
259
+ {
260
+ if (!waiting) return;
261
+ engineLoadingScreenDraw((performance.now() - start) / 1e3);
262
+ setTimeout(drawFrame, 16);
263
+ };
264
+ headlessMode || drawFrame();
265
+ try
266
+ {
267
+ for (let count; count !== engineLoads.length;)
268
+ {
269
+ count = engineLoads.length;
270
+ await Promise.allSettled(engineLoads);
271
+ }
272
+ }
273
+ finally { waiting = false; }
274
+ }
275
+
276
+ // one frame of the loading screen, once loading has taken half a second, so a fast load never shows it; input
277
+ // while it shows is dropped, as it is under the splash
278
+ function engineLoadingScreenDraw(elapsed)
279
+ {
280
+ if (headlessMode || !loadingScreen || elapsed < .5) return;
281
+ inputClear();
282
+ engineUpdateCanvas();
283
+ loadingScreen(engineLoadsDone / engineLoads.length);
284
+ }
285
+
140
286
  /**
141
287
  * @callback GameInitCallback - Called after the engine starts, can be async
142
288
  * @return {void|Promise<void>}
143
289
  * @memberof Engine
144
290
  */
291
+ /**
292
+ * @callback LoadingScreenCallback - Draws the loading screen on mainContext, each frame while the game loads
293
+ * @param {number} progress - The part of the loads done, 0 to 1
294
+ * @memberof Engine
295
+ */
145
296
  /**
146
297
  * @callback GameCallback - Update or render function for the game
147
298
  * @memberof Engine
148
299
  */
149
300
 
150
301
  /** Startup LittleJS engine with your callback functions
151
- * @param {GameInitCallback} gameInit - Called once after the engine starts up, can be async for loading
152
- * @param {GameCallback} gameUpdate - Called every frame before objects are updated (60fps), use for game logic
153
- * @param {GameCallback} gameUpdatePost - Called after physics and objects are updated, even when paused, use for UI updates
154
- * @param {GameCallback} gameRender - Called before objects are rendered, use for drawing backgrounds/world elements
155
- * @param {GameCallback} gameRenderPost - Called after objects are rendered, use for drawing UI/overlays
302
+ * @param {GameInitCallback} [gameInit] - Called once after the engine starts up, can be async for loading
303
+ * @param {GameCallback} [gameUpdate] - Called every frame before objects are updated (60fps), use for game logic
304
+ * @param {GameCallback} [gameUpdatePost] - Called after physics and objects are updated, even when paused, use for UI updates
305
+ * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
306
+ * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
156
307
  * @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
157
308
  * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
309
+ * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
310
+ * so set canvasFixedSize or canvasMaxSize to fit a smaller element
158
311
  * @example
159
312
  * // Basic engine startup
160
313
  * engineInit(
@@ -169,17 +322,14 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
169
322
  async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
170
323
  {
171
324
  showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
172
- ASSERT(!mainContext, 'engine already initialized');
325
+ ASSERT(!engineInitialized, 'engine already initialized');
173
326
  // runtime guard so release builds (where the assert is stripped) don't
174
327
  // double-register listeners / double-add canvases on a second call
175
- if (mainContext) return;
328
+ if (engineInitialized) return;
329
+ engineInitialized = true;
330
+ engineLoads = [], engineLoadsDone = 0;
176
331
  ASSERT(isArray(imageSources), 'pass in images as array');
177
332
 
178
- // ensure body exists for minimal HTML where the script runs before <body> is parsed
179
- if (!document.body)
180
- document.documentElement.appendChild(document.createElement('body'));
181
- rootElement ||= document.body;
182
-
183
333
  // allow passing in empty functions
184
334
  gameInit ||= ()=>{};
185
335
  gameUpdate ||= ()=>{};
@@ -190,14 +340,15 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
190
340
  // Called automatically by engine to setup render system
191
341
  function enginePreRender()
192
342
  {
193
- // mainCanvasSize is set by engineUpdateCanvas which always runs first,
194
- // it is css pixels so it does not match the canvas backing store
343
+ // the level editor's own view while it is open, in debug builds, before the camera goes to WebGL
344
+ editorPreRender();
195
345
 
196
346
  // disable smoothing for pixel art
197
347
  mainContext.imageSmoothingEnabled = !tilesPixelated;
198
348
 
199
349
  // setup gl rendering if enabled
200
350
  glPreRender();
351
+ setShader(); // a shader left set last frame does not carry into this one
201
352
 
202
353
  // plugins that draw underneath the 2D layer
203
354
  pluginList.forEach(plugin=>plugin.preRender?.());
@@ -206,6 +357,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
206
357
  // internal update loop for engine
207
358
  function engineUpdate(frameTimeMS=0)
208
359
  {
360
+ const manualStepAtStart = engineManualStep;
361
+
209
362
  // update time keeping
210
363
  let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
211
364
  // skip delta on the very first frame so timeReal doesn't jump
@@ -214,8 +367,14 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
214
367
  frameTimeLastMS = frameTimeMS;
215
368
  if (debug || debugWatermark)
216
369
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
217
- const debugSpeedUp = debug && keyIsDown('Equal'); // +
218
- const debugSpeedDown = debug && keyIsDown('Minus'); // -
370
+ // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
371
+ if (!manualStepAtStart)
372
+ frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
373
+ audioUpdateVolume();
374
+ // the time keys work while the debug overlay is open, or always when debugKeysAlways is set
375
+ const debugKeys = debug && (debugOverlay || debugKeysAlways);
376
+ const debugSpeedUp = debugKeys && keyIsDown('Equal'); // +
377
+ const debugSpeedDown = debugKeys && keyIsDown('Minus'); // -
219
378
  const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
220
379
 
221
380
  // apply time deltas
@@ -223,40 +382,75 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
223
382
  timeReal += frameTimeDeltaMS * debugScale / 1e3;
224
383
  const combinedScale = timeScale * debugScale;
225
384
  frameTimeDeltaMS *= combinedScale;
226
- // when paused tick on unscaled time so the pause update rate stays
227
- // fixed instead of following however fast the display refreshes
228
- frameTimeBufferMS += paused ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
229
- if (paused || combinedScale <= 1)
230
- frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
231
-
232
- // apply time delta smoothing, improves smoothness of framerate in some browsers
233
- let wasUpdated = false, deltaSmooth = 0;
234
- if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
385
+ let wasUpdated = false;
386
+ if (engineVariableStep)
235
387
  {
236
- // force at least one update each frame since it is waiting for refresh
237
- deltaSmooth = frameTimeBufferMS;
238
- frameTimeBufferMS = 0;
388
+ // one update for the frame with timeDelta the time it covers, per-frame values are the game's to scale;
389
+ // engineStep's frames are exactly 1/60, its first too, which has no frame before it to take a delta from
390
+ if (frameTimeDeltaUnscaledMS > 0 || manualStepAtStart)
391
+ {
392
+ // frozen stands still as in the fixed step, and timeDelta keeps the last update's
393
+ const frozenTick = paused || !combinedScale;
394
+ if (!frozenTick)
395
+ {
396
+ timeDelta = manualStepAtStart ? combinedScale / frameRate :
397
+ min(frameTimeDeltaUnscaledMS, 50) * combinedScale / 1e3; // clamp min framerate
398
+ time += timeDelta;
399
+ ++frame;
400
+ }
401
+ engineTick(frozenTick);
402
+ }
239
403
  }
240
-
241
- // update multiple frames if necessary in case of slow framerate
242
- for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
404
+ else
243
405
  {
244
- // increment frame and update time, paused does not advance time
245
- if (!paused)
246
- time = frame++ / frameRate;
406
+ // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead
407
+ // of following however fast the display refreshes, and gameUpdatePost and input still run to leave it
408
+ const frozen = paused || !combinedScale;
409
+ frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
410
+ frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
411
+
412
+ // apply time delta smoothing, improves smoothness of framerate in some browsers
413
+ let deltaSmooth = 0;
414
+ if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
415
+ {
416
+ // force at least one update each frame since it is waiting for refresh
417
+ deltaSmooth = frameTimeBufferMS;
418
+ frameTimeBufferMS = 0;
419
+ }
420
+
421
+ // update multiple frames if necessary in case of slow framerate
422
+ for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
423
+ {
424
+ // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
425
+ const frozenTick = paused || !(timeScale * debugScale);
426
+
427
+ // increment frame and update time, frozen does not advance time
428
+ if (!frozenTick)
429
+ time = timeFixedStart + (frame++ - frameFixedStart) / frameRate;
430
+ engineTick(frozenTick);
431
+ }
247
432
 
248
- // update game and objects, when paused update everything except them
433
+ // add the time smoothing back in
434
+ frameTimeBufferMS += deltaSmooth;
435
+ }
436
+
437
+ // one tick of the loop: update game and objects, when frozen update everything except them
438
+ function engineTick(frozenTick)
439
+ {
249
440
  wasUpdated = true;
250
441
  engineUpdateCanvas();
251
442
  inputUpdate();
252
- if (!paused)
443
+ if (!frozenTick)
253
444
  gameUpdate();
254
445
  pluginList.forEach(plugin=>plugin.update?.());
255
- if (paused)
446
+ if (frozenTick)
256
447
  {
257
448
  // update object transforms even when paused
258
449
  for (const o of engineObjects)
259
450
  o.parent || o.updateTransforms();
451
+
452
+ // objects made and destroyed while paused, like a menu's effects, still leave the list
453
+ engineObjects = engineObjects.filter(o=>!o.destroyed);
260
454
  }
261
455
  else
262
456
  engineObjectsUpdate();
@@ -269,8 +463,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
269
463
  renderFrame();
270
464
  }
271
465
 
272
- // add the time smoothing back in
273
- frameTimeBufferMS += deltaSmooth;
466
+ // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
467
+ if (engineManualStep && !manualStepAtStart)
468
+ frameTimeBufferMS = -.5e3 / frameRate;
274
469
 
275
470
  // check if the window changed so a resize is picked up even when
276
471
  // the game is not updating, for example when timeScale is 0
@@ -289,8 +484,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
289
484
  // than the fixed update rate would otherwise redraw identical frames
290
485
  if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
291
486
  renderFrame();
292
- if (!engineManualStep)
293
- requestAnimationFrame(engineUpdate);
487
+ engineManualStep || engineScheduleFrame();
294
488
 
295
489
  function renderFrame()
296
490
  {
@@ -314,6 +508,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
314
508
 
315
509
  // post rendering
316
510
  gameRenderPost();
511
+ setShader(); // plugin, input and debug draws start from the engine's state
512
+ setAdditiveBlendMode(false);
317
513
  pluginList.forEach(plugin=>plugin.render?.());
318
514
  inputRender();
319
515
  debugRender();
@@ -323,24 +519,32 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
323
519
  primitiveCount = 0;
324
520
  }
325
521
  }
326
- engineUpdateInternal = engineUpdate;
327
522
 
328
523
  // skip setup if headless
329
- if (headlessMode) return startEngine();
524
+ if (headlessMode) return startEngine([]);
525
+
526
+ // ensure body exists for minimal HTML where the script runs before <body> is parsed
527
+ if (!document.body)
528
+ document.documentElement.appendChild(document.createElement('body'));
529
+ rootElement ||= document.body;
330
530
 
331
531
  // setup webgl
332
532
  glInit(rootElement);
333
533
 
334
534
  // setup html
335
- const styleRoot =
535
+ let styleRoot =
336
536
  'margin:0;' + // fill the window
337
537
  'overflow:hidden;' + // no scroll bars
338
538
  'background:#000;' + // set background color
339
539
  'user-select:none;' + // prevent hold to select
340
540
  '-webkit-user-select:none;' + // compatibility for ios
341
541
  'touch-action:none;' + // prevent mobile pinch to resize
342
- '-webkit-touch-callout:none'; // compatibility for ios
343
- rootElement.style.cssText = styleRoot;
542
+ '-webkit-touch-callout:none;'; // compatibility for ios
543
+ // the canvases center on a root element with a height of its own, not the page; one sized only by its
544
+ // children has none, since the canvases are placed apart from it, and would clip them all away
545
+ if (rootElement !== document.body && rootElement.clientHeight && getComputedStyle(rootElement).position === 'static')
546
+ styleRoot += 'position:relative;';
547
+ rootElement.style.cssText = styleRoot + rootElement.style.cssText; // its own inline styles come after and win
344
548
  mainCanvas = rootElement.appendChild(document.createElement('canvas'));
345
549
  drawContext = mainContext = mainCanvas.getContext('2d');
346
550
 
@@ -367,6 +571,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
367
571
  workReadCanvas = workReadContext.canvas;
368
572
 
369
573
  // create promises for loading images
574
+ /** @type {Array<Promise<any>>} */
370
575
  const promises = imageSources.map((src, i)=> loadTexture(i, src));
371
576
 
372
577
  // no images to load
@@ -379,7 +584,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
379
584
  if (showSplashScreen)
380
585
  {
381
586
  // draw splash screen
382
- promises.push(new Promise(resolve =>
587
+ /** @type {Promise<void>} */
588
+ const splash = new Promise(resolve =>
383
589
  {
384
590
  let t = 0;
385
591
  updateSplash();
@@ -389,17 +595,24 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
389
595
  drawEngineLogo(t+=.01);
390
596
  t>1 ? resolve() : setTimeout(updateSplash, 16);
391
597
  }
392
- }));
598
+ });
599
+ promises.push(splash);
393
600
  }
394
601
 
395
- // wait for all the promises to finish
396
- await Promise.all(promises);
397
- return startEngine();
602
+ // the splash first, the images load under it, then the loading screen for the rest
603
+ showSplashScreen && await promises.at(-1);
604
+ return startEngine(promises);
398
605
 
399
- async function startEngine()
606
+ // gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
607
+ // done, the loading screen showing in the meantime; an error in gameInit reaches the caller
608
+ async function startEngine(images)
400
609
  {
401
- // wait for gameInit to load
402
- await gameInit();
610
+ const init = (async ()=> { await Promise.all(images); await gameInit(); })();
611
+ engineAddLoad(init);
612
+ await engineWaitForLoads();
613
+ engineLoads = undefined;
614
+ await init;
615
+ engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
403
616
  engineManualStep || engineUpdate();
404
617
  }
405
618
  }
@@ -440,9 +653,9 @@ function engineUpdateCanvas()
440
653
  mainCanvasSize.x = min(innerWidth, canvasMaxSize.x) | 0;
441
654
  mainCanvasSize.y = min(innerHeight, canvasMaxSize.y) | 0;
442
655
 
443
- // responsive aspect ratio
444
- const innerAspect = innerWidth / innerHeight;
445
- ASSERT(canvasMinAspect <= canvasMaxAspect);
656
+ // responsive aspect ratio, of the size after canvasMaxSize, which can change its shape
657
+ const innerAspect = mainCanvasSize.x / mainCanvasSize.y;
658
+ ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect);
446
659
  if (canvasMaxAspect && innerAspect > canvasMaxAspect)
447
660
  {
448
661
  // full height
@@ -495,19 +708,38 @@ function engineUpdateCanvas()
495
708
  mainContext.fillStyle = BLACK.toString();
496
709
  }
497
710
 
498
- // set default line join and cap
711
+ // set default line join and cap, round on purpose: it looks better, suits text and keeps sharp corners from
712
+ // spiking far out; WebGL outlines are square and mitered for speed, the two are not meant to match
499
713
  mainContext.lineJoin = 'round';
500
714
  mainContext.lineCap = 'round';
501
715
  }
502
716
 
717
+ // ask for the next frame of the loop, once however often it is called before that frame, and skip it if manual
718
+ // step was turned on since, so turning it off and on again within a frame can not start a second loop
719
+ function engineScheduleFrame()
720
+ {
721
+ if (engineFrameScheduled) return;
722
+ engineFrameScheduled = true;
723
+ const next = (frameTimeMS)=>
724
+ {
725
+ engineFrameScheduled = false;
726
+ engineManualStep || engineUpdateInternal(frameTimeMS);
727
+ };
728
+ if (typeof requestAnimationFrame === 'function')
729
+ requestAnimationFrame(next);
730
+ else // a headless server in Node has no display to wait for, a timer keeps the pace
731
+ setTimeout(()=> next(performance.now()), 1e3 / frameRate);
732
+ }
733
+
503
734
  // max frames engineStep can advance in one call, 10 minutes at 60fps
504
735
  // large counts block until they finish, so this catches runaway values
505
736
  const engineStepMaxFrames = 36000;
506
737
 
507
738
  /** Advance the engine by a number of frames
508
- * Requires setEngineManualStep(true) before engineInit
739
+ * Requires setEngineManualStep(true), before engineInit or while running; it stops early if an update turns it off
509
740
  * Respects paused exactly as the normal update loop does
510
- * @param {number} [frames] - number of engine update ticks, max 36000, each running one fixed update at timeScale 1
741
+ * @param {number} [frames] - frames of 1/60 of a second to advance, max 36000; timeScale sets how many fixed
742
+ * updates they run, as in the normal loop, one each at timeScale 1
511
743
  * @example
512
744
  * setHeadlessMode(true);
513
745
  * setEngineManualStep(true);
@@ -517,7 +749,7 @@ const engineStepMaxFrames = 36000;
517
749
  function engineStep(frames=1)
518
750
  {
519
751
  ASSERT(engineManualStep,
520
- 'engineStep requires setEngineManualStep(true) before engineInit');
752
+ 'engineStep requires setEngineManualStep(true)');
521
753
  ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete');
522
754
  // runtime guard so release builds (where the asserts are stripped) can't
523
755
  // start a second requestAnimationFrame chain or call an undefined update
@@ -525,42 +757,73 @@ function engineStep(frames=1)
525
757
  ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames,
526
758
  'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames);
527
759
  frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze
528
- for (let i = frames; i > 0; --i)
760
+ for (let i = frames; i > 0 && engineManualStep; --i) // an update that turns manual step off hands back the loop
529
761
  engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate);
530
762
  }
531
763
 
532
- /** Update each engine object, remove destroyed objects, and update time
533
- * can be called manually if objects need to be updated outside of main loop
764
+ /** Update each engine object and remove destroyed objects; time and frame are advanced by the engine loop, not here
765
+ * - Can be called manually if objects need to be updated outside of main loop
534
766
  * @memberof Engine */
535
767
  function engineObjectsUpdate()
536
768
  {
537
- // get list of solid objects for physics optimization
769
+ ++engineObjectsUpdateCount;
770
+ engineObjectsCollidePairs.clear();
771
+ // objects update in render order, which rendering keeps them in, so a headless run or a frame that rendered
772
+ // nothing updates them the same way; the sort is stable, and nearly free on a list that is already sorted
773
+ engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
774
+ // get list of solid objects for physics optimization, in update order, which 3D collision pairs by;
775
+ // 2D checks the static ones last, so a contact with a moving object can not leave something back inside a static
776
+ // solid it was already pushed out of
538
777
  engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
778
+ engineObjectsCollideStaticLast = engineObjectsCollide.filter(o=>o.mass)
779
+ .concat(engineObjectsCollide.filter(o=>!o.mass));
539
780
 
540
781
  // update physics before object update
541
782
  for (const o of engineObjects)
542
783
  if (!o.parent && !o.destroyed)
543
784
  o.updatePhysics();
544
785
 
545
- // recursive object update
786
+ // recursive object update: the children are walked from a copy on a shared stack, since a child that
787
+ // destroys itself leaves its parent's list on the spot and the next child would slide past the loop
788
+ function updateChildObjects(children)
789
+ {
790
+ if (!children.length) return; // most objects have none, and this runs for every one
791
+ const start = engineChildStack.length;
792
+ for (const child of children)
793
+ engineChildStack.push(child);
794
+ for (let i = start; i < engineChildStack.length; ++i)
795
+ updateChildObject(engineChildStack[i]);
796
+ engineChildStack.length = start;
797
+ }
798
+ const pass = engineObjectsUpdateCount;
546
799
  function updateChildObject(o)
547
800
  {
548
- if (o.destroyed) return;
801
+ if (o.destroyed || o.updatePass === pass) return;
549
802
 
803
+ // its parent is up to date, so it updates from where it is now, and its children from where it is after
804
+ o.updatePass = pass;
805
+ o.updateTransforms(false);
550
806
  o.update();
551
- for (const child of o.children)
552
- updateChildObject(child);
807
+ o.children.length && o.updateTransforms(false);
808
+ updateChildObjects(o.children);
553
809
  }
554
- for (const o of engineObjects)
810
+ function updateTopObject(o)
555
811
  {
556
- if (o.parent || o.destroyed) continue;
812
+ if (o.parent || o.destroyed || o.updatePass === pass) return; // a child that let go is not updated twice
557
813
 
558
- // update top level objects
814
+ // update top level objects, each child places itself before it updates so it sees this frame's position,
815
+ // then the whole tree is placed again so what the children changed in their localPos lands before render
816
+ o.updatePass = pass;
559
817
  o.update();
560
- for (const child of o.children)
561
- updateChildObject(child);
818
+ updateChildObjects(o.children);
562
819
  o.updateTransforms();
563
820
  }
821
+ for (const o of engineObjects)
822
+ updateTopObject(o);
823
+
824
+ // a child let go during the update from a place in the list already walked is on its own now, updated here
825
+ for (const o of engineObjects)
826
+ updateTopObject(o);
564
827
 
565
828
  // remove destroyed objects
566
829
  engineObjects = engineObjects.filter(o=>!o.destroyed);
@@ -570,7 +833,7 @@ function engineObjectsUpdate()
570
833
  * - This can be used to clear out all objects when restarting a level
571
834
  * - Objects with the persistent flag set are left alone, for things that outlive a level
572
835
  * - Objects can override their destroy function to do cleanup or stick around
573
- * @param {boolean} [immediate] - should attached effects be allowed to die off?
836
+ * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first
574
837
  * @memberof Engine */
575
838
  function engineObjectsDestroy(immediate=true)
576
839
  {
@@ -580,32 +843,43 @@ function engineObjectsDestroy(immediate=true)
580
843
  }
581
844
 
582
845
  /** Collects all object within a given area
846
+ * - An object is collected when its box overlaps the area, or with testCenters when its center is inside it
847
+ * - Objects destroyed this frame are left out, they are only in the list until the frame ends
583
848
  * @param {Vector2} [pos] - Center of test area, or undefined for all objects
584
- * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
849
+ * @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2,
850
+ * left out or 0 the objects that overlap the point at pos
585
851
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
852
+ * @param {boolean} [testCenters] - Test only each object's center, a little faster, and ignores object sizes
586
853
  * @return {Array<EngineObject>} - List of collected objects
587
854
  * @memberof Engine */
588
- function engineObjectsCollect(pos, size, objects=engineObjects)
855
+ function engineObjectsCollect(pos, size, objects=engineObjects, testCenters=false)
589
856
  {
590
857
  const collectedObjects = [];
591
858
  if (!pos)
592
859
  {
593
860
  // all objects
594
861
  for (const o of objects)
595
- collectedObjects.push(o);
862
+ o.destroyed || collectedObjects.push(o);
596
863
  }
597
- else if (size instanceof Vector2)
864
+ else if (!size || size instanceof Vector2)
598
865
  {
599
- // bounding box test
866
+ // bounding box test, a point when there is no size or a size of 0
867
+ const boxSize = size instanceof Vector2 ? size : vec2();
600
868
  for (const o of objects)
601
- o.isOverlapping(pos, size) && collectedObjects.push(o);
869
+ o.destroyed || (testCenters ? isOverlapping(pos, boxSize, o.pos) : o.isOverlapping(pos, boxSize))
870
+ && collectedObjects.push(o);
602
871
  }
603
872
  else
604
873
  {
605
- // circle test
606
- const sizeSquared = size*size;
874
+ // circle test, a diameter like every other size, against the nearest point of each box
875
+ const radiusSquared = (size/2)**2;
607
876
  for (const o of objects)
608
- pos.distanceSquared(o.pos) < sizeSquared && collectedObjects.push(o);
877
+ {
878
+ if (o.destroyed) continue;
879
+ const dx = testCenters ? pos.x - o.pos.x : max(abs(pos.x - o.pos.x) - o.size.x/2, 0);
880
+ const dy = testCenters ? pos.y - o.pos.y : max(abs(pos.y - o.pos.y) - o.size.y/2, 0);
881
+ dx*dx + dy*dy < radiusSquared && collectedObjects.push(o);
882
+ }
609
883
  }
610
884
  return collectedObjects;
611
885
  }
@@ -616,16 +890,23 @@ function engineObjectsCollect(pos, size, objects=engineObjects)
616
890
  * @memberof Engine
617
891
  */
618
892
 
619
- /** Triggers a callback for each object within a given area
893
+ /** Triggers a callback for each object within a given area, objects destroyed this frame left out
620
894
  * @param {Vector2} [pos] - Center of test area, or undefined for all objects
621
- * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
622
- * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test
895
+ * @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2
896
+ * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test, needed
897
+ * (marked optional only because the area before it is)
623
898
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
899
+ * @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect
624
900
  * @memberof Engine */
625
- function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects)
626
- { engineObjectsCollect(pos, size, objects).forEach(o => callbackFunction(o)); }
901
+ function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects, testCenters=false)
902
+ {
903
+ // an object an earlier callback destroyed is skipped
904
+ for (const o of engineObjectsCollect(pos, size, objects, testCenters))
905
+ o.destroyed || callbackFunction(o);
906
+ }
627
907
 
628
- /** Return a list of objects intersecting a ray
908
+ /** Return a list of objects intersecting a ray, objects destroyed this frame left out
909
+ * - Only objects with collideRaycast set are hit, which setCollision turns on
629
910
  * @param {Vector2} start
630
911
  * @param {Vector2} end
631
912
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
@@ -636,13 +917,13 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
636
917
  const hitObjects = [];
637
918
  for (const o of objects)
638
919
  {
639
- if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size))
920
+ if (o.collideRaycast && !o.destroyed && isIntersecting(start, end, o.pos, o.size))
640
921
  {
641
- debugRaycast && debugRect(o.pos, o.size, '#f00');
922
+ debugRaycast && debugRect(o.pos, o.size, '#f00', 0, 0, false, false);
642
923
  hitObjects.push(o);
643
924
  }
644
925
  }
645
926
 
646
- debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
927
+ debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02, 0, false);
647
928
  return hitObjects;
648
929
  }