littlejsengine 1.26.0 → 1.27.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 +28 -0
- package/FAQ.md +3 -2
- package/README.md +4 -3
- package/REFERENCE.md +47 -3
- package/dist/littlejs.d.ts +204 -19
- package/dist/littlejs.esm.js +855 -113
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +854 -113
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +834 -107
- package/package.json +1 -1
- package/plugins/lightSystem.js +84 -2
- package/plugins/newgrounds.js +92 -0
- package/plugins/parallax.js +3 -1
- package/plugins/postProcess.js +5 -1
- package/plugins/render3d.js +14 -4
- package/plugins/render3dEditor.js +20 -6
- package/plugins/render3dExtras.js +1 -0
- package/plugins/wavedash.js +235 -4
- package/src/engine.js +39 -7
- package/src/engineDraw.js +3 -0
- package/src/engineExport.js +1 -0
- package/src/engineInput.js +7 -6
- package/src/engineObject.js +244 -65
- package/src/engineParticles.js +8 -3
- package/src/engineTileLayer.js +95 -10
- package/src/engineWebGL.js +4 -4
package/plugins/wavedash.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LittleJS Wavedash Plugin
|
|
3
|
-
* - The Wavedash twin of the Newgrounds plugin: achievements and
|
|
3
|
+
* - The Wavedash twin of the Newgrounds plugin: achievements, leaderboards and cloud saves, the same shape so a game
|
|
4
|
+
* can switch, and stats and presence of Wavedash's own
|
|
4
5
|
* - Wavedash serves the game's page and puts its SDK in window.Wavedash before the game runs, so nothing is bundled;
|
|
5
6
|
* off Wavedash (local, itch, GitHub Pages) there is none, and every call does nothing
|
|
6
7
|
* - Make the plugin when the game can draw, at the end of gameInit: Wavedash.init is called then, and until it is
|
|
@@ -32,17 +33,33 @@ const wavedashDisplayTypes = {number: 0, seconds: 1, milliseconds: 2, ticks: 3};
|
|
|
32
33
|
// the SDK Wavedash put on the page, read at each call, undefined off Wavedash
|
|
33
34
|
const wavedashSDK = ()=> globalThis['Wavedash'];
|
|
34
35
|
|
|
36
|
+
// the file a cloud save slot is kept in
|
|
37
|
+
const wavedashSavePath = (slot)=> 'saves/slot' + slot + '.json';
|
|
38
|
+
|
|
35
39
|
// 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
40
|
// the answer is in, so nothing waits on it after
|
|
37
|
-
function wavedashWait(name, answer)
|
|
41
|
+
function wavedashWait(name, answer, quiet=false)
|
|
38
42
|
{
|
|
39
43
|
let timer;
|
|
40
|
-
const
|
|
44
|
+
const timedOut = {}; // what the time limit gives, told apart from any answer
|
|
45
|
+
const limit = new Promise((resolve)=> timer = setTimeout(()=> resolve(timedOut), wavedashTimeoutMS));
|
|
41
46
|
return Promise.race([Promise.resolve(answer), limit])
|
|
42
|
-
.
|
|
47
|
+
.then((value)=> value !== timedOut ? value : quiet ? undefined :
|
|
48
|
+
void console.warn('Wavedash ' + name + ' took over ' + wavedashTimeoutMS / 1e3 + ' seconds'),
|
|
49
|
+
(error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); })
|
|
43
50
|
.finally(()=> clearTimeout(timer));
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
// a value as the text a cloud save keeps, undefined with a warning for one JSON can not hold, as a circular object,
|
|
54
|
+
// a BigInt, a function or undefined
|
|
55
|
+
function wavedashSaveText(slot, data)
|
|
56
|
+
{
|
|
57
|
+
let text;
|
|
58
|
+
try { text = JSON.stringify(data); } catch (error) {}
|
|
59
|
+
text === undefined && console.warn('Wavedash cloudSave: slot ' + slot + ' was given a value JSON can not hold');
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
///////////////////////////////////////////////////////////////////////////////
|
|
47
64
|
/**
|
|
48
65
|
* WavedashMedal: a medal that is also a Wavedash achievement, unlocked on Wavedash by its identifier
|
|
@@ -121,6 +138,14 @@ class WavedashPlugin
|
|
|
121
138
|
this.achievementTries = Object.create(null); // how many times each one not taken yet was sent
|
|
122
139
|
/** @type {number|undefined} */
|
|
123
140
|
this.achievementRetry = undefined; // the timer that sends refused ones again
|
|
141
|
+
/** @type {Promise<boolean>|undefined} */
|
|
142
|
+
this.stats = undefined; // the player's stats loading, asked for the first time a stat is
|
|
143
|
+
/** @type {Map<string, Promise<*>>} */
|
|
144
|
+
this.slotQueues = new Map; // each slot's last save or load, the next waits for it, as they share a file
|
|
145
|
+
/** @type {Map<string, Set<Promise<*>>>} */
|
|
146
|
+
this.slotCalls = new Map; // each slot's file calls Wavedash has not answered, a timed out one may still change it
|
|
147
|
+
/** @type {Map<string, 'failed'|'notSave'>} */
|
|
148
|
+
this.loadFailures = new Map; // why each slot's last load gave undefined, by its file, see loadFailure
|
|
124
149
|
|
|
125
150
|
// Wavedash keeps its loading screen until init, which is called once
|
|
126
151
|
wavedashWait('init', this.call('init'));
|
|
@@ -199,6 +224,212 @@ class WavedashPlugin
|
|
|
199
224
|
return result?.['success'] ? result['data'] : undefined;
|
|
200
225
|
}
|
|
201
226
|
|
|
227
|
+
/** Save a value to one of the player's cloud save slots, kept by Wavedash across devices; off Wavedash it does
|
|
228
|
+
* nothing
|
|
229
|
+
* - Any value JSON can hold, stored as the file saves/slot#.json
|
|
230
|
+
* - Wavedash takes at most 30 saves a minute and 300 an hour from a player, so save at checkpoints, the end of a
|
|
231
|
+
* level or when asked, not every frame; one over the limit is refused, said in the console
|
|
232
|
+
* @param {number} slot - The slot number, any whole number the game picks
|
|
233
|
+
* @param {*} data - The value to save
|
|
234
|
+
* @return {Promise<boolean>} - Whether it saved
|
|
235
|
+
* @example
|
|
236
|
+
* wavedash.cloudSave(1, {level, coins}); */
|
|
237
|
+
async cloudSave(slot, data)
|
|
238
|
+
{
|
|
239
|
+
ASSERT(isNumber(slot), 'Wavedash cloudSave: slot must be a number', slot);
|
|
240
|
+
if (!this.isActive()) return false;
|
|
241
|
+
const text = wavedashSaveText(slot, data); // as it is now, the save waits its turn
|
|
242
|
+
if (text === undefined) return false;
|
|
243
|
+
return this.slotQueue(slot, false, async (track)=>
|
|
244
|
+
{
|
|
245
|
+
// written first, and only a write Wavedash says it made is uploaded, never an older file at that path
|
|
246
|
+
const path = wavedashSavePath(slot);
|
|
247
|
+
const written = await wavedashWait('cloudSave', track(this.call('writeLocalFile', path,
|
|
248
|
+
new TextEncoder().encode(text))));
|
|
249
|
+
if (written !== true && !written?.['success'])
|
|
250
|
+
{
|
|
251
|
+
written && console.warn('Wavedash refused writing slot ' + slot + ': ' +
|
|
252
|
+
(written['message'] ?? written['error'] ?? 'no reason given'));
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
const result = await wavedashWait('cloudSave', track(this.call('uploadRemoteFile', path)));
|
|
256
|
+
result && !result['success'] && console.warn('Wavedash refused cloudSave of slot ' + slot + ': ' +
|
|
257
|
+
(result['message'] ?? result['error'] ?? 'no reason given'));
|
|
258
|
+
return !!result?.['success'];
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Load the value a cloud save slot holds: null when Wavedash says it holds none, undefined when it could not be
|
|
263
|
+
* loaded or off Wavedash, said in the console on Wavedash
|
|
264
|
+
* - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
|
|
265
|
+
* it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
|
|
266
|
+
* - A saved null loads as null, as an empty slot does
|
|
267
|
+
* @param {number} slot - The slot number
|
|
268
|
+
* @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
|
|
269
|
+
* @example
|
|
270
|
+
* const save = await wavedash.cloudLoad(1); */
|
|
271
|
+
async cloudLoad(slot)
|
|
272
|
+
{
|
|
273
|
+
ASSERT(isNumber(slot), 'Wavedash cloudLoad: slot must be a number', slot);
|
|
274
|
+
const failed = {value: undefined, failure: /** @type {'failed'} */ ('failed')};
|
|
275
|
+
const {value, failure} = this.isActive() ?
|
|
276
|
+
await this.slotQueue(slot, failed, (track)=> this.loadSlot(slot, track)) : failed;
|
|
277
|
+
const key = wavedashSavePath(slot);
|
|
278
|
+
failure ? this.loadFailures.set(key, failure) : this.loadFailures.delete(key);
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
|
|
283
|
+
* game may offer to start over, 'failed' when it could not be loaded, as off Wavedash or on a timeout, where a
|
|
284
|
+
* game must not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
|
|
285
|
+
* @param {number} slot - The slot number
|
|
286
|
+
* @return {'failed'|'notSave'|undefined} */
|
|
287
|
+
loadFailure(slot) { return this.loadFailures.get(wavedashSavePath(slot)); }
|
|
288
|
+
|
|
289
|
+
/** A slot's load, its value and why it failed, if it did
|
|
290
|
+
* @param {number} slot
|
|
291
|
+
* @param {function(*): *} track
|
|
292
|
+
* @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
|
|
293
|
+
* @ignore */
|
|
294
|
+
async loadSlot(slot, track)
|
|
295
|
+
{
|
|
296
|
+
const path = wavedashSavePath(slot);
|
|
297
|
+
const failed = (message)=>
|
|
298
|
+
{
|
|
299
|
+
console.warn('Wavedash ' + message);
|
|
300
|
+
return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
|
|
301
|
+
};
|
|
302
|
+
const downloaded = await wavedashWait('cloudLoad', track(this.call('downloadRemoteFile', path)));
|
|
303
|
+
if (downloaded !== true && !downloaded?.['success']) // an answer of true or of {success: true}
|
|
304
|
+
{
|
|
305
|
+
// a file Wavedash says is not there is an empty slot, anything else a load that failed; it may answer
|
|
306
|
+
// false or {success: true, data: false}
|
|
307
|
+
const exists = await wavedashWait('cloudLoad', track(this.call('remoteFileExists', path)));
|
|
308
|
+
if (exists === false || exists?.['success'] && exists['data'] === false)
|
|
309
|
+
return {value: null, failure: undefined};
|
|
310
|
+
return failed('could not load slot ' + slot);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// the file's text, its bytes given as they are or in a response's data; none is a read that failed, as a
|
|
314
|
+
// save is never empty
|
|
315
|
+
const answer = await wavedashWait('cloudLoad', track(this.call('readLocalFile', path)));
|
|
316
|
+
const bytes = answer instanceof Uint8Array || answer instanceof ArrayBuffer || typeof answer == 'string' ?
|
|
317
|
+
answer : answer?.['data'];
|
|
318
|
+
const text = !bytes ? '' : typeof bytes == 'string' ? bytes : new TextDecoder().decode(bytes);
|
|
319
|
+
if (!text) return failed('could not read slot ' + slot);
|
|
320
|
+
try { return {value: JSON.parse(text), failure: undefined}; }
|
|
321
|
+
catch (error)
|
|
322
|
+
{
|
|
323
|
+
console.warn('Wavedash cloudLoad: slot ' + slot + ' holds a file that is not a save');
|
|
324
|
+
return {value: undefined, failure: 'notSave'};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Run a slot's save or load after the one before it, as they share the slot's local file; other slots go on
|
|
329
|
+
* at once, and one that fails does not hold up the next
|
|
330
|
+
* - A call that timed out may still change the file when Wavedash gets to it, so the next waits for it to be
|
|
331
|
+
* answered too, and gives up with failed when it is not, leaving the file alone
|
|
332
|
+
* @param {number} slot
|
|
333
|
+
* @param {*} failed - What the task gives when it can not run
|
|
334
|
+
* @param {function(function(*): *): Promise<*>} task - Given track, which keeps a call's answer until it is in
|
|
335
|
+
* @return {Promise<*>}
|
|
336
|
+
* @ignore */
|
|
337
|
+
slotQueue(slot, failed, task)
|
|
338
|
+
{
|
|
339
|
+
const key = wavedashSavePath(slot); // by its file, so a slot given as '1' and as 1 share one
|
|
340
|
+
const calls = this.slotCalls.get(key) || new Set;
|
|
341
|
+
this.slotCalls.set(key, calls);
|
|
342
|
+
const track = (answer)=>
|
|
343
|
+
{
|
|
344
|
+
if (typeof answer?.then == 'function') // any promise, one made elsewhere too
|
|
345
|
+
{
|
|
346
|
+
calls.add(answer);
|
|
347
|
+
const done = ()=>
|
|
348
|
+
{
|
|
349
|
+
calls.delete(answer);
|
|
350
|
+
// the last of them, answered after the slot was let go of, lets go of its calls too
|
|
351
|
+
calls.size || this.slotQueues.has(key) || this.slotCalls.get(key) !== calls ||
|
|
352
|
+
this.slotCalls.delete(key);
|
|
353
|
+
};
|
|
354
|
+
answer.then(done, done);
|
|
355
|
+
}
|
|
356
|
+
return answer;
|
|
357
|
+
};
|
|
358
|
+
const start = async ()=>
|
|
359
|
+
{
|
|
360
|
+
if (calls.size && !await wavedashWait('slot ' + slot, Promise.allSettled([...calls]).then(()=> true), true))
|
|
361
|
+
{
|
|
362
|
+
console.warn('Wavedash slot ' + slot + ' is still busy with a call that timed out, not used');
|
|
363
|
+
return failed;
|
|
364
|
+
}
|
|
365
|
+
return task(track);
|
|
366
|
+
};
|
|
367
|
+
const run = (this.slotQueues.get(key) || Promise.resolve()).then(start, start);
|
|
368
|
+
const done = run.catch(()=> {});
|
|
369
|
+
this.slotQueues.set(key, done);
|
|
370
|
+
done.then(()=>
|
|
371
|
+
{
|
|
372
|
+
// the slot is let go of once nothing waits on it, and its calls with it once all are answered
|
|
373
|
+
if (this.slotQueues.get(key) !== done) return;
|
|
374
|
+
this.slotQueues.delete(key);
|
|
375
|
+
calls.size || this.slotCalls.get(key) !== calls || this.slotCalls.delete(key);
|
|
376
|
+
});
|
|
377
|
+
return run;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Set one of the player's stats, as made for the game in the Wavedash developer portal, which can unlock an
|
|
381
|
+
* achievement set to follow it; Wavedash keeps it about a second later, or at once with storeNow; off Wavedash it
|
|
382
|
+
* does nothing
|
|
383
|
+
* @param {string} name - The stat's identifier
|
|
384
|
+
* @param {number} value
|
|
385
|
+
* @param {boolean} [storeNow] - Keep it now, as at the end of a game, not a second later
|
|
386
|
+
* @return {Promise<boolean>} - Whether Wavedash took it */
|
|
387
|
+
async setStat(name, value, storeNow=false)
|
|
388
|
+
{
|
|
389
|
+
ASSERT(typeof name === 'string' && name !== '', 'Wavedash setStat: name must be a string', name);
|
|
390
|
+
ASSERT(isNumber(value), 'Wavedash setStat: value must be a number', value);
|
|
391
|
+
if (!this.isActive() || !await this.statsLoaded()) return false;
|
|
392
|
+
return this.call('setStat', name, value, !!storeNow) === true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Read one of the player's stats, 0 for one never set or off Wavedash
|
|
396
|
+
* @param {string} name - The stat's identifier
|
|
397
|
+
* @return {Promise<number>} */
|
|
398
|
+
async getStat(name)
|
|
399
|
+
{
|
|
400
|
+
if (!this.isActive() || !await this.statsLoaded()) return 0;
|
|
401
|
+
const value = this.call('getStat', name);
|
|
402
|
+
return isNumber(value) ? value : 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The player's stats loaded from Wavedash, which they must be before they are read or set, asked for the first
|
|
406
|
+
* time a stat is
|
|
407
|
+
* @return {Promise<boolean>} - Whether they loaded
|
|
408
|
+
* @ignore */
|
|
409
|
+
statsLoaded()
|
|
410
|
+
{
|
|
411
|
+
return this.stats ||= wavedashWait('requestStats', this.call('requestStats')).then((result)=>
|
|
412
|
+
{
|
|
413
|
+
const loaded = !!result?.['success'];
|
|
414
|
+
loaded || (this.stats = undefined); // asked for again next time
|
|
415
|
+
return loaded;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Set what the player is doing, kept with their presence on Wavedash; off Wavedash it does nothing
|
|
420
|
+
* @param {string} [status] - One line of what they are doing, like 'In a race'
|
|
421
|
+
* @param {string} [details] - More about it, like 'Lap 2 of 3'; both left out clears it
|
|
422
|
+
* @return {Promise<boolean>} - Whether it was set */
|
|
423
|
+
async setPresence(status, details)
|
|
424
|
+
{
|
|
425
|
+
if (!this.isActive()) return false;
|
|
426
|
+
const presence = {};
|
|
427
|
+
status === undefined || (presence['status'] = String(status));
|
|
428
|
+
details === undefined || (presence['details'] = String(details));
|
|
429
|
+
const result = await wavedashWait('setPresence', this.call('updateUserPresence', presence));
|
|
430
|
+
return !!result?.['success'];
|
|
431
|
+
}
|
|
432
|
+
|
|
202
433
|
/** Send every unlocked WavedashMedal's achievement Wavedash has not taken yet, again every two seconds while it
|
|
203
434
|
* refuses some, as it does until it has loaded the player's achievements; one refused for about a minute is
|
|
204
435
|
* taken as an identifier Wavedash does not have, said once in the console, and not sent again this visit
|
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.27.0';
|
|
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
|
|
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
|
|
@@ -190,6 +197,10 @@ let engineCollideGridMin = 64;
|
|
|
190
197
|
// everything
|
|
191
198
|
let engineCollideGrid;
|
|
192
199
|
|
|
200
|
+
// where each object that collides with solids was before this frame's moves, kept only when a one way solid is among
|
|
201
|
+
// them, so a one way test goes by where both were whichever of the two updates first
|
|
202
|
+
let engineObjectsOneWayStart;
|
|
203
|
+
|
|
193
204
|
// the grid of a list of solids, its cells about twice a typical solid so most are in one to four
|
|
194
205
|
function engineCollideGridBuild(list)
|
|
195
206
|
{
|
|
@@ -433,7 +444,9 @@ function engineLoadingScreenDraw(elapsed)
|
|
|
433
444
|
* @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
|
|
434
445
|
* @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
|
|
435
446
|
* @param {Array<string>|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path
|
|
436
|
-
* @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
|
|
447
|
+
* @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body; it is
|
|
448
|
+
* styled for a game (no scroll bars or selection, touch-action none),
|
|
449
|
+
* where its own inline style does not say otherwise
|
|
437
450
|
* It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
|
|
438
451
|
* so set canvasFixedSize or canvasMaxSize to fit a smaller element
|
|
439
452
|
* @example
|
|
@@ -496,8 +509,10 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
496
509
|
// by ~page-load-time when RAF starts handing real timestamps
|
|
497
510
|
if (!frameTimeLastMS) frameTimeDeltaMS = 0;
|
|
498
511
|
frameTimeLastMS = frameTimeMS;
|
|
499
|
-
|
|
500
|
-
|
|
512
|
+
// the first frame's rate seeds the average, so it does not start out climbing from 0; a gap of over a
|
|
513
|
+
// second, a hidden tab coming back, is not a frame
|
|
514
|
+
if (frameTimeDeltaMS && frameTimeDeltaMS < 1e3)
|
|
515
|
+
averageFPS = averageFPS ? lerp(averageFPS, 1e3/frameTimeDeltaMS, .05) : 1e3/frameTimeDeltaMS;
|
|
501
516
|
// the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
|
|
502
517
|
if (!manualStepAtStart)
|
|
503
518
|
frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
|
|
@@ -946,19 +961,36 @@ function engineObjectsUpdate()
|
|
|
946
961
|
// every frame makes this function deoptimize again and again in a game with no grid
|
|
947
962
|
if (engineObjectsCollideStaticLast.length >= engineCollideGridMin)
|
|
948
963
|
engineCollideGrid = {list: engineObjectsCollideStaticLast, built: undefined};
|
|
964
|
+
// written only when there is a one way solid, as the grid is
|
|
965
|
+
if (engineObjectsCollide.some((o)=> o.oneWay))
|
|
966
|
+
engineObjectsOneWayStart = new Map(engineObjectsCollide.map((o)=> [o, o.pos.copy()]));
|
|
949
967
|
|
|
950
968
|
// update physics before object update, each solid put where it moved to in the grid, for the movers after it;
|
|
951
969
|
// the grid is let go even when a callback throws, so an updatePhysics called before the next update checks all
|
|
970
|
+
// a one way solid with no mass moves first, nothing pushes it, so what lands on it or passes up through it sees it
|
|
971
|
+
// where it is now and where it was, whatever the render order
|
|
972
|
+
const oneWayFirst = (o)=> o.oneWay && !o.mass;
|
|
952
973
|
try
|
|
953
974
|
{
|
|
975
|
+
if (engineObjectsOneWayStart)
|
|
976
|
+
for (const o of engineObjects)
|
|
977
|
+
if (!o.parent && !o.destroyed && oneWayFirst(o))
|
|
978
|
+
{
|
|
979
|
+
o.updatePhysics();
|
|
980
|
+
engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
|
|
981
|
+
}
|
|
954
982
|
for (const o of engineObjects)
|
|
955
|
-
if (!o.parent && !o.destroyed)
|
|
983
|
+
if (!o.parent && !o.destroyed && !(engineObjectsOneWayStart && oneWayFirst(o)))
|
|
956
984
|
{
|
|
957
985
|
o.updatePhysics();
|
|
958
986
|
engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
|
|
959
987
|
}
|
|
960
988
|
}
|
|
961
|
-
finally
|
|
989
|
+
finally
|
|
990
|
+
{
|
|
991
|
+
engineCollideGrid && (engineCollideGrid = undefined);
|
|
992
|
+
engineObjectsOneWayStart && (engineObjectsOneWayStart = undefined);
|
|
993
|
+
}
|
|
962
994
|
|
|
963
995
|
// recursive object update: the children are walked from a copy on a shared stack, since a child that
|
|
964
996
|
// destroys itself leaves its parent's list on the spot and the next child would slide past the loop
|
package/src/engineDraw.js
CHANGED
|
@@ -548,6 +548,9 @@ class SpriteAnimator
|
|
|
548
548
|
* across the sprite or the mesh's own uv
|
|
549
549
|
* - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
|
|
550
550
|
* lightCount, lights[i], lightColors[i] and shadow()
|
|
551
|
+
* - In 3D the snippet may also define void mainNormal(inout vec3 n), given the normal facing the camera after the
|
|
552
|
+
* normal map, in world space, to bend it per pixel for waves or ripples; the lighting, specular, reflection and
|
|
553
|
+
* fog then all use it, as they use a normal map
|
|
551
554
|
* - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
|
|
552
555
|
* parts of a surface still shadows with the whole of it
|
|
553
556
|
* @example
|
package/src/engineExport.js
CHANGED
package/src/engineInput.js
CHANGED
|
@@ -656,8 +656,9 @@ function inputInit()
|
|
|
656
656
|
}
|
|
657
657
|
function onContextMenu(e)
|
|
658
658
|
{
|
|
659
|
-
// prevent right click menu, but a text field keeps its copy and paste menu
|
|
660
|
-
|
|
659
|
+
// prevent right click menu, but a text field keeps its copy and paste menu and a link its open in new tab
|
|
660
|
+
const target = /** @type {HTMLElement} */ (e.target);
|
|
661
|
+
if (inputPreventDefault && !isTextInput(target) && !target?.closest?.('a[href]'))
|
|
661
662
|
e.preventDefault();
|
|
662
663
|
}
|
|
663
664
|
function onBlur()
|
|
@@ -678,6 +679,10 @@ function inputInit()
|
|
|
678
679
|
// handle all touch events the same way
|
|
679
680
|
function handleTouch(e)
|
|
680
681
|
{
|
|
682
|
+
// fix stalled audio requiring user interaction, with touch input off too since the page's sound needs it
|
|
683
|
+
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
684
|
+
audioResume();
|
|
685
|
+
|
|
681
686
|
if (!touchInputEnable)
|
|
682
687
|
{
|
|
683
688
|
// turned off mid touch, the finger that drove the mouse lets go of it
|
|
@@ -688,10 +693,6 @@ function inputInit()
|
|
|
688
693
|
}
|
|
689
694
|
inputLastTouchTime = performance.now();
|
|
690
695
|
|
|
691
|
-
// fix stalled audio requiring user interaction
|
|
692
|
-
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
693
|
-
audioResume();
|
|
694
|
-
|
|
695
696
|
// when the touch gamepad is enabled it owns touch input: suppress the
|
|
696
697
|
// touch->mouse passthrough entirely unless touchGamepadPassthrough is set
|
|
697
698
|
// (its own zones drive gameplay via pointer events)
|