littlejsengine 1.18.29 → 1.20.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 (51) hide show
  1. package/FAQ.md +841 -815
  2. package/README.md +211 -199
  3. package/dist/littlejs.d.ts +4383 -812
  4. package/dist/littlejs.esm.js +31848 -16680
  5. package/dist/littlejs.esm.min.js +14 -1
  6. package/dist/littlejs.js +31230 -16166
  7. package/dist/littlejs.min.js +13 -1
  8. package/dist/littlejs.release.js +27419 -15457
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +375 -0
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -330
  16. package/plugins/math3d.js +911 -0
  17. package/plugins/medalSystem.js +118 -72
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +196 -115
  21. package/plugins/postProcess.js +284 -166
  22. package/plugins/render3d.js +4072 -0
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +86 -48
  27. package/plugins/threejs.js +183 -158
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +646 -526
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +8 -15
  32. package/src/engine.js +345 -194
  33. package/src/engineAudio.js +1022 -777
  34. package/src/engineBuild.mjs +289 -253
  35. package/src/engineDebug.js +912 -735
  36. package/src/engineDraw.js +1774 -1510
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +419 -396
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineLogo.js +4 -3
  41. package/src/engineMath.js +1274 -1183
  42. package/src/engineObject.js +674 -551
  43. package/src/engineParticles.js +583 -532
  44. package/src/engineRelease.js +67 -45
  45. package/src/engineSettings.js +808 -726
  46. package/src/engineTileLayer.js +995 -741
  47. package/src/engineUtilities.js +32 -13
  48. package/src/engineWebGL.js +1162 -941
  49. package/src/jsconfig.json +17 -13
  50. package/plugins/box2d.wasm.js +0 -630
  51. 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.18.29';
35
+ const engineVersion = '1.20.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -56,6 +56,9 @@ let engineObjects = [];
56
56
  * @memberof Engine */
57
57
  let engineObjectsCollide = [];
58
58
 
59
+ // the same objects with the static ones last, the order 2D physics checks them in
60
+ let engineObjectsCollideStaticLast = [];
61
+
59
62
  /** Current update frame, used to calculate time
60
63
  * @type {number}
61
64
  * @memberof Engine */
@@ -66,7 +69,7 @@ let frame = 0;
66
69
  * @memberof Engine */
67
70
  let time = 0;
68
71
 
69
- /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
72
+ /** 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
73
  * @type {number}
71
74
  * @memberof Engine */
72
75
  let timeReal = 0;
@@ -89,7 +92,25 @@ function setPaused(isPaused=true) { paused = isPaused; }
89
92
 
90
93
  // Engine internal variables
91
94
  let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
95
+ let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
92
96
  let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
97
+ let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
98
+
99
+ // the pairs of objects asked about a collision this update, so the other's own physics does not ask again: each
100
+ // asker's others, with true for a pair both said to resolve, and false for one left overlapping, ignored or only
101
+ // nudged apart; a map for each asker so the lookup stays quick when many objects pile up on one spot
102
+ const engineObjectsCollidePairs = new Map;
103
+ function engineObjectsCollidePairAnswer(asker, other)
104
+ { return engineObjectsCollidePairs.get(asker)?.get(other); }
105
+ function engineObjectsCollidePairAdd(asker, other, resolve=false)
106
+ {
107
+ let others = engineObjectsCollidePairs.get(asker);
108
+ others || engineObjectsCollidePairs.set(asker, others = new Map);
109
+ others.set(other, resolve);
110
+ }
111
+ let engineInitialized = false; // engineInit ran, with or without a canvas
112
+ let engineObjectsUpdateCount = 0; // passes of engineObjectsUpdate so far, how a child knows it moved this pass
113
+ const engineChildStack = []; // the children being updated, taken off the live lists so one leaving does not skip the next
93
114
  let showEngineVersion = true;
94
115
 
95
116
  ///////////////////////////////////////////////////////////////////////////////
@@ -98,12 +119,13 @@ let showEngineVersion = true;
98
119
  const pluginList = [];
99
120
  class EnginePlugin
100
121
  {
101
- constructor(update, render, glContextLost, glContextRestored)
122
+ constructor(update, render, glContextLost, glContextRestored, preRender)
102
123
  {
103
124
  this.update = update;
104
125
  this.render = render;
105
126
  this.glContextLost = glContextLost;
106
127
  this.glContextRestored = glContextRestored;
128
+ this.preRender = preRender;
107
129
  }
108
130
  }
109
131
 
@@ -113,20 +135,23 @@ class EnginePlugin
113
135
  */
114
136
 
115
137
  /** Add a new update function for a plugin
138
+ * - update runs on every fixed tick, paused and timeScale 0 included; a plugin that simulates should skip those
116
139
  * @param {PluginCallback} [update]
117
140
  * @param {PluginCallback} [render]
118
141
  * @param {PluginCallback} [glContextLost]
119
142
  * @param {PluginCallback} [glContextRestored]
143
+ * @param {PluginCallback} [preRender] - Called after the canvas is cleared and before gameRender
120
144
  * @memberof Engine */
121
- function engineAddPlugin(update, render, glContextLost, glContextRestored)
145
+ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRender)
122
146
  {
123
147
  // make sure plugin functions are unique
124
148
  ASSERT(!pluginList.find(p=>
125
149
  p.update === update && p.render === render &&
126
150
  p.glContextLost === glContextLost &&
127
- p.glContextRestored === glContextRestored));
151
+ p.glContextRestored === glContextRestored &&
152
+ p.preRender === preRender));
128
153
 
129
- const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored);
154
+ const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored, preRender);
130
155
  pluginList.push(plugin);
131
156
  }
132
157
 
@@ -144,13 +169,15 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
144
169
  */
145
170
 
146
171
  /** Startup LittleJS engine with your callback functions
147
- * @param {GameInitCallback} gameInit - Called once after the engine starts up, can be async for loading
148
- * @param {GameCallback} gameUpdate - Called every frame before objects are updated (60fps), use for game logic
149
- * @param {GameCallback} gameUpdatePost - Called after physics and objects are updated, even when paused, use for UI updates
150
- * @param {GameCallback} gameRender - Called before objects are rendered, use for drawing backgrounds/world elements
151
- * @param {GameCallback} gameRenderPost - Called after objects are rendered, use for drawing UI/overlays
172
+ * @param {GameInitCallback} [gameInit] - Called once after the engine starts up, can be async for loading
173
+ * @param {GameCallback} [gameUpdate] - Called every frame before objects are updated (60fps), use for game logic
174
+ * @param {GameCallback} [gameUpdatePost] - Called after physics and objects are updated, even when paused, use for UI updates
175
+ * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
176
+ * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
152
177
  * @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
153
178
  * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
179
+ * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
180
+ * so set canvasFixedSize or canvasMaxSize to fit a smaller element
154
181
  * @example
155
182
  * // Basic engine startup
156
183
  * engineInit(
@@ -165,17 +192,13 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
165
192
  async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
166
193
  {
167
194
  showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
168
- ASSERT(!mainContext, 'engine already initialized');
195
+ ASSERT(!engineInitialized, 'engine already initialized');
169
196
  // runtime guard so release builds (where the assert is stripped) don't
170
197
  // double-register listeners / double-add canvases on a second call
171
- if (mainContext) return;
198
+ if (engineInitialized) return;
199
+ engineInitialized = true;
172
200
  ASSERT(isArray(imageSources), 'pass in images as array');
173
201
 
174
- // ensure body exists for minimal HTML where the script runs before <body> is parsed
175
- if (!document.body)
176
- document.documentElement.appendChild(document.createElement('body'));
177
- rootElement ||= document.body;
178
-
179
202
  // allow passing in empty functions
180
203
  gameInit ||= ()=>{};
181
204
  gameUpdate ||= ()=>{};
@@ -186,19 +209,25 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
186
209
  // Called automatically by engine to setup render system
187
210
  function enginePreRender()
188
211
  {
189
- // save canvas size
190
- mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height);
212
+ // the level editor's own view while it is open, in debug builds, before the camera goes to WebGL
213
+ editorPreRender();
191
214
 
192
215
  // disable smoothing for pixel art
193
216
  mainContext.imageSmoothingEnabled = !tilesPixelated;
194
217
 
195
218
  // setup gl rendering if enabled
196
219
  glPreRender();
220
+ setShader(); // a shader left set last frame does not carry into this one
221
+
222
+ // plugins that draw underneath the 2D layer
223
+ pluginList.forEach(plugin=>plugin.preRender?.());
197
224
  }
198
225
 
199
226
  // internal update loop for engine
200
227
  function engineUpdate(frameTimeMS=0)
201
228
  {
229
+ const manualStepAtStart = engineManualStep;
230
+
202
231
  // update time keeping
203
232
  let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
204
233
  // skip delta on the very first frame so timeReal doesn't jump
@@ -207,30 +236,61 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
207
236
  frameTimeLastMS = frameTimeMS;
208
237
  if (debug || debugWatermark)
209
238
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
210
- const debugSpeedUp = debug && keyIsDown('Equal'); // +
211
- const debugSpeedDown = debug && keyIsDown('Minus'); // -
239
+ audioUpdateVolume();
240
+ // the time keys work while the debug overlay is open, or always when debugKeysAlways is set
241
+ const debugKeys = debug && (debugOverlay || debugKeysAlways);
242
+ const debugSpeedUp = debugKeys && keyIsDown('Equal'); // +
243
+ const debugSpeedDown = debugKeys && keyIsDown('Minus'); // -
212
244
  const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
213
245
 
214
246
  // apply time deltas
247
+ const frameTimeDeltaUnscaledMS = frameTimeDeltaMS;
215
248
  timeReal += frameTimeDeltaMS * debugScale / 1e3;
216
249
  const combinedScale = timeScale * debugScale;
217
250
  frameTimeDeltaMS *= combinedScale;
218
- frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
219
- if (combinedScale <= 1)
220
- frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
251
+ // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead of
252
+ // following however fast the display refreshes, and gameUpdatePost and input still run to leave it
253
+ const frozen = paused || !combinedScale;
254
+ frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
255
+ frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
256
+
257
+ // apply time delta smoothing, improves smoothness of framerate in some browsers
258
+ let wasUpdated = false, deltaSmooth = 0;
259
+ if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
260
+ {
261
+ // force at least one update each frame since it is waiting for refresh
262
+ deltaSmooth = frameTimeBufferMS;
263
+ frameTimeBufferMS = 0;
264
+ }
221
265
 
222
- let wasUpdated = false;
223
- if (paused)
266
+ // update multiple frames if necessary in case of slow framerate
267
+ for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
224
268
  {
225
- // update everything except the game and objects
269
+ // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
270
+ const frozenTick = paused || !(timeScale * debugScale);
271
+
272
+ // increment frame and update time, frozen does not advance time
273
+ if (!frozenTick)
274
+ time = frame++ / frameRate;
275
+
276
+ // update game and objects, when frozen update everything except them
226
277
  wasUpdated = true;
227
- updateCanvas();
278
+ engineUpdateCanvas();
228
279
  inputUpdate();
280
+ if (!frozenTick)
281
+ gameUpdate();
229
282
  pluginList.forEach(plugin=>plugin.update?.());
283
+ if (frozenTick)
284
+ {
285
+ // update object transforms even when paused
286
+ for (const o of engineObjects)
287
+ o.parent || o.updateTransforms();
230
288
 
231
- // update object transforms even when paused
232
- for (const o of engineObjects)
233
- o.parent || o.updateTransforms();
289
+ // objects made and destroyed while paused, like a menu's effects, still leave the list
290
+ engineObjects = engineObjects.filter(o=>!o.destroyed);
291
+ }
292
+ else
293
+ engineObjectsUpdate();
234
294
 
235
295
  // do post update
236
296
  debugUpdate();
@@ -239,47 +299,32 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
239
299
  if (debugVideoCaptureIsActive())
240
300
  renderFrame();
241
301
  }
242
- else
243
- {
244
- // apply time delta smoothing, improves smoothness of framerate in some browsers
245
- let deltaSmooth = 0;
246
- if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
247
- {
248
- // force at least one update each frame since it is waiting for refresh
249
- deltaSmooth = frameTimeBufferMS;
250
- frameTimeBufferMS = 0;
251
- }
252
302
 
253
- // update multiple frames if necessary in case of slow framerate
254
- for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
255
- {
256
- // increment frame and update time
257
- time = frame++ / frameRate;
303
+ // add the time smoothing back in
304
+ frameTimeBufferMS += deltaSmooth;
258
305
 
259
- // update game and objects
260
- wasUpdated = true;
261
- updateCanvas();
262
- inputUpdate();
263
- gameUpdate();
264
- pluginList.forEach(plugin=>plugin.update?.());
265
- engineObjectsUpdate();
266
-
267
- // do post update
268
- debugUpdate();
269
- gameUpdatePost();
270
- inputUpdatePost();
271
- if (debugVideoCaptureIsActive())
272
- renderFrame();
273
- }
306
+ // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
307
+ if (engineManualStep && !manualStepAtStart)
308
+ frameTimeBufferMS = -.5e3 / frameRate;
274
309
 
275
- // add the time smoothing back in
276
- frameTimeBufferMS += deltaSmooth;
310
+ // check if the window changed so a resize is picked up even when
311
+ // the game is not updating, for example when timeScale is 0
312
+ let windowChanged = false;
313
+ if (!headlessMode)
314
+ {
315
+ const dpr = devicePixelRatio;
316
+ windowChanged = windowWidthLast !== innerWidth ||
317
+ windowHeightLast !== innerHeight || windowPixelRatioLast !== dpr;
318
+ windowWidthLast = innerWidth;
319
+ windowHeightLast = innerHeight;
320
+ windowPixelRatioLast = dpr;
277
321
  }
278
322
 
279
- if (!debugVideoCaptureIsActive())
323
+ // render only when something changed, displays that refresh faster
324
+ // than the fixed update rate would otherwise redraw identical frames
325
+ if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
280
326
  renderFrame();
281
- if (!engineManualStep)
282
- requestAnimationFrame(engineUpdate);
327
+ engineManualStep || engineScheduleFrame();
283
328
 
284
329
  function renderFrame()
285
330
  {
@@ -287,17 +332,24 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
287
332
 
288
333
  // canvas must be updated before rendering
289
334
  if (!wasUpdated)
290
- updateCanvas();
335
+ engineUpdateCanvas();
291
336
 
292
337
  // render the game and objects
293
338
  enginePreRender();
294
339
  gameRender();
295
340
  engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
296
341
  for (const o of engineObjects)
297
- o.destroyed || o.render();
342
+ {
343
+ if (o.destroyed) continue;
344
+ setShader(o.shader); // each object draws with its own shader, or none
345
+ o.render();
346
+ }
347
+ setShader(); // back to the engine's for gameRenderPost
298
348
 
299
349
  // post rendering
300
350
  gameRenderPost();
351
+ setShader(); // plugin, input and debug draws start from the engine's state
352
+ setAdditiveBlendMode(false);
301
353
  pluginList.forEach(plugin=>plugin.render?.());
302
354
  inputRender();
303
355
  debugRender();
@@ -307,102 +359,32 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
307
359
  primitiveCount = 0;
308
360
  }
309
361
  }
310
- engineUpdateInternal = engineUpdate;
311
-
312
- function updateCanvas()
313
- {
314
- if (headlessMode) return;
315
-
316
- if (canvasFixedSize.x)
317
- {
318
- // set canvas fixed size
319
- mainCanvasSize = canvasFixedSize.copy();
320
-
321
- // fit to window using css width and height
322
- const innerAspect = innerWidth / innerHeight;
323
- const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
324
- const w = innerAspect < fixedAspect ? '100%' : '';
325
- const h = innerAspect < fixedAspect ? '' : '100%';
326
- mainCanvas.style.width = w;
327
- mainCanvas.style.height = h;
328
- if (glCanvas)
329
- {
330
- glCanvas.style.width = w;
331
- glCanvas.style.height = h;
332
- }
333
- }
334
- else
335
- {
336
- // apply device pixel ratio for crisp rendering
337
- const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
338
- const viewWidth = innerWidth * dpr | 0;
339
- const viewHeight = innerHeight * dpr | 0;
340
-
341
- // get main canvas size based on window size
342
- mainCanvasSize.x = min(viewWidth, canvasMaxSize.x);
343
- mainCanvasSize.y = min(viewHeight, canvasMaxSize.y);
344
-
345
- // responsive aspect ratio with native resolution
346
- const innerAspect = viewWidth / viewHeight;
347
- ASSERT(canvasMinAspect <= canvasMaxAspect);
348
- if (canvasMaxAspect && innerAspect > canvasMaxAspect)
349
- {
350
- // full height
351
- const w = mainCanvasSize.y * canvasMaxAspect | 0;
352
- mainCanvasSize.x = min(w, canvasMaxSize.x);
353
- }
354
- else if (innerAspect < canvasMinAspect)
355
- {
356
- // full width
357
- const h = mainCanvasSize.x / canvasMinAspect | 0;
358
- mainCanvasSize.y = min(h, canvasMaxSize.y);
359
- }
360
-
361
- // set CSS display size so backing store renders at viewport size
362
- const cssW = (mainCanvasSize.x / dpr | 0) + 'px';
363
- const cssH = (mainCanvasSize.y / dpr | 0) + 'px';
364
- mainCanvas.style.width = cssW;
365
- mainCanvas.style.height = cssH;
366
- if (glCanvas)
367
- {
368
- glCanvas.style.width = cssW;
369
- glCanvas.style.height = cssH;
370
- }
371
- }
372
-
373
- // clear main canvas and set size
374
- mainCanvas.width = mainCanvasSize.x;
375
- mainCanvas.height = mainCanvasSize.y;
376
-
377
- // apply the clear color to main canvas
378
- if (canvasClearColor.a > 0 && !glEnable)
379
- {
380
- mainContext.fillStyle = canvasClearColor.toString();
381
- mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y);
382
- mainContext.fillStyle = BLACK.toString();
383
- }
384
362
 
385
- // set default line join and cap
386
- mainContext.lineJoin = 'round';
387
- mainContext.lineCap = 'round';
388
- }
389
-
390
363
  // skip setup if headless
391
364
  if (headlessMode) return startEngine();
392
365
 
366
+ // ensure body exists for minimal HTML where the script runs before <body> is parsed
367
+ if (!document.body)
368
+ document.documentElement.appendChild(document.createElement('body'));
369
+ rootElement ||= document.body;
370
+
393
371
  // setup webgl
394
372
  glInit(rootElement);
395
373
 
396
374
  // setup html
397
- const styleRoot =
375
+ let styleRoot =
398
376
  'margin:0;' + // fill the window
399
377
  'overflow:hidden;' + // no scroll bars
400
378
  'background:#000;' + // set background color
401
379
  'user-select:none;' + // prevent hold to select
402
380
  '-webkit-user-select:none;' + // compatibility for ios
403
381
  'touch-action:none;' + // prevent mobile pinch to resize
404
- '-webkit-touch-callout:none'; // compatibility for ios
405
- rootElement.style.cssText = styleRoot;
382
+ '-webkit-touch-callout:none;'; // compatibility for ios
383
+ // the canvases center on a root element with a height of its own, not the page; one sized only by its
384
+ // children has none, since the canvases are placed apart from it, and would clip them all away
385
+ if (rootElement !== document.body && rootElement.clientHeight && getComputedStyle(rootElement).position === 'static')
386
+ styleRoot += 'position:relative;';
387
+ rootElement.style.cssText = styleRoot + rootElement.style.cssText; // its own inline styles come after and win
406
388
  mainCanvas = rootElement.appendChild(document.createElement('canvas'));
407
389
  drawContext = mainContext = mainCanvas.getContext('2d');
408
390
 
@@ -419,16 +401,17 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
419
401
  if (glCanvas)
420
402
  glCanvas.style.cssText = styleCanvas;
421
403
  setCanvasPixelated(canvasPixelated);
422
- updateCanvas();
404
+ engineUpdateCanvas();
423
405
  glPreRender();
424
406
 
425
407
  // create offscreen canvases for image processing
426
- workCanvas = new OffscreenCanvas(64, 64);
427
- workContext = workCanvas.getContext('2d');
428
- workReadCanvas = new OffscreenCanvas(64, 64);
429
- workReadContext = workReadCanvas.getContext('2d', { willReadFrequently: true });
408
+ workContext = createCanvasContext(64);
409
+ workCanvas = workContext.canvas;
410
+ workReadContext = createCanvasContext(64, 64, true);
411
+ workReadCanvas = workReadContext.canvas;
430
412
 
431
413
  // create promises for loading images
414
+ /** @type {Array<Promise<any>>} */
432
415
  const promises = imageSources.map((src, i)=> loadTexture(i, src));
433
416
 
434
417
  // no images to load
@@ -441,7 +424,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
441
424
  if (showSplashScreen)
442
425
  {
443
426
  // draw splash screen
444
- promises.push(new Promise(resolve =>
427
+ /** @type {Promise<void>} */
428
+ const splash = new Promise(resolve =>
445
429
  {
446
430
  let t = 0;
447
431
  updateSplash();
@@ -451,7 +435,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
451
435
  drawEngineLogo(t+=.01);
452
436
  t>1 ? resolve() : setTimeout(updateSplash, 16);
453
437
  }
454
- }));
438
+ });
439
+ promises.push(splash);
455
440
  }
456
441
 
457
442
  // wait for all the promises to finish
@@ -462,18 +447,134 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
462
447
  {
463
448
  // wait for gameInit to load
464
449
  await gameInit();
450
+ engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
465
451
  engineManualStep || engineUpdate();
466
452
  }
467
453
  }
468
454
 
455
+ // Resize the canvas to fit the window and prepare it for a new frame
456
+ // Called automatically each frame and by the splash screen before the loop starts
457
+ // mainCanvasSize is css pixels and the backing store is that scaled by the
458
+ // pixel ratio, so the ratio only changes sharpness, never how big things look
459
+ function engineUpdateCanvas()
460
+ {
461
+ if (headlessMode) return;
462
+
463
+ // the backing store is scaled by this, every size below is css pixels
464
+ const dpr = getCanvasPixelRatio();
465
+
466
+ if (canvasFixedSize.x)
467
+ {
468
+ // set canvas fixed size
469
+ mainCanvasSize = canvasFixedSize.copy();
470
+
471
+ // fit to window using css width and height
472
+ const innerAspect = innerWidth / innerHeight;
473
+ const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
474
+ const w = innerAspect < fixedAspect ? '100%' : '';
475
+ const h = innerAspect < fixedAspect ? '' : '100%';
476
+ mainCanvas.style.width = w;
477
+ mainCanvas.style.height = h;
478
+ if (glCanvas)
479
+ {
480
+ glCanvas.style.width = w;
481
+ glCanvas.style.height = h;
482
+ }
483
+ }
484
+ else
485
+ {
486
+ // get main canvas size based on window size, in css pixels so
487
+ // canvasMaxSize caps how big the canvas looks, not its resolution
488
+ mainCanvasSize.x = min(innerWidth, canvasMaxSize.x) | 0;
489
+ mainCanvasSize.y = min(innerHeight, canvasMaxSize.y) | 0;
490
+
491
+ // responsive aspect ratio, of the size after canvasMaxSize, which can change its shape
492
+ const innerAspect = mainCanvasSize.x / mainCanvasSize.y;
493
+ ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect);
494
+ if (canvasMaxAspect && innerAspect > canvasMaxAspect)
495
+ {
496
+ // full height
497
+ const w = mainCanvasSize.y * canvasMaxAspect | 0;
498
+ mainCanvasSize.x = min(w, canvasMaxSize.x);
499
+ }
500
+ else if (innerAspect < canvasMinAspect)
501
+ {
502
+ // full width
503
+ const h = mainCanvasSize.x / canvasMinAspect | 0;
504
+ mainCanvasSize.y = min(h, canvasMaxSize.y);
505
+ }
506
+
507
+ // css size is the canvas size, the backing store is scaled up below
508
+ mainCanvas.style.width = mainCanvasSize.x + 'px';
509
+ mainCanvas.style.height = mainCanvasSize.y + 'px';
510
+ if (glCanvas)
511
+ {
512
+ glCanvas.style.width = mainCanvasSize.x + 'px';
513
+ glCanvas.style.height = mainCanvasSize.y + 'px';
514
+ }
515
+ }
516
+
517
+ // clear main canvas and set size
518
+ // only set the size when it changes, setting it invalidates the canvas
519
+ // frame which makes the browser rebuild the display list for the page
520
+ const bufferSizeX = mainCanvasSize.x * dpr | 0;
521
+ const bufferSizeY = mainCanvasSize.y * dpr | 0;
522
+ if (mainCanvas.width !== bufferSizeX || mainCanvas.height !== bufferSizeY)
523
+ {
524
+ mainCanvas.width = bufferSizeX;
525
+ mainCanvas.height = bufferSizeY;
526
+ }
527
+ else
528
+ {
529
+ // setting the size also resets the context state, match that
530
+ mainContext.setTransform(1, 0, 0, 1, 0, 0);
531
+ mainContext.globalCompositeOperation = 'source-over';
532
+ mainContext.clearRect(0, 0, bufferSizeX, bufferSizeY);
533
+ }
534
+
535
+ // scale the context so 2d drawing is in css pixels
536
+ mainContext.setTransform(dpr, 0, 0, dpr, 0, 0);
537
+
538
+ // apply the clear color to main canvas
539
+ if (canvasClearColor.a > 0 && !glEnable)
540
+ {
541
+ mainContext.fillStyle = canvasClearColor.toString();
542
+ mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y);
543
+ mainContext.fillStyle = BLACK.toString();
544
+ }
545
+
546
+ // set default line join and cap, round on purpose: it looks better, suits text and keeps sharp corners from
547
+ // spiking far out; WebGL outlines are square and mitered for speed, the two are not meant to match
548
+ mainContext.lineJoin = 'round';
549
+ mainContext.lineCap = 'round';
550
+ }
551
+
552
+ // ask for the next frame of the loop, once however often it is called before that frame, and skip it if manual
553
+ // step was turned on since, so turning it off and on again within a frame can not start a second loop
554
+ function engineScheduleFrame()
555
+ {
556
+ if (engineFrameScheduled) return;
557
+ engineFrameScheduled = true;
558
+ const next = (frameTimeMS)=>
559
+ {
560
+ engineFrameScheduled = false;
561
+ engineManualStep || engineUpdateInternal(frameTimeMS);
562
+ };
563
+ if (typeof requestAnimationFrame === 'function')
564
+ requestAnimationFrame(next);
565
+ else // a headless server in Node has no display to wait for, a timer keeps the pace
566
+ setTimeout(()=> next(performance.now()), 1e3 / frameRate);
567
+ }
568
+
469
569
  // max frames engineStep can advance in one call, 10 minutes at 60fps
470
570
  // large counts block until they finish, so this catches runaway values
471
571
  const engineStepMaxFrames = 36000;
472
572
 
473
573
  /** Advance the engine by a number of frames
474
- * Requires setEngineManualStep(true) before engineInit
574
+ * Requires setEngineManualStep(true), before engineInit or while running; it stops early if an update turns it off
475
575
  * Respects paused exactly as the normal update loop does
476
- * @param {number} [frames] - number of engine update ticks, max 36000, each running one fixed update at timeScale 1
576
+ * @param {number} [frames] - frames of 1/60 of a second to advance, max 36000; timeScale sets how many fixed
577
+ * updates they run, as in the normal loop, one each at timeScale 1
477
578
  * @example
478
579
  * setHeadlessMode(true);
479
580
  * setEngineManualStep(true);
@@ -483,7 +584,7 @@ const engineStepMaxFrames = 36000;
483
584
  function engineStep(frames=1)
484
585
  {
485
586
  ASSERT(engineManualStep,
486
- 'engineStep requires setEngineManualStep(true) before engineInit');
587
+ 'engineStep requires setEngineManualStep(true)');
487
588
  ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete');
488
589
  // runtime guard so release builds (where the asserts are stripped) can't
489
590
  // start a second requestAnimationFrame chain or call an undefined update
@@ -491,42 +592,73 @@ function engineStep(frames=1)
491
592
  ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames,
492
593
  'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames);
493
594
  frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze
494
- for (let i = frames; i > 0; --i)
595
+ for (let i = frames; i > 0 && engineManualStep; --i) // an update that turns manual step off hands back the loop
495
596
  engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate);
496
597
  }
497
598
 
498
- /** Update each engine object, remove destroyed objects, and update time
499
- * can be called manually if objects need to be updated outside of main loop
599
+ /** Update each engine object and remove destroyed objects; time and frame are advanced by the engine loop, not here
600
+ * - Can be called manually if objects need to be updated outside of main loop
500
601
  * @memberof Engine */
501
602
  function engineObjectsUpdate()
502
603
  {
503
- // get list of solid objects for physics optimization
604
+ ++engineObjectsUpdateCount;
605
+ engineObjectsCollidePairs.clear();
606
+ // objects update in render order, which rendering keeps them in, so a headless run or a frame that rendered
607
+ // nothing updates them the same way; the sort is stable, and nearly free on a list that is already sorted
608
+ engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
609
+ // get list of solid objects for physics optimization, in update order, which 3D collision pairs by;
610
+ // 2D checks the static ones last, so a contact with a moving object can not leave something back inside a static
611
+ // solid it was already pushed out of
504
612
  engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
613
+ engineObjectsCollideStaticLast = engineObjectsCollide.filter(o=>o.mass)
614
+ .concat(engineObjectsCollide.filter(o=>!o.mass));
505
615
 
506
616
  // update physics before object update
507
617
  for (const o of engineObjects)
508
618
  if (!o.parent && !o.destroyed)
509
619
  o.updatePhysics();
510
620
 
511
- // recursive object update
621
+ // recursive object update: the children are walked from a copy on a shared stack, since a child that
622
+ // destroys itself leaves its parent's list on the spot and the next child would slide past the loop
623
+ function updateChildObjects(children)
624
+ {
625
+ if (!children.length) return; // most objects have none, and this runs for every one
626
+ const start = engineChildStack.length;
627
+ for (const child of children)
628
+ engineChildStack.push(child);
629
+ for (let i = start; i < engineChildStack.length; ++i)
630
+ updateChildObject(engineChildStack[i]);
631
+ engineChildStack.length = start;
632
+ }
633
+ const pass = engineObjectsUpdateCount;
512
634
  function updateChildObject(o)
513
635
  {
514
- if (o.destroyed) return;
636
+ if (o.destroyed || o.updatePass === pass) return;
515
637
 
638
+ // its parent is up to date, so it updates from where it is now, and its children from where it is after
639
+ o.updatePass = pass;
640
+ o.updateTransforms(false);
516
641
  o.update();
517
- for (const child of o.children)
518
- updateChildObject(child);
642
+ o.children.length && o.updateTransforms(false);
643
+ updateChildObjects(o.children);
519
644
  }
520
- for (const o of engineObjects)
645
+ function updateTopObject(o)
521
646
  {
522
- if (o.parent || o.destroyed) continue;
647
+ if (o.parent || o.destroyed || o.updatePass === pass) return; // a child that let go is not updated twice
523
648
 
524
- // update top level objects
649
+ // update top level objects, each child places itself before it updates so it sees this frame's position,
650
+ // then the whole tree is placed again so what the children changed in their localPos lands before render
651
+ o.updatePass = pass;
525
652
  o.update();
526
- for (const child of o.children)
527
- updateChildObject(child);
653
+ updateChildObjects(o.children);
528
654
  o.updateTransforms();
529
655
  }
656
+ for (const o of engineObjects)
657
+ updateTopObject(o);
658
+
659
+ // a child let go during the update from a place in the list already walked is on its own now, updated here
660
+ for (const o of engineObjects)
661
+ updateTopObject(o);
530
662
 
531
663
  // remove destroyed objects
532
664
  engineObjects = engineObjects.filter(o=>!o.destroyed);
@@ -534,43 +666,55 @@ function engineObjectsUpdate()
534
666
 
535
667
  /** Destroy and remove all objects
536
668
  * - This can be used to clear out all objects when restarting a level
669
+ * - Objects with the persistent flag set are left alone, for things that outlive a level
537
670
  * - Objects can override their destroy function to do cleanup or stick around
538
- * @param {boolean} [immediate] - should attached effects be allowed to die off?
671
+ * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first
539
672
  * @memberof Engine */
540
673
  function engineObjectsDestroy(immediate=true)
541
674
  {
542
675
  for (const o of engineObjects)
543
- o.parent || o.destroy(immediate);
676
+ o.parent || o.persistent || o.destroy(immediate);
544
677
  engineObjects = engineObjects.filter(o=>!o.destroyed);
545
678
  }
546
679
 
547
680
  /** Collects all object within a given area
681
+ * - An object is collected when its box overlaps the area, or with testCenters when its center is inside it
682
+ * - Objects destroyed this frame are left out, they are only in the list until the frame ends
548
683
  * @param {Vector2} [pos] - Center of test area, or undefined for all objects
549
- * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
684
+ * @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2,
685
+ * left out or 0 the objects that overlap the point at pos
550
686
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
687
+ * @param {boolean} [testCenters] - Test only each object's center, a little faster, and ignores object sizes
551
688
  * @return {Array<EngineObject>} - List of collected objects
552
689
  * @memberof Engine */
553
- function engineObjectsCollect(pos, size, objects=engineObjects)
690
+ function engineObjectsCollect(pos, size, objects=engineObjects, testCenters=false)
554
691
  {
555
692
  const collectedObjects = [];
556
693
  if (!pos)
557
694
  {
558
695
  // all objects
559
696
  for (const o of objects)
560
- collectedObjects.push(o);
697
+ o.destroyed || collectedObjects.push(o);
561
698
  }
562
- else if (size instanceof Vector2)
699
+ else if (!size || size instanceof Vector2)
563
700
  {
564
- // bounding box test
701
+ // bounding box test, a point when there is no size or a size of 0
702
+ const boxSize = size instanceof Vector2 ? size : vec2();
565
703
  for (const o of objects)
566
- o.isOverlapping(pos, size) && collectedObjects.push(o);
704
+ o.destroyed || (testCenters ? isOverlapping(pos, boxSize, o.pos) : o.isOverlapping(pos, boxSize))
705
+ && collectedObjects.push(o);
567
706
  }
568
707
  else
569
708
  {
570
- // circle test
571
- const sizeSquared = size*size;
709
+ // circle test, a diameter like every other size, against the nearest point of each box
710
+ const radiusSquared = (size/2)**2;
572
711
  for (const o of objects)
573
- pos.distanceSquared(o.pos) < sizeSquared && collectedObjects.push(o);
712
+ {
713
+ if (o.destroyed) continue;
714
+ const dx = testCenters ? pos.x - o.pos.x : max(abs(pos.x - o.pos.x) - o.size.x/2, 0);
715
+ const dy = testCenters ? pos.y - o.pos.y : max(abs(pos.y - o.pos.y) - o.size.y/2, 0);
716
+ dx*dx + dy*dy < radiusSquared && collectedObjects.push(o);
717
+ }
574
718
  }
575
719
  return collectedObjects;
576
720
  }
@@ -581,16 +725,23 @@ function engineObjectsCollect(pos, size, objects=engineObjects)
581
725
  * @memberof Engine
582
726
  */
583
727
 
584
- /** Triggers a callback for each object within a given area
728
+ /** Triggers a callback for each object within a given area, objects destroyed this frame left out
585
729
  * @param {Vector2} [pos] - Center of test area, or undefined for all objects
586
- * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
587
- * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test
730
+ * @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2
731
+ * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test, needed
732
+ * (marked optional only because the area before it is)
588
733
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
734
+ * @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect
589
735
  * @memberof Engine */
590
- function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects)
591
- { engineObjectsCollect(pos, size, objects).forEach(o => callbackFunction(o)); }
736
+ function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects, testCenters=false)
737
+ {
738
+ // an object an earlier callback destroyed is skipped
739
+ for (const o of engineObjectsCollect(pos, size, objects, testCenters))
740
+ o.destroyed || callbackFunction(o);
741
+ }
592
742
 
593
- /** Return a list of objects intersecting a ray
743
+ /** Return a list of objects intersecting a ray, objects destroyed this frame left out
744
+ * - Only objects with collideRaycast set are hit, which setCollision turns on
594
745
  * @param {Vector2} start
595
746
  * @param {Vector2} end
596
747
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
@@ -601,13 +752,13 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
601
752
  const hitObjects = [];
602
753
  for (const o of objects)
603
754
  {
604
- if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size))
755
+ if (o.collideRaycast && !o.destroyed && isIntersecting(start, end, o.pos, o.size))
605
756
  {
606
- debugRaycast && debugRect(o.pos, o.size, '#f00');
757
+ debugRaycast && debugRect(o.pos, o.size, '#f00', 0, 0, false, false);
607
758
  hitObjects.push(o);
608
759
  }
609
760
  }
610
761
 
611
- debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
762
+ debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02, 0, false);
612
763
  return hitObjects;
613
764
  }