littlejsengine 1.18.25 → 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,613 +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.25';
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;
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;
613
613
  }