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