littlejsengine 1.18.25 → 1.18.27

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,616 +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.25';
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;
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.27';
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;
616
616
  }
617
617
  /**
618
618
  * LittleJS - Release Mode
@@ -2346,6 +2346,14 @@ let gamepadsEnable = true;
2346
2346
  * @memberof Settings */
2347
2347
  let gamepadDirectionEmulateStick = true;
2348
2348
 
2349
+ /** If true, axes that do not rest near center are ignored on gamepads without
2350
+ * standard mapping. Steering wheels and flight sticks report pedal and throttle
2351
+ * axes that rest at full deflection, which otherwise reads as a stick held down.
2352
+ * @type {boolean}
2353
+ * @default
2354
+ * @memberof Settings */
2355
+ let gamepadAxisFilterEnable = true;
2356
+
2349
2357
  /** If true the WASD keys are also routed to the direction keys (for better accessibility)
2350
2358
  * @type {boolean}
2351
2359
  * @default
@@ -2684,6 +2692,11 @@ function setGamepadsEnable(enable) { gamepadsEnable = enable; }
2684
2692
  * @memberof Settings */
2685
2693
  function setGamepadDirectionEmulateStick(enable) { gamepadDirectionEmulateStick = enable; }
2686
2694
 
2695
+ /** Set if axes that do not rest near center are ignored on non-standard gamepads
2696
+ * @param {boolean} enable
2697
+ * @memberof Settings */
2698
+ function setGamepadAxisFilterEnable(enable) { gamepadAxisFilterEnable = enable; }
2699
+
2687
2700
  /** Set if true the WASD keys are also routed to the direction keys
2688
2701
  * @param {boolean} enable
2689
2702
  * @memberof Settings */
@@ -3428,6 +3441,12 @@ let workReadCanvas;
3428
3441
  * @memberof Draw */
3429
3442
  let workReadContext;
3430
3443
 
3444
+ /** Extra canvas to composite behind the engine canvases when combining canvases
3445
+ * Set by plugins that render to their own canvas below the LittleJS canvases
3446
+ * @type {HTMLCanvasElement}
3447
+ * @memberof Draw */
3448
+ let backgroundCanvas;
3449
+
3431
3450
  /** The size of the main canvas (and other secondary canvases)
3432
3451
  * @type {Vector2}
3433
3452
  * @memberof Draw */
@@ -4579,6 +4598,13 @@ function setAdditiveBlendMode(additive=true)
4579
4598
  drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
4580
4599
  }
4581
4600
 
4601
+ /** Set an extra canvas to composite behind the engine canvases when combining
4602
+ * Plugins that insert their own canvas below the LittleJS canvases should set
4603
+ * this so it appears in screenshots and video capture
4604
+ * @param {HTMLCanvasElement} [canvas]
4605
+ * @memberof Draw */
4606
+ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
4607
+
4582
4608
  /** Combines LittleJS canvases onto the main canvas
4583
4609
  * This is necessary for things like screenshots and video
4584
4610
  * @memberof Draw */
@@ -4591,6 +4617,8 @@ function combineCanvases()
4591
4617
  // leaving workContext.fillStyle transparent can't silently no-op this
4592
4618
  workContext.fillStyle = '#000';
4593
4619
  workContext.fillRect(0,0,w,h);
4620
+ if (backgroundCanvas)
4621
+ workContext.drawImage(backgroundCanvas, 0, 0, w, h);
4594
4622
  glCopyToContext(workContext);
4595
4623
  workContext.drawImage(mainCanvas, 0, 0);
4596
4624
  mainContext.drawImage(workCanvas, 0, 0);
@@ -4834,9 +4862,15 @@ class ImageFont
4834
4862
  tileInfo.pos.x = x*sizePaddedX + padding;
4835
4863
  tileInfo.pos.y = y*sizePaddedY + padding;
4836
4864
 
4837
- // draw the tile
4838
- drawPos.x = pos.x + i * size.x - centerOffset |0;
4839
- drawPos.y = pos.y + j * size.y |0;
4865
+ // snap the glyph edges to whole pixels
4866
+ // tiles are drawn from their center, so snapping the center
4867
+ // to a whole pixel puts the edges on half pixels when the
4868
+ // size is even, and a row or column of the glyph then has
4869
+ // no pixel center inside it and is not rasterized at all
4870
+ // ceil picks the nearest aligned position, breaking ties
4871
+ // downward to match how this used to truncate
4872
+ drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
4873
+ drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
4840
4874
  drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
4841
4875
  }
4842
4876
  });
@@ -4983,6 +5017,7 @@ function inputClear()
4983
5017
  touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
4984
5018
  gamepadStickData.length = 0;
4985
5019
  gamepadDpadData.length = 0;
5020
+ gamepadAxisCentered.length = 0;
4986
5021
  }
4987
5022
 
4988
5023
  ///////////////////////////////////////////////////////////////////////////////
@@ -5220,6 +5255,11 @@ const inputData = [[]];
5220
5255
 
5221
5256
  // gamepad internal variables
5222
5257
  const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5258
+ // per gamepad, how many consecutive frames each axis has rested inside the
5259
+ // dead zone, used to tell stick axes from axes that rest at full deflection
5260
+ const gamepadAxisCentered = [];
5261
+ // how long an axis must rest inside the dead zone before it counts as a stick
5262
+ const gamepadAxisCenteredFrames = 15;
5223
5263
 
5224
5264
  // touch gamepad internal variables
5225
5265
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
@@ -5468,7 +5508,7 @@ function inputUpdate()
5468
5508
  // gamepad: any button held or stick moved
5469
5509
  let gamepadActive = false;
5470
5510
  for (let s = gamepadStickCount(); s-- && !gamepadActive;)
5471
- gamepadActive = gamepadStick(s).lengthSquared() > .04;
5511
+ gamepadActive = gamepadStick(s).lengthSquared() > .2;
5472
5512
  for (let b = 17; b-- && !gamepadActive;)
5473
5513
  gamepadActive = gamepadIsDown(b);
5474
5514
 
@@ -5496,12 +5536,12 @@ function inputUpdate()
5496
5536
  // gamepads are updated by engine every frame automatically
5497
5537
  function gamepadsUpdate()
5498
5538
  {
5539
+ const deadZoneMin=.3, deadZoneMax=.8;
5499
5540
  const applyDeadZones = (v)=>
5500
5541
  {
5501
- const min=.3, max=.8;
5502
5542
  const deadZone = (v)=>
5503
- v > min ? percent(v, min, max) :
5504
- v < -min ? -percent(-v, min, max) : 0;
5543
+ v > deadZoneMin ? percent(v, deadZoneMin, deadZoneMax) :
5544
+ v < -deadZoneMin ? -percent(-v, deadZoneMin, deadZoneMax) : 0;
5505
5545
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5506
5546
  };
5507
5547
 
@@ -5585,6 +5625,7 @@ function inputUpdate()
5585
5625
  gamepadStickData[i] = undefined;
5586
5626
  gamepadDpadData[i] = undefined;
5587
5627
  gamepadHadInput[i] = undefined;
5628
+ gamepadAxisCentered[i] = undefined;
5588
5629
  continue;
5589
5630
  }
5590
5631
 
@@ -5593,8 +5634,30 @@ function inputUpdate()
5593
5634
  const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
5594
5635
 
5595
5636
  // read analog sticks
5637
+ // gamepads without standard mapping (steering wheels, flight sticks)
5638
+ // can report axes that rest at full deflection instead of center,
5639
+ // which would otherwise read as a stick held down forever, so only
5640
+ // trust an axis once it has rested inside the dead zone for a moment
5641
+ const isStandard = gamepad.mapping === 'standard';
5642
+ const centered = gamepadAxisCentered[i] ?? (gamepadAxisCentered[i] = []);
5643
+ const readAxis = (j)=>
5644
+ {
5645
+ const v = gamepad.axes[j];
5646
+ if (isStandard && j < 4)
5647
+ return v; // spec guarantees axes 0-3 are the two sticks
5648
+ if (!gamepadAxisFilterEnable)
5649
+ return v;
5650
+
5651
+ // once an axis has proven it rests at center it stays trusted,
5652
+ // otherwise moving it would immediately disqualify it again
5653
+ const frames = centered[j] | 0;
5654
+ if (frames > gamepadAxisCenteredFrames)
5655
+ return v;
5656
+ centered[j] = abs(v) < deadZoneMin ? frames + 1 : 0;
5657
+ return 0;
5658
+ };
5596
5659
  for (let j = 0; j < gamepad.axes.length-1; j+=2)
5597
- sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
5660
+ sticks[j>>1] = applyDeadZones(vec2(readAxis(j), readAxis(j+1)));
5598
5661
 
5599
5662
  // read buttons
5600
5663
  let hadInput = false;
@@ -16111,6 +16174,9 @@ class ThreeJSPlugin
16111
16174
  rootElement.insertBefore(threeCanvas, rootElement.firstChild);
16112
16175
  threeCanvas.style.cssText = mainCanvas.style.cssText;
16113
16176
 
16177
+ // composite the 3D canvas into screenshots and video capture
16178
+ setBackgroundCanvas(threeCanvas);
16179
+
16114
16180
  // render automatically each frame after the engine renders
16115
16181
  engineAddPlugin(undefined, ()=> this.render());
16116
16182
  }