littlejsengine 1.25.0 → 1.26.1

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/FAQ.md +49 -11
  3. package/README.md +6 -2
  4. package/REFERENCE.md +129 -21
  5. package/dist/littlejs.d.ts +487 -102
  6. package/dist/littlejs.esm.js +2898 -719
  7. package/dist/littlejs.esm.min.js +3 -3
  8. package/dist/littlejs.js +2888 -716
  9. package/dist/littlejs.min.js +2 -2
  10. package/dist/littlejs.release.js +2702 -527
  11. package/package.json +4 -4
  12. package/plugins/box2d.js +110 -34
  13. package/plugins/gltf.js +74 -22
  14. package/plugins/lightSystem.js +410 -28
  15. package/plugins/math3d.js +4 -2
  16. package/plugins/medalSystem.js +19 -7
  17. package/plugins/newgrounds.js +4 -2
  18. package/plugins/parallax.js +1 -0
  19. package/plugins/particleEffects.js +44 -10
  20. package/plugins/pathFinder.js +9 -11
  21. package/plugins/pluginExport.js +9 -0
  22. package/plugins/postProcess.js +9 -3
  23. package/plugins/render3d.js +499 -2389
  24. package/plugins/render3dDebug.js +5 -8
  25. package/plugins/render3dEditor.js +81 -6
  26. package/plugins/render3dExtras.js +103 -31
  27. package/plugins/render3dLevel.js +53 -5
  28. package/plugins/render3dMesh.js +1158 -0
  29. package/plugins/render3dObject.js +1451 -0
  30. package/plugins/render3dVoxels.js +15 -3
  31. package/plugins/textureSheet.js +9 -8
  32. package/plugins/threejs.js +1 -1
  33. package/plugins/tweenSystem.js +76 -51
  34. package/plugins/uiSystem.js +37 -8
  35. package/plugins/wavedash.js +229 -0
  36. package/src/engine.js +199 -40
  37. package/src/engineAudio.js +37 -21
  38. package/src/engineBuild.mjs +6 -1
  39. package/src/engineDebug.js +22 -162
  40. package/src/engineDraw.js +142 -34
  41. package/src/engineEditor.js +79 -18
  42. package/src/engineExport.js +1 -3
  43. package/src/engineInput.js +34 -24
  44. package/src/engineMath.js +35 -14
  45. package/src/engineObject.js +60 -41
  46. package/src/engineParticles.js +58 -11
  47. package/src/engineRelease.js +1 -5
  48. package/src/engineSettings.js +48 -9
  49. package/src/engineTileLayer.js +11 -4
  50. package/src/engineUtilities.js +41 -9
  51. package/src/engineWebGL.js +37 -13
@@ -0,0 +1,229 @@
1
+ /**
2
+ * LittleJS Wavedash Plugin
3
+ * - The Wavedash twin of the Newgrounds plugin: achievements and leaderboards, the same shape so a game can switch
4
+ * - Wavedash serves the game's page and puts its SDK in window.Wavedash before the game runs, so nothing is bundled;
5
+ * off Wavedash (local, itch, GitHub Pages) there is none, and every call does nothing
6
+ * - Make the plugin when the game can draw, at the end of gameInit: Wavedash.init is called then, and until it is
7
+ * Wavedash keeps its loading screen over the game
8
+ * - WavedashMedal is a Medal with the identifier of its Wavedash achievement; on Wavedash an unlock is sent as that
9
+ * achievement and Wavedash shows its own toast in place of the engine's popup, off it the medal unlocks as any does
10
+ * - Wavedash refuses an achievement until it has loaded the player's, a moment after launch, so refused and earlier
11
+ * unlocks are sent again every two seconds until it takes them; medals unlocked before, in the save, are sent too
12
+ * - Leaderboards are made from code: give the plugin a table of them, each with how it sorts and shows its scores,
13
+ * and they are made at once, so each one exists from the first launch
14
+ * - The SDK checks its arguments' types and throws on a wrong one, so every call passes real booleans and whole
15
+ * numbers and is caught; a game built with its own minifier keeps the SDK's names, see REFERENCE
16
+ * @namespace Wavedash
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ /** Global Wavedash plugin object
22
+ * @type {WavedashPlugin}
23
+ * @memberof Wavedash */
24
+ let wavedash;
25
+
26
+ // Engine internal variables not exposed to documentation
27
+ const wavedashRetryMS = 2e3; // how long to wait before sending refused achievements again
28
+ const wavedashRetries = 30; // how many times one is sent before it is taken as never to be, about a minute
29
+ const wavedashTimeoutMS = 15e3; // how long a leaderboard call may take, as the Newgrounds plugin waits
30
+ const wavedashDisplayTypes = {number: 0, seconds: 1, milliseconds: 2, ticks: 3}; // the SDK's display types
31
+
32
+ // the SDK Wavedash put on the page, read at each call, undefined off Wavedash
33
+ const wavedashSDK = ()=> globalThis['Wavedash'];
34
+
35
+ // a call's answer, or undefined if it does not come in time or fails, with a warning; the time limit is let go of once
36
+ // the answer is in, so nothing waits on it after
37
+ function wavedashWait(name, answer)
38
+ {
39
+ let timer;
40
+ const limit = new Promise((resolve)=> timer = setTimeout(resolve, wavedashTimeoutMS));
41
+ return Promise.race([Promise.resolve(answer), limit])
42
+ .catch((error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); })
43
+ .finally(()=> clearTimeout(timer));
44
+ }
45
+
46
+ ///////////////////////////////////////////////////////////////////////////////
47
+ /**
48
+ * WavedashMedal: a medal that is also a Wavedash achievement, unlocked on Wavedash by its identifier
49
+ * @extends Medal
50
+ * @memberof Wavedash
51
+ * @example
52
+ * const medal_finish = new WavedashMedal(0, 'ACH_01_FINISH', 'Finish', 'Finish a level');
53
+ * medal_finish.unlock(); // Wavedash's toast on Wavedash, the engine's popup anywhere else
54
+ */
55
+ class WavedashMedal extends Medal
56
+ {
57
+ /** Create a WavedashMedal and add it to the list of medals
58
+ * @param {number} id - The unique identifier of the medal, as for any Medal
59
+ * @param {string} achievement - The identifier of its Wavedash achievement, as made with the Wavedash CLI
60
+ * @param {string} name - Name of the medal
61
+ * @param {string} [description] - Description of the medal
62
+ * @param {string} [icon] - Icon for the medal
63
+ * @param {string} [src] - Image location for the medal
64
+ */
65
+ constructor(id, achievement, name, description, icon, src)
66
+ {
67
+ ASSERT(typeof achievement === 'string' && achievement !== '', 'WavedashMedal: achievement must be the identifier of a Wavedash achievement', achievement);
68
+ super(id, name, description, icon, src);
69
+
70
+ /** @property {string} - The identifier of its Wavedash achievement */
71
+ this.achievement = achievement;
72
+ }
73
+
74
+ /** Unlocks the medal if not already unlocked, saved as any medal is; on Wavedash its achievement is sent, until
75
+ * Wavedash takes it, and Wavedash shows its own toast in place of the engine's popup; one unlocked before the
76
+ * plugin is made is sent when it is
77
+ * @return {Promise<boolean>} - Whether the medal is unlocked */
78
+ unlock()
79
+ {
80
+ // off Wavedash, or testing medals with debugMedals, the engine's own popup and nothing sent
81
+ if (medalsPreventUnlock || this.unlocked || !wavedashSDK() || debugMedals)
82
+ return super.unlock();
83
+ this.unlocked = true;
84
+ medalsSave();
85
+ wavedash?.sendAchievements();
86
+ return Promise.resolve(true);
87
+ }
88
+ }
89
+
90
+ ///////////////////////////////////////////////////////////////////////////////
91
+ /**
92
+ * Wavedash plugin: starts the Wavedash SDK, sends achievements and posts and reads leaderboards
93
+ * @memberof Wavedash
94
+ * @example
95
+ * // at the end of gameInit, with the leaderboards the game posts to
96
+ * new WavedashPlugin({LEVEL_1: {lowerWins: true, display: 'milliseconds'}, HIGH_SCORE: {}});
97
+ * wavedash.postScore('LEVEL_1', timeMs);
98
+ */
99
+ class WavedashPlugin
100
+ {
101
+ /** Start the Wavedash SDK when the game is on Wavedash, and make its leaderboards
102
+ * @param {Object<string, {lowerWins?: boolean, display?: string}>} [leaderboards] - The game's leaderboards by
103
+ * name: lowerWins for times and golf, where a lower score is better, higher wins when left out; display how
104
+ * Wavedash shows a score, 'number', 'seconds', 'milliseconds' or 'ticks' (60 a second), 'number' when left out
105
+ */
106
+ constructor(leaderboards={})
107
+ {
108
+ ASSERT(!wavedash, 'WavedashPlugin already initialized');
109
+ ASSERT(!!leaderboards && typeof leaderboards === 'object', 'WavedashPlugin: leaderboards is an object of them by name');
110
+ wavedash = this;
111
+
112
+ /** @property {Object<string, Promise<string|undefined>>} - Each leaderboard's id by its name, once Wavedash
113
+ * has made or found it, undefined when it could not
114
+ * @type {Object<string, Promise<string|undefined>>} */
115
+ this.leaderboards = Object.create(null); // no inherited names, a board may be called constructor
116
+ /** @type {Object<string, {lowerWins?: boolean, display?: string}>} */
117
+ this.leaderboardSettings = leaderboards; // how each one sorts and shows its scores, read as its own entries
118
+ /** @type {Set<string>} */
119
+ this.achievementsSent = new Set; // the achievements Wavedash took this visit, or refused for good
120
+ /** @type {Object<string, number>} */
121
+ this.achievementTries = Object.create(null); // how many times each one not taken yet was sent
122
+ /** @type {number|undefined} */
123
+ this.achievementRetry = undefined; // the timer that sends refused ones again
124
+
125
+ // Wavedash keeps its loading screen until init, which is called once
126
+ wavedashWait('init', this.call('init'));
127
+ for (const name in leaderboards)
128
+ this.leaderboard(name);
129
+ this.sendAchievements(); // the medals the save already has, unlocked before or off Wavedash
130
+ }
131
+
132
+ /** Whether the game is on Wavedash, its SDK on the page
133
+ * @return {boolean} */
134
+ isActive() { return !!wavedashSDK(); }
135
+
136
+ /** Call the SDK, undefined off Wavedash; a call that throws, as on an argument of the wrong type, says so in
137
+ * the console and gives undefined
138
+ * @param {string} name - The SDK function
139
+ * @param {...*} args
140
+ * @return {*}
141
+ * @ignore */
142
+ call(name, ...args)
143
+ {
144
+ const sdk = wavedashSDK();
145
+ if (!sdk) return;
146
+ try { return sdk[name](...args); }
147
+ catch (error) { console.warn('Wavedash ' + name + ' failed: ' + error); }
148
+ }
149
+
150
+ /** A leaderboard's id by its name, made on Wavedash the first time it is asked for, with the settings it was given
151
+ * @param {string} name
152
+ * @return {Promise<string|undefined>}
153
+ * @ignore */
154
+ leaderboard(name)
155
+ {
156
+ ASSERT(typeof name === 'string' && name !== '', 'Wavedash: a leaderboard name must be a string', name);
157
+ if (this.leaderboards[name]) return this.leaderboards[name];
158
+ const settings = Object.prototype.hasOwnProperty.call(this.leaderboardSettings, name) ?
159
+ this.leaderboardSettings[name] : undefined; // its own entry, not an inherited one
160
+ const {lowerWins=false, display='number'} = settings || {};
161
+ const known = wavedashDisplayTypes.hasOwnProperty(display);
162
+ ASSERT(known, 'Wavedash: a leaderboard display is number, seconds, milliseconds or ticks', display);
163
+ const made = this.call('getOrCreateLeaderboard', name, lowerWins ? 0 : 1, known ? wavedashDisplayTypes[display] : 0);
164
+ const id = wavedashWait('leaderboard ' + name, made).then((result)=>
165
+ result?.['success'] ? result['data']?.['id'] : undefined);
166
+ // one that could not be made is asked for again next time
167
+ id.then((value)=> value === undefined && this.leaderboards[name] === id && delete this.leaderboards[name]);
168
+ return this.leaderboards[name] = id;
169
+ }
170
+
171
+ /** Post a score to a leaderboard, Wavedash keeping the player's best; off Wavedash it does nothing
172
+ * - A leaderboard not in the table given to the plugin is made the first time, higher wins and shown as a number
173
+ * @param {string} name - The leaderboard's name
174
+ * @param {number} score - A whole number, milliseconds for a time
175
+ * @return {Promise<boolean>} - Whether it was posted */
176
+ async postScore(name, score)
177
+ {
178
+ ASSERT(isNumber(score), 'Wavedash postScore: score must be a number', score);
179
+ if (!this.isActive()) return false;
180
+ const id = await this.leaderboard(name);
181
+ if (id === undefined) return false;
182
+ const result = await wavedashWait('postScore', this.call('uploadLeaderboardScore', id, round(score), true));
183
+ return !!result?.['success'];
184
+ }
185
+
186
+ /** Read a leaderboard's entries, undefined off Wavedash or when it could not be read
187
+ * @param {string} name - The leaderboard's name
188
+ * @param {number} [offset] - How many entries to skip over
189
+ * @param {number} [limit] - How many entries to read
190
+ * @param {boolean} [friendsOnly] - Only the player and their friends
191
+ * @return {Promise<Array<Object>|undefined>} - The entries, as Wavedash gives them */
192
+ async getScores(name, offset=0, limit=10, friendsOnly=false)
193
+ {
194
+ if (!this.isActive()) return;
195
+ const id = await this.leaderboard(name);
196
+ if (id === undefined) return;
197
+ const result = await wavedashWait('getScores',
198
+ this.call('listLeaderboardEntries', id, offset|0, limit|0, !!friendsOnly));
199
+ return result?.['success'] ? result['data'] : undefined;
200
+ }
201
+
202
+ /** Send every unlocked WavedashMedal's achievement Wavedash has not taken yet, again every two seconds while it
203
+ * refuses some, as it does until it has loaded the player's achievements; one refused for about a minute is
204
+ * taken as an identifier Wavedash does not have, said once in the console, and not sent again this visit
205
+ * @ignore */
206
+ sendAchievements()
207
+ {
208
+ if (!this.isActive() || debugMedals) return;
209
+ let refused = false;
210
+ medalsForEach((medal)=>
211
+ {
212
+ const achievement = medal instanceof WavedashMedal && medal.unlocked && medal.achievement;
213
+ if (!achievement || this.achievementsSent.has(achievement)) return;
214
+ const result = this.call('setAchievement', achievement, true);
215
+ if (result === true || result?.['success'] === true)
216
+ this.achievementsSent.add(achievement);
217
+ else if ((this.achievementTries[achievement] = (this.achievementTries[achievement] || 0) + 1) < wavedashRetries)
218
+ refused = true;
219
+ else
220
+ {
221
+ console.warn('Wavedash refused achievement ' + achievement + ' ' + wavedashRetries +
222
+ ' times; is it made, with that identifier?');
223
+ this.achievementsSent.add(achievement); // not sent again this visit
224
+ }
225
+ });
226
+ clearTimeout(this.achievementRetry);
227
+ this.achievementRetry = refused ? setTimeout(()=> this.sendAchievements(), wavedashRetryMS) : undefined;
228
+ }
229
+ }
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.25.0';
35
+ const engineVersion = '1.26.1';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -66,6 +66,8 @@ let engineObjectsCollideStaticLast = [];
66
66
  let frame = 0;
67
67
 
68
68
  /** Current engine time since start in seconds
69
+ * - Below 20 FPS it falls behind the clock, since a frame catches up at most 50 ms of updates, so a timer in
70
+ * real seconds, one that must keep up on a slow device, goes by timeReal
69
71
  * @type {number}
70
72
  * @memberof Engine */
71
73
  let time = 0;
@@ -91,8 +93,13 @@ function getPaused() { return paused; }
91
93
  * @memberof Engine */
92
94
  function setPaused(isPaused=true) { paused = isPaused; }
93
95
 
96
+ /** Frames drawn per second, smoothed over the last few seconds, in release builds too
97
+ * @type {number}
98
+ * @memberof Engine */
99
+ let averageFPS = 0;
100
+
94
101
  // Engine internal variables
95
- let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
102
+ let frameTimeLastMS = 0, frameTimeBufferMS = 0;
96
103
 
97
104
  // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
98
105
  // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
@@ -179,7 +186,134 @@ function engineObjectsCollidePairAdd(asker, other, resolve=false)
179
186
  others || engineObjectsCollidePairs.set(asker, others = new Map);
180
187
  others.set(other, resolve);
181
188
  }
189
+
190
+ // with this many solids or more, a mover finds what is near it through a grid of cells rather than checking every
191
+ // solid, a big game's cost going from every pair to what is close; it resolves the same contacts in the same order,
192
+ // so a game plays the same either way, and a few solids are quicker checked all
193
+ let engineCollideGridMin = 64;
194
+ // the grid for this update, {list, built}: the list of solids, and the grid made from it the first time a 2D mover
195
+ // walks it, so a frame with no 2D mover, as 3D solids alone, makes none; built is each cell's solids, each solid's
196
+ // cells, and each solid's place in the list, which the contacts are taken in; solids too big for cells are near
197
+ // everything
198
+ let engineCollideGrid;
199
+
200
+ // the grid of a list of solids, its cells about twice a typical solid so most are in one to four
201
+ function engineCollideGridBuild(list)
202
+ {
203
+ let extent = 0;
204
+ for (const o of list)
205
+ extent += min(max(abs(o.size.x), abs(o.size.y)), 16) || 0; // a negative size is a mirrored one
206
+ const index = new Map;
207
+ const grid = {size: max(2 * extent / list.length, .5), cells: new Map, at: new Map, index, big: new Set,
208
+ seen: new Map, stamp: 0, byIndex: (a, b)=> index.get(a) - index.get(b)}; // what a query found, and its order
209
+ list.forEach((o, i)=> { index.set(o, i); engineCollideGridPlace(grid, o); });
210
+ return grid;
211
+ }
212
+
213
+ // the cells an object's box covers, first and last along x and y, in one kept array to read at once; a negative size
214
+ // is a mirrored sprite of that size
215
+ const engineCollideGridBox = [0, 0, 0, 0];
216
+ function engineCollideGridCells(grid, o)
217
+ {
218
+ const s = grid.size, w = abs(o.size.x) / 2, h = abs(o.size.y) / 2, box = engineCollideGridBox;
219
+ box[0] = floor((o.pos.x - w) / s), box[1] = floor((o.pos.y - h) / s);
220
+ box[2] = floor((o.pos.x + w) / s), box[3] = floor((o.pos.y + h) / s);
221
+ return box;
222
+ }
223
+
224
+ // whether a box is kept in its cells: not over 1024 of them, and within 2^31 cells of the origin, past which a cell
225
+ // loop would not end, as ++ stops changing a number past 2^53, and the keys would not be exact; one with no finite
226
+ // box does not fit either, and is near every mover
227
+ const engineCollideGridFits = (x0, y0, x1, y1)=> (x1 - x0 + 1) * (y1 - y0 + 1) <= 1024 &&
228
+ abs(x0) < 2**31 && abs(y0) < 2**31 && abs(x1) < 2**31 && abs(y1) < 2**31;
229
+
230
+ // put a solid in the cells its box covers now, out of those it was in; a box on a cell's edge is in both cells, so
231
+ // solids that touch share one
232
+ function engineCollideGridPlace(grid, o)
233
+ {
234
+ if (!grid.index.has(o)) return;
235
+ const [x0, y0, x1, y1] = engineCollideGridCells(grid, o);
236
+ const big = !engineCollideGridFits(x0, y0, x1, y1);
237
+ const was = grid.at.get(o);
238
+ if (was && was[0] === x0 && was[1] === y0 && was[2] === x1 && was[3] === y1 && was[4] === big) return;
239
+ if (was && !was[4])
240
+ for (let x = was[0]; x <= was[2]; ++x)
241
+ for (let y = was[1]; y <= was[3]; ++y)
242
+ {
243
+ const cell = grid.cells.get(x * 1048576 + y);
244
+ cell.splice(cell.indexOf(o), 1);
245
+ }
246
+ grid.big.delete(o);
247
+ if (big)
248
+ grid.big.add(o);
249
+ else
250
+ for (let x = x0; x <= x1; ++x)
251
+ for (let y = y0; y <= y1; ++y)
252
+ {
253
+ const key = x * 1048576 + y;
254
+ const cell = grid.cells.get(key);
255
+ cell ? cell.push(o) : grid.cells.set(key, [o]);
256
+ }
257
+ grid.at.set(o, [x0, y0, x1, y1, big]);
258
+ }
259
+
260
+ // the solids a mover is near, after a place in the list, in list order, filled into near; each found once, by the
261
+ // query's stamp
262
+ function engineCollideGridNear(grid, o, after, near)
263
+ {
264
+ const [x0, y0, x1, y1] = engineCollideGridCells(grid, o);
265
+ const stamp = ++grid.stamp;
266
+ near.length = 0;
267
+ for (const other of grid.big)
268
+ engineCollideGridAdd(grid, other, after, stamp, near);
269
+ if (engineCollideGridFits(x0, y0, x1, y1))
270
+ {
271
+ for (let x = x0; x <= x1; ++x)
272
+ for (let y = y0; y <= y1; ++y)
273
+ {
274
+ const cell = grid.cells.get(x * 1048576 + y);
275
+ if (cell)
276
+ for (const other of cell)
277
+ engineCollideGridAdd(grid, other, after, stamp, near);
278
+ }
279
+ }
280
+ else
281
+ for (const other of grid.index.keys()) // a mover too big for cells is near everything
282
+ engineCollideGridAdd(grid, other, after, stamp, near);
283
+ near.sort(grid.byIndex);
284
+ }
285
+
286
+ // add a solid to what a query found, once, when it is after the place in the list
287
+ function engineCollideGridAdd(grid, other, after, stamp, near)
288
+ {
289
+ if (grid.seen.get(other) === stamp) return;
290
+ grid.seen.set(other, stamp);
291
+ grid.index.get(other) > after && near.push(other);
292
+ }
293
+
294
+ // the solids a mover checks, as checking every one would reach them: in list order, and found again from where it is
295
+ // whenever its box has moved, as a push does, so a solid it is pushed into later in the list is still checked
296
+ function* engineCollideGridWalk(o)
297
+ {
298
+ const grid = engineCollideGrid.built ||= engineCollideGridBuild(engineCollideGrid.list);
299
+ let after = -1, x, y, w, h, k = 0;
300
+ const near = [];
301
+ for (;;)
302
+ {
303
+ if (o.pos.x !== x || o.pos.y !== y || o.size.x !== w || o.size.y !== h)
304
+ {
305
+ x = o.pos.x, y = o.pos.y, w = o.size.x, h = o.size.y;
306
+ engineCollideGridNear(grid, o, after, near);
307
+ k = 0;
308
+ }
309
+ if (k >= near.length) return;
310
+ const other = near[k++];
311
+ after = grid.index.get(other);
312
+ yield other;
313
+ }
314
+ }
182
315
  let engineInitialized = false; // engineInit ran, with or without a canvas
316
+ let engineInitDone; // the promise the first engineInit hands back, which a second call hands back too
183
317
  // the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
184
318
  // loop starts
185
319
  let engineLoads, engineLoadsDone = 0;
@@ -223,7 +357,7 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
223
357
  p.update === update && p.render === render &&
224
358
  p.glContextLost === glContextLost &&
225
359
  p.glContextRestored === glContextRestored &&
226
- p.preRender === preRender));
360
+ p.preRender === preRender), 'engineAddPlugin: this plugin was already added');
227
361
 
228
362
  const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored, preRender);
229
363
  pluginList.push(plugin);
@@ -305,8 +439,10 @@ function engineLoadingScreenDraw(elapsed)
305
439
  * @param {GameCallback} [gameUpdatePost] - Called after physics and objects are updated, even when paused, use for UI updates
306
440
  * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
307
441
  * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
308
- * @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
309
- * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
442
+ * @param {Array<string>|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path
443
+ * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body; it is
444
+ * styled for a game (no scroll bars or selection, touch-action none),
445
+ * where its own inline style does not say otherwise
310
446
  * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
311
447
  * so set canvasFixedSize or canvasMaxSize to fit a smaller element
312
448
  * @example
@@ -323,13 +459,15 @@ function engineLoadingScreenDraw(elapsed)
323
459
  * @return {Promise<void>} - Done when the images have loaded and gameInit has run */
324
460
  async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
325
461
  {
326
- showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
327
462
  ASSERT(!engineInitialized, 'engine already initialized');
328
463
  // runtime guard so release builds (where the assert is stripped) don't
329
- // double-register listeners / double-add canvases on a second call
330
- if (engineInitialized) return;
464
+ // double-register listeners / double-add canvases on a second call, which is done when the first is
465
+ if (engineInitialized) return engineInitDone;
331
466
  engineInitialized = true;
467
+ showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
332
468
  engineLoads = [], engineLoadsDone = 0;
469
+ if (typeof imageSources === 'string')
470
+ imageSources = [imageSources]; // one image given alone
333
471
  ASSERT(isArray(imageSources), 'pass in images as array');
334
472
 
335
473
  // allow passing in empty functions
@@ -367,8 +505,10 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
367
505
  // by ~page-load-time when RAF starts handing real timestamps
368
506
  if (!frameTimeLastMS) frameTimeDeltaMS = 0;
369
507
  frameTimeLastMS = frameTimeMS;
370
- if (debug || debugWatermark)
371
- averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
508
+ // the first frame's rate seeds the average, so it does not start out climbing from 0; a gap of over a
509
+ // second, a hidden tab coming back, is not a frame
510
+ if (frameTimeDeltaMS && frameTimeDeltaMS < 1e3)
511
+ averageFPS = averageFPS ? lerp(averageFPS, 1e3/frameTimeDeltaMS, .05) : 1e3/frameTimeDeltaMS;
372
512
  // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
373
513
  if (!manualStepAtStart)
374
514
  frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
@@ -471,8 +611,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
471
611
  debugUpdate();
472
612
  gameUpdatePost();
473
613
  inputUpdatePost();
474
- if (debugVideoCaptureIsActive())
475
- renderFrame();
476
614
  }
477
615
 
478
616
  // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
@@ -494,7 +632,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
494
632
 
495
633
  // render only when something changed, displays that refresh faster
496
634
  // than the fixed update rate would otherwise redraw identical frames
497
- if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
635
+ if (wasUpdated || windowChanged)
498
636
  renderFrame();
499
637
  engineManualStep || engineScheduleFrame();
500
638
 
@@ -526,14 +664,13 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
526
664
  inputRender();
527
665
  debugRender();
528
666
  glFlush();
529
- debugRenderPost();
530
667
  drawCount = 0;
531
668
  primitiveCount = 0;
532
669
  }
533
670
  }
534
671
 
535
672
  // skip setup if headless
536
- if (headlessMode) return startEngine([]);
673
+ if (headlessMode) return engineInitDone = startEngine([]);
537
674
 
538
675
  // ensure body exists for minimal HTML where the script runs before <body> is parsed
539
676
  if (!document.body)
@@ -611,21 +748,25 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
611
748
  promises.push(splash);
612
749
  }
613
750
 
614
- // the splash first, the images load under it, then the loading screen for the rest
615
- showSplashScreen && await promises[promises.length - 1];
616
- return startEngine(promises);
751
+ // kept before anything is awaited, so a second call has it
752
+ return engineInitDone = startEngine(promises);
617
753
 
618
- // gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
619
- // done, the loading screen showing in the meantime; an error in gameInit reaches the caller
754
+ // the splash first, the images load under it, then gameInit runs once they are in, and the game loop starts once
755
+ // it and everything loaded while it ran are done, the loading screen showing in the meantime; an error in gameInit
756
+ // reaches the caller
620
757
  async function startEngine(images)
621
758
  {
759
+ showSplashScreen && !headlessMode && await images[images.length - 1]; // headless has no splash
622
760
  const init = (async ()=> { await Promise.all(images); await gameInit(); })();
623
761
  engineAddLoad(init);
624
762
  await engineWaitForLoads();
625
763
  engineLoads = undefined;
626
764
  await init;
627
765
  engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
628
- engineManualStep || engineUpdate();
766
+ if (engineManualStep) return;
767
+ if (debug) return engineUpdate(); // a debug build stops at an error, the first frame's as any
768
+ try { engineUpdate(); }
769
+ catch (error) { engineFrameFailed(error); } // a release build goes on past it, as on any later frame
629
770
  }
630
771
  }
631
772
 
@@ -643,7 +784,7 @@ function engineUpdateCanvas()
643
784
  if (canvasFixedSize.x)
644
785
  {
645
786
  // set canvas fixed size
646
- mainCanvasSize = canvasFixedSize.copy();
787
+ mainCanvasSize.set(canvasFixedSize.x, canvasFixedSize.y);
647
788
 
648
789
  // fit to window using css width and height
649
790
  const innerAspect = innerWidth / innerHeight;
@@ -667,7 +808,8 @@ function engineUpdateCanvas()
667
808
 
668
809
  // responsive aspect ratio, of the size after canvasMaxSize, which can change its shape
669
810
  const innerAspect = mainCanvasSize.x / mainCanvasSize.y;
670
- ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect);
811
+ ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect,
812
+ 'canvasMinAspect must not be above canvasMaxAspect', canvasMinAspect, canvasMaxAspect);
671
813
  if (canvasMaxAspect && innerAspect > canvasMaxAspect)
672
814
  {
673
815
  // full height
@@ -703,9 +845,12 @@ function engineUpdateCanvas()
703
845
  }
704
846
  else
705
847
  {
706
- // setting the size also resets the context state, match that
848
+ // setting the size also resets the context state, match that, what a game may have left on it too
707
849
  mainContext.setTransform(1, 0, 0, 1, 0, 0);
708
850
  mainContext.globalCompositeOperation = 'source-over';
851
+ mainContext.globalAlpha = 1;
852
+ mainContext.filter = 'none';
853
+ mainContext.shadowColor = 'rgba(0,0,0,0)';
709
854
  mainContext.clearRect(0, 0, bufferSizeX, bufferSizeY);
710
855
  }
711
856
 
@@ -726,6 +871,18 @@ function engineUpdateCanvas()
726
871
  mainContext.lineCap = 'round';
727
872
  }
728
873
 
874
+ // a release build goes on past an error in a frame, a frozen game is the worst a player can get; an error is logged
875
+ // when it is not the last one again, one every frame would flood the console
876
+ function engineFrameFailed(error)
877
+ {
878
+ const text = String(error);
879
+ text === engineFrameErrorLast || console.error(error);
880
+ engineFrameErrorLast = text;
881
+ // the frame's input is cleared as its tick would have, or a key press that threw would be pressed again
882
+ inputUpdatePost();
883
+ engineScheduleFrame();
884
+ }
885
+
729
886
  // ask for the next frame of the loop, once however often it is called before that frame, and skip it if manual
730
887
  // step was turned on since, so turning it off and on again within a frame can not start a second loop
731
888
  function engineScheduleFrame()
@@ -738,17 +895,7 @@ function engineScheduleFrame()
738
895
  if (engineManualStep) return;
739
896
  if (debug) return engineUpdateInternal(frameTimeMS); // a debug build stops at an error, where it shows it
740
897
  try { engineUpdateInternal(frameTimeMS); }
741
- catch (error)
742
- {
743
- // a release build goes on past an error in a frame, a frozen game is the worst a player can get; an error
744
- // is logged when it is not the last one again, one every frame would flood the console
745
- const text = String(error);
746
- text === engineFrameErrorLast || console.error(error);
747
- engineFrameErrorLast = text;
748
- // the frame's input is cleared as its tick would have, or a key press that threw would be pressed again
749
- inputUpdatePost();
750
- engineScheduleFrame();
751
- }
898
+ catch (error) { engineFrameFailed(error); }
752
899
  };
753
900
  if (typeof requestAnimationFrame === 'function')
754
901
  requestAnimationFrame(next);
@@ -769,7 +916,7 @@ const engineStepMaxFrames = 36000;
769
916
  * setHeadlessMode(true);
770
917
  * setEngineManualStep(true);
771
918
  * await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost);
772
- * engineStep(600); // advance 10 seconds of game time
919
+ * engineStep(600); // 600 updates of 1/60 second; the first is at time 0, so time is then 599/60
773
920
  * @memberof Engine */
774
921
  function engineStep(frames=1)
775
922
  {
@@ -806,11 +953,23 @@ function engineObjectsUpdate()
806
953
  o.collideSolidObjects && (engineObjectsCollide.push(o), (o.mass ? engineObjectsCollideStaticLast : fixed).push(o));
807
954
  for (const o of fixed)
808
955
  engineObjectsCollideStaticLast.push(o);
956
+ // written only when there is a grid: V8 keeps a variable that is never written as a constant, and writing it
957
+ // every frame makes this function deoptimize again and again in a game with no grid
958
+ if (engineObjectsCollideStaticLast.length >= engineCollideGridMin)
959
+ engineCollideGrid = {list: engineObjectsCollideStaticLast, built: undefined};
809
960
 
810
- // update physics before object update
811
- for (const o of engineObjects)
812
- if (!o.parent && !o.destroyed)
813
- o.updatePhysics();
961
+ // update physics before object update, each solid put where it moved to in the grid, for the movers after it;
962
+ // the grid is let go even when a callback throws, so an updatePhysics called before the next update checks all
963
+ try
964
+ {
965
+ for (const o of engineObjects)
966
+ if (!o.parent && !o.destroyed)
967
+ {
968
+ o.updatePhysics();
969
+ engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
970
+ }
971
+ }
972
+ finally { engineCollideGrid && (engineCollideGrid = undefined); }
814
973
 
815
974
  // recursive object update: the children are walked from a copy on a shared stack, since a child that
816
975
  // destroys itself leaves its parent's list on the spot and the next child would slide past the loop