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 Debug System
@@ -3037,6 +3037,14 @@ let gamepadsEnable = true;
3037
3037
  * @memberof Settings */
3038
3038
  let gamepadDirectionEmulateStick = true;
3039
3039
 
3040
+ /** If true, axes that do not rest near center are ignored on gamepads without
3041
+ * standard mapping. Steering wheels and flight sticks report pedal and throttle
3042
+ * axes that rest at full deflection, which otherwise reads as a stick held down.
3043
+ * @type {boolean}
3044
+ * @default
3045
+ * @memberof Settings */
3046
+ let gamepadAxisFilterEnable = true;
3047
+
3040
3048
  /** If true the WASD keys are also routed to the direction keys (for better accessibility)
3041
3049
  * @type {boolean}
3042
3050
  * @default
@@ -3375,6 +3383,11 @@ function setGamepadsEnable(enable) { gamepadsEnable = enable; }
3375
3383
  * @memberof Settings */
3376
3384
  function setGamepadDirectionEmulateStick(enable) { gamepadDirectionEmulateStick = enable; }
3377
3385
 
3386
+ /** Set if axes that do not rest near center are ignored on non-standard gamepads
3387
+ * @param {boolean} enable
3388
+ * @memberof Settings */
3389
+ function setGamepadAxisFilterEnable(enable) { gamepadAxisFilterEnable = enable; }
3390
+
3378
3391
  /** Set if true the WASD keys are also routed to the direction keys
3379
3392
  * @param {boolean} enable
3380
3393
  * @memberof Settings */
@@ -4119,6 +4132,12 @@ let workReadCanvas;
4119
4132
  * @memberof Draw */
4120
4133
  let workReadContext;
4121
4134
 
4135
+ /** Extra canvas to composite behind the engine canvases when combining canvases
4136
+ * Set by plugins that render to their own canvas below the LittleJS canvases
4137
+ * @type {HTMLCanvasElement}
4138
+ * @memberof Draw */
4139
+ let backgroundCanvas;
4140
+
4122
4141
  /** The size of the main canvas (and other secondary canvases)
4123
4142
  * @type {Vector2}
4124
4143
  * @memberof Draw */
@@ -5270,6 +5289,13 @@ function setAdditiveBlendMode(additive=true)
5270
5289
  drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5271
5290
  }
5272
5291
 
5292
+ /** Set an extra canvas to composite behind the engine canvases when combining
5293
+ * Plugins that insert their own canvas below the LittleJS canvases should set
5294
+ * this so it appears in screenshots and video capture
5295
+ * @param {HTMLCanvasElement} [canvas]
5296
+ * @memberof Draw */
5297
+ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
5298
+
5273
5299
  /** Combines LittleJS canvases onto the main canvas
5274
5300
  * This is necessary for things like screenshots and video
5275
5301
  * @memberof Draw */
@@ -5282,6 +5308,8 @@ function combineCanvases()
5282
5308
  // leaving workContext.fillStyle transparent can't silently no-op this
5283
5309
  workContext.fillStyle = '#000';
5284
5310
  workContext.fillRect(0,0,w,h);
5311
+ if (backgroundCanvas)
5312
+ workContext.drawImage(backgroundCanvas, 0, 0, w, h);
5285
5313
  glCopyToContext(workContext);
5286
5314
  workContext.drawImage(mainCanvas, 0, 0);
5287
5315
  mainContext.drawImage(workCanvas, 0, 0);
@@ -5525,9 +5553,15 @@ class ImageFont
5525
5553
  tileInfo.pos.x = x*sizePaddedX + padding;
5526
5554
  tileInfo.pos.y = y*sizePaddedY + padding;
5527
5555
 
5528
- // draw the tile
5529
- drawPos.x = pos.x + i * size.x - centerOffset |0;
5530
- drawPos.y = pos.y + j * size.y |0;
5556
+ // snap the glyph edges to whole pixels
5557
+ // tiles are drawn from their center, so snapping the center
5558
+ // to a whole pixel puts the edges on half pixels when the
5559
+ // size is even, and a row or column of the glyph then has
5560
+ // no pixel center inside it and is not rasterized at all
5561
+ // ceil picks the nearest aligned position, breaking ties
5562
+ // downward to match how this used to truncate
5563
+ drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
5564
+ drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
5531
5565
  drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
5532
5566
  }
5533
5567
  });
@@ -5674,6 +5708,7 @@ function inputClear()
5674
5708
  touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
5675
5709
  gamepadStickData.length = 0;
5676
5710
  gamepadDpadData.length = 0;
5711
+ gamepadAxisCentered.length = 0;
5677
5712
  }
5678
5713
 
5679
5714
  ///////////////////////////////////////////////////////////////////////////////
@@ -5911,6 +5946,11 @@ const inputData = [[]];
5911
5946
 
5912
5947
  // gamepad internal variables
5913
5948
  const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5949
+ // per gamepad, how many consecutive frames each axis has rested inside the
5950
+ // dead zone, used to tell stick axes from axes that rest at full deflection
5951
+ const gamepadAxisCentered = [];
5952
+ // how long an axis must rest inside the dead zone before it counts as a stick
5953
+ const gamepadAxisCenteredFrames = 15;
5914
5954
 
5915
5955
  // touch gamepad internal variables
5916
5956
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
@@ -6159,7 +6199,7 @@ function inputUpdate()
6159
6199
  // gamepad: any button held or stick moved
6160
6200
  let gamepadActive = false;
6161
6201
  for (let s = gamepadStickCount(); s-- && !gamepadActive;)
6162
- gamepadActive = gamepadStick(s).lengthSquared() > .04;
6202
+ gamepadActive = gamepadStick(s).lengthSquared() > .2;
6163
6203
  for (let b = 17; b-- && !gamepadActive;)
6164
6204
  gamepadActive = gamepadIsDown(b);
6165
6205
 
@@ -6187,12 +6227,12 @@ function inputUpdate()
6187
6227
  // gamepads are updated by engine every frame automatically
6188
6228
  function gamepadsUpdate()
6189
6229
  {
6230
+ const deadZoneMin=.3, deadZoneMax=.8;
6190
6231
  const applyDeadZones = (v)=>
6191
6232
  {
6192
- const min=.3, max=.8;
6193
6233
  const deadZone = (v)=>
6194
- v > min ? percent(v, min, max) :
6195
- v < -min ? -percent(-v, min, max) : 0;
6234
+ v > deadZoneMin ? percent(v, deadZoneMin, deadZoneMax) :
6235
+ v < -deadZoneMin ? -percent(-v, deadZoneMin, deadZoneMax) : 0;
6196
6236
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
6197
6237
  };
6198
6238
 
@@ -6276,6 +6316,7 @@ function inputUpdate()
6276
6316
  gamepadStickData[i] = undefined;
6277
6317
  gamepadDpadData[i] = undefined;
6278
6318
  gamepadHadInput[i] = undefined;
6319
+ gamepadAxisCentered[i] = undefined;
6279
6320
  continue;
6280
6321
  }
6281
6322
 
@@ -6284,8 +6325,30 @@ function inputUpdate()
6284
6325
  const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
6285
6326
 
6286
6327
  // read analog sticks
6328
+ // gamepads without standard mapping (steering wheels, flight sticks)
6329
+ // can report axes that rest at full deflection instead of center,
6330
+ // which would otherwise read as a stick held down forever, so only
6331
+ // trust an axis once it has rested inside the dead zone for a moment
6332
+ const isStandard = gamepad.mapping === 'standard';
6333
+ const centered = gamepadAxisCentered[i] ?? (gamepadAxisCentered[i] = []);
6334
+ const readAxis = (j)=>
6335
+ {
6336
+ const v = gamepad.axes[j];
6337
+ if (isStandard && j < 4)
6338
+ return v; // spec guarantees axes 0-3 are the two sticks
6339
+ if (!gamepadAxisFilterEnable)
6340
+ return v;
6341
+
6342
+ // once an axis has proven it rests at center it stays trusted,
6343
+ // otherwise moving it would immediately disqualify it again
6344
+ const frames = centered[j] | 0;
6345
+ if (frames > gamepadAxisCenteredFrames)
6346
+ return v;
6347
+ centered[j] = abs(v) < deadZoneMin ? frames + 1 : 0;
6348
+ return 0;
6349
+ };
6287
6350
  for (let j = 0; j < gamepad.axes.length-1; j+=2)
6288
- sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
6351
+ sticks[j>>1] = applyDeadZones(vec2(readAxis(j), readAxis(j+1)));
6289
6352
 
6290
6353
  // read buttons
6291
6354
  let hadInput = false;
@@ -16802,6 +16865,9 @@ class ThreeJSPlugin
16802
16865
  rootElement.insertBefore(threeCanvas, rootElement.firstChild);
16803
16866
  threeCanvas.style.cssText = mainCanvas.style.cssText;
16804
16867
 
16868
+ // composite the 3D canvas into screenshots and video capture
16869
+ setBackgroundCanvas(threeCanvas);
16870
+
16805
16871
  // render automatically each frame after the engine renders
16806
16872
  engineAddPlugin(undefined, ()=> this.render());
16807
16873
  }
@@ -16986,6 +17052,7 @@ export
16986
17052
  glCircleSides,
16987
17053
  gamepadsEnable,
16988
17054
  gamepadDirectionEmulateStick,
17055
+ gamepadAxisFilterEnable,
16989
17056
  inputWASDEmulateDirection,
16990
17057
  touchInputEnable,
16991
17058
  touchGamepadEnable,
@@ -17042,6 +17109,7 @@ export
17042
17109
  setTouchInputEnable,
17043
17110
  setGamepadsEnable,
17044
17111
  setGamepadDirectionEmulateStick,
17112
+ setGamepadAxisFilterEnable,
17045
17113
  setInputWASDEmulateDirection,
17046
17114
  setTouchGamepadEnable,
17047
17115
  setTouchGamepadPassthrough,
@@ -17157,6 +17225,7 @@ export
17157
17225
  workContext,
17158
17226
  workReadCanvas,
17159
17227
  workReadContext,
17228
+ backgroundCanvas,
17160
17229
  mainCanvasSize,
17161
17230
  textureInfos,
17162
17231
  drawCount,
@@ -17182,6 +17251,7 @@ export
17182
17251
  drawText,
17183
17252
  drawTextScreen,
17184
17253
  setAdditiveBlendMode,
17254
+ setBackgroundCanvas,
17185
17255
  combineCanvases,
17186
17256
  engineImageFont,
17187
17257
  ImageFont,