littlejsengine 1.25.0 → 1.26.0

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 +87 -0
  2. package/FAQ.md +49 -11
  3. package/README.md +6 -2
  4. package/REFERENCE.md +120 -18
  5. package/dist/littlejs.d.ts +464 -99
  6. package/dist/littlejs.esm.js +2853 -704
  7. package/dist/littlejs.esm.min.js +3 -3
  8. package/dist/littlejs.js +2844 -701
  9. package/dist/littlejs.min.js +2 -2
  10. package/dist/littlejs.release.js +2658 -512
  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 +4 -2
  23. package/plugins/render3d.js +485 -2385
  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 +184 -36
  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 +139 -34
  41. package/src/engineEditor.js +79 -18
  42. package/src/engineExport.js +0 -3
  43. package/src/engineInput.js +27 -18
  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.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -179,7 +179,134 @@ function engineObjectsCollidePairAdd(asker, other, resolve=false)
179
179
  others || engineObjectsCollidePairs.set(asker, others = new Map);
180
180
  others.set(other, resolve);
181
181
  }
182
+
183
+ // with this many solids or more, a mover finds what is near it through a grid of cells rather than checking every
184
+ // solid, a big game's cost going from every pair to what is close; it resolves the same contacts in the same order,
185
+ // so a game plays the same either way, and a few solids are quicker checked all
186
+ let engineCollideGridMin = 64;
187
+ // the grid for this update, {list, built}: the list of solids, and the grid made from it the first time a 2D mover
188
+ // walks it, so a frame with no 2D mover, as 3D solids alone, makes none; built is each cell's solids, each solid's
189
+ // cells, and each solid's place in the list, which the contacts are taken in; solids too big for cells are near
190
+ // everything
191
+ let engineCollideGrid;
192
+
193
+ // the grid of a list of solids, its cells about twice a typical solid so most are in one to four
194
+ function engineCollideGridBuild(list)
195
+ {
196
+ let extent = 0;
197
+ for (const o of list)
198
+ extent += min(max(abs(o.size.x), abs(o.size.y)), 16) || 0; // a negative size is a mirrored one
199
+ const index = new Map;
200
+ const grid = {size: max(2 * extent / list.length, .5), cells: new Map, at: new Map, index, big: new Set,
201
+ seen: new Map, stamp: 0, byIndex: (a, b)=> index.get(a) - index.get(b)}; // what a query found, and its order
202
+ list.forEach((o, i)=> { index.set(o, i); engineCollideGridPlace(grid, o); });
203
+ return grid;
204
+ }
205
+
206
+ // 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
207
+ // is a mirrored sprite of that size
208
+ const engineCollideGridBox = [0, 0, 0, 0];
209
+ function engineCollideGridCells(grid, o)
210
+ {
211
+ const s = grid.size, w = abs(o.size.x) / 2, h = abs(o.size.y) / 2, box = engineCollideGridBox;
212
+ box[0] = floor((o.pos.x - w) / s), box[1] = floor((o.pos.y - h) / s);
213
+ box[2] = floor((o.pos.x + w) / s), box[3] = floor((o.pos.y + h) / s);
214
+ return box;
215
+ }
216
+
217
+ // 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
218
+ // loop would not end, as ++ stops changing a number past 2^53, and the keys would not be exact; one with no finite
219
+ // box does not fit either, and is near every mover
220
+ const engineCollideGridFits = (x0, y0, x1, y1)=> (x1 - x0 + 1) * (y1 - y0 + 1) <= 1024 &&
221
+ abs(x0) < 2**31 && abs(y0) < 2**31 && abs(x1) < 2**31 && abs(y1) < 2**31;
222
+
223
+ // 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
224
+ // solids that touch share one
225
+ function engineCollideGridPlace(grid, o)
226
+ {
227
+ if (!grid.index.has(o)) return;
228
+ const [x0, y0, x1, y1] = engineCollideGridCells(grid, o);
229
+ const big = !engineCollideGridFits(x0, y0, x1, y1);
230
+ const was = grid.at.get(o);
231
+ if (was && was[0] === x0 && was[1] === y0 && was[2] === x1 && was[3] === y1 && was[4] === big) return;
232
+ if (was && !was[4])
233
+ for (let x = was[0]; x <= was[2]; ++x)
234
+ for (let y = was[1]; y <= was[3]; ++y)
235
+ {
236
+ const cell = grid.cells.get(x * 1048576 + y);
237
+ cell.splice(cell.indexOf(o), 1);
238
+ }
239
+ grid.big.delete(o);
240
+ if (big)
241
+ grid.big.add(o);
242
+ else
243
+ for (let x = x0; x <= x1; ++x)
244
+ for (let y = y0; y <= y1; ++y)
245
+ {
246
+ const key = x * 1048576 + y;
247
+ const cell = grid.cells.get(key);
248
+ cell ? cell.push(o) : grid.cells.set(key, [o]);
249
+ }
250
+ grid.at.set(o, [x0, y0, x1, y1, big]);
251
+ }
252
+
253
+ // the solids a mover is near, after a place in the list, in list order, filled into near; each found once, by the
254
+ // query's stamp
255
+ function engineCollideGridNear(grid, o, after, near)
256
+ {
257
+ const [x0, y0, x1, y1] = engineCollideGridCells(grid, o);
258
+ const stamp = ++grid.stamp;
259
+ near.length = 0;
260
+ for (const other of grid.big)
261
+ engineCollideGridAdd(grid, other, after, stamp, near);
262
+ if (engineCollideGridFits(x0, y0, x1, y1))
263
+ {
264
+ for (let x = x0; x <= x1; ++x)
265
+ for (let y = y0; y <= y1; ++y)
266
+ {
267
+ const cell = grid.cells.get(x * 1048576 + y);
268
+ if (cell)
269
+ for (const other of cell)
270
+ engineCollideGridAdd(grid, other, after, stamp, near);
271
+ }
272
+ }
273
+ else
274
+ for (const other of grid.index.keys()) // a mover too big for cells is near everything
275
+ engineCollideGridAdd(grid, other, after, stamp, near);
276
+ near.sort(grid.byIndex);
277
+ }
278
+
279
+ // add a solid to what a query found, once, when it is after the place in the list
280
+ function engineCollideGridAdd(grid, other, after, stamp, near)
281
+ {
282
+ if (grid.seen.get(other) === stamp) return;
283
+ grid.seen.set(other, stamp);
284
+ grid.index.get(other) > after && near.push(other);
285
+ }
286
+
287
+ // the solids a mover checks, as checking every one would reach them: in list order, and found again from where it is
288
+ // whenever its box has moved, as a push does, so a solid it is pushed into later in the list is still checked
289
+ function* engineCollideGridWalk(o)
290
+ {
291
+ const grid = engineCollideGrid.built ||= engineCollideGridBuild(engineCollideGrid.list);
292
+ let after = -1, x, y, w, h, k = 0;
293
+ const near = [];
294
+ for (;;)
295
+ {
296
+ if (o.pos.x !== x || o.pos.y !== y || o.size.x !== w || o.size.y !== h)
297
+ {
298
+ x = o.pos.x, y = o.pos.y, w = o.size.x, h = o.size.y;
299
+ engineCollideGridNear(grid, o, after, near);
300
+ k = 0;
301
+ }
302
+ if (k >= near.length) return;
303
+ const other = near[k++];
304
+ after = grid.index.get(other);
305
+ yield other;
306
+ }
307
+ }
182
308
  let engineInitialized = false; // engineInit ran, with or without a canvas
309
+ let engineInitDone; // the promise the first engineInit hands back, which a second call hands back too
183
310
  // the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
184
311
  // loop starts
185
312
  let engineLoads, engineLoadsDone = 0;
@@ -223,7 +350,7 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
223
350
  p.update === update && p.render === render &&
224
351
  p.glContextLost === glContextLost &&
225
352
  p.glContextRestored === glContextRestored &&
226
- p.preRender === preRender));
353
+ p.preRender === preRender), 'engineAddPlugin: this plugin was already added');
227
354
 
228
355
  const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored, preRender);
229
356
  pluginList.push(plugin);
@@ -305,7 +432,7 @@ function engineLoadingScreenDraw(elapsed)
305
432
  * @param {GameCallback} [gameUpdatePost] - Called after physics and objects are updated, even when paused, use for UI updates
306
433
  * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
307
434
  * @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'])
435
+ * @param {Array<string>|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path
309
436
  * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
310
437
  * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
311
438
  * so set canvasFixedSize or canvasMaxSize to fit a smaller element
@@ -323,13 +450,15 @@ function engineLoadingScreenDraw(elapsed)
323
450
  * @return {Promise<void>} - Done when the images have loaded and gameInit has run */
324
451
  async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
325
452
  {
326
- showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
327
453
  ASSERT(!engineInitialized, 'engine already initialized');
328
454
  // 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;
455
+ // double-register listeners / double-add canvases on a second call, which is done when the first is
456
+ if (engineInitialized) return engineInitDone;
331
457
  engineInitialized = true;
458
+ showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
332
459
  engineLoads = [], engineLoadsDone = 0;
460
+ if (typeof imageSources === 'string')
461
+ imageSources = [imageSources]; // one image given alone
333
462
  ASSERT(isArray(imageSources), 'pass in images as array');
334
463
 
335
464
  // allow passing in empty functions
@@ -471,8 +600,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
471
600
  debugUpdate();
472
601
  gameUpdatePost();
473
602
  inputUpdatePost();
474
- if (debugVideoCaptureIsActive())
475
- renderFrame();
476
603
  }
477
604
 
478
605
  // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
@@ -494,7 +621,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
494
621
 
495
622
  // render only when something changed, displays that refresh faster
496
623
  // than the fixed update rate would otherwise redraw identical frames
497
- if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
624
+ if (wasUpdated || windowChanged)
498
625
  renderFrame();
499
626
  engineManualStep || engineScheduleFrame();
500
627
 
@@ -526,14 +653,13 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
526
653
  inputRender();
527
654
  debugRender();
528
655
  glFlush();
529
- debugRenderPost();
530
656
  drawCount = 0;
531
657
  primitiveCount = 0;
532
658
  }
533
659
  }
534
660
 
535
661
  // skip setup if headless
536
- if (headlessMode) return startEngine([]);
662
+ if (headlessMode) return engineInitDone = startEngine([]);
537
663
 
538
664
  // ensure body exists for minimal HTML where the script runs before <body> is parsed
539
665
  if (!document.body)
@@ -611,21 +737,25 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
611
737
  promises.push(splash);
612
738
  }
613
739
 
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);
740
+ // kept before anything is awaited, so a second call has it
741
+ return engineInitDone = startEngine(promises);
617
742
 
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
743
+ // the splash first, the images load under it, then gameInit runs once they are in, and the game loop starts once
744
+ // it and everything loaded while it ran are done, the loading screen showing in the meantime; an error in gameInit
745
+ // reaches the caller
620
746
  async function startEngine(images)
621
747
  {
748
+ showSplashScreen && !headlessMode && await images[images.length - 1]; // headless has no splash
622
749
  const init = (async ()=> { await Promise.all(images); await gameInit(); })();
623
750
  engineAddLoad(init);
624
751
  await engineWaitForLoads();
625
752
  engineLoads = undefined;
626
753
  await init;
627
754
  engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
628
- engineManualStep || engineUpdate();
755
+ if (engineManualStep) return;
756
+ if (debug) return engineUpdate(); // a debug build stops at an error, the first frame's as any
757
+ try { engineUpdate(); }
758
+ catch (error) { engineFrameFailed(error); } // a release build goes on past it, as on any later frame
629
759
  }
630
760
  }
631
761
 
@@ -643,7 +773,7 @@ function engineUpdateCanvas()
643
773
  if (canvasFixedSize.x)
644
774
  {
645
775
  // set canvas fixed size
646
- mainCanvasSize = canvasFixedSize.copy();
776
+ mainCanvasSize.set(canvasFixedSize.x, canvasFixedSize.y);
647
777
 
648
778
  // fit to window using css width and height
649
779
  const innerAspect = innerWidth / innerHeight;
@@ -667,7 +797,8 @@ function engineUpdateCanvas()
667
797
 
668
798
  // responsive aspect ratio, of the size after canvasMaxSize, which can change its shape
669
799
  const innerAspect = mainCanvasSize.x / mainCanvasSize.y;
670
- ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect);
800
+ ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect,
801
+ 'canvasMinAspect must not be above canvasMaxAspect', canvasMinAspect, canvasMaxAspect);
671
802
  if (canvasMaxAspect && innerAspect > canvasMaxAspect)
672
803
  {
673
804
  // full height
@@ -703,9 +834,12 @@ function engineUpdateCanvas()
703
834
  }
704
835
  else
705
836
  {
706
- // setting the size also resets the context state, match that
837
+ // setting the size also resets the context state, match that, what a game may have left on it too
707
838
  mainContext.setTransform(1, 0, 0, 1, 0, 0);
708
839
  mainContext.globalCompositeOperation = 'source-over';
840
+ mainContext.globalAlpha = 1;
841
+ mainContext.filter = 'none';
842
+ mainContext.shadowColor = 'rgba(0,0,0,0)';
709
843
  mainContext.clearRect(0, 0, bufferSizeX, bufferSizeY);
710
844
  }
711
845
 
@@ -726,6 +860,18 @@ function engineUpdateCanvas()
726
860
  mainContext.lineCap = 'round';
727
861
  }
728
862
 
863
+ // 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
864
+ // when it is not the last one again, one every frame would flood the console
865
+ function engineFrameFailed(error)
866
+ {
867
+ const text = String(error);
868
+ text === engineFrameErrorLast || console.error(error);
869
+ engineFrameErrorLast = text;
870
+ // the frame's input is cleared as its tick would have, or a key press that threw would be pressed again
871
+ inputUpdatePost();
872
+ engineScheduleFrame();
873
+ }
874
+
729
875
  // ask for the next frame of the loop, once however often it is called before that frame, and skip it if manual
730
876
  // step was turned on since, so turning it off and on again within a frame can not start a second loop
731
877
  function engineScheduleFrame()
@@ -738,17 +884,7 @@ function engineScheduleFrame()
738
884
  if (engineManualStep) return;
739
885
  if (debug) return engineUpdateInternal(frameTimeMS); // a debug build stops at an error, where it shows it
740
886
  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
- }
887
+ catch (error) { engineFrameFailed(error); }
752
888
  };
753
889
  if (typeof requestAnimationFrame === 'function')
754
890
  requestAnimationFrame(next);
@@ -769,7 +905,7 @@ const engineStepMaxFrames = 36000;
769
905
  * setHeadlessMode(true);
770
906
  * setEngineManualStep(true);
771
907
  * await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost);
772
- * engineStep(600); // advance 10 seconds of game time
908
+ * engineStep(600); // 600 updates of 1/60 second; the first is at time 0, so time is then 599/60
773
909
  * @memberof Engine */
774
910
  function engineStep(frames=1)
775
911
  {
@@ -806,11 +942,23 @@ function engineObjectsUpdate()
806
942
  o.collideSolidObjects && (engineObjectsCollide.push(o), (o.mass ? engineObjectsCollideStaticLast : fixed).push(o));
807
943
  for (const o of fixed)
808
944
  engineObjectsCollideStaticLast.push(o);
945
+ // written only when there is a grid: V8 keeps a variable that is never written as a constant, and writing it
946
+ // every frame makes this function deoptimize again and again in a game with no grid
947
+ if (engineObjectsCollideStaticLast.length >= engineCollideGridMin)
948
+ engineCollideGrid = {list: engineObjectsCollideStaticLast, built: undefined};
809
949
 
810
- // update physics before object update
811
- for (const o of engineObjects)
812
- if (!o.parent && !o.destroyed)
813
- o.updatePhysics();
950
+ // update physics before object update, each solid put where it moved to in the grid, for the movers after it;
951
+ // the grid is let go even when a callback throws, so an updatePhysics called before the next update checks all
952
+ try
953
+ {
954
+ for (const o of engineObjects)
955
+ if (!o.parent && !o.destroyed)
956
+ {
957
+ o.updatePhysics();
958
+ engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
959
+ }
960
+ }
961
+ finally { engineCollideGrid && (engineCollideGrid = undefined); }
814
962
 
815
963
  // recursive object update: the children are walked from a copy on a shared stack, since a child that
816
964
  // destroys itself leaves its parent's list on the spot and the next child would slide past the loop