littlejsengine 1.19.3 → 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 (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  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 +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +58 -17
  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 +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  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 +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. 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.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;
@@ -91,6 +94,23 @@ function setPaused(isPaused=true) { paused = isPaused; }
91
94
  let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
92
95
  let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
93
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
94
114
  let showEngineVersion = true;
95
115
 
96
116
  ///////////////////////////////////////////////////////////////////////////////
@@ -115,6 +135,7 @@ class EnginePlugin
115
135
  */
116
136
 
117
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
118
139
  * @param {PluginCallback} [update]
119
140
  * @param {PluginCallback} [render]
120
141
  * @param {PluginCallback} [glContextLost]
@@ -148,13 +169,15 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
148
169
  */
149
170
 
150
171
  /** 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
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
156
177
  * @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
157
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
158
181
  * @example
159
182
  * // Basic engine startup
160
183
  * engineInit(
@@ -169,17 +192,13 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
169
192
  async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
170
193
  {
171
194
  showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
172
- ASSERT(!mainContext, 'engine already initialized');
195
+ ASSERT(!engineInitialized, 'engine already initialized');
173
196
  // runtime guard so release builds (where the assert is stripped) don't
174
197
  // double-register listeners / double-add canvases on a second call
175
- if (mainContext) return;
198
+ if (engineInitialized) return;
199
+ engineInitialized = true;
176
200
  ASSERT(isArray(imageSources), 'pass in images as array');
177
201
 
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
202
  // allow passing in empty functions
184
203
  gameInit ||= ()=>{};
185
204
  gameUpdate ||= ()=>{};
@@ -190,14 +209,15 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
190
209
  // Called automatically by engine to setup render system
191
210
  function enginePreRender()
192
211
  {
193
- // mainCanvasSize is set by engineUpdateCanvas which always runs first,
194
- // it is css pixels so it does not match the canvas backing store
212
+ // the level editor's own view while it is open, in debug builds, before the camera goes to WebGL
213
+ editorPreRender();
195
214
 
196
215
  // disable smoothing for pixel art
197
216
  mainContext.imageSmoothingEnabled = !tilesPixelated;
198
217
 
199
218
  // setup gl rendering if enabled
200
219
  glPreRender();
220
+ setShader(); // a shader left set last frame does not carry into this one
201
221
 
202
222
  // plugins that draw underneath the 2D layer
203
223
  pluginList.forEach(plugin=>plugin.preRender?.());
@@ -206,6 +226,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
206
226
  // internal update loop for engine
207
227
  function engineUpdate(frameTimeMS=0)
208
228
  {
229
+ const manualStepAtStart = engineManualStep;
230
+
209
231
  // update time keeping
210
232
  let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
211
233
  // skip delta on the very first frame so timeReal doesn't jump
@@ -214,8 +236,11 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
214
236
  frameTimeLastMS = frameTimeMS;
215
237
  if (debug || debugWatermark)
216
238
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
217
- const debugSpeedUp = debug && keyIsDown('Equal'); // +
218
- 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'); // -
219
244
  const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
220
245
 
221
246
  // apply time deltas
@@ -223,11 +248,11 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
223
248
  timeReal += frameTimeDeltaMS * debugScale / 1e3;
224
249
  const combinedScale = timeScale * debugScale;
225
250
  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
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
231
256
 
232
257
  // apply time delta smoothing, improves smoothness of framerate in some browsers
233
258
  let wasUpdated = false, deltaSmooth = 0;
@@ -241,22 +266,28 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
241
266
  // update multiple frames if necessary in case of slow framerate
242
267
  for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
243
268
  {
244
- // increment frame and update time, paused does not advance time
245
- if (!paused)
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)
246
274
  time = frame++ / frameRate;
247
275
 
248
- // update game and objects, when paused update everything except them
276
+ // update game and objects, when frozen update everything except them
249
277
  wasUpdated = true;
250
278
  engineUpdateCanvas();
251
279
  inputUpdate();
252
- if (!paused)
280
+ if (!frozenTick)
253
281
  gameUpdate();
254
282
  pluginList.forEach(plugin=>plugin.update?.());
255
- if (paused)
283
+ if (frozenTick)
256
284
  {
257
285
  // update object transforms even when paused
258
286
  for (const o of engineObjects)
259
287
  o.parent || o.updateTransforms();
288
+
289
+ // objects made and destroyed while paused, like a menu's effects, still leave the list
290
+ engineObjects = engineObjects.filter(o=>!o.destroyed);
260
291
  }
261
292
  else
262
293
  engineObjectsUpdate();
@@ -272,6 +303,10 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
272
303
  // add the time smoothing back in
273
304
  frameTimeBufferMS += deltaSmooth;
274
305
 
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;
309
+
275
310
  // check if the window changed so a resize is picked up even when
276
311
  // the game is not updating, for example when timeScale is 0
277
312
  let windowChanged = false;
@@ -289,8 +324,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
289
324
  // than the fixed update rate would otherwise redraw identical frames
290
325
  if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
291
326
  renderFrame();
292
- if (!engineManualStep)
293
- requestAnimationFrame(engineUpdate);
327
+ engineManualStep || engineScheduleFrame();
294
328
 
295
329
  function renderFrame()
296
330
  {
@@ -314,6 +348,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
314
348
 
315
349
  // post rendering
316
350
  gameRenderPost();
351
+ setShader(); // plugin, input and debug draws start from the engine's state
352
+ setAdditiveBlendMode(false);
317
353
  pluginList.forEach(plugin=>plugin.render?.());
318
354
  inputRender();
319
355
  debugRender();
@@ -323,24 +359,32 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
323
359
  primitiveCount = 0;
324
360
  }
325
361
  }
326
- engineUpdateInternal = engineUpdate;
327
362
 
328
363
  // skip setup if headless
329
364
  if (headlessMode) return startEngine();
330
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
+
331
371
  // setup webgl
332
372
  glInit(rootElement);
333
373
 
334
374
  // setup html
335
- const styleRoot =
375
+ let styleRoot =
336
376
  'margin:0;' + // fill the window
337
377
  'overflow:hidden;' + // no scroll bars
338
378
  'background:#000;' + // set background color
339
379
  'user-select:none;' + // prevent hold to select
340
380
  '-webkit-user-select:none;' + // compatibility for ios
341
381
  'touch-action:none;' + // prevent mobile pinch to resize
342
- '-webkit-touch-callout:none'; // compatibility for ios
343
- 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
344
388
  mainCanvas = rootElement.appendChild(document.createElement('canvas'));
345
389
  drawContext = mainContext = mainCanvas.getContext('2d');
346
390
 
@@ -367,6 +411,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
367
411
  workReadCanvas = workReadContext.canvas;
368
412
 
369
413
  // create promises for loading images
414
+ /** @type {Array<Promise<any>>} */
370
415
  const promises = imageSources.map((src, i)=> loadTexture(i, src));
371
416
 
372
417
  // no images to load
@@ -379,7 +424,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
379
424
  if (showSplashScreen)
380
425
  {
381
426
  // draw splash screen
382
- promises.push(new Promise(resolve =>
427
+ /** @type {Promise<void>} */
428
+ const splash = new Promise(resolve =>
383
429
  {
384
430
  let t = 0;
385
431
  updateSplash();
@@ -389,7 +435,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
389
435
  drawEngineLogo(t+=.01);
390
436
  t>1 ? resolve() : setTimeout(updateSplash, 16);
391
437
  }
392
- }));
438
+ });
439
+ promises.push(splash);
393
440
  }
394
441
 
395
442
  // wait for all the promises to finish
@@ -400,6 +447,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
400
447
  {
401
448
  // wait for gameInit to load
402
449
  await gameInit();
450
+ engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
403
451
  engineManualStep || engineUpdate();
404
452
  }
405
453
  }
@@ -440,9 +488,9 @@ function engineUpdateCanvas()
440
488
  mainCanvasSize.x = min(innerWidth, canvasMaxSize.x) | 0;
441
489
  mainCanvasSize.y = min(innerHeight, canvasMaxSize.y) | 0;
442
490
 
443
- // responsive aspect ratio
444
- const innerAspect = innerWidth / innerHeight;
445
- ASSERT(canvasMinAspect <= canvasMaxAspect);
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);
446
494
  if (canvasMaxAspect && innerAspect > canvasMaxAspect)
447
495
  {
448
496
  // full height
@@ -495,19 +543,38 @@ function engineUpdateCanvas()
495
543
  mainContext.fillStyle = BLACK.toString();
496
544
  }
497
545
 
498
- // set default line join and cap
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
499
548
  mainContext.lineJoin = 'round';
500
549
  mainContext.lineCap = 'round';
501
550
  }
502
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
+
503
569
  // max frames engineStep can advance in one call, 10 minutes at 60fps
504
570
  // large counts block until they finish, so this catches runaway values
505
571
  const engineStepMaxFrames = 36000;
506
572
 
507
573
  /** Advance the engine by a number of frames
508
- * Requires setEngineManualStep(true) before engineInit
574
+ * Requires setEngineManualStep(true), before engineInit or while running; it stops early if an update turns it off
509
575
  * 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
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
511
578
  * @example
512
579
  * setHeadlessMode(true);
513
580
  * setEngineManualStep(true);
@@ -517,7 +584,7 @@ const engineStepMaxFrames = 36000;
517
584
  function engineStep(frames=1)
518
585
  {
519
586
  ASSERT(engineManualStep,
520
- 'engineStep requires setEngineManualStep(true) before engineInit');
587
+ 'engineStep requires setEngineManualStep(true)');
521
588
  ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete');
522
589
  // runtime guard so release builds (where the asserts are stripped) can't
523
590
  // start a second requestAnimationFrame chain or call an undefined update
@@ -525,42 +592,73 @@ function engineStep(frames=1)
525
592
  ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames,
526
593
  'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames);
527
594
  frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze
528
- 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
529
596
  engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate);
530
597
  }
531
598
 
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
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
534
601
  * @memberof Engine */
535
602
  function engineObjectsUpdate()
536
603
  {
537
- // 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
538
612
  engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
613
+ engineObjectsCollideStaticLast = engineObjectsCollide.filter(o=>o.mass)
614
+ .concat(engineObjectsCollide.filter(o=>!o.mass));
539
615
 
540
616
  // update physics before object update
541
617
  for (const o of engineObjects)
542
618
  if (!o.parent && !o.destroyed)
543
619
  o.updatePhysics();
544
620
 
545
- // 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;
546
634
  function updateChildObject(o)
547
635
  {
548
- if (o.destroyed) return;
636
+ if (o.destroyed || o.updatePass === pass) return;
549
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);
550
641
  o.update();
551
- for (const child of o.children)
552
- updateChildObject(child);
642
+ o.children.length && o.updateTransforms(false);
643
+ updateChildObjects(o.children);
553
644
  }
554
- for (const o of engineObjects)
645
+ function updateTopObject(o)
555
646
  {
556
- 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
557
648
 
558
- // 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;
559
652
  o.update();
560
- for (const child of o.children)
561
- updateChildObject(child);
653
+ updateChildObjects(o.children);
562
654
  o.updateTransforms();
563
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);
564
662
 
565
663
  // remove destroyed objects
566
664
  engineObjects = engineObjects.filter(o=>!o.destroyed);
@@ -570,7 +668,7 @@ function engineObjectsUpdate()
570
668
  * - This can be used to clear out all objects when restarting a level
571
669
  * - Objects with the persistent flag set are left alone, for things that outlive a level
572
670
  * - Objects can override their destroy function to do cleanup or stick around
573
- * @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
574
672
  * @memberof Engine */
575
673
  function engineObjectsDestroy(immediate=true)
576
674
  {
@@ -580,32 +678,43 @@ function engineObjectsDestroy(immediate=true)
580
678
  }
581
679
 
582
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
583
683
  * @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
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
585
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
586
688
  * @return {Array<EngineObject>} - List of collected objects
587
689
  * @memberof Engine */
588
- function engineObjectsCollect(pos, size, objects=engineObjects)
690
+ function engineObjectsCollect(pos, size, objects=engineObjects, testCenters=false)
589
691
  {
590
692
  const collectedObjects = [];
591
693
  if (!pos)
592
694
  {
593
695
  // all objects
594
696
  for (const o of objects)
595
- collectedObjects.push(o);
697
+ o.destroyed || collectedObjects.push(o);
596
698
  }
597
- else if (size instanceof Vector2)
699
+ else if (!size || size instanceof Vector2)
598
700
  {
599
- // 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();
600
703
  for (const o of objects)
601
- o.isOverlapping(pos, size) && collectedObjects.push(o);
704
+ o.destroyed || (testCenters ? isOverlapping(pos, boxSize, o.pos) : o.isOverlapping(pos, boxSize))
705
+ && collectedObjects.push(o);
602
706
  }
603
707
  else
604
708
  {
605
- // circle test
606
- 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;
607
711
  for (const o of objects)
608
- 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
+ }
609
718
  }
610
719
  return collectedObjects;
611
720
  }
@@ -616,16 +725,23 @@ function engineObjectsCollect(pos, size, objects=engineObjects)
616
725
  * @memberof Engine
617
726
  */
618
727
 
619
- /** 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
620
729
  * @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
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)
623
733
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
734
+ * @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect
624
735
  * @memberof Engine */
625
- function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects)
626
- { 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
+ }
627
742
 
628
- /** 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
629
745
  * @param {Vector2} start
630
746
  * @param {Vector2} end
631
747
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
@@ -636,13 +752,13 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
636
752
  const hitObjects = [];
637
753
  for (const o of objects)
638
754
  {
639
- if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size))
755
+ if (o.collideRaycast && !o.destroyed && isIntersecting(start, end, o.pos, o.size))
640
756
  {
641
- debugRaycast && debugRect(o.pos, o.size, '#f00');
757
+ debugRaycast && debugRect(o.pos, o.size, '#f00', 0, 0, false, false);
642
758
  hitObjects.push(o);
643
759
  }
644
760
  }
645
761
 
646
- debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
762
+ debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02, 0, false);
647
763
  return hitObjects;
648
764
  }