littlejsengine 1.18.28 → 1.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.28';
35
+ const engineVersion = '1.19.3';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -89,6 +89,7 @@ function setPaused(isPaused=true) { paused = isPaused; }
89
89
 
90
90
  // Engine internal variables
91
91
  let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
92
+ let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
92
93
  let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
93
94
  let showEngineVersion = true;
94
95
 
@@ -98,12 +99,13 @@ let showEngineVersion = true;
98
99
  const pluginList = [];
99
100
  class EnginePlugin
100
101
  {
101
- constructor(update, render, glContextLost, glContextRestored)
102
+ constructor(update, render, glContextLost, glContextRestored, preRender)
102
103
  {
103
104
  this.update = update;
104
105
  this.render = render;
105
106
  this.glContextLost = glContextLost;
106
107
  this.glContextRestored = glContextRestored;
108
+ this.preRender = preRender;
107
109
  }
108
110
  }
109
111
 
@@ -117,16 +119,18 @@ class EnginePlugin
117
119
  * @param {PluginCallback} [render]
118
120
  * @param {PluginCallback} [glContextLost]
119
121
  * @param {PluginCallback} [glContextRestored]
122
+ * @param {PluginCallback} [preRender] - Called after the canvas is cleared and before gameRender
120
123
  * @memberof Engine */
121
- function engineAddPlugin(update, render, glContextLost, glContextRestored)
124
+ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRender)
122
125
  {
123
126
  // make sure plugin functions are unique
124
127
  ASSERT(!pluginList.find(p=>
125
128
  p.update === update && p.render === render &&
126
129
  p.glContextLost === glContextLost &&
127
- p.glContextRestored === glContextRestored));
130
+ p.glContextRestored === glContextRestored &&
131
+ p.preRender === preRender));
128
132
 
129
- const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored);
133
+ const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored, preRender);
130
134
  pluginList.push(plugin);
131
135
  }
132
136
 
@@ -186,14 +190,17 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
186
190
  // Called automatically by engine to setup render system
187
191
  function enginePreRender()
188
192
  {
189
- // save canvas size
190
- mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height);
193
+ // mainCanvasSize is set by engineUpdateCanvas which always runs first,
194
+ // it is css pixels so it does not match the canvas backing store
191
195
 
192
196
  // disable smoothing for pixel art
193
197
  mainContext.imageSmoothingEnabled = !tilesPixelated;
194
198
 
195
199
  // setup gl rendering if enabled
196
200
  glPreRender();
201
+
202
+ // plugins that draw underneath the 2D layer
203
+ pluginList.forEach(plugin=>plugin.preRender?.());
197
204
  }
198
205
 
199
206
  // internal update loop for engine
@@ -212,25 +219,47 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
212
219
  const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
213
220
 
214
221
  // apply time deltas
222
+ const frameTimeDeltaUnscaledMS = frameTimeDeltaMS;
215
223
  timeReal += frameTimeDeltaMS * debugScale / 1e3;
216
224
  const combinedScale = timeScale * debugScale;
217
225
  frameTimeDeltaMS *= combinedScale;
218
- frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
219
- if (combinedScale <= 1)
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)
220
230
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
221
231
 
222
- let wasUpdated = false;
223
- if (paused)
232
+ // apply time delta smoothing, improves smoothness of framerate in some browsers
233
+ let wasUpdated = false, deltaSmooth = 0;
234
+ if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
224
235
  {
225
- // update everything except the game and objects
236
+ // force at least one update each frame since it is waiting for refresh
237
+ deltaSmooth = frameTimeBufferMS;
238
+ frameTimeBufferMS = 0;
239
+ }
240
+
241
+ // update multiple frames if necessary in case of slow framerate
242
+ for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
243
+ {
244
+ // increment frame and update time, paused does not advance time
245
+ if (!paused)
246
+ time = frame++ / frameRate;
247
+
248
+ // update game and objects, when paused update everything except them
226
249
  wasUpdated = true;
227
- updateCanvas();
250
+ engineUpdateCanvas();
228
251
  inputUpdate();
252
+ if (!paused)
253
+ gameUpdate();
229
254
  pluginList.forEach(plugin=>plugin.update?.());
230
-
231
- // update object transforms even when paused
232
- for (const o of engineObjects)
233
- o.parent || o.updateTransforms();
255
+ if (paused)
256
+ {
257
+ // update object transforms even when paused
258
+ for (const o of engineObjects)
259
+ o.parent || o.updateTransforms();
260
+ }
261
+ else
262
+ engineObjectsUpdate();
234
263
 
235
264
  // do post update
236
265
  debugUpdate();
@@ -239,44 +268,26 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
239
268
  if (debugVideoCaptureIsActive())
240
269
  renderFrame();
241
270
  }
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
-
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;
258
-
259
- // update game and objects
260
- wasUpdated = true;
261
- updateCanvas();
262
- inputUpdate();
263
- gameUpdate();
264
- pluginList.forEach(plugin=>plugin.update?.());
265
- engineObjectsUpdate();
266
271
 
267
- // do post update
268
- debugUpdate();
269
- gameUpdatePost();
270
- inputUpdatePost();
271
- if (debugVideoCaptureIsActive())
272
- renderFrame();
273
- }
272
+ // add the time smoothing back in
273
+ frameTimeBufferMS += deltaSmooth;
274
274
 
275
- // add the time smoothing back in
276
- frameTimeBufferMS += deltaSmooth;
275
+ // check if the window changed so a resize is picked up even when
276
+ // the game is not updating, for example when timeScale is 0
277
+ let windowChanged = false;
278
+ if (!headlessMode)
279
+ {
280
+ const dpr = devicePixelRatio;
281
+ windowChanged = windowWidthLast !== innerWidth ||
282
+ windowHeightLast !== innerHeight || windowPixelRatioLast !== dpr;
283
+ windowWidthLast = innerWidth;
284
+ windowHeightLast = innerHeight;
285
+ windowPixelRatioLast = dpr;
277
286
  }
278
287
 
279
- if (!debugVideoCaptureIsActive())
288
+ // render only when something changed, displays that refresh faster
289
+ // than the fixed update rate would otherwise redraw identical frames
290
+ if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
280
291
  renderFrame();
281
292
  if (!engineManualStep)
282
293
  requestAnimationFrame(engineUpdate);
@@ -287,14 +298,19 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
287
298
 
288
299
  // canvas must be updated before rendering
289
300
  if (!wasUpdated)
290
- updateCanvas();
301
+ engineUpdateCanvas();
291
302
 
292
303
  // render the game and objects
293
304
  enginePreRender();
294
305
  gameRender();
295
306
  engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
296
307
  for (const o of engineObjects)
297
- o.destroyed || o.render();
308
+ {
309
+ if (o.destroyed) continue;
310
+ setShader(o.shader); // each object draws with its own shader, or none
311
+ o.render();
312
+ }
313
+ setShader(); // back to the engine's for gameRenderPost
298
314
 
299
315
  // post rendering
300
316
  gameRenderPost();
@@ -309,84 +325,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
309
325
  }
310
326
  engineUpdateInternal = engineUpdate;
311
327
 
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
-
385
- // set default line join and cap
386
- mainContext.lineJoin = 'round';
387
- mainContext.lineCap = 'round';
388
- }
389
-
390
328
  // skip setup if headless
391
329
  if (headlessMode) return startEngine();
392
330
 
@@ -419,14 +357,14 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
419
357
  if (glCanvas)
420
358
  glCanvas.style.cssText = styleCanvas;
421
359
  setCanvasPixelated(canvasPixelated);
422
- updateCanvas();
360
+ engineUpdateCanvas();
423
361
  glPreRender();
424
362
 
425
363
  // 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 });
364
+ workContext = createCanvasContext(64);
365
+ workCanvas = workContext.canvas;
366
+ workReadContext = createCanvasContext(64, 64, true);
367
+ workReadCanvas = workReadContext.canvas;
430
368
 
431
369
  // create promises for loading images
432
370
  const promises = imageSources.map((src, i)=> loadTexture(i, src));
@@ -466,6 +404,102 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
466
404
  }
467
405
  }
468
406
 
407
+ // Resize the canvas to fit the window and prepare it for a new frame
408
+ // Called automatically each frame and by the splash screen before the loop starts
409
+ // mainCanvasSize is css pixels and the backing store is that scaled by the
410
+ // pixel ratio, so the ratio only changes sharpness, never how big things look
411
+ function engineUpdateCanvas()
412
+ {
413
+ if (headlessMode) return;
414
+
415
+ // the backing store is scaled by this, every size below is css pixels
416
+ const dpr = getCanvasPixelRatio();
417
+
418
+ if (canvasFixedSize.x)
419
+ {
420
+ // set canvas fixed size
421
+ mainCanvasSize = canvasFixedSize.copy();
422
+
423
+ // fit to window using css width and height
424
+ const innerAspect = innerWidth / innerHeight;
425
+ const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
426
+ const w = innerAspect < fixedAspect ? '100%' : '';
427
+ const h = innerAspect < fixedAspect ? '' : '100%';
428
+ mainCanvas.style.width = w;
429
+ mainCanvas.style.height = h;
430
+ if (glCanvas)
431
+ {
432
+ glCanvas.style.width = w;
433
+ glCanvas.style.height = h;
434
+ }
435
+ }
436
+ else
437
+ {
438
+ // get main canvas size based on window size, in css pixels so
439
+ // canvasMaxSize caps how big the canvas looks, not its resolution
440
+ mainCanvasSize.x = min(innerWidth, canvasMaxSize.x) | 0;
441
+ mainCanvasSize.y = min(innerHeight, canvasMaxSize.y) | 0;
442
+
443
+ // responsive aspect ratio
444
+ const innerAspect = innerWidth / innerHeight;
445
+ ASSERT(canvasMinAspect <= canvasMaxAspect);
446
+ if (canvasMaxAspect && innerAspect > canvasMaxAspect)
447
+ {
448
+ // full height
449
+ const w = mainCanvasSize.y * canvasMaxAspect | 0;
450
+ mainCanvasSize.x = min(w, canvasMaxSize.x);
451
+ }
452
+ else if (innerAspect < canvasMinAspect)
453
+ {
454
+ // full width
455
+ const h = mainCanvasSize.x / canvasMinAspect | 0;
456
+ mainCanvasSize.y = min(h, canvasMaxSize.y);
457
+ }
458
+
459
+ // css size is the canvas size, the backing store is scaled up below
460
+ mainCanvas.style.width = mainCanvasSize.x + 'px';
461
+ mainCanvas.style.height = mainCanvasSize.y + 'px';
462
+ if (glCanvas)
463
+ {
464
+ glCanvas.style.width = mainCanvasSize.x + 'px';
465
+ glCanvas.style.height = mainCanvasSize.y + 'px';
466
+ }
467
+ }
468
+
469
+ // clear main canvas and set size
470
+ // only set the size when it changes, setting it invalidates the canvas
471
+ // frame which makes the browser rebuild the display list for the page
472
+ const bufferSizeX = mainCanvasSize.x * dpr | 0;
473
+ const bufferSizeY = mainCanvasSize.y * dpr | 0;
474
+ if (mainCanvas.width !== bufferSizeX || mainCanvas.height !== bufferSizeY)
475
+ {
476
+ mainCanvas.width = bufferSizeX;
477
+ mainCanvas.height = bufferSizeY;
478
+ }
479
+ else
480
+ {
481
+ // setting the size also resets the context state, match that
482
+ mainContext.setTransform(1, 0, 0, 1, 0, 0);
483
+ mainContext.globalCompositeOperation = 'source-over';
484
+ mainContext.clearRect(0, 0, bufferSizeX, bufferSizeY);
485
+ }
486
+
487
+ // scale the context so 2d drawing is in css pixels
488
+ mainContext.setTransform(dpr, 0, 0, dpr, 0, 0);
489
+
490
+ // apply the clear color to main canvas
491
+ if (canvasClearColor.a > 0 && !glEnable)
492
+ {
493
+ mainContext.fillStyle = canvasClearColor.toString();
494
+ mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y);
495
+ mainContext.fillStyle = BLACK.toString();
496
+ }
497
+
498
+ // set default line join and cap
499
+ mainContext.lineJoin = 'round';
500
+ mainContext.lineCap = 'round';
501
+ }
502
+
469
503
  // max frames engineStep can advance in one call, 10 minutes at 60fps
470
504
  // large counts block until they finish, so this catches runaway values
471
505
  const engineStepMaxFrames = 36000;
@@ -534,13 +568,14 @@ function engineObjectsUpdate()
534
568
 
535
569
  /** Destroy and remove all objects
536
570
  * - This can be used to clear out all objects when restarting a level
571
+ * - Objects with the persistent flag set are left alone, for things that outlive a level
537
572
  * - Objects can override their destroy function to do cleanup or stick around
538
573
  * @param {boolean} [immediate] - should attached effects be allowed to die off?
539
574
  * @memberof Engine */
540
575
  function engineObjectsDestroy(immediate=true)
541
576
  {
542
577
  for (const o of engineObjects)
543
- o.parent || o.destroy(immediate);
578
+ o.parent || o.persistent || o.destroy(immediate);
544
579
  engineObjects = engineObjects.filter(o=>!o.destroyed);
545
580
  }
546
581