littlejsengine 1.18.24 → 1.18.26

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.
@@ -3,584 +3,616 @@
3
3
 
4
4
  'use strict';
5
5
 
6
- /**
7
- * LittleJS - The Tiny Fast JavaScript Game Engine
8
- * MIT License - Copyright 2021 Frank Force
9
- *
10
- * Engine Features
11
- * - Object oriented system with EngineObject base class
12
- * - Automatic object lifecycle (update, physics, collision, rendering)
13
- * - Engine helper classes: Vector2, Color, Timer, RandomGenerator
14
- * - Hybrid rendering with WebGL batching and Canvas2D fallback
15
- * - Audio system with wave, mp3, or ZzFX sound effects
16
- * - Input system with keyboard, mouse, gamepad, and touch support
17
- * - Tile layer rendering and collision detection
18
- * - Particle effect system with emitters
19
- * - Medal/achievement system with local storage
20
- * - Comprehensive debug tools and visualizations
21
- * - Fixed 60 FPS timestep with configurable time scale
22
- * - Raycast and spatial query utilities
23
- * - Plugin system for extending engine functionality
24
- * - Start with engineInit() and provide your game callbacks
25
- * @namespace Engine
26
- */
27
-
28
- /** Name of engine
29
- * @type {string}
30
- * @default
31
- * @memberof Engine */
32
- const engineName = 'LittleJS';
33
-
34
- /** Version of engine
35
- * @type {string}
36
- * @default
37
- * @memberof Engine */
38
- const engineVersion = '1.18.24';
39
-
40
- /** Frames per second to update
41
- * @type {number}
42
- * @default
43
- * @memberof Engine */
44
- const frameRate = 60;
45
-
46
- /** How many seconds each frame lasts, engine uses a fixed time step
47
- * @type {number}
48
- * @default 1/60
49
- * @memberof Engine */
50
- const timeDelta = 1/frameRate;
51
-
52
- /** Array containing all engine objects
53
- * @type {Array<EngineObject>}
54
- * @memberof Engine */
55
- let engineObjects = [];
56
-
57
- /** Array with only objects set to collide with other objects this frame (for optimization)
58
- * @type {Array<EngineObject>}
59
- * @memberof Engine */
60
- let engineObjectsCollide = [];
61
-
62
- /** Current update frame, used to calculate time
63
- * @type {number}
64
- * @memberof Engine */
65
- let frame = 0;
66
-
67
- /** Current engine time since start in seconds
68
- * @type {number}
69
- * @memberof Engine */
70
- let time = 0;
71
-
72
- /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
73
- * @type {number}
74
- * @memberof Engine */
75
- let timeReal = 0;
76
-
77
- /** Is the game paused? Causes time and objects to not be updated
78
- * @type {boolean}
79
- * @default false
80
- * @memberof Engine */
81
- let paused = false;
82
-
83
- /** Get if game is paused
84
- * @return {boolean}
85
- * @memberof Engine */
86
- function getPaused() { return paused; }
87
-
88
- /** Set if game is paused
89
- * @param {boolean} [isPaused]
90
- * @memberof Engine */
91
- function setPaused(isPaused=true) { paused = isPaused; }
92
-
93
- // Engine internal variables
94
- let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
95
- let showEngineVersion = true;
96
-
97
- ///////////////////////////////////////////////////////////////////////////////
98
- // plugin hooks
99
-
100
- const pluginList = [];
101
- class EnginePlugin
102
- {
103
- constructor(update, render, glContextLost, glContextRestored)
104
- {
105
- this.update = update;
106
- this.render = render;
107
- this.glContextLost = glContextLost;
108
- this.glContextRestored = glContextRestored;
109
- }
110
- }
111
-
112
- /**
113
- * @callback PluginCallback - Update or render function for a plugin
114
- * @memberof Engine
115
- */
116
-
117
- /** Add a new update function for a plugin
118
- * @param {PluginCallback} [update]
119
- * @param {PluginCallback} [render]
120
- * @param {PluginCallback} [glContextLost]
121
- * @param {PluginCallback} [glContextRestored]
122
- * @memberof Engine */
123
- function engineAddPlugin(update, render, glContextLost, glContextRestored)
124
- {
125
- // make sure plugin functions are unique
126
- ASSERT(!pluginList.find(p=>
127
- p.update === update && p.render === render &&
128
- p.glContextLost === glContextLost &&
129
- p.glContextRestored === glContextRestored));
130
-
131
- const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored);
132
- pluginList.push(plugin);
133
- }
134
-
135
- ///////////////////////////////////////////////////////////////////////////////
136
- // Main Engine Functions
137
-
138
- /**
139
- * @callback GameInitCallback - Called after the engine starts, can be async
140
- * @return {void|Promise<void>}
141
- * @memberof Engine
142
- */
143
- /**
144
- * @callback GameCallback - Update or render function for the game
145
- * @memberof Engine
146
- */
147
-
148
- /** Startup LittleJS engine with your callback functions
149
- * @param {GameInitCallback} gameInit - Called once after the engine starts up, can be async for loading
150
- * @param {GameCallback} gameUpdate - Called every frame before objects are updated (60fps), use for game logic
151
- * @param {GameCallback} gameUpdatePost - Called after physics and objects are updated, even when paused, use for UI updates
152
- * @param {GameCallback} gameRender - Called before objects are rendered, use for drawing backgrounds/world elements
153
- * @param {GameCallback} gameRenderPost - Called after objects are rendered, use for drawing UI/overlays
154
- * @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
155
- * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
156
- * @example
157
- * // Basic engine startup
158
- * engineInit(
159
- * ()=> { LOG('Game initialized!'); }, // gameInit
160
- * ()=> { updateGameLogic(); }, // gameUpdate
161
- * ()=> { updateUI(); }, // gameUpdatePost
162
- * ()=> { drawBackground(); }, // gameRender
163
- * ()=> { drawHUD(); }, // gameRenderPost
164
- * ['tiles.png', 'tilesLevel.png'] // images to load
165
- * );
166
- * @memberof Engine */
167
- async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
168
- {
169
- showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
170
- ASSERT(!mainContext, 'engine already initialized');
171
- // runtime guard so release builds (where the assert is stripped) don't
172
- // double-register listeners / double-add canvases on a second call
173
- if (mainContext) return;
174
- ASSERT(isArray(imageSources), 'pass in images as array');
175
-
176
- // ensure body exists for minimal HTML where the script runs before <body> is parsed
177
- if (!document.body)
178
- document.documentElement.appendChild(document.createElement('body'));
179
- rootElement ||= document.body;
180
-
181
- // allow passing in empty functions
182
- gameInit ||= ()=>{};
183
- gameUpdate ||= ()=>{};
184
- gameUpdatePost ||= ()=>{};
185
- gameRender ||= ()=>{};
186
- gameRenderPost ||= ()=>{};
187
-
188
- // Called automatically by engine to setup render system
189
- function enginePreRender()
190
- {
191
- // save canvas size
192
- mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height);
193
-
194
- // disable smoothing for pixel art
195
- mainContext.imageSmoothingEnabled = !tilesPixelated;
196
-
197
- // setup gl rendering if enabled
198
- glPreRender();
199
- }
200
-
201
- // internal update loop for engine
202
- function engineUpdate(frameTimeMS=0)
203
- {
204
- // update time keeping
205
- let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
206
- // skip delta on the very first frame so timeReal doesn't jump
207
- // by ~page-load-time when RAF starts handing real timestamps
208
- if (!frameTimeLastMS) frameTimeDeltaMS = 0;
209
- frameTimeLastMS = frameTimeMS;
210
- if (debug || debugWatermark)
211
- averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
212
- const debugSpeedUp = debug && keyIsDown('Equal'); // +
213
- const debugSpeedDown = debug && keyIsDown('Minus'); // -
214
- const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
215
-
216
- // apply time deltas
217
- timeReal += frameTimeDeltaMS * debugScale / 1e3;
218
- const combinedScale = timeScale * debugScale;
219
- frameTimeDeltaMS *= combinedScale;
220
- frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
221
- if (combinedScale <= 1)
222
- frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
223
-
224
- let wasUpdated = false;
225
- if (paused)
226
- {
227
- // update everything except the game and objects
228
- wasUpdated = true;
229
- updateCanvas();
230
- inputUpdate();
231
- pluginList.forEach(plugin=>plugin.update?.());
232
-
233
- // update object transforms even when paused
234
- for (const o of engineObjects)
235
- o.parent || o.updateTransforms();
236
-
237
- // do post update
238
- debugUpdate();
239
- gameUpdatePost();
240
- inputUpdatePost();
241
- if (debugVideoCaptureIsActive())
242
- renderFrame();
243
- }
244
- else
245
- {
246
- // apply time delta smoothing, improves smoothness of framerate in some browsers
247
- let deltaSmooth = 0;
248
- if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
249
- {
250
- // force at least one update each frame since it is waiting for refresh
251
- deltaSmooth = frameTimeBufferMS;
252
- frameTimeBufferMS = 0;
253
- }
254
-
255
- // update multiple frames if necessary in case of slow framerate
256
- for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
257
- {
258
- // increment frame and update time
259
- time = frame++ / frameRate;
260
-
261
- // update game and objects
262
- wasUpdated = true;
263
- updateCanvas();
264
- inputUpdate();
265
- gameUpdate();
266
- pluginList.forEach(plugin=>plugin.update?.());
267
- engineObjectsUpdate();
268
-
269
- // do post update
270
- debugUpdate();
271
- gameUpdatePost();
272
- inputUpdatePost();
273
- if (debugVideoCaptureIsActive())
274
- renderFrame();
275
- }
276
-
277
- // add the time smoothing back in
278
- frameTimeBufferMS += deltaSmooth;
279
- }
280
-
281
- if (!debugVideoCaptureIsActive())
282
- renderFrame();
283
- requestAnimationFrame(engineUpdate);
284
-
285
- function renderFrame()
286
- {
287
- if (headlessMode) return;
288
-
289
- // canvas must be updated before rendering
290
- if (!wasUpdated)
291
- updateCanvas();
292
-
293
- // render the game and objects
294
- enginePreRender();
295
- gameRender();
296
- engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
297
- for (const o of engineObjects)
298
- o.destroyed || o.render();
299
-
300
- // post rendering
301
- gameRenderPost();
302
- pluginList.forEach(plugin=>plugin.render?.());
303
- inputRender();
304
- debugRender();
305
- glFlush();
306
- debugRenderPost();
307
- drawCount = 0;
308
- primitiveCount = 0;
309
- }
310
- }
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
-
385
- // set default line join and cap
386
- mainContext.lineJoin = 'round';
387
- mainContext.lineCap = 'round';
388
- }
389
-
390
- // skip setup if headless
391
- if (headlessMode) return startEngine();
392
-
393
- // setup webgl
394
- glInit(rootElement);
395
-
396
- // setup html
397
- const styleRoot =
398
- 'margin:0;' + // fill the window
399
- 'overflow:hidden;' + // no scroll bars
400
- 'background:#000;' + // set background color
401
- 'user-select:none;' + // prevent hold to select
402
- '-webkit-user-select:none;' + // compatibility for ios
403
- 'touch-action:none;' + // prevent mobile pinch to resize
404
- '-webkit-touch-callout:none'; // compatibility for ios
405
- rootElement.style.cssText = styleRoot;
406
- mainCanvas = rootElement.appendChild(document.createElement('canvas'));
407
- drawContext = mainContext = mainCanvas.getContext('2d');
408
-
409
- // init stuff and start engine
410
- inputInit();
411
- audioInit();
412
- debugInit();
413
-
414
- // setup canvases
415
- // transform way is still more reliable than flexbox or grid
416
- const styleCanvas = 'position:absolute;'+ // allow canvases to overlap
417
- 'top:50%;left:50%;transform:translate(-50%,-50%)'; // center on screen
418
- mainCanvas.style.cssText = styleCanvas;
419
- if (glCanvas)
420
- glCanvas.style.cssText = styleCanvas;
421
- setCanvasPixelated(canvasPixelated);
422
- updateCanvas();
423
- glPreRender();
424
-
425
- // 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 });
430
-
431
- // create promises for loading images
432
- const promises = imageSources.map((src, i)=> loadTexture(i, src));
433
-
434
- // no images to load
435
- if (!imageSources.length)
436
- promises.push(loadTexture(0));
437
-
438
- // load engine font image
439
- promises.push(imageFontInit());
440
-
441
- if (showSplashScreen)
442
- {
443
- // draw splash screen
444
- promises.push(new Promise(resolve =>
445
- {
446
- let t = 0;
447
- updateSplash();
448
- function updateSplash()
449
- {
450
- inputClear();
451
- drawEngineLogo(t+=.01);
452
- t>1 ? resolve() : setTimeout(updateSplash, 16);
453
- }
454
- }));
455
- }
456
-
457
- // wait for all the promises to finish
458
- await Promise.all(promises);
459
- return startEngine();
460
-
461
- async function startEngine()
462
- {
463
- // wait for gameInit to load
464
- await gameInit();
465
- engineUpdate();
466
- }
467
- }
468
-
469
- /** Update each engine object, remove destroyed objects, and update time
470
- * can be called manually if objects need to be updated outside of main loop
471
- * @memberof Engine */
472
- function engineObjectsUpdate()
473
- {
474
- // get list of solid objects for physics optimization
475
- engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
476
-
477
- // update physics before object update
478
- for (const o of engineObjects)
479
- if (!o.parent && !o.destroyed)
480
- o.updatePhysics();
481
-
482
- // recursive object update
483
- function updateChildObject(o)
484
- {
485
- if (o.destroyed) return;
486
-
487
- o.update();
488
- for (const child of o.children)
489
- updateChildObject(child);
490
- }
491
- for (const o of engineObjects)
492
- {
493
- if (o.parent || o.destroyed) continue;
494
-
495
- // update top level objects
496
- o.update();
497
- for (const child of o.children)
498
- updateChildObject(child);
499
- o.updateTransforms();
500
- }
501
-
502
- // remove destroyed objects
503
- engineObjects = engineObjects.filter(o=>!o.destroyed);
504
- }
505
-
506
- /** Destroy and remove all objects
507
- * - This can be used to clear out all objects when restarting a level
508
- * - Objects can override their destroy function to do cleanup or stick around
509
- * @param {boolean} [immediate] - should attached effects be allowed to die off?
510
- * @memberof Engine */
511
- function engineObjectsDestroy(immediate=true)
512
- {
513
- for (const o of engineObjects)
514
- o.parent || o.destroy(immediate);
515
- engineObjects = engineObjects.filter(o=>!o.destroyed);
516
- }
517
-
518
- /** Collects all object within a given area
519
- * @param {Vector2} [pos] - Center of test area, or undefined for all objects
520
- * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
521
- * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
522
- * @return {Array<EngineObject>} - List of collected objects
523
- * @memberof Engine */
524
- function engineObjectsCollect(pos, size, objects=engineObjects)
525
- {
526
- const collectedObjects = [];
527
- if (!pos)
528
- {
529
- // all objects
530
- for (const o of objects)
531
- collectedObjects.push(o);
532
- }
533
- else if (size instanceof Vector2)
534
- {
535
- // bounding box test
536
- for (const o of objects)
537
- o.isOverlapping(pos, size) && collectedObjects.push(o);
538
- }
539
- else
540
- {
541
- // circle test
542
- const sizeSquared = size*size;
543
- for (const o of objects)
544
- pos.distanceSquared(o.pos) < sizeSquared && collectedObjects.push(o);
545
- }
546
- return collectedObjects;
547
- }
548
-
549
- /**
550
- * @callback ObjectCallbackFunction - Function that processes an object
551
- * @param {EngineObject} object
552
- * @memberof Engine
553
- */
554
-
555
- /** Triggers a callback for each object within a given area
556
- * @param {Vector2} [pos] - Center of test area, or undefined for all objects
557
- * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
558
- * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test
559
- * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
560
- * @memberof Engine */
561
- function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects)
562
- { engineObjectsCollect(pos, size, objects).forEach(o => callbackFunction(o)); }
563
-
564
- /** Return a list of objects intersecting a ray
565
- * @param {Vector2} start
566
- * @param {Vector2} end
567
- * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
568
- * @return {Array<EngineObject>} - List of objects hit
569
- * @memberof Engine */
570
- function engineObjectsRaycast(start, end, objects=engineObjects)
571
- {
572
- const hitObjects = [];
573
- for (const o of objects)
574
- {
575
- if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size))
576
- {
577
- debugRaycast && debugRect(o.pos, o.size, '#f00');
578
- hitObjects.push(o);
579
- }
580
- }
581
-
582
- debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
583
- return hitObjects;
6
+ /**
7
+ * LittleJS - The Tiny Fast JavaScript Game Engine
8
+ * MIT License - Copyright 2021 Frank Force
9
+ *
10
+ * Engine Features
11
+ * - Object oriented system with EngineObject base class
12
+ * - Automatic object lifecycle (update, physics, collision, rendering)
13
+ * - Engine helper classes: Vector2, Color, Timer, RandomGenerator
14
+ * - Hybrid rendering with WebGL batching and Canvas2D fallback
15
+ * - Audio system with wave, mp3, or ZzFX sound effects
16
+ * - Input system with keyboard, mouse, gamepad, and touch support
17
+ * - Tile layer rendering and collision detection
18
+ * - Particle effect system with emitters
19
+ * - Medal/achievement system with local storage
20
+ * - Comprehensive debug tools and visualizations
21
+ * - Fixed 60 FPS timestep with configurable time scale
22
+ * - Raycast and spatial query utilities
23
+ * - Plugin system for extending engine functionality
24
+ * - Start with engineInit() and provide your game callbacks
25
+ * @namespace Engine
26
+ */
27
+
28
+ /** Name of engine
29
+ * @type {string}
30
+ * @default
31
+ * @memberof Engine */
32
+ const engineName = 'LittleJS';
33
+
34
+ /** Version of engine
35
+ * @type {string}
36
+ * @default
37
+ * @memberof Engine */
38
+ const engineVersion = '1.18.26';
39
+
40
+ /** Frames per second to update
41
+ * @type {number}
42
+ * @default
43
+ * @memberof Engine */
44
+ const frameRate = 60;
45
+
46
+ /** How many seconds each frame lasts, engine uses a fixed time step
47
+ * @type {number}
48
+ * @default 1/60
49
+ * @memberof Engine */
50
+ const timeDelta = 1/frameRate;
51
+
52
+ /** Array containing all engine objects
53
+ * @type {Array<EngineObject>}
54
+ * @memberof Engine */
55
+ let engineObjects = [];
56
+
57
+ /** Array with only objects set to collide with other objects this frame (for optimization)
58
+ * @type {Array<EngineObject>}
59
+ * @memberof Engine */
60
+ let engineObjectsCollide = [];
61
+
62
+ /** Current update frame, used to calculate time
63
+ * @type {number}
64
+ * @memberof Engine */
65
+ let frame = 0;
66
+
67
+ /** Current engine time since start in seconds
68
+ * @type {number}
69
+ * @memberof Engine */
70
+ let time = 0;
71
+
72
+ /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
73
+ * @type {number}
74
+ * @memberof Engine */
75
+ let timeReal = 0;
76
+
77
+ /** Is the game paused? Causes time and objects to not be updated
78
+ * @type {boolean}
79
+ * @default false
80
+ * @memberof Engine */
81
+ let paused = false;
82
+
83
+ /** Get if game is paused
84
+ * @return {boolean}
85
+ * @memberof Engine */
86
+ function getPaused() { return paused; }
87
+
88
+ /** Set if game is paused
89
+ * @param {boolean} [isPaused]
90
+ * @memberof Engine */
91
+ function setPaused(isPaused=true) { paused = isPaused; }
92
+
93
+ // Engine internal variables
94
+ let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
95
+ let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
96
+ let showEngineVersion = true;
97
+
98
+ ///////////////////////////////////////////////////////////////////////////////
99
+ // plugin hooks
100
+
101
+ const pluginList = [];
102
+ class EnginePlugin
103
+ {
104
+ constructor(update, render, glContextLost, glContextRestored)
105
+ {
106
+ this.update = update;
107
+ this.render = render;
108
+ this.glContextLost = glContextLost;
109
+ this.glContextRestored = glContextRestored;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * @callback PluginCallback - Update or render function for a plugin
115
+ * @memberof Engine
116
+ */
117
+
118
+ /** Add a new update function for a plugin
119
+ * @param {PluginCallback} [update]
120
+ * @param {PluginCallback} [render]
121
+ * @param {PluginCallback} [glContextLost]
122
+ * @param {PluginCallback} [glContextRestored]
123
+ * @memberof Engine */
124
+ function engineAddPlugin(update, render, glContextLost, glContextRestored)
125
+ {
126
+ // make sure plugin functions are unique
127
+ ASSERT(!pluginList.find(p=>
128
+ p.update === update && p.render === render &&
129
+ p.glContextLost === glContextLost &&
130
+ p.glContextRestored === glContextRestored));
131
+
132
+ const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored);
133
+ pluginList.push(plugin);
134
+ }
135
+
136
+ ///////////////////////////////////////////////////////////////////////////////
137
+ // Main Engine Functions
138
+
139
+ /**
140
+ * @callback GameInitCallback - Called after the engine starts, can be async
141
+ * @return {void|Promise<void>}
142
+ * @memberof Engine
143
+ */
144
+ /**
145
+ * @callback GameCallback - Update or render function for the game
146
+ * @memberof Engine
147
+ */
148
+
149
+ /** Startup LittleJS engine with your callback functions
150
+ * @param {GameInitCallback} gameInit - Called once after the engine starts up, can be async for loading
151
+ * @param {GameCallback} gameUpdate - Called every frame before objects are updated (60fps), use for game logic
152
+ * @param {GameCallback} gameUpdatePost - Called after physics and objects are updated, even when paused, use for UI updates
153
+ * @param {GameCallback} gameRender - Called before objects are rendered, use for drawing backgrounds/world elements
154
+ * @param {GameCallback} gameRenderPost - Called after objects are rendered, use for drawing UI/overlays
155
+ * @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
156
+ * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
157
+ * @example
158
+ * // Basic engine startup
159
+ * engineInit(
160
+ * ()=> { LOG('Game initialized!'); }, // gameInit
161
+ * ()=> { updateGameLogic(); }, // gameUpdate
162
+ * ()=> { updateUI(); }, // gameUpdatePost
163
+ * ()=> { drawBackground(); }, // gameRender
164
+ * ()=> { drawHUD(); }, // gameRenderPost
165
+ * ['tiles.png', 'tilesLevel.png'] // images to load
166
+ * );
167
+ * @memberof Engine */
168
+ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
169
+ {
170
+ showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
171
+ ASSERT(!mainContext, 'engine already initialized');
172
+ // runtime guard so release builds (where the assert is stripped) don't
173
+ // double-register listeners / double-add canvases on a second call
174
+ if (mainContext) return;
175
+ ASSERT(isArray(imageSources), 'pass in images as array');
176
+
177
+ // ensure body exists for minimal HTML where the script runs before <body> is parsed
178
+ if (!document.body)
179
+ document.documentElement.appendChild(document.createElement('body'));
180
+ rootElement ||= document.body;
181
+
182
+ // allow passing in empty functions
183
+ gameInit ||= ()=>{};
184
+ gameUpdate ||= ()=>{};
185
+ gameUpdatePost ||= ()=>{};
186
+ gameRender ||= ()=>{};
187
+ gameRenderPost ||= ()=>{};
188
+
189
+ // Called automatically by engine to setup render system
190
+ function enginePreRender()
191
+ {
192
+ // save canvas size
193
+ mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height);
194
+
195
+ // disable smoothing for pixel art
196
+ mainContext.imageSmoothingEnabled = !tilesPixelated;
197
+
198
+ // setup gl rendering if enabled
199
+ glPreRender();
200
+ }
201
+
202
+ // internal update loop for engine
203
+ function engineUpdate(frameTimeMS=0)
204
+ {
205
+ // update time keeping
206
+ let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
207
+ // skip delta on the very first frame so timeReal doesn't jump
208
+ // by ~page-load-time when RAF starts handing real timestamps
209
+ if (!frameTimeLastMS) frameTimeDeltaMS = 0;
210
+ frameTimeLastMS = frameTimeMS;
211
+ if (debug || debugWatermark)
212
+ averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
213
+ const debugSpeedUp = debug && keyIsDown('Equal'); // +
214
+ const debugSpeedDown = debug && keyIsDown('Minus'); // -
215
+ const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
216
+
217
+ // apply time deltas
218
+ timeReal += frameTimeDeltaMS * debugScale / 1e3;
219
+ const combinedScale = timeScale * debugScale;
220
+ frameTimeDeltaMS *= combinedScale;
221
+ frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
222
+ if (combinedScale <= 1)
223
+ frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
224
+
225
+ let wasUpdated = false;
226
+ if (paused)
227
+ {
228
+ // update everything except the game and objects
229
+ wasUpdated = true;
230
+ updateCanvas();
231
+ inputUpdate();
232
+ pluginList.forEach(plugin=>plugin.update?.());
233
+
234
+ // update object transforms even when paused
235
+ for (const o of engineObjects)
236
+ o.parent || o.updateTransforms();
237
+
238
+ // do post update
239
+ debugUpdate();
240
+ gameUpdatePost();
241
+ inputUpdatePost();
242
+ if (debugVideoCaptureIsActive())
243
+ renderFrame();
244
+ }
245
+ else
246
+ {
247
+ // apply time delta smoothing, improves smoothness of framerate in some browsers
248
+ let deltaSmooth = 0;
249
+ if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
250
+ {
251
+ // force at least one update each frame since it is waiting for refresh
252
+ deltaSmooth = frameTimeBufferMS;
253
+ frameTimeBufferMS = 0;
254
+ }
255
+
256
+ // update multiple frames if necessary in case of slow framerate
257
+ for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
258
+ {
259
+ // increment frame and update time
260
+ time = frame++ / frameRate;
261
+
262
+ // update game and objects
263
+ wasUpdated = true;
264
+ updateCanvas();
265
+ inputUpdate();
266
+ gameUpdate();
267
+ pluginList.forEach(plugin=>plugin.update?.());
268
+ engineObjectsUpdate();
269
+
270
+ // do post update
271
+ debugUpdate();
272
+ gameUpdatePost();
273
+ inputUpdatePost();
274
+ if (debugVideoCaptureIsActive())
275
+ renderFrame();
276
+ }
277
+
278
+ // add the time smoothing back in
279
+ frameTimeBufferMS += deltaSmooth;
280
+ }
281
+
282
+ if (!debugVideoCaptureIsActive())
283
+ renderFrame();
284
+ if (!engineManualStep)
285
+ requestAnimationFrame(engineUpdate);
286
+
287
+ function renderFrame()
288
+ {
289
+ if (headlessMode) return;
290
+
291
+ // canvas must be updated before rendering
292
+ if (!wasUpdated)
293
+ updateCanvas();
294
+
295
+ // render the game and objects
296
+ enginePreRender();
297
+ gameRender();
298
+ engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
299
+ for (const o of engineObjects)
300
+ o.destroyed || o.render();
301
+
302
+ // post rendering
303
+ gameRenderPost();
304
+ pluginList.forEach(plugin=>plugin.render?.());
305
+ inputRender();
306
+ debugRender();
307
+ glFlush();
308
+ debugRenderPost();
309
+ drawCount = 0;
310
+ primitiveCount = 0;
311
+ }
312
+ }
313
+ engineUpdateInternal = engineUpdate;
314
+
315
+ function updateCanvas()
316
+ {
317
+ if (headlessMode) return;
318
+
319
+ if (canvasFixedSize.x)
320
+ {
321
+ // set canvas fixed size
322
+ mainCanvasSize = canvasFixedSize.copy();
323
+
324
+ // fit to window using css width and height
325
+ const innerAspect = innerWidth / innerHeight;
326
+ const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
327
+ const w = innerAspect < fixedAspect ? '100%' : '';
328
+ const h = innerAspect < fixedAspect ? '' : '100%';
329
+ mainCanvas.style.width = w;
330
+ mainCanvas.style.height = h;
331
+ if (glCanvas)
332
+ {
333
+ glCanvas.style.width = w;
334
+ glCanvas.style.height = h;
335
+ }
336
+ }
337
+ else
338
+ {
339
+ // apply device pixel ratio for crisp rendering
340
+ const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
341
+ const viewWidth = innerWidth * dpr | 0;
342
+ const viewHeight = innerHeight * dpr | 0;
343
+
344
+ // get main canvas size based on window size
345
+ mainCanvasSize.x = min(viewWidth, canvasMaxSize.x);
346
+ mainCanvasSize.y = min(viewHeight, canvasMaxSize.y);
347
+
348
+ // responsive aspect ratio with native resolution
349
+ const innerAspect = viewWidth / viewHeight;
350
+ ASSERT(canvasMinAspect <= canvasMaxAspect);
351
+ if (canvasMaxAspect && innerAspect > canvasMaxAspect)
352
+ {
353
+ // full height
354
+ const w = mainCanvasSize.y * canvasMaxAspect | 0;
355
+ mainCanvasSize.x = min(w, canvasMaxSize.x);
356
+ }
357
+ else if (innerAspect < canvasMinAspect)
358
+ {
359
+ // full width
360
+ const h = mainCanvasSize.x / canvasMinAspect | 0;
361
+ mainCanvasSize.y = min(h, canvasMaxSize.y);
362
+ }
363
+
364
+ // set CSS display size so backing store renders at viewport size
365
+ const cssW = (mainCanvasSize.x / dpr | 0) + 'px';
366
+ const cssH = (mainCanvasSize.y / dpr | 0) + 'px';
367
+ mainCanvas.style.width = cssW;
368
+ mainCanvas.style.height = cssH;
369
+ if (glCanvas)
370
+ {
371
+ glCanvas.style.width = cssW;
372
+ glCanvas.style.height = cssH;
373
+ }
374
+ }
375
+
376
+ // clear main canvas and set size
377
+ mainCanvas.width = mainCanvasSize.x;
378
+ mainCanvas.height = mainCanvasSize.y;
379
+
380
+ // apply the clear color to main canvas
381
+ if (canvasClearColor.a > 0 && !glEnable)
382
+ {
383
+ mainContext.fillStyle = canvasClearColor.toString();
384
+ mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y);
385
+ mainContext.fillStyle = BLACK.toString();
386
+ }
387
+
388
+ // set default line join and cap
389
+ mainContext.lineJoin = 'round';
390
+ mainContext.lineCap = 'round';
391
+ }
392
+
393
+ // skip setup if headless
394
+ if (headlessMode) return startEngine();
395
+
396
+ // setup webgl
397
+ glInit(rootElement);
398
+
399
+ // setup html
400
+ const styleRoot =
401
+ 'margin:0;' + // fill the window
402
+ 'overflow:hidden;' + // no scroll bars
403
+ 'background:#000;' + // set background color
404
+ 'user-select:none;' + // prevent hold to select
405
+ '-webkit-user-select:none;' + // compatibility for ios
406
+ 'touch-action:none;' + // prevent mobile pinch to resize
407
+ '-webkit-touch-callout:none'; // compatibility for ios
408
+ rootElement.style.cssText = styleRoot;
409
+ mainCanvas = rootElement.appendChild(document.createElement('canvas'));
410
+ drawContext = mainContext = mainCanvas.getContext('2d');
411
+
412
+ // init stuff and start engine
413
+ inputInit();
414
+ audioInit();
415
+ debugInit();
416
+
417
+ // setup canvases
418
+ // transform way is still more reliable than flexbox or grid
419
+ const styleCanvas = 'position:absolute;'+ // allow canvases to overlap
420
+ 'top:50%;left:50%;transform:translate(-50%,-50%)'; // center on screen
421
+ mainCanvas.style.cssText = styleCanvas;
422
+ if (glCanvas)
423
+ glCanvas.style.cssText = styleCanvas;
424
+ setCanvasPixelated(canvasPixelated);
425
+ updateCanvas();
426
+ glPreRender();
427
+
428
+ // create offscreen canvases for image processing
429
+ workCanvas = new OffscreenCanvas(64, 64);
430
+ workContext = workCanvas.getContext('2d');
431
+ workReadCanvas = new OffscreenCanvas(64, 64);
432
+ workReadContext = workReadCanvas.getContext('2d', { willReadFrequently: true });
433
+
434
+ // create promises for loading images
435
+ const promises = imageSources.map((src, i)=> loadTexture(i, src));
436
+
437
+ // no images to load
438
+ if (!imageSources.length)
439
+ promises.push(loadTexture(0));
440
+
441
+ // load engine font image
442
+ promises.push(imageFontInit());
443
+
444
+ if (showSplashScreen)
445
+ {
446
+ // draw splash screen
447
+ promises.push(new Promise(resolve =>
448
+ {
449
+ let t = 0;
450
+ updateSplash();
451
+ function updateSplash()
452
+ {
453
+ inputClear();
454
+ drawEngineLogo(t+=.01);
455
+ t>1 ? resolve() : setTimeout(updateSplash, 16);
456
+ }
457
+ }));
458
+ }
459
+
460
+ // wait for all the promises to finish
461
+ await Promise.all(promises);
462
+ return startEngine();
463
+
464
+ async function startEngine()
465
+ {
466
+ // wait for gameInit to load
467
+ await gameInit();
468
+ engineManualStep || engineUpdate();
469
+ }
470
+ }
471
+
472
+ // max frames engineStep can advance in one call, 10 minutes at 60fps
473
+ // large counts block until they finish, so this catches runaway values
474
+ const engineStepMaxFrames = 36000;
475
+
476
+ /** Advance the engine by a number of frames
477
+ * Requires setEngineManualStep(true) before engineInit
478
+ * Respects paused exactly as the normal update loop does
479
+ * @param {number} [frames] - number of engine update ticks, max 36000, each running one fixed update at timeScale 1
480
+ * @example
481
+ * setHeadlessMode(true);
482
+ * setEngineManualStep(true);
483
+ * await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost);
484
+ * engineStep(600); // advance 10 seconds of game time
485
+ * @memberof Engine */
486
+ function engineStep(frames=1)
487
+ {
488
+ ASSERT(engineManualStep,
489
+ 'engineStep requires setEngineManualStep(true) before engineInit');
490
+ ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete');
491
+ // runtime guard so release builds (where the asserts are stripped) can't
492
+ // start a second requestAnimationFrame chain or call an undefined update
493
+ if (!engineManualStep || !engineUpdateInternal) return;
494
+ ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames,
495
+ 'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames);
496
+ frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze
497
+ for (let i = frames; i > 0; --i)
498
+ engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate);
499
+ }
500
+
501
+ /** Update each engine object, remove destroyed objects, and update time
502
+ * can be called manually if objects need to be updated outside of main loop
503
+ * @memberof Engine */
504
+ function engineObjectsUpdate()
505
+ {
506
+ // get list of solid objects for physics optimization
507
+ engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
508
+
509
+ // update physics before object update
510
+ for (const o of engineObjects)
511
+ if (!o.parent && !o.destroyed)
512
+ o.updatePhysics();
513
+
514
+ // recursive object update
515
+ function updateChildObject(o)
516
+ {
517
+ if (o.destroyed) return;
518
+
519
+ o.update();
520
+ for (const child of o.children)
521
+ updateChildObject(child);
522
+ }
523
+ for (const o of engineObjects)
524
+ {
525
+ if (o.parent || o.destroyed) continue;
526
+
527
+ // update top level objects
528
+ o.update();
529
+ for (const child of o.children)
530
+ updateChildObject(child);
531
+ o.updateTransforms();
532
+ }
533
+
534
+ // remove destroyed objects
535
+ engineObjects = engineObjects.filter(o=>!o.destroyed);
536
+ }
537
+
538
+ /** Destroy and remove all objects
539
+ * - This can be used to clear out all objects when restarting a level
540
+ * - Objects can override their destroy function to do cleanup or stick around
541
+ * @param {boolean} [immediate] - should attached effects be allowed to die off?
542
+ * @memberof Engine */
543
+ function engineObjectsDestroy(immediate=true)
544
+ {
545
+ for (const o of engineObjects)
546
+ o.parent || o.destroy(immediate);
547
+ engineObjects = engineObjects.filter(o=>!o.destroyed);
548
+ }
549
+
550
+ /** Collects all object within a given area
551
+ * @param {Vector2} [pos] - Center of test area, or undefined for all objects
552
+ * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
553
+ * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
554
+ * @return {Array<EngineObject>} - List of collected objects
555
+ * @memberof Engine */
556
+ function engineObjectsCollect(pos, size, objects=engineObjects)
557
+ {
558
+ const collectedObjects = [];
559
+ if (!pos)
560
+ {
561
+ // all objects
562
+ for (const o of objects)
563
+ collectedObjects.push(o);
564
+ }
565
+ else if (size instanceof Vector2)
566
+ {
567
+ // bounding box test
568
+ for (const o of objects)
569
+ o.isOverlapping(pos, size) && collectedObjects.push(o);
570
+ }
571
+ else
572
+ {
573
+ // circle test
574
+ const sizeSquared = size*size;
575
+ for (const o of objects)
576
+ pos.distanceSquared(o.pos) < sizeSquared && collectedObjects.push(o);
577
+ }
578
+ return collectedObjects;
579
+ }
580
+
581
+ /**
582
+ * @callback ObjectCallbackFunction - Function that processes an object
583
+ * @param {EngineObject} object
584
+ * @memberof Engine
585
+ */
586
+
587
+ /** Triggers a callback for each object within a given area
588
+ * @param {Vector2} [pos] - Center of test area, or undefined for all objects
589
+ * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
590
+ * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test
591
+ * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
592
+ * @memberof Engine */
593
+ function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects)
594
+ { engineObjectsCollect(pos, size, objects).forEach(o => callbackFunction(o)); }
595
+
596
+ /** Return a list of objects intersecting a ray
597
+ * @param {Vector2} start
598
+ * @param {Vector2} end
599
+ * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
600
+ * @return {Array<EngineObject>} - List of objects hit
601
+ * @memberof Engine */
602
+ function engineObjectsRaycast(start, end, objects=engineObjects)
603
+ {
604
+ const hitObjects = [];
605
+ for (const o of objects)
606
+ {
607
+ if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size))
608
+ {
609
+ debugRaycast && debugRect(o.pos, o.size, '#f00');
610
+ hitObjects.push(o);
611
+ }
612
+ }
613
+
614
+ debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
615
+ return hitObjects;
584
616
  }
585
617
  /**
586
618
  * LittleJS Debug System
@@ -2688,12 +2720,17 @@ function shareURL(title, url, callback)
2688
2720
 
2689
2721
  /** Read save data from local storage
2690
2722
  * @param {string} saveName - unique name for the game/save
2691
- * @param {Object} [defaultSaveData] - default values for save
2723
+ * @param {Object} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
2692
2724
  * @return {Object}
2693
2725
  * @memberof Utilities */
2694
2726
  function readSaveData(saveName, defaultSaveData)
2695
2727
  {
2696
- ASSERT(isStringLike(saveName), 'loadData requires saveName string');
2728
+ ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
2729
+ ASSERT(defaultSaveData === undefined ||
2730
+ (typeof defaultSaveData === 'object' && defaultSaveData !== null),
2731
+ 'readSaveData: default must be an object - the result is ' +
2732
+ '{...default, ...loaded}, so a scalar default yields {}. ' +
2733
+ 'Use readSaveData(key, {best:0}).best');
2697
2734
 
2698
2735
  // tolerate localStorage being unavailable (iOS private mode, sandboxed
2699
2736
  // iframes) and corrupt JSON in stored data
@@ -2717,7 +2754,7 @@ function readSaveData(saveName, defaultSaveData)
2717
2754
  * @memberof Utilities */
2718
2755
  function writeSaveData(saveName, saveData)
2719
2756
  {
2720
- ASSERT(isStringLike(saveName), 'saveData requires saveName string');
2757
+ ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
2721
2758
  // tolerate localStorage being unavailable or quota exceeded
2722
2759
  try { localStorage[saveName] = JSON.stringify(saveData); }
2723
2760
  catch { LOG('writeSaveData: failed to write', saveName); }
@@ -2885,6 +2922,13 @@ let showSplashScreen = false;
2885
2922
  * @memberof Settings */
2886
2923
  let headlessMode = false;
2887
2924
 
2925
+ /** Disables the automatic requestAnimationFrame loop so the engine only
2926
+ * advances when engineStep is called, for tests and frame-stepping tools
2927
+ * @type {boolean}
2928
+ * @default
2929
+ * @memberof Settings */
2930
+ let engineManualStep = false;
2931
+
2888
2932
  ///////////////////////////////////////////////////////////////////////////////
2889
2933
  // WebGL settings
2890
2934
 
@@ -3235,6 +3279,12 @@ function setShowSplashScreen(show) { showSplashScreen = show; }
3235
3279
  * @memberof Settings */
3236
3280
  function setHeadlessMode(headless) { headlessMode = headless; }
3237
3281
 
3282
+ /** Set if the engine only advances when engineStep is called
3283
+ * Must be set before engineInit
3284
+ * @param {boolean} [enable]
3285
+ * @memberof Settings */
3286
+ function setEngineManualStep(enable=true) { engineManualStep = enable; }
3287
+
3238
3288
  /** Set if WebGL rendering is enabled
3239
3289
  * @param {boolean} enable
3240
3290
  * @memberof Settings */
@@ -5475,9 +5525,15 @@ class ImageFont
5475
5525
  tileInfo.pos.x = x*sizePaddedX + padding;
5476
5526
  tileInfo.pos.y = y*sizePaddedY + padding;
5477
5527
 
5478
- // draw the tile
5479
- drawPos.x = pos.x + i * size.x - centerOffset |0;
5480
- drawPos.y = pos.y + j * size.y |0;
5528
+ // snap the glyph edges to whole pixels
5529
+ // tiles are drawn from their center, so snapping the center
5530
+ // to a whole pixel puts the edges on half pixels when the
5531
+ // size is even, and a row or column of the glyph then has
5532
+ // no pixel center inside it and is not rasterized at all
5533
+ // ceil picks the nearest aligned position, breaking ties
5534
+ // downward to match how this used to truncate
5535
+ drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
5536
+ drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
5481
5537
  drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
5482
5538
  }
5483
5539
  });
@@ -16871,6 +16927,7 @@ export
16871
16927
  getPaused,
16872
16928
  setPaused,
16873
16929
  engineInit,
16930
+ engineStep,
16874
16931
  engineObjectsUpdate,
16875
16932
  engineObjectsDestroy,
16876
16933
  engineObjectsCollect,
@@ -16918,6 +16975,7 @@ export
16918
16975
  fontDefault,
16919
16976
  showSplashScreen,
16920
16977
  headlessMode,
16978
+ engineManualStep,
16921
16979
  tileDefaultSize,
16922
16980
  tileDefaultPadding,
16923
16981
  tileDefaultBleed,
@@ -16972,6 +17030,7 @@ export
16972
17030
  setFontDefault,
16973
17031
  setShowSplashScreen,
16974
17032
  setHeadlessMode,
17033
+ setEngineManualStep,
16975
17034
  setGLEnable,
16976
17035
  setTileDefaultSize,
16977
17036
  setTileDefaultPadding,