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/FAQ.md +197 -15
- package/README.md +30 -11
- package/dist/littlejs.d.ts +166 -74
- package/dist/littlejs.esm.js +610 -234
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +597 -229
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +590 -220
- package/package.json +4 -1
- package/plugins/box2d.js +17 -6
- package/plugins/medalSystem.js +2 -1
- package/plugins/newgrounds.js +22 -3
- package/plugins/pathFinder.js +40 -9
- package/plugins/pluginExport.js +4 -3
- package/plugins/postProcess.js +8 -1
- package/plugins/tweenSystem.js +33 -20
- package/plugins/uiSystem.js +21 -3
- package/src/engine.js +16 -4
- package/src/engineAudio.js +30 -15
- package/src/engineBuild.mjs +1 -1
- package/src/engineDebug.js +7 -9
- package/src/engineDraw.js +144 -33
- package/src/engineExport.js +9 -2
- package/src/engineInput.js +17 -12
- package/src/engineLogo.js +1 -1
- package/src/engineMath.js +21 -7
- package/src/engineObject.js +19 -11
- package/src/engineParticles.js +26 -27
- package/src/engineSettings.js +3 -2
- package/src/engineTileLayer.js +62 -43
- package/src/engineUtilities.js +67 -11
- package/src/engineWebGL.js +37 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "littlejsengine",
|
|
3
|
-
"version": "1.18.
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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 =
|
|
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);
|
package/plugins/medalSystem.js
CHANGED
|
@@ -147,7 +147,8 @@ class Medal
|
|
|
147
147
|
/** @property {boolean} - Is the medal unlocked? */
|
|
148
148
|
this.unlocked = false;
|
|
149
149
|
|
|
150
|
-
|
|
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
|
|
package/plugins/newgrounds.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/plugins/pathFinder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
119
|
+
/** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
|
|
120
|
+
this.debugTime = 1;
|
|
114
121
|
|
|
115
|
-
|
|
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
|
|
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 ||
|
|
278
|
+
if (!card1 || !card1.walkable) continue;
|
|
270
279
|
const card2 = this.getNode(current.pos.x, current.pos.y + dy);
|
|
271
|
-
if (!card2 ||
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
package/plugins/pluginExport.js
CHANGED
|
@@ -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,
|
package/plugins/postProcess.js
CHANGED
|
@@ -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
|
}
|
package/plugins/tweenSystem.js
CHANGED
|
@@ -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
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
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 (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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:
|
|
440
|
-
function pingPongContinuation(
|
|
450
|
+
// Continuation for pingPong: swaps start and end on the same tween each iteration.
|
|
451
|
+
function pingPongContinuation(tween)
|
|
441
452
|
{
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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.
|
package/plugins/uiSystem.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 (
|
|
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(
|
|
436
|
+
promises.push(imageFontInit());
|
|
425
437
|
|
|
426
438
|
if (showSplashScreen)
|
|
427
439
|
{
|
package/src/engineAudio.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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,
|
|
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 (
|
|
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()
|
|
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
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
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;
|
package/src/engineBuild.mjs
CHANGED
|
@@ -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} --
|
|
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!'); }
|