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.
- package/CHANGELOG.md +87 -0
- package/FAQ.md +49 -11
- package/README.md +6 -2
- package/REFERENCE.md +120 -18
- package/dist/littlejs.d.ts +464 -99
- package/dist/littlejs.esm.js +2853 -704
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +2844 -701
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +2658 -512
- package/package.json +4 -4
- package/plugins/box2d.js +110 -34
- package/plugins/gltf.js +74 -22
- package/plugins/lightSystem.js +410 -28
- package/plugins/math3d.js +4 -2
- package/plugins/medalSystem.js +19 -7
- package/plugins/newgrounds.js +4 -2
- package/plugins/parallax.js +1 -0
- package/plugins/particleEffects.js +44 -10
- package/plugins/pathFinder.js +9 -11
- package/plugins/pluginExport.js +9 -0
- package/plugins/postProcess.js +4 -2
- package/plugins/render3d.js +485 -2385
- package/plugins/render3dDebug.js +5 -8
- package/plugins/render3dEditor.js +81 -6
- package/plugins/render3dExtras.js +103 -31
- package/plugins/render3dLevel.js +53 -5
- package/plugins/render3dMesh.js +1158 -0
- package/plugins/render3dObject.js +1451 -0
- package/plugins/render3dVoxels.js +15 -3
- package/plugins/textureSheet.js +9 -8
- package/plugins/threejs.js +1 -1
- package/plugins/tweenSystem.js +76 -51
- package/plugins/uiSystem.js +37 -8
- package/plugins/wavedash.js +229 -0
- package/src/engine.js +184 -36
- package/src/engineAudio.js +37 -21
- package/src/engineBuild.mjs +6 -1
- package/src/engineDebug.js +22 -162
- package/src/engineDraw.js +139 -34
- package/src/engineEditor.js +79 -18
- package/src/engineExport.js +0 -3
- package/src/engineInput.js +27 -18
- package/src/engineMath.js +35 -14
- package/src/engineObject.js +60 -41
- package/src/engineParticles.js +58 -11
- package/src/engineRelease.js +1 -5
- package/src/engineSettings.js +48 -9
- package/src/engineTileLayer.js +11 -4
- package/src/engineUtilities.js +41 -9
- 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.
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
615
|
-
|
|
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
|
|
619
|
-
// done, the loading screen showing in the meantime; an error in gameInit
|
|
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
|
-
|
|
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
|
|
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); //
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|