littlejsengine 1.18.8 → 1.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.18.8",
3
+ "version": "1.18.15",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -40,6 +40,9 @@
40
40
  "build": "node src/engineBuild.mjs",
41
41
  "test": "node --test --import ./test/setup.mjs \"test/**/*.test.mjs\""
42
42
  },
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
43
46
  "devDependencies": {
44
47
  "bestzip": "^2.2.1",
45
48
  "electron": "^38.2.0",
package/plugins/box2d.js CHANGED
@@ -83,6 +83,11 @@ class Box2dObject extends EngineObject
83
83
  // destroy physics body, fixtures, and joints
84
84
  ASSERT(this.body, 'Box2dObject has no body to destroy');
85
85
  box2d.world.DestroyBody(this.body);
86
+
87
+ // remove from tracked list so paused / headless sessions don't leak
88
+ const i = box2d.objects.indexOf(this);
89
+ if (i >= 0)
90
+ box2d.objects.splice(i, 1);
86
91
  super.destroy();
87
92
  }
88
93
 
@@ -171,7 +176,9 @@ class Box2dObject extends EngineObject
171
176
  /** Add a box shape to the body
172
177
  * @param {Vector2} [size]
173
178
  * @param {Vector2} [offset]
174
- * @param {number} [angle]
179
+ * @param {number} [angle] - LittleJS convention (clockwise positive).
180
+ * Negated internally to match Box2D's CCW-positive convention so the
181
+ * fixture aligns with the same angle passed to drawRect/drawTile.
175
182
  * @param {number} [density]
176
183
  * @param {number} [friction]
177
184
  * @param {number} [restitution]
@@ -184,7 +191,7 @@ class Box2dObject extends EngineObject
184
191
  ASSERT(isNumber(angle), 'angle must be a number');
185
192
 
186
193
  const shape = new box2d.instance.b2PolygonShape();
187
- shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
194
+ shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
188
195
  return this.addShape(shape, density, friction, restitution, isSensor);
189
196
  }
190
197
 
@@ -486,9 +493,10 @@ class Box2dObject extends EngineObject
486
493
  {
487
494
  const data = new box2d.instance.b2MassData();
488
495
  this.body.GetMassData(data);
489
- localCenter && data.set_center(box2d.vec2dTo(localCenter));
490
- mass && data.set_mass(mass);
491
- momentOfInertia && data.set_I(momentOfInertia);
496
+ // use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
497
+ if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
498
+ if (mass !== undefined) data.set_mass(mass);
499
+ if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
492
500
  this.body.SetMassData(data);
493
501
  }
494
502
 
@@ -1660,6 +1668,8 @@ class Box2dPlugin
1660
1668
  const fixtureB = contact.GetFixtureB();
1661
1669
  const objectA = fixtureA.GetBody().object;
1662
1670
  const objectB = fixtureB.GetBody().object;
1671
+ // raw user-created b2Bodies may have no .object — skip those
1672
+ if (!objectA || !objectB) return;
1663
1673
  objectA.beginContact(objectB);
1664
1674
  objectB.beginContact(objectA);
1665
1675
  }
@@ -1670,6 +1680,7 @@ class Box2dPlugin
1670
1680
  const fixtureB = contact.GetFixtureB();
1671
1681
  const objectA = fixtureA.GetBody().object;
1672
1682
  const objectB = fixtureB.GetBody().object;
1683
+ if (!objectA || !objectB) return;
1673
1684
  objectA.endContact(objectB);
1674
1685
  objectB.endContact(objectA);
1675
1686
  };
@@ -2048,7 +2059,7 @@ async function box2dInit()
2048
2059
  debugDraw.DrawTransform = function(transform)
2049
2060
  {
2050
2061
  transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
2051
- const pos = vec2(transform.get_p());
2062
+ const pos = box2d.vec2From(transform.get_p());
2052
2063
  const angle = -transform.get_q().GetAngle();
2053
2064
  const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
2054
2065
  const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
@@ -147,7 +147,8 @@ class Medal
147
147
  /** @property {boolean} - Is the medal unlocked? */
148
148
  this.unlocked = false;
149
149
 
150
- // load the source image if provided
150
+ /** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any */
151
+ this.image = undefined;
151
152
  if (src)
152
153
  (this.image = new Image).src = src;
153
154
 
@@ -63,13 +63,18 @@ class NewgroundsPlugin
63
63
  ASSERT(!cipher || cryptoJS, 'must provide cryptojs if there is a cipher');
64
64
 
65
65
  newgrounds = this; // set global newgrounds object
66
+ /** @property {string} - The newgrounds App ID */
66
67
  this.app_id = app_id;
68
+ /** @property {string|undefined} - AES-128/Base64 encryption key, if any */
67
69
  this.cipher = cipher;
70
+ /** @property {Object|undefined} - CryptoJS instance used when cipher is set */
68
71
  this.cryptoJS = cryptoJS;
72
+ /** @property {string} - Hostname used when logging views */
69
73
  this.host = location ? location.hostname : '';
70
74
 
71
75
  // get session id from url search params
72
76
  const url = new URL(location.href);
77
+ /** @property {string|null} - Newgrounds session id from the URL (null when not logged in) */
73
78
  this.session_id = url.searchParams.get('ngio_session_id');
74
79
 
75
80
  if (!this.session_id)
@@ -77,7 +82,20 @@ class NewgroundsPlugin
77
82
 
78
83
  // get medals
79
84
  const medalsResult = this.call('Medal.getList');
80
- this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
85
+
86
+ // bail early if the first call failed (offline / bad session /
87
+ // server error) so we don't block the main thread on more sync
88
+ // XHRs that are guaranteed to also fail
89
+ if (!medalsResult || !medalsResult.result || medalsResult.result.error)
90
+ {
91
+ debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
92
+ this.medals = [];
93
+ this.scoreboards = [];
94
+ return;
95
+ }
96
+
97
+ /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
98
+ this.medals = medalsResult.result.data?.['medals'] || [];
81
99
  debugMedals && LOG(this.medals);
82
100
  for (const newgroundsMedal of this.medals)
83
101
  {
@@ -97,10 +115,11 @@ class NewgroundsPlugin
97
115
  medal.description = medal.description + ` (${ medal.value })`;
98
116
  }
99
117
  }
100
-
118
+
101
119
  // get scoreboards
102
120
  const scoreboardResult = this.call('ScoreBoard.getBoards');
103
- this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
121
+ /** @property {Array} - Scoreboards fetched from Newgrounds */
122
+ this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
104
123
  debugMedals && LOG(this.scoreboards);
105
124
 
106
125
  // keep the session alive with a ping every minute
@@ -94,7 +94,9 @@ class PathFinder
94
94
  // .size + .getCollisionData.
95
95
  if (isVector2(source))
96
96
  {
97
+ /** @property {Vector2} - Grid dimensions in tiles */
97
98
  this.size = source.floor();
99
+ /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any */
98
100
  this.tileLayer = undefined;
99
101
  }
100
102
  else
@@ -106,13 +108,18 @@ class PathFinder
106
108
  }
107
109
 
108
110
  // Tunables (public, freely re-assignable).
111
+ /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */
109
112
  this.heuristicWeight = 1;
110
- this.maxLoop = 500;
113
+ /** @property {number} - Maximum A* expansions before giving up */
114
+ this.maxLoop = 1e3;
115
+ /** @property {boolean} - If true, post-process paths with two-pass smoothing */
111
116
  this.smoothPath = true;
117
+ /** @property {boolean} - If true, draw debug visualization during findPath */
112
118
  this.debug = false;
113
- this.debugTime = 2;
119
+ /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
120
+ this.debugTime = 1;
114
121
 
115
- // Pre-allocate the node array — one node per tile, reused across calls.
122
+ /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes */
116
123
  this.nodes = new Array(this.size.x * this.size.y);
117
124
  for (let y = 0; y < this.size.y; ++y)
118
125
  for (let x = 0; x < this.size.x; ++x)
@@ -264,11 +271,13 @@ class PathFinder
264
271
  if (dx !== 0 && dy !== 0)
265
272
  {
266
273
  // Diagonal step: refuse if either cardinal neighbor is
267
- // blocked or has cost. Prevents cutting through corners.
274
+ // blocked. Prevents cutting through walls at corners.
275
+ // (Costed-but-walkable cardinals do not block — diagonal
276
+ // movement around expensive terrain is standard A*.)
268
277
  const card1 = this.getNode(current.pos.x + dx, current.pos.y);
269
- if (!card1 || card1.cost > 0 || !card1.walkable) continue;
278
+ if (!card1 || !card1.walkable) continue;
270
279
  const card2 = this.getNode(current.pos.x, current.pos.y + dy);
271
- if (!card2 || card2.cost > 0 || !card2.walkable) continue;
280
+ if (!card2 || !card2.walkable) continue;
272
281
  stepCost = PATHFINDER_DIAGONAL_COST;
273
282
  }
274
283
 
@@ -286,9 +295,12 @@ class PathFinder
286
295
  // Best path so far through neighbor — record it.
287
296
  neighbor.parent = current;
288
297
  neighbor.g = tentativeG;
289
- const gdx = endNode.pos.x - neighbor.pos.x;
290
- const gdy = endNode.pos.y - neighbor.pos.y;
291
- neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
298
+ // Octile heuristic — tightest admissible distance for an
299
+ // 8-connected grid with cardinal cost 1 and diagonal cost √2.
300
+ const adx = abs(endNode.pos.x - neighbor.pos.x);
301
+ const ady = abs(endNode.pos.y - neighbor.pos.y);
302
+ const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady);
303
+ neighbor.f = neighbor.g + h * this.heuristicWeight;
292
304
  }
293
305
  }
294
306
 
@@ -570,6 +582,24 @@ class PathFinder
570
582
  path.push(original[original.length - 1]);
571
583
  }
572
584
 
585
+ /** Drop any middle node that lies exactly on the line through its two
586
+ * neighbors. Backstop for the smoothing passes — the corners pass
587
+ * intentionally keeps truly-straight runs, and the string-pulling pass
588
+ * checks collinearity against the original path, not the in-progress
589
+ * result, so it can leave 3+ collinear nodes in some edge cases.
590
+ * @param {PathFinderNode[]} path
591
+ * @private */
592
+ dropCollinearNodes(path)
593
+ {
594
+ for (let i = path.length - 2; i >= 1; --i)
595
+ {
596
+ const a = path[i - 1], b = path[i], c = path[i + 1];
597
+ if ((b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) ===
598
+ (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x))
599
+ path.splice(i, 1);
600
+ }
601
+ }
602
+
573
603
  /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
574
604
  * and clear (walkable, zero-cost). Used by isLineClear's hot path.
575
605
  * @param {number} x
@@ -737,6 +767,7 @@ class PathFinder
737
767
  {
738
768
  this.smoothPathCorners(nodePath);
739
769
  this.smoothPathStringPull(nodePath);
770
+ this.dropCollinearNodes(nodePath);
740
771
  }
741
772
 
742
773
  // Convert to world-space Vector2 path. Return copies, not live node
@@ -7,16 +7,16 @@ export
7
7
  // Medals
8
8
  medals,
9
9
  medalsPreventUnlock,
10
- medalsInit,
11
- medalsForEach,
12
- Medal,
13
10
  medalDisplayTime,
14
11
  medalDisplaySlideTime,
15
12
  medalDisplaySize,
13
+ medalsInit,
14
+ medalsForEach,
16
15
  setMedalDisplayTime,
17
16
  setMedalDisplaySlideTime,
18
17
  setMedalDisplaySize,
19
18
  setMedalsPreventUnlock,
19
+ Medal,
20
20
 
21
21
  // Newgrounds
22
22
  newgrounds,
@@ -29,6 +29,7 @@ export
29
29
 
30
30
  // ZzFXMusic
31
31
  ZzFXMusic,
32
+ zzfxM,
32
33
 
33
34
  // UI System
34
35
  uiSystem,
@@ -107,10 +107,14 @@ class PostProcessPlugin
107
107
  function postProcessRender()
108
108
  {
109
109
  if (headlessMode || !glEnable) return;
110
-
110
+
111
111
  // clear out the buffer
112
112
  glFlush();
113
113
 
114
+ // ensure we render to the default framebuffer (in case any earlier
115
+ // caller this frame left a render target bound)
116
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
117
+
114
118
  // setup shader program to draw a quad
115
119
  glContext.useProgram(postProcess.shader);
116
120
  glContext.bindVertexArray(postProcess.vao);
@@ -151,6 +155,9 @@ class PostProcessPlugin
151
155
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
152
156
  }
153
157
 
158
+ // restore default so subsequent dynamic texture uploads aren't flipped
159
+ glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
160
+
154
161
  // force it to set instanced mode
155
162
  glSetInstancedMode(true);
156
163
  }
@@ -63,13 +63,21 @@ class Tween
63
63
  }
64
64
  ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
65
65
 
66
+ /** @property {function(number|Vector2|Color):void} - Called with the interpolated value each frame */
66
67
  this.callback = callback;
68
+ /** @property {number|Vector2|Color} - Starting value */
67
69
  this.start = start;
70
+ /** @property {number|Vector2|Color} - Ending value */
68
71
  this.end = end;
72
+ /** @property {number} - Total duration in seconds */
69
73
  this.duration = duration;
74
+ /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
70
75
  this.life = duration;
76
+ /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
71
77
  this.ease = options.ease || Ease.LINEAR;
78
+ /** @property {boolean} - If true, advance even when the game is paused */
72
79
  this.useRealTime = !!options.useRealTime;
80
+ /** @property {boolean} - If true, stop advancing until cleared */
73
81
  this.paused = !!options.paused;
74
82
 
75
83
  /** @private completion callback set by then(), loop(), pingPong(). */
@@ -250,7 +258,7 @@ const Ease =
250
258
  * @param {number} x
251
259
  * @returns {number}
252
260
  * @memberof TweenSystem */
253
- EXPO: (x) => 2 ** (10 * x - 10),
261
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
254
262
 
255
263
  /** Back ease-in: overshoots backward at the start before snapping forward.
256
264
  * @param {number} x
@@ -263,6 +271,8 @@ const Ease =
263
271
  * @returns {number}
264
272
  * @memberof TweenSystem */
265
273
  ELASTIC: (x) =>
274
+ x === 0 ? 0 :
275
+ x === 1 ? 1 :
266
276
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
267
277
 
268
278
  /** Spring-like ease-out: oscillates outward after passing the target.
@@ -423,29 +433,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
423
433
  }
424
434
 
425
435
  // Continuation that schedules the next loop iteration when one finishes.
426
- // Called from the completed tween's `then` slot. Decrements the counter and
427
- // only spawns a new tween if more iterations remain.
428
- function loopContinuation(prev)
436
+ // Reuses the same Tween object across iterations so the user's handle
437
+ // from `.loop()` keeps working — calling `.stop()` mid-loop now cancels
438
+ // the entire chain instead of just the current iteration.
439
+ function loopContinuation(tween)
429
440
  {
430
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
431
- const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
432
- { ease: prev.ease, useRealTime: prev.useRealTime });
433
- next.loopRemaining = prev.loopRemaining === Infinity
434
- ? Infinity
435
- : prev.loopRemaining - 1;
436
- next.thenCallback = () => loopContinuation(next);
441
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
442
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
443
+ tween.life = tween.duration;
444
+ tween.thenCallback = () => loopContinuation(tween);
445
+ tweenActive.push(tween);
446
+ // snap to start for the new iteration (matches Tween constructor behavior)
447
+ tween.callback(tween.interp(tween.duration));
437
448
  }
438
449
 
439
- // Continuation for pingPong: spawns a new tween with start and end swapped.
440
- function pingPongContinuation(prev)
450
+ // Continuation for pingPong: swaps start and end on the same tween each iteration.
451
+ function pingPongContinuation(tween)
441
452
  {
442
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
443
- const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
444
- { ease: prev.ease, useRealTime: prev.useRealTime });
445
- next.loopRemaining = prev.loopRemaining === Infinity
446
- ? Infinity
447
- : prev.loopRemaining - 1;
448
- next.thenCallback = () => pingPongContinuation(next);
453
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
454
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
455
+ const tmp = tween.start;
456
+ tween.start = tween.end;
457
+ tween.end = tmp;
458
+ tween.life = tween.duration;
459
+ tween.thenCallback = () => pingPongContinuation(tween);
460
+ tweenActive.push(tween);
461
+ tween.callback(tween.interp(tween.duration));
449
462
  }
450
463
 
451
464
  /** Engine plugin hook: advance every active tween by the appropriate delta.
@@ -456,11 +456,17 @@ class UISystemPlugin
456
456
  * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
457
457
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
458
458
  {
459
- function setCallback(callback, listenerType)
459
+ // remove any prior listeners so repeated setup calls don't stack
460
+ if (this._dragListeners)
461
+ for (const [type, listener] of this._dragListeners)
462
+ document.removeEventListener(type, listener);
463
+ this._dragListeners = [];
464
+ const setCallback = (callback, listenerType)=>
460
465
  {
461
- function listener(e) { e.preventDefault(); callback && callback(e); }
466
+ const listener = (e)=> { e.preventDefault(); callback && callback(e); };
462
467
  document.addEventListener(listenerType, listener);
463
- }
468
+ this._dragListeners.push([listenerType, listener]);
469
+ };
464
470
  setCallback(onDrop, 'drop');
465
471
  setCallback(onDragEnter, 'dragenter');
466
472
  setCallback(onDragLeave, 'dragleave');
@@ -784,6 +790,15 @@ class UIObject
784
790
  if (this.destroyed)
785
791
  return;
786
792
 
793
+ // clear ui-system references that point at this object so events
794
+ // don't keep firing against a destroyed target (especially the
795
+ // keydown listener attached for keyInputObject)
796
+ if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
797
+ if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
798
+ if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
799
+ if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
800
+ if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
801
+
787
802
  // disconnect from parent and destroy children
788
803
  this.destroyed = 1;
789
804
  this.parent?.removeChild(this);
@@ -792,6 +807,8 @@ class UIObject
792
807
  child.parent = undefined;
793
808
  child.destroy();
794
809
  }
810
+ // clear references so destroyed children can be GC'd
811
+ this.children.length = 0;
795
812
  }
796
813
 
797
814
  /** Check if the mouse is overlapping this ui object
@@ -1381,6 +1398,7 @@ class UISlider extends UIObject
1381
1398
  {
1382
1399
  // toggle value between 0 and 1
1383
1400
  this.value = this.value ? 0 : 1;
1401
+ this.onChange();
1384
1402
  this.onRelease();
1385
1403
  super.navigatePressed();
1386
1404
  }
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.18.8';
35
+ const engineVersion = '1.18.15';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -161,12 +161,20 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
161
161
  * ['tiles.png', 'tilesLevel.png'] // images to load
162
162
  * );
163
163
  * @memberof Engine */
164
- async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement=document.body)
164
+ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
165
165
  {
166
166
  showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
167
167
  ASSERT(!mainContext, 'engine already initialized');
168
+ // runtime guard so release builds (where the assert is stripped) don't
169
+ // double-register listeners / double-add canvases on a second call
170
+ if (mainContext) return;
168
171
  ASSERT(isArray(imageSources), 'pass in images as array');
169
172
 
173
+ // ensure body exists for minimal HTML where the script runs before <body> is parsed
174
+ if (!document.body)
175
+ document.documentElement.appendChild(document.createElement('body'));
176
+ rootElement ||= document.body;
177
+
170
178
  // allow passing in empty functions
171
179
  gameInit ||= ()=>{};
172
180
  gameUpdate ||= ()=>{};
@@ -192,6 +200,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
192
200
  {
193
201
  // update time keeping
194
202
  let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
203
+ // skip delta on the very first frame so timeReal doesn't jump
204
+ // by ~page-load-time when RAF starts handing real timestamps
205
+ if (!frameTimeLastMS) frameTimeDeltaMS = 0;
195
206
  frameTimeLastMS = frameTimeMS;
196
207
  if (debug || debugWatermark)
197
208
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
@@ -204,7 +215,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
204
215
  const combinedScale = timeScale * debugScale;
205
216
  frameTimeDeltaMS *= combinedScale;
206
217
  frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
207
- if (debugScale <= 1)
218
+ if (combinedScale <= 1)
208
219
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
209
220
 
210
221
  let wasUpdated = false;
@@ -291,6 +302,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
291
302
  glFlush();
292
303
  debugRenderPost();
293
304
  drawCount = 0;
305
+ primitiveCount = 0;
294
306
  }
295
307
  }
296
308
 
@@ -421,7 +433,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
421
433
  promises.push(loadTexture(0));
422
434
 
423
435
  // load engine font image
424
- promises.push(fontImageInit());
436
+ promises.push(imageFontInit());
425
437
 
426
438
  if (showSplashScreen)
427
439
  {
@@ -103,7 +103,7 @@ class Sound
103
103
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
104
104
  this.onloadCallback = onloadCallback;
105
105
 
106
- if (Array.isArray(asset))
106
+ if (isArray(asset))
107
107
  {
108
108
  // generate zzfx sound — copy so we don't mutate the caller's array
109
109
  const zzfxSound = asset.slice();
@@ -195,7 +195,7 @@ class Sound
195
195
  }
196
196
 
197
197
  /** Get how long this sound is in seconds
198
- * @return {number} - How long the sound is in seconds (undefined if loading)
198
+ * @return {number} - How long the sound is in seconds (0 if loading)
199
199
  */
200
200
  getDuration()
201
201
  { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
@@ -352,10 +352,14 @@ class SoundInstance
352
352
  {
353
353
  if (fadeTime)
354
354
  {
355
- // ramp off gain
355
+ // ramp off gain from current volume (not 1, or low-volume
356
+ // instances would jump back up before fading);
357
+ // cancel any prior scheduling so stacked stop calls don't
358
+ // re-anchor partway through a previous fade
356
359
  const startFade = audioContext.currentTime;
357
360
  const endFade = startFade + fadeTime;
358
- this.gainNode.gain.linearRampToValueAtTime(1, startFade);
361
+ this.gainNode.gain.cancelScheduledValues(startFade);
362
+ this.gainNode.gain.setValueAtTime(this.volume, startFade);
359
363
  this.gainNode.gain.linearRampToValueAtTime(0, endFade);
360
364
  this.source.stop(endFade);
361
365
  }
@@ -403,13 +407,14 @@ class SoundInstance
403
407
  */
404
408
  getCurrentTime()
405
409
  {
406
- const deltaTime = mod(audioContext.currentTime - this.startTime,
407
- this.getDuration());
408
- return this.isPlaying() ? deltaTime : this.pausedTime;
410
+ if (!this.isPlaying()) return this.pausedTime;
411
+ const duration = this.getDuration();
412
+ // guard mod against 0 duration (rate=0 or sound not loaded)
413
+ return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
409
414
  }
410
415
 
411
416
  /** Get the total duration of this sound
412
- * @return {number} - Total duration in seconds
417
+ * @return {number} - Total duration in seconds (0 if loading)
413
418
  */
414
419
  getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
415
420
 
@@ -423,16 +428,17 @@ class SoundInstance
423
428
 
424
429
  /** Speak text with passed in settings
425
430
  * @param {string} text - The text to speak
426
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
427
431
  * @param {number} [volume] - How much to scale volume by
428
432
  * @param {number} [rate] - How quickly to speak
429
433
  * @param {number} [pitch] - How much to change the pitch by
434
+ * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
430
435
  * @return {SpeechSynthesisUtterance} - The utterance that was spoken
431
436
  * @memberof Audio */
432
- function speak(text, language='', volume=1, rate=1, pitch=1)
437
+ function speak(text, volume=1, rate=1, pitch=1, language='')
433
438
  {
439
+ ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
434
440
  if (!soundEnable || headlessMode) return;
435
- if (!speechSynthesis) return;
441
+ if (typeof speechSynthesis === 'undefined') return;
436
442
 
437
443
  // common languages (not supported by all browsers)
438
444
  // en - english, it - italian, fr - french, de - german, es - spanish
@@ -450,7 +456,11 @@ function speak(text, language='', volume=1, rate=1, pitch=1)
450
456
 
451
457
  /** Stop all queued speech
452
458
  * @memberof Audio */
453
- function speakStop() {speechSynthesis?.cancel();}
459
+ function speakStop()
460
+ {
461
+ if (typeof speechSynthesis !== 'undefined')
462
+ speechSynthesis.cancel();
463
+ }
454
464
 
455
465
  /** Get frequency of a note on a musical scale
456
466
  * @param {number} semitoneOffset - How many semitones away from the root note
@@ -512,9 +522,14 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
512
522
  const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
513
523
  source.connect(pannerNode).connect(gainNode);
514
524
 
515
- // callback when the sound ends
516
- if (onended)
517
- source.addEventListener('ended', ()=> onended(source));
525
+ // disconnect nodes when the sound ends so the audio graph doesn't grow
526
+ // unbounded across many play() calls (source.stop() also fires 'ended')
527
+ source.addEventListener('ended', ()=>
528
+ {
529
+ gainNode.disconnect();
530
+ pannerNode.disconnect();
531
+ if (onended) onended(source);
532
+ });
518
533
 
519
534
  // play and return sound
520
535
  const startOffset = offset * rate;
@@ -196,7 +196,7 @@ function closureCompilerStep(filename)
196
196
  fs.copyFileSync(filename, filenameTemp);
197
197
  try
198
198
  {
199
- execSync(`npx google-closure-compiler --js=${filenameTemp} --js_output_file=${filename} --warning_level=VERBOSE --jscomp_off=*`);
199
+ execSync(`npx google-closure-compiler --js=${filenameTemp} --js_output_file=${filename} --jscomp_off=*`);
200
200
  fs.rmSync(filenameTemp);
201
201
  }
202
202
  catch (e) { handleError(e, 'Failed to run Closure Compiler step!'); }