littlejsengine 1.23.0 → 1.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COPYRIGHT.txt +65 -38
- package/EDITOR.md +359 -0
- package/README.md +11 -6
- package/REFERENCE.md +2314 -0
- package/dist/littlejs.d.ts +10 -9
- package/dist/littlejs.esm.js +1405 -1234
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +1405 -1234
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +1339 -1228
- package/package.json +4 -3
- package/plugins/gltf.js +29 -13
- package/plugins/postProcess.js +5 -2
- package/plugins/render3d.js +44 -14
- package/plugins/render3dEditor.js +46 -16
- package/plugins/render3dLevel.js +28 -2
- package/plugins/textureSheet.js +1 -0
- package/plugins/tweenSystem.js +39 -34
- package/plugins/uiSystem.js +7 -3
- package/plugins/zzfxm.js +161 -159
- package/src/engine.js +1 -1
- package/src/engineAudio.js +7 -1
- package/src/engineBuild.mjs +17 -2
- package/src/engineDraw.js +11 -7
- package/src/engineEditor.js +52 -18
- package/src/engineObject.js +675 -675
- package/src/engineRelease.js +5 -1
- package/src/engineTileLayer.js +2 -1
- package/src/engineUtilities.js +291 -291
- package/src/engineWebGL.js +12 -3
package/dist/littlejs.esm.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.23.
|
|
38
|
+
const engineVersion = '1.23.1';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -3169,295 +3169,295 @@ const PURPLE = debugProtectConstant(rgb(.5,0,1));
|
|
|
3169
3169
|
* @type {Color}
|
|
3170
3170
|
* @memberof Math */
|
|
3171
3171
|
const MAGENTA = debugProtectConstant(rgb(1,0,1));
|
|
3172
|
-
/**
|
|
3173
|
-
* LittleJS Utility Classes and Functions
|
|
3174
|
-
* - Timer - tracks time automatically with support for pause and real-time modes
|
|
3175
|
-
* - Time formatting helper
|
|
3176
|
-
* - JSON file fetching
|
|
3177
|
-
* - File saving (text, canvas, data URLs)
|
|
3178
|
-
* - Native share dialog support
|
|
3179
|
-
* - Local storage save data management
|
|
3180
|
-
* - Value noise (1D and 2D)
|
|
3181
|
-
* @namespace Utilities
|
|
3182
|
-
*/
|
|
3183
|
-
|
|
3184
|
-
/**
|
|
3185
|
-
* Timer object tracks how long has passed since it was set
|
|
3186
|
-
* @memberof Engine
|
|
3187
|
-
* @example
|
|
3188
|
-
* let a = new Timer; // creates a timer that is not set
|
|
3189
|
-
* a.set(3); // sets the timer to 3 seconds
|
|
3190
|
-
*
|
|
3191
|
-
* let b = new Timer(1); // creates a timer with 1 second left
|
|
3192
|
-
* b.unset(); // unset the timer
|
|
3193
|
-
*/
|
|
3194
|
-
class Timer
|
|
3195
|
-
{
|
|
3196
|
-
/** Create a timer object set time passed in
|
|
3197
|
-
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
|
|
3198
|
-
* @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
|
|
3199
|
-
constructor(timeLeft, useRealTime=false)
|
|
3200
|
-
{
|
|
3201
|
-
ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
|
|
3202
|
-
this.useRealTime = useRealTime;
|
|
3203
|
-
const globalTime = this.getGlobalTime();
|
|
3204
|
-
/** @type {number|undefined} */
|
|
3205
|
-
this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
|
|
3206
|
-
/** @type {number|undefined} */
|
|
3207
|
-
this.setTime = timeLeft;
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
|
-
/** Set the timer with seconds passed in
|
|
3211
|
-
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
|
|
3212
|
-
set(timeLeft=0)
|
|
3213
|
-
{
|
|
3214
|
-
ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
|
|
3215
|
-
const globalTime = this.getGlobalTime();
|
|
3216
|
-
this.time = globalTime + timeLeft;
|
|
3217
|
-
this.setTime = timeLeft;
|
|
3218
|
-
}
|
|
3219
|
-
|
|
3220
|
-
/** Set if the timer should keep running even when the game is paused
|
|
3221
|
-
* @param {boolean} [useRealTime] */
|
|
3222
|
-
setUseRealTime(useRealTime=true)
|
|
3223
|
-
{
|
|
3224
|
-
ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
|
|
3225
|
-
this.useRealTime = useRealTime;
|
|
3226
|
-
}
|
|
3227
|
-
|
|
3228
|
-
/** Unset the timer */
|
|
3229
|
-
unset() { this.time = undefined; }
|
|
3230
|
-
|
|
3231
|
-
/** Returns true if set
|
|
3232
|
-
* @return {boolean} */
|
|
3233
|
-
isSet() { return this.time !== undefined; }
|
|
3234
|
-
|
|
3235
|
-
/** Returns true if set and has not elapsed
|
|
3236
|
-
* @return {boolean} */
|
|
3237
|
-
active() { return this.getGlobalTime() < this.time; }
|
|
3238
|
-
|
|
3239
|
-
/** Returns true if set and elapsed
|
|
3240
|
-
* @return {boolean} */
|
|
3241
|
-
elapsed() { return this.getGlobalTime() >= this.time; }
|
|
3242
|
-
|
|
3243
|
-
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
3244
|
-
* @return {number} */
|
|
3245
|
-
get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
|
|
3246
|
-
|
|
3247
|
-
/** Get percentage elapsed based on time it was set to, returns 0 if not set.
|
|
3248
|
-
* Zero-duration timers report 1 (already elapsed).
|
|
3249
|
-
* @return {number} */
|
|
3250
|
-
getPercent()
|
|
3251
|
-
{
|
|
3252
|
-
if (!this.isSet()) return 0;
|
|
3253
|
-
if (!this.setTime) return 1;
|
|
3254
|
-
return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
|
|
3255
|
-
}
|
|
3256
|
-
|
|
3257
|
-
/** Get the time this timer was set to, returns 0 if not set
|
|
3258
|
-
* @return {number} */
|
|
3259
|
-
getSetTime() { return this.isSet() ? this.setTime : 0; }
|
|
3260
|
-
|
|
3261
|
-
/** Get the current global time this timer is based on
|
|
3262
|
-
* @return {number} */
|
|
3263
|
-
getGlobalTime() { return this.useRealTime ? timeReal : time; }
|
|
3264
|
-
|
|
3265
|
-
/** Returns this timer expressed as a string
|
|
3266
|
-
* @return {string} */
|
|
3267
|
-
toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
|
|
3268
|
-
|
|
3269
|
-
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
3270
|
-
* @return {number} */
|
|
3271
|
-
valueOf() { return this.get(); }
|
|
3272
|
-
}
|
|
3273
|
-
|
|
3274
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
3275
|
-
|
|
3276
|
-
/** Formats seconds to mm:ss style for display purposes
|
|
3277
|
-
* @param {number} t - time in seconds
|
|
3278
|
-
* @return {string}
|
|
3279
|
-
* @memberof Utilities */
|
|
3280
|
-
function formatTime(t)
|
|
3281
|
-
{
|
|
3282
|
-
const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
|
|
3283
|
-
t = abs(t)|0;
|
|
3284
|
-
return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
|
|
3285
|
-
}
|
|
3286
|
-
|
|
3287
|
-
/** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
|
|
3288
|
-
* @param {string} url - URL of JSON file
|
|
3289
|
-
* @return {Promise<object>}
|
|
3290
|
-
* @memberof Utilities */
|
|
3291
|
-
async function fetchJSON(url)
|
|
3292
|
-
{
|
|
3293
|
-
const response = await fetch(url);
|
|
3294
|
-
if (!response.ok)
|
|
3295
|
-
throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
|
|
3296
|
-
const json = await response.json();
|
|
3297
|
-
editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
|
|
3298
|
-
return json;
|
|
3299
|
-
}
|
|
3300
|
-
|
|
3301
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
3302
|
-
|
|
3303
|
-
/** Save a text file to disk
|
|
3304
|
-
* @param {string} text
|
|
3305
|
-
* @param {string} [filename]
|
|
3306
|
-
* @param {string} [type]
|
|
3307
|
-
* @memberof Utilities */
|
|
3308
|
-
function saveText(text, filename='text', type='text/plain')
|
|
3309
|
-
{ saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
|
|
3310
|
-
|
|
3311
|
-
/** Create an offscreen canvas to draw into, and return its 2D context
|
|
3312
|
-
* - The canvas is context.canvas, which is what TextureInfo and the like take
|
|
3313
|
-
* @param {number} width - In pixels
|
|
3314
|
-
* @param {number} [height] - In pixels, defaults to the width for a square
|
|
3315
|
-
* @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
|
|
3316
|
-
* @return {OffscreenCanvasRenderingContext2D}
|
|
3317
|
-
* @memberof Utilities */
|
|
3318
|
-
function createCanvasContext(width, height=width, willReadFrequently=false)
|
|
3319
|
-
{
|
|
3320
|
-
ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
|
|
3321
|
-
return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
|
|
3322
|
-
}
|
|
3323
|
-
|
|
3324
|
-
/** Save a canvas to disk
|
|
3325
|
-
* @param {HTMLCanvasElement|OffscreenCanvas} canvas
|
|
3326
|
-
* @param {string} [filename]
|
|
3327
|
-
* @param {string} [type]
|
|
3328
|
-
* @memberof Utilities */
|
|
3329
|
-
function saveCanvas(canvas, filename='screenshot', type='image/png')
|
|
3330
|
-
{
|
|
3331
|
-
if (canvas instanceof OffscreenCanvas)
|
|
3332
|
-
{
|
|
3333
|
-
// copy to temporary canvas and save
|
|
3334
|
-
const saveCanvas = document.createElement('canvas');
|
|
3335
|
-
saveCanvas.width = canvas.width;
|
|
3336
|
-
saveCanvas.height = canvas.height;
|
|
3337
|
-
saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
|
|
3338
|
-
saveDataURL(saveCanvas.toDataURL(type), filename);
|
|
3339
|
-
}
|
|
3340
|
-
else
|
|
3341
|
-
saveDataURL(canvas.toDataURL(type), filename);
|
|
3342
|
-
}
|
|
3343
|
-
|
|
3344
|
-
/** Save a data url to disk
|
|
3345
|
-
* @param {string} url
|
|
3346
|
-
* @param {string} [filename]
|
|
3347
|
-
* @param {number} [revokeTime] - how long before revoking the url
|
|
3348
|
-
* @memberof Utilities */
|
|
3349
|
-
function saveDataURL(url, filename='download', revokeTime)
|
|
3350
|
-
{
|
|
3351
|
-
ASSERT(isStringLike(url), 'saveDataURL requires url string');
|
|
3352
|
-
ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
|
|
3353
|
-
|
|
3354
|
-
// create link for saving screenshots
|
|
3355
|
-
const link = document.createElement('a');
|
|
3356
|
-
link.download = filename;
|
|
3357
|
-
link.href = url;
|
|
3358
|
-
link.click();
|
|
3359
|
-
if (revokeTime !== undefined)
|
|
3360
|
-
setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
|
|
3361
|
-
}
|
|
3362
|
-
|
|
3363
|
-
/** Share content using the native share dialog if available
|
|
3364
|
-
* @param {string} title - title of the share
|
|
3365
|
-
* @param {string} url - url to share
|
|
3366
|
-
* @param {Function} [callback] - Called when share is complete
|
|
3367
|
-
* @memberof Utilities */
|
|
3368
|
-
function shareURL(title, url, callback)
|
|
3369
|
-
{
|
|
3370
|
-
ASSERT(isStringLike(title), 'shareURL requires title string');
|
|
3371
|
-
ASSERT(isStringLike(url), 'shareURL requires url string');
|
|
3372
|
-
navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
|
|
3373
|
-
}
|
|
3374
|
-
|
|
3375
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
3376
|
-
|
|
3377
|
-
/** Read save data from local storage
|
|
3378
|
-
* - The result has the type of defaultSaveData, or any when there is none
|
|
3379
|
-
* @template {Object<string, any>} [T=any]
|
|
3380
|
-
* @param {string} saveName - unique name for the game/save
|
|
3381
|
-
* @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
|
|
3382
|
-
* @return {T}
|
|
3383
|
-
* @memberof Utilities */
|
|
3384
|
-
function readSaveData(saveName, defaultSaveData)
|
|
3385
|
-
{
|
|
3386
|
-
ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
|
|
3387
|
-
ASSERT(defaultSaveData === undefined ||
|
|
3388
|
-
(typeof defaultSaveData === 'object' && defaultSaveData !== null),
|
|
3389
|
-
'readSaveData: default must be an object - the result is ' +
|
|
3390
|
-
'{...default, ...loaded}, so a scalar default yields {}. ' +
|
|
3391
|
-
'Use readSaveData(key, {best:0}).best');
|
|
3392
|
-
|
|
3393
|
-
// tolerate localStorage being unavailable (iOS private mode, sandboxed
|
|
3394
|
-
// iframes) and corrupt JSON in stored data
|
|
3395
|
-
let loadedData = {};
|
|
3396
|
-
try
|
|
3397
|
-
{
|
|
3398
|
-
const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
|
|
3399
|
-
if (data)
|
|
3400
|
-
{
|
|
3401
|
-
try { loadedData = JSON.parse(data); }
|
|
3402
|
-
catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
3403
|
-
}
|
|
3404
|
-
}
|
|
3405
|
-
catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
|
|
3406
|
-
return { .../** @type {object} */ (defaultSaveData), ...loadedData };
|
|
3407
|
-
}
|
|
3408
|
-
|
|
3409
|
-
/** Write save data to local storage
|
|
3410
|
-
* @param {string} saveName - unique name for the game/save
|
|
3411
|
-
* @param {object} saveData - object containing data to be saved
|
|
3412
|
-
* @memberof Utilities */
|
|
3413
|
-
function writeSaveData(saveName, saveData)
|
|
3414
|
-
{
|
|
3415
|
-
ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
|
|
3416
|
-
// tolerate localStorage being unavailable or quota exceeded
|
|
3417
|
-
try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
|
|
3418
|
-
catch { LOG('writeSaveData: failed to write', saveName); }
|
|
3419
|
-
}
|
|
3420
|
-
|
|
3421
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
3422
|
-
|
|
3423
|
-
// Deterministic well-distributed hash of an integer lattice index to [0, 1).
|
|
3424
|
-
// Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
|
|
3425
|
-
function noiseHash(i)
|
|
3426
|
-
{
|
|
3427
|
-
let h = (i | 0) ^ 0x9e3779b9;
|
|
3428
|
-
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
|
|
3429
|
-
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
3430
|
-
h ^= h >>> 16;
|
|
3431
|
-
return (h >>> 0) / 2**32;
|
|
3432
|
-
}
|
|
3433
|
-
|
|
3434
|
-
/** 1D value noise — returns a smooth value in [0, 1] for any real x.
|
|
3435
|
-
* Integer inputs land on deterministic lattice values; non-integer inputs
|
|
3436
|
-
* are interpolated with smoothStep for C1 continuity.
|
|
3437
|
-
* @param {number} x
|
|
3438
|
-
* @return {number}
|
|
3439
|
-
* @memberof Utilities */
|
|
3440
|
-
function noise1D(x)
|
|
3441
|
-
{
|
|
3442
|
-
const i = floor(x);
|
|
3443
|
-
return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
|
|
3444
|
-
}
|
|
3445
|
-
|
|
3446
|
-
/** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
|
|
3447
|
-
* @param {number} x
|
|
3448
|
-
* @param {number} y
|
|
3449
|
-
* @return {number}
|
|
3450
|
-
* @memberof Utilities */
|
|
3451
|
-
function noise2D(x, y)
|
|
3452
|
-
{
|
|
3453
|
-
const ix = floor(x), iy = floor(y);
|
|
3454
|
-
const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
|
|
3455
|
-
// large prime decorrelates neighboring rows
|
|
3456
|
-
const h = (a, b) => noiseHash(a + b * 374761393);
|
|
3457
|
-
return lerp(
|
|
3458
|
-
lerp(h(ix, iy ), h(ix + 1, iy ), fx),
|
|
3459
|
-
lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
|
|
3460
|
-
fy);
|
|
3172
|
+
/**
|
|
3173
|
+
* LittleJS Utility Classes and Functions
|
|
3174
|
+
* - Timer - tracks time automatically with support for pause and real-time modes
|
|
3175
|
+
* - Time formatting helper
|
|
3176
|
+
* - JSON file fetching
|
|
3177
|
+
* - File saving (text, canvas, data URLs)
|
|
3178
|
+
* - Native share dialog support
|
|
3179
|
+
* - Local storage save data management
|
|
3180
|
+
* - Value noise (1D and 2D)
|
|
3181
|
+
* @namespace Utilities
|
|
3182
|
+
*/
|
|
3183
|
+
|
|
3184
|
+
/**
|
|
3185
|
+
* Timer object tracks how long has passed since it was set
|
|
3186
|
+
* @memberof Engine
|
|
3187
|
+
* @example
|
|
3188
|
+
* let a = new Timer; // creates a timer that is not set
|
|
3189
|
+
* a.set(3); // sets the timer to 3 seconds
|
|
3190
|
+
*
|
|
3191
|
+
* let b = new Timer(1); // creates a timer with 1 second left
|
|
3192
|
+
* b.unset(); // unset the timer
|
|
3193
|
+
*/
|
|
3194
|
+
class Timer
|
|
3195
|
+
{
|
|
3196
|
+
/** Create a timer object set time passed in
|
|
3197
|
+
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
|
|
3198
|
+
* @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
|
|
3199
|
+
constructor(timeLeft, useRealTime=false)
|
|
3200
|
+
{
|
|
3201
|
+
ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
|
|
3202
|
+
this.useRealTime = useRealTime;
|
|
3203
|
+
const globalTime = this.getGlobalTime();
|
|
3204
|
+
/** @type {number|undefined} */
|
|
3205
|
+
this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
|
|
3206
|
+
/** @type {number|undefined} */
|
|
3207
|
+
this.setTime = timeLeft;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
/** Set the timer with seconds passed in
|
|
3211
|
+
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
|
|
3212
|
+
set(timeLeft=0)
|
|
3213
|
+
{
|
|
3214
|
+
ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
|
|
3215
|
+
const globalTime = this.getGlobalTime();
|
|
3216
|
+
this.time = globalTime + timeLeft;
|
|
3217
|
+
this.setTime = timeLeft;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
/** Set if the timer should keep running even when the game is paused
|
|
3221
|
+
* @param {boolean} [useRealTime] */
|
|
3222
|
+
setUseRealTime(useRealTime=true)
|
|
3223
|
+
{
|
|
3224
|
+
ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
|
|
3225
|
+
this.useRealTime = useRealTime;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
/** Unset the timer */
|
|
3229
|
+
unset() { this.time = undefined; }
|
|
3230
|
+
|
|
3231
|
+
/** Returns true if set
|
|
3232
|
+
* @return {boolean} */
|
|
3233
|
+
isSet() { return this.time !== undefined; }
|
|
3234
|
+
|
|
3235
|
+
/** Returns true if set and has not elapsed
|
|
3236
|
+
* @return {boolean} */
|
|
3237
|
+
active() { return this.getGlobalTime() < this.time; }
|
|
3238
|
+
|
|
3239
|
+
/** Returns true if set and elapsed
|
|
3240
|
+
* @return {boolean} */
|
|
3241
|
+
elapsed() { return this.getGlobalTime() >= this.time; }
|
|
3242
|
+
|
|
3243
|
+
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
3244
|
+
* @return {number} */
|
|
3245
|
+
get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
|
|
3246
|
+
|
|
3247
|
+
/** Get percentage elapsed based on time it was set to, returns 0 if not set.
|
|
3248
|
+
* Zero-duration timers report 1 (already elapsed).
|
|
3249
|
+
* @return {number} */
|
|
3250
|
+
getPercent()
|
|
3251
|
+
{
|
|
3252
|
+
if (!this.isSet()) return 0;
|
|
3253
|
+
if (!(this.setTime > 0)) return 1;
|
|
3254
|
+
return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
/** Get the time this timer was set to, returns 0 if not set
|
|
3258
|
+
* @return {number} */
|
|
3259
|
+
getSetTime() { return this.isSet() ? this.setTime : 0; }
|
|
3260
|
+
|
|
3261
|
+
/** Get the current global time this timer is based on
|
|
3262
|
+
* @return {number} */
|
|
3263
|
+
getGlobalTime() { return this.useRealTime ? timeReal : time; }
|
|
3264
|
+
|
|
3265
|
+
/** Returns this timer expressed as a string
|
|
3266
|
+
* @return {string} */
|
|
3267
|
+
toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
|
|
3268
|
+
|
|
3269
|
+
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
3270
|
+
* @return {number} */
|
|
3271
|
+
valueOf() { return this.get(); }
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3275
|
+
|
|
3276
|
+
/** Formats seconds to mm:ss style for display purposes
|
|
3277
|
+
* @param {number} t - time in seconds
|
|
3278
|
+
* @return {string}
|
|
3279
|
+
* @memberof Utilities */
|
|
3280
|
+
function formatTime(t)
|
|
3281
|
+
{
|
|
3282
|
+
const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
|
|
3283
|
+
t = abs(t)|0;
|
|
3284
|
+
return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
/** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
|
|
3288
|
+
* @param {string} url - URL of JSON file
|
|
3289
|
+
* @return {Promise<object>}
|
|
3290
|
+
* @memberof Utilities */
|
|
3291
|
+
async function fetchJSON(url)
|
|
3292
|
+
{
|
|
3293
|
+
const response = await fetch(url);
|
|
3294
|
+
if (!response.ok)
|
|
3295
|
+
throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
|
|
3296
|
+
const json = await response.json();
|
|
3297
|
+
editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
|
|
3298
|
+
return json;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3302
|
+
|
|
3303
|
+
/** Save a text file to disk
|
|
3304
|
+
* @param {string} text
|
|
3305
|
+
* @param {string} [filename]
|
|
3306
|
+
* @param {string} [type]
|
|
3307
|
+
* @memberof Utilities */
|
|
3308
|
+
function saveText(text, filename='text', type='text/plain')
|
|
3309
|
+
{ saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
|
|
3310
|
+
|
|
3311
|
+
/** Create an offscreen canvas to draw into, and return its 2D context
|
|
3312
|
+
* - The canvas is context.canvas, which is what TextureInfo and the like take
|
|
3313
|
+
* @param {number} width - In pixels
|
|
3314
|
+
* @param {number} [height] - In pixels, defaults to the width for a square
|
|
3315
|
+
* @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
|
|
3316
|
+
* @return {OffscreenCanvasRenderingContext2D}
|
|
3317
|
+
* @memberof Utilities */
|
|
3318
|
+
function createCanvasContext(width, height=width, willReadFrequently=false)
|
|
3319
|
+
{
|
|
3320
|
+
ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
|
|
3321
|
+
return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
/** Save a canvas to disk
|
|
3325
|
+
* @param {HTMLCanvasElement|OffscreenCanvas} canvas
|
|
3326
|
+
* @param {string} [filename]
|
|
3327
|
+
* @param {string} [type]
|
|
3328
|
+
* @memberof Utilities */
|
|
3329
|
+
function saveCanvas(canvas, filename='screenshot', type='image/png')
|
|
3330
|
+
{
|
|
3331
|
+
if (canvas instanceof OffscreenCanvas)
|
|
3332
|
+
{
|
|
3333
|
+
// copy to temporary canvas and save
|
|
3334
|
+
const saveCanvas = document.createElement('canvas');
|
|
3335
|
+
saveCanvas.width = canvas.width;
|
|
3336
|
+
saveCanvas.height = canvas.height;
|
|
3337
|
+
saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
|
|
3338
|
+
saveDataURL(saveCanvas.toDataURL(type), filename);
|
|
3339
|
+
}
|
|
3340
|
+
else
|
|
3341
|
+
saveDataURL(canvas.toDataURL(type), filename);
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
/** Save a data url to disk
|
|
3345
|
+
* @param {string} url
|
|
3346
|
+
* @param {string} [filename]
|
|
3347
|
+
* @param {number} [revokeTime] - how long before revoking the url
|
|
3348
|
+
* @memberof Utilities */
|
|
3349
|
+
function saveDataURL(url, filename='download', revokeTime)
|
|
3350
|
+
{
|
|
3351
|
+
ASSERT(isStringLike(url), 'saveDataURL requires url string');
|
|
3352
|
+
ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
|
|
3353
|
+
|
|
3354
|
+
// create link for saving screenshots
|
|
3355
|
+
const link = document.createElement('a');
|
|
3356
|
+
link.download = filename;
|
|
3357
|
+
link.href = url;
|
|
3358
|
+
link.click();
|
|
3359
|
+
if (revokeTime !== undefined)
|
|
3360
|
+
setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
/** Share content using the native share dialog if available
|
|
3364
|
+
* @param {string} title - title of the share
|
|
3365
|
+
* @param {string} url - url to share
|
|
3366
|
+
* @param {Function} [callback] - Called when share is complete
|
|
3367
|
+
* @memberof Utilities */
|
|
3368
|
+
function shareURL(title, url, callback)
|
|
3369
|
+
{
|
|
3370
|
+
ASSERT(isStringLike(title), 'shareURL requires title string');
|
|
3371
|
+
ASSERT(isStringLike(url), 'shareURL requires url string');
|
|
3372
|
+
navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3376
|
+
|
|
3377
|
+
/** Read save data from local storage
|
|
3378
|
+
* - The result has the type of defaultSaveData, or any when there is none
|
|
3379
|
+
* @template {Object<string, any>} [T=any]
|
|
3380
|
+
* @param {string} saveName - unique name for the game/save
|
|
3381
|
+
* @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
|
|
3382
|
+
* @return {T}
|
|
3383
|
+
* @memberof Utilities */
|
|
3384
|
+
function readSaveData(saveName, defaultSaveData)
|
|
3385
|
+
{
|
|
3386
|
+
ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
|
|
3387
|
+
ASSERT(defaultSaveData === undefined ||
|
|
3388
|
+
(typeof defaultSaveData === 'object' && defaultSaveData !== null),
|
|
3389
|
+
'readSaveData: default must be an object - the result is ' +
|
|
3390
|
+
'{...default, ...loaded}, so a scalar default yields {}. ' +
|
|
3391
|
+
'Use readSaveData(key, {best:0}).best');
|
|
3392
|
+
|
|
3393
|
+
// tolerate localStorage being unavailable (iOS private mode, sandboxed
|
|
3394
|
+
// iframes) and corrupt JSON in stored data
|
|
3395
|
+
let loadedData = {};
|
|
3396
|
+
try
|
|
3397
|
+
{
|
|
3398
|
+
const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
|
|
3399
|
+
if (data)
|
|
3400
|
+
{
|
|
3401
|
+
try { loadedData = JSON.parse(data); }
|
|
3402
|
+
catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
|
|
3406
|
+
return { .../** @type {object} */ (defaultSaveData), ...loadedData };
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
/** Write save data to local storage
|
|
3410
|
+
* @param {string} saveName - unique name for the game/save
|
|
3411
|
+
* @param {object} saveData - object containing data to be saved
|
|
3412
|
+
* @memberof Utilities */
|
|
3413
|
+
function writeSaveData(saveName, saveData)
|
|
3414
|
+
{
|
|
3415
|
+
ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
|
|
3416
|
+
// tolerate localStorage being unavailable or quota exceeded
|
|
3417
|
+
try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
|
|
3418
|
+
catch { LOG('writeSaveData: failed to write', saveName); }
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3422
|
+
|
|
3423
|
+
// Deterministic well-distributed hash of an integer lattice index to [0, 1).
|
|
3424
|
+
// Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
|
|
3425
|
+
function noiseHash(i)
|
|
3426
|
+
{
|
|
3427
|
+
let h = (i | 0) ^ 0x9e3779b9;
|
|
3428
|
+
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
|
|
3429
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
3430
|
+
h ^= h >>> 16;
|
|
3431
|
+
return (h >>> 0) / 2**32;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
/** 1D value noise — returns a smooth value in [0, 1] for any real x.
|
|
3435
|
+
* Integer inputs land on deterministic lattice values; non-integer inputs
|
|
3436
|
+
* are interpolated with smoothStep for C1 continuity.
|
|
3437
|
+
* @param {number} x
|
|
3438
|
+
* @return {number}
|
|
3439
|
+
* @memberof Utilities */
|
|
3440
|
+
function noise1D(x)
|
|
3441
|
+
{
|
|
3442
|
+
const i = floor(x);
|
|
3443
|
+
return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
/** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
|
|
3447
|
+
* @param {number} x
|
|
3448
|
+
* @param {number} y
|
|
3449
|
+
* @return {number}
|
|
3450
|
+
* @memberof Utilities */
|
|
3451
|
+
function noise2D(x, y)
|
|
3452
|
+
{
|
|
3453
|
+
const ix = floor(x), iy = floor(y);
|
|
3454
|
+
const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
|
|
3455
|
+
// large prime decorrelates neighboring rows
|
|
3456
|
+
const h = (a, b) => noiseHash(a + b * 374761393);
|
|
3457
|
+
return lerp(
|
|
3458
|
+
lerp(h(ix, iy ), h(ix + 1, iy ), fx),
|
|
3459
|
+
lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
|
|
3460
|
+
fy);
|
|
3461
3461
|
}
|
|
3462
3462
|
/**
|
|
3463
3463
|
* LittleJS Engine Settings
|
|
@@ -4317,679 +4317,679 @@ function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; }
|
|
|
4317
4317
|
* @param {boolean} [show]
|
|
4318
4318
|
* @memberof Debug */
|
|
4319
4319
|
function setDebugTweakables(show=true) { debugTweakables = show; }
|
|
4320
|
-
/**
|
|
4321
|
-
* LittleJS Object System
|
|
4322
|
-
* - EngineObject is the base class for all game objects
|
|
4323
|
-
* - Handles automatic updating, rendering, physics, and collision
|
|
4324
|
-
* - Supports parent-child hierarchies with transform inheritance
|
|
4325
|
-
* - 2D physics with velocity, acceleration, damping, and gravity
|
|
4326
|
-
* - Collision system with tiles and other objects
|
|
4327
|
-
* - Renders sprites from tile sheets with color and rotation
|
|
4328
|
-
* - Objects sorted by renderOrder for layered rendering
|
|
4329
|
-
*/
|
|
4330
|
-
|
|
4331
|
-
/**
|
|
4332
|
-
* LittleJS Object Base Object Class
|
|
4333
|
-
* - Top level object class used by the engine
|
|
4334
|
-
* - Automatically adds self to object list
|
|
4335
|
-
* - Will be updated and rendered each frame
|
|
4336
|
-
* - Renders as a sprite from a tilesheet by default
|
|
4337
|
-
* - Can have color and additive color applied
|
|
4338
|
-
* - 2D Physics and collision system
|
|
4339
|
-
* - Sorted by renderOrder
|
|
4340
|
-
* - Objects can have children attached
|
|
4341
|
-
* - Parents are updated before children, and set child transform
|
|
4342
|
-
* - Call destroy() to get rid of objects
|
|
4343
|
-
*
|
|
4344
|
-
* The physics system used by objects is simple and fast with some caveats...
|
|
4345
|
-
* - Collision uses the axis aligned size, the object's rotation angle is only for rendering
|
|
4346
|
-
* - Objects are guaranteed to not intersect tile collision from physics
|
|
4347
|
-
* - If an object starts or is moved inside tile collision, it will not collide with that tile
|
|
4348
|
-
* - Collision for objects can be set to be solid to block other objects
|
|
4349
|
-
* - Objects may get pushed into overlapping other solid objects, if so they will push away
|
|
4350
|
-
* - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
|
|
4351
|
-
* - Solid objects are more performance intensive and should be used sparingly
|
|
4352
|
-
* @memberof Engine
|
|
4353
|
-
* @example
|
|
4354
|
-
* // create an engine object, normally you would first extend the class with your own
|
|
4355
|
-
* const pos = vec2(2,3);
|
|
4356
|
-
* const object = new EngineObject(pos);
|
|
4357
|
-
*/
|
|
4358
|
-
class EngineObject
|
|
4359
|
-
{
|
|
4360
|
-
/** Create an engine object and adds it to the list of objects
|
|
4361
|
-
* @param {Vector2} [pos=vec2()] - World space position of the object
|
|
4362
|
-
* @param {Vector2} [size=vec2(1)] - World space size of the object
|
|
4363
|
-
* @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
|
|
4364
|
-
* @param {number} [angle] - Angle the object is rotated by
|
|
4365
|
-
* @param {Color} [color=WHITE] - Color to apply to tile when rendered
|
|
4366
|
-
* @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
|
|
4367
|
-
*/
|
|
4368
|
-
constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
|
|
4369
|
-
{
|
|
4370
|
-
// check passed in params
|
|
4371
|
-
ASSERT(isVector2(pos), 'object pos must be a vec2');
|
|
4372
|
-
ASSERT(isVector2(size), 'object size must be a vec2');
|
|
4373
|
-
ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
|
|
4374
|
-
ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
|
|
4375
|
-
ASSERT(isColor(color), 'object color should be a valid rgba color');
|
|
4376
|
-
ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
|
|
4377
|
-
|
|
4378
|
-
/** @property {Vector2} - World space position of the object */
|
|
4379
|
-
this.pos = pos.copy();
|
|
4380
|
-
/** @property {Vector2} - World space width and height of the object */
|
|
4381
|
-
this.size = size.copy();
|
|
4382
|
-
/** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
|
|
4383
|
-
* @type {Vector2|undefined} */
|
|
4384
|
-
this.drawSize = undefined;
|
|
4385
|
-
/** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
|
|
4386
|
-
* @type {TileInfo|undefined} */
|
|
4387
|
-
this.tileInfo = tileInfo;
|
|
4388
|
-
/** @property {number} - Angle to rotate the object */
|
|
4389
|
-
this.angle = angle;
|
|
4390
|
-
/** @property {Color} - Color to apply when rendered */
|
|
4391
|
-
this.color = color.copy();
|
|
4392
|
-
/** @property {Color|undefined} - Additive color to apply when rendered
|
|
4393
|
-
* @type {Color|undefined} */
|
|
4394
|
-
this.additiveColor = undefined;
|
|
4395
|
-
/** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
|
|
4396
|
-
* @type {Shader|undefined} */
|
|
4397
|
-
this.shader = undefined;
|
|
4398
|
-
/** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
|
|
4399
|
-
this.castShadow = true;
|
|
4400
|
-
/** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
|
|
4401
|
-
* brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
|
|
4402
|
-
* renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
|
|
4403
|
-
* too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
|
|
4404
|
-
this.emissive = 0;
|
|
4405
|
-
/** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
|
|
4406
|
-
this.mirror = false;
|
|
4407
|
-
/** @property {boolean} - Has object been destroyed? */
|
|
4408
|
-
this.destroyed = false;
|
|
4409
|
-
this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
|
|
4410
|
-
|
|
4411
|
-
// physical properties
|
|
4412
|
-
/** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
|
|
4413
|
-
* collide on its own, the moving ones collide with it */
|
|
4414
|
-
this.mass = objectDefaultMass;
|
|
4415
|
-
/** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
4416
|
-
this.damping = objectDefaultDamping;
|
|
4417
|
-
/** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
4418
|
-
this.angleDamping = objectDefaultAngleDamping;
|
|
4419
|
-
/** @property {number} - How bouncy the object is when colliding (0-1) */
|
|
4420
|
-
this.restitution = objectDefaultRestitution;
|
|
4421
|
-
/** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
|
|
4422
|
-
* once, the more slippery of the object and its ground is used */
|
|
4423
|
-
this.friction = objectDefaultFriction;
|
|
4424
|
-
/** @property {number} - How much to scale gravity by for this object */
|
|
4425
|
-
this.gravityScale = 1;
|
|
4426
|
-
/** @property {number} - Objects are sorted by render order */
|
|
4427
|
-
this.renderOrder = renderOrder;
|
|
4428
|
-
/** @property {Vector2} - Velocity of the object, in world units per frame */
|
|
4429
|
-
this.velocity = vec2();
|
|
4430
|
-
/** @property {number} - Angular velocity of the object, in radians per frame */
|
|
4431
|
-
this.angleVelocity = 0;
|
|
4432
|
-
/** @property {number} - Track when object was created */
|
|
4433
|
-
this.spawnTime = time;
|
|
4434
|
-
/** @property {Array<EngineObject>} - List of children of this object
|
|
4435
|
-
* @type {Array<EngineObject>} */
|
|
4436
|
-
this.children = [];
|
|
4437
|
-
/** @property {boolean} - Limit object speed along x and y axis */
|
|
4438
|
-
this.clampSpeed = true;
|
|
4439
|
-
/** @property {EngineObject|undefined} - Object we are standing on, if any
|
|
4440
|
-
* @type {EngineObject|undefined} */
|
|
4441
|
-
this.groundObject = undefined;
|
|
4442
|
-
|
|
4443
|
-
// parent child system
|
|
4444
|
-
/** @property {EngineObject|undefined} - Parent of object if in local space
|
|
4445
|
-
* @type {EngineObject|undefined} */
|
|
4446
|
-
this.parent = undefined;
|
|
4447
|
-
/** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
|
|
4448
|
-
* @type {Vector2|undefined} */
|
|
4449
|
-
this.localPos = undefined;
|
|
4450
|
-
/** @property {number} - Local angle if child */
|
|
4451
|
-
this.localAngle = 0;
|
|
4452
|
-
|
|
4453
|
-
// collision flags
|
|
4454
|
-
/** @property {boolean} - Object collides with the level, its tile collision layers
|
|
4455
|
-
* @type {boolean} */
|
|
4456
|
-
this.collideLevel = false;
|
|
4457
|
-
/** @property {boolean} - Object collides with solid objects */
|
|
4458
|
-
this.collideSolidObjects = false;
|
|
4459
|
-
/** @property {boolean} - Object collides with and blocks other objects */
|
|
4460
|
-
this.isSolid = false;
|
|
4461
|
-
/** @property {boolean} - Object collides with raycasts */
|
|
4462
|
-
this.collideRaycast = false;
|
|
4463
|
-
|
|
4464
|
-
/** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
|
|
4465
|
-
* - Calling destroy on it still destroys it, and its children go with it either way */
|
|
4466
|
-
this.persistent = false;
|
|
4467
|
-
|
|
4468
|
-
// add to list of objects
|
|
4469
|
-
engineObjects.push(this);
|
|
4470
|
-
}
|
|
4471
|
-
|
|
4472
|
-
/** Update the object transform, called automatically by engine even when paused
|
|
4473
|
-
* @param {boolean} [updateChildren] - Also update the children's transforms */
|
|
4474
|
-
updateTransforms(updateChildren=true)
|
|
4475
|
-
{
|
|
4476
|
-
const parent = this.parent;
|
|
4477
|
-
if (parent)
|
|
4478
|
-
{
|
|
4479
|
-
// compose with parent transform inline to avoid intermediate vector allocs
|
|
4480
|
-
const mirror = parent.getMirrorSign();
|
|
4481
|
-
const lp = this.localPos, pp = parent.pos;
|
|
4482
|
-
const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
|
|
4483
|
-
if (pa)
|
|
4484
|
-
{
|
|
4485
|
-
const c = cos(-pa), s = sin(-pa);
|
|
4486
|
-
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
4487
|
-
}
|
|
4488
|
-
else
|
|
4489
|
-
this.pos.set(lx + pp.x, ly + pp.y);
|
|
4490
|
-
this.angle = mirror*this.localAngle + pa;
|
|
4491
|
-
}
|
|
4492
|
-
|
|
4493
|
-
// update children
|
|
4494
|
-
if (updateChildren)
|
|
4495
|
-
for (const child of this.children)
|
|
4496
|
-
child.updateTransforms();
|
|
4497
|
-
}
|
|
4498
|
-
|
|
4499
|
-
/** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
|
|
4500
|
-
updatePhysics()
|
|
4501
|
-
{
|
|
4502
|
-
// child objects do not have physics
|
|
4503
|
-
ASSERT(!this.parent);
|
|
4504
|
-
|
|
4505
|
-
// bail if a collision callback destroyed us mid-frame
|
|
4506
|
-
if (this.destroyed) return;
|
|
4507
|
-
|
|
4508
|
-
// limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
|
|
4509
|
-
// has a mass, which tile collision needs; anything else moves as fast as it is told
|
|
4510
|
-
if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
|
|
4511
|
-
{
|
|
4512
|
-
this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
|
|
4513
|
-
this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
|
|
4514
|
-
}
|
|
4515
|
-
|
|
4516
|
-
// physics sanity checks
|
|
4517
|
-
ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
|
|
4518
|
-
ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
|
|
4519
|
-
|
|
4520
|
-
// apply physics; only the solver needs where the object was, so only then is it copied
|
|
4521
|
-
const solve = enablePhysicsSolver && this.mass;
|
|
4522
|
-
const oldPos = solve ? this.pos.copy() : undefined;
|
|
4523
|
-
this.velocity.x *= this.damping;
|
|
4524
|
-
this.velocity.y *= this.damping;
|
|
4525
|
-
if (this.mass)
|
|
4526
|
-
{
|
|
4527
|
-
// apply gravity only if it has mass
|
|
4528
|
-
this.velocity.x += gravity.x * this.gravityScale;
|
|
4529
|
-
this.velocity.y += gravity.y * this.gravityScale;
|
|
4530
|
-
}
|
|
4531
|
-
this.pos.x += this.velocity.x;
|
|
4532
|
-
this.pos.y += this.velocity.y;
|
|
4533
|
-
this.angle += this.angleVelocity *= this.angleDamping;
|
|
4534
|
-
|
|
4535
|
-
// don't do collision for static objects or if solver disabled
|
|
4536
|
-
if (!solve) return;
|
|
4537
|
-
|
|
4538
|
-
// which way is down for this object, a negative gravityScale falls up and lands on ceilings
|
|
4539
|
-
const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
|
|
4540
|
-
const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
|
|
4541
|
-
if (this.groundObject)
|
|
4542
|
-
{
|
|
4543
|
-
// apply friction in local space of ground object
|
|
4544
|
-
const friction = max(this.friction, this.groundObject.friction);
|
|
4545
|
-
const groundSpeed = this.groundObject.velocity.x;
|
|
4546
|
-
this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
|
|
4547
|
-
this.groundObject = undefined;
|
|
4548
|
-
}
|
|
4549
|
-
|
|
4550
|
-
// an object with no width or height has no box to push out of, or to be pushed out of
|
|
4551
|
-
if (this.collideSolidObjects && this.size.x && this.size.y)
|
|
4552
|
-
{
|
|
4553
|
-
// check collisions against solid objects
|
|
4554
|
-
const epsilon = .001; // necessary to push slightly outside of the collision
|
|
4555
|
-
for (const o of engineObjectsCollideStaticLast)
|
|
4556
|
-
{
|
|
4557
|
-
// skip destroyed, child objects, self collision, or objects with no box
|
|
4558
|
-
if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
|
|
4559
|
-
|
|
4560
|
-
// non solid objects don't collide with each other
|
|
4561
|
-
if (!this.isSolid && !o.isSolid) continue;
|
|
4562
|
-
|
|
4563
|
-
// check collision
|
|
4564
|
-
if (!this.isOverlappingObject(o)) continue;
|
|
4565
|
-
|
|
4566
|
-
// each moving object checks its own contacts, so a pair the other one already asked about this frame
|
|
4567
|
-
// is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
|
|
4568
|
-
// to resolve, pushed back together since, is resolved again
|
|
4569
|
-
const answer = engineObjectsCollidePairAnswer(o, this);
|
|
4570
|
-
if (answer === false) continue;
|
|
4571
|
-
if (!answer)
|
|
4572
|
-
{
|
|
4573
|
-
// notify objects of collision and check if should be resolved
|
|
4574
|
-
const collide1 = this.collideWithObject(o);
|
|
4575
|
-
const collide2 = o.collideWithObject(this);
|
|
4576
|
-
if (!collide1 || !collide2)
|
|
4577
|
-
{
|
|
4578
|
-
engineObjectsCollidePairAdd(this, o);
|
|
4579
|
-
continue;
|
|
4580
|
-
}
|
|
4581
|
-
}
|
|
4582
|
-
|
|
4583
|
-
const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
|
|
4584
|
-
if (wasOverlapping && (!o.mass || o.groundObject))
|
|
4585
|
-
{
|
|
4586
|
-
// a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
|
|
4587
|
-
// at once and carries it along, it would only drift out slowly and the solid would pass through;
|
|
4588
|
-
// an object standing on something counts as fixed too, so a stack on an elevator rides together;
|
|
4589
|
-
// it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
|
|
4590
|
-
const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
|
|
4591
|
-
if (push)
|
|
4592
|
-
{
|
|
4593
|
-
this.pos.x += push.x + sign(push.x) * epsilon;
|
|
4594
|
-
this.pos.y += push.y + sign(push.y) * epsilon;
|
|
4595
|
-
const restitution = max(this.restitution, o.restitution);
|
|
4596
|
-
if (push.x)
|
|
4597
|
-
{
|
|
4598
|
-
const v = this.velocity.x - o.velocity.x;
|
|
4599
|
-
if (v * push.x < 0) // moving into it
|
|
4600
|
-
this.velocity.x = o.velocity.x - v * restitution;
|
|
4601
|
-
}
|
|
4602
|
-
else
|
|
4603
|
-
{
|
|
4604
|
-
const v = this.velocity.y - o.velocity.y;
|
|
4605
|
-
if (v * push.y < 0) // moving into it
|
|
4606
|
-
this.velocity.y = o.velocity.y - v * restitution;
|
|
4607
|
-
if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
|
|
4608
|
-
this.groundObject = o;
|
|
4609
|
-
}
|
|
4610
|
-
}
|
|
4611
|
-
engineObjectsCollidePairAdd(this, o);
|
|
4612
|
-
|
|
4613
|
-
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
4614
|
-
continue;
|
|
4615
|
-
}
|
|
4616
|
-
if (wasOverlapping)
|
|
4617
|
-
{
|
|
4618
|
-
// if already was touching, try to push away
|
|
4619
|
-
const deltaPos = oldPos.subtract(o.pos);
|
|
4620
|
-
const length = deltaPos.length();
|
|
4621
|
-
const pushAwayAccel = .001;
|
|
4622
|
-
const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
|
|
4623
|
-
this.velocity = this.velocity.add(velocity);
|
|
4624
|
-
if (o.mass) // push away other object if not fixed
|
|
4625
|
-
o.velocity = o.velocity.subtract(velocity);
|
|
4626
|
-
engineObjectsCollidePairAdd(this, o);
|
|
4627
|
-
|
|
4628
|
-
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
4629
|
-
continue;
|
|
4630
|
-
}
|
|
4631
|
-
|
|
4632
|
-
// check for collision
|
|
4633
|
-
const sizeBoth = this.size.add(o.size);
|
|
4634
|
-
// prefer to push up if small delta, up away from this object's own gravity
|
|
4635
|
-
const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
|
|
4636
|
-
const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
|
|
4637
|
-
const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
|
|
4638
|
-
const restitution = max(this.restitution, o.restitution);
|
|
4639
|
-
|
|
4640
|
-
if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
|
|
4641
|
-
{
|
|
4642
|
-
// push outside object collision
|
|
4643
|
-
this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
|
|
4644
|
-
if ((o.groundObject && wasFalling) || !o.mass)
|
|
4645
|
-
{
|
|
4646
|
-
// set ground object if landed on something
|
|
4647
|
-
if (wasFalling)
|
|
4648
|
-
this.groundObject = o;
|
|
4649
|
-
|
|
4650
|
-
// bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
|
|
4651
|
-
// platform moving down instead of landing on it again every few frames
|
|
4652
|
-
this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
|
|
4653
|
-
}
|
|
4654
|
-
else // o has mass here, the other branch already handled the massless case
|
|
4655
|
-
{
|
|
4656
|
-
// inelastic collision
|
|
4657
|
-
const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
|
|
4658
|
-
|
|
4659
|
-
// elastic collision
|
|
4660
|
-
const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
|
|
4661
|
-
+ o.velocity.y * 2 * o.mass / (this.mass + o.mass);
|
|
4662
|
-
const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
|
|
4663
|
-
+ this.velocity.y * 2 * this.mass / (this.mass + o.mass);
|
|
4664
|
-
|
|
4665
|
-
// lerp between elastic or inelastic based on restitution
|
|
4666
|
-
this.velocity.y = lerp(inelastic, elastic0, restitution);
|
|
4667
|
-
o.velocity.y = lerp(inelastic, elastic1, restitution);
|
|
4668
|
-
}
|
|
4669
|
-
}
|
|
4670
|
-
if (!smallStepUp && isBlockedX) // resolve x collision
|
|
4671
|
-
{
|
|
4672
|
-
// push outside collision
|
|
4673
|
-
this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
|
|
4674
|
-
if (o.mass)
|
|
4675
|
-
{
|
|
4676
|
-
// inelastic collision
|
|
4677
|
-
const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
|
|
4678
|
-
|
|
4679
|
-
// elastic collision
|
|
4680
|
-
const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
|
|
4681
|
-
+ o.velocity.x * 2 * o.mass / (this.mass + o.mass);
|
|
4682
|
-
const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
|
|
4683
|
-
+ this.velocity.x * 2 * this.mass / (this.mass + o.mass);
|
|
4684
|
-
|
|
4685
|
-
// lerp between elastic or inelastic based on restitution
|
|
4686
|
-
this.velocity.x = lerp(inelastic, elastic0, restitution);
|
|
4687
|
-
o.velocity.x = lerp(inelastic, elastic1, restitution);
|
|
4688
|
-
}
|
|
4689
|
-
else // bounce if other object is fixed, relative to it as a landing is
|
|
4690
|
-
this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
|
|
4691
|
-
}
|
|
4692
|
-
engineObjectsCollidePairAdd(this, o, true);
|
|
4693
|
-
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
|
|
4694
|
-
}
|
|
4695
|
-
}
|
|
4696
|
-
if (this.collideLevel)
|
|
4697
|
-
{
|
|
4698
|
-
// check collision against tiles
|
|
4699
|
-
const hitLayer = tileCollisionTest(this.pos, this.size, this);
|
|
4700
|
-
if (hitLayer)
|
|
4701
|
-
{
|
|
4702
|
-
// if already was stuck in collision, don't do anything
|
|
4703
|
-
// this should not happen unless something starts in collision
|
|
4704
|
-
if (!tileCollisionTest(oldPos, this.size, this))
|
|
4705
|
-
{
|
|
4706
|
-
// test which side we bounced off (or both if a corner)
|
|
4707
|
-
const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
|
|
4708
|
-
const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
|
|
4709
|
-
const restitution = max(this.restitution, hitLayer.restitution);
|
|
4710
|
-
if (isBlockedX)
|
|
4711
|
-
{
|
|
4712
|
-
// a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
|
|
4713
|
-
// down off a ceiling ledge when gravity points up; zero gravity counts as down
|
|
4714
|
-
const epsilon = 1e-3;
|
|
4715
|
-
const maxMove = .1;
|
|
4716
|
-
const y = gravityY > 0 ?
|
|
4717
|
-
ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
|
|
4718
|
-
floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
|
|
4719
|
-
if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
|
|
4720
|
-
{
|
|
4721
|
-
this.pos.y = y;
|
|
4722
|
-
debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
|
|
4723
|
-
return;
|
|
4724
|
-
}
|
|
4725
|
-
|
|
4726
|
-
// move to previous X position and bounce
|
|
4727
|
-
this.pos.x = oldPos.x;
|
|
4728
|
-
this.velocity.x *= -restitution;
|
|
4729
|
-
}
|
|
4730
|
-
if (isBlockedY || !isBlockedX)
|
|
4731
|
-
{
|
|
4732
|
-
if (wasFalling)
|
|
4733
|
-
{
|
|
4734
|
-
// adjust position to slightly away from nearest tile
|
|
4735
|
-
// this prevents gap between object and ground
|
|
4736
|
-
const epsilon = .0001;
|
|
4737
|
-
const offset = this.size.y/2 + epsilon;
|
|
4738
|
-
// rounded in the layer's space as its collision test is, or a bottom a hair below a
|
|
4739
|
-
// grid line would round to the row under it, inside the floor, and fall through
|
|
4740
|
-
const layerY = hitLayer.pos.y;
|
|
4741
|
-
this.pos.y = layerY + (gravityY < 0 ?
|
|
4742
|
-
floor(oldPos.y - layerY - this.size.y/2) + offset :
|
|
4743
|
-
ceil( oldPos.y - layerY + this.size.y/2) - offset);
|
|
4744
|
-
|
|
4745
|
-
// set ground object for tile collision
|
|
4746
|
-
this.groundObject = hitLayer;
|
|
4747
|
-
}
|
|
4748
|
-
else
|
|
4749
|
-
{
|
|
4750
|
-
// move to previous Y position
|
|
4751
|
-
this.pos.y = oldPos.y;
|
|
4752
|
-
this.groundObject = undefined;
|
|
4753
|
-
}
|
|
4754
|
-
// bounce velocity
|
|
4755
|
-
this.velocity.y *= -restitution;
|
|
4756
|
-
}
|
|
4757
|
-
debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
|
|
4758
|
-
}
|
|
4759
|
-
}
|
|
4760
|
-
}
|
|
4761
|
-
}
|
|
4762
|
-
|
|
4763
|
-
/** Update the object, called automatically by engine once each frame. Does nothing by default. */
|
|
4764
|
-
update() {}
|
|
4765
|
-
|
|
4766
|
-
/** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
|
|
4767
|
-
render()
|
|
4768
|
-
{
|
|
4769
|
-
// default object render
|
|
4770
|
-
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
|
|
4771
|
-
}
|
|
4772
|
-
|
|
4773
|
-
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
4774
|
-
renderLight() {}
|
|
4775
|
-
|
|
4776
|
-
/** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
|
|
4777
|
-
* Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
|
|
4778
|
-
* screen space WebGL draws in render() are skipped during the pass */
|
|
4779
|
-
renderShadow() { this.render(); }
|
|
4780
|
-
|
|
4781
|
-
/** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
|
|
4782
|
-
* 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
|
|
4783
|
-
* glows; override to glow a part, like a robot's eyes */
|
|
4784
|
-
renderEmissive() { this.render(); }
|
|
4785
|
-
|
|
4786
|
-
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
4787
|
-
* @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
|
|
4788
|
-
destroy(immediate=false)
|
|
4789
|
-
{
|
|
4790
|
-
if (this.destroyed) return;
|
|
4791
|
-
|
|
4792
|
-
// disconnect from parent and destroy children
|
|
4793
|
-
this.destroyed = true;
|
|
4794
|
-
this.parent?.removeChild(this);
|
|
4795
|
-
for (const child of this.children)
|
|
4796
|
-
{
|
|
4797
|
-
child.parent = undefined;
|
|
4798
|
-
child.destroy(immediate);
|
|
4799
|
-
}
|
|
4800
|
-
}
|
|
4801
|
-
|
|
4802
|
-
/** Convert from local space to world space
|
|
4803
|
-
* @param {Vector2} pos - local space point */
|
|
4804
|
-
localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
|
|
4805
|
-
|
|
4806
|
-
/** Convert from world space to local space
|
|
4807
|
-
* @param {Vector2} pos - world space point */
|
|
4808
|
-
worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
|
|
4809
|
-
|
|
4810
|
-
/** Convert from local space to world space for a vector (rotation only)
|
|
4811
|
-
* @param {Vector2} vec - local space vector */
|
|
4812
|
-
localToWorldVector(vec) { return vec.rotate(this.angle); }
|
|
4813
|
-
|
|
4814
|
-
/** Convert from world space to local space for a vector (rotation only)
|
|
4815
|
-
* @param {Vector2} vec - world space vector */
|
|
4816
|
-
worldToLocalVector(vec) { return vec.rotate(-this.angle); }
|
|
4817
|
-
|
|
4818
|
-
/** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
4819
|
-
* - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
|
|
4820
|
-
* positions it only tries, so keep it free of side effects or guard them to once a frame
|
|
4821
|
-
* - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
|
|
4822
|
-
* saved in update, as the platformer example does
|
|
4823
|
-
* @param {number} tileData - the value of the tile at the position
|
|
4824
|
-
* @param {Vector2} pos - the tile's bottom left corner in world space
|
|
4825
|
-
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
|
|
4826
|
-
collideWithTile(tileData, pos) { return tileData > 0; }
|
|
4827
|
-
|
|
4828
|
-
/** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
4829
|
-
* - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
|
|
4830
|
-
* destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
|
|
4831
|
-
* bullet that should hit one thing checks its own destroyed flag first
|
|
4832
|
-
* @param {EngineObject} object - the object to test against
|
|
4833
|
-
* @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
|
|
4834
|
-
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
|
|
4835
|
-
*/
|
|
4836
|
-
collideWithObject(object, push) { return true; }
|
|
4837
|
-
|
|
4838
|
-
/** Get this object's up vector
|
|
4839
|
-
* @param {number} [scale] - length of the vector
|
|
4840
|
-
* @return {Vector2} */
|
|
4841
|
-
getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
|
|
4842
|
-
|
|
4843
|
-
/** Get this object's right vector
|
|
4844
|
-
* @param {number} [scale] - length of the vector
|
|
4845
|
-
* @return {Vector2} */
|
|
4846
|
-
getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
|
|
4847
|
-
|
|
4848
|
-
/** How long since the object was created
|
|
4849
|
-
* @return {number} */
|
|
4850
|
-
getAliveTime() { return time - this.spawnTime; }
|
|
4851
|
-
|
|
4852
|
-
/** Get the speed of this object
|
|
4853
|
-
* @return {number} */
|
|
4854
|
-
getSpeed() { return this.velocity.length(); }
|
|
4855
|
-
|
|
4856
|
-
/** Apply acceleration to this object (adjust velocity, not affected by mass)
|
|
4857
|
-
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
4858
|
-
* @param {Vector2} acceleration */
|
|
4859
|
-
applyAcceleration(acceleration)
|
|
4860
|
-
{ if (this.mass) this.velocity = this.velocity.add(acceleration); }
|
|
4861
|
-
|
|
4862
|
-
/** Apply angular acceleration to this object
|
|
4863
|
-
* - Does nothing on a static object (mass 0), set its angleVelocity instead
|
|
4864
|
-
* @param {number} acceleration */
|
|
4865
|
-
applyAngularAcceleration(acceleration)
|
|
4866
|
-
{ if (this.mass) this.angleVelocity += acceleration; }
|
|
4867
|
-
|
|
4868
|
-
/** Apply force to this object (adjust velocity, affected by mass)
|
|
4869
|
-
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
4870
|
-
* @param {Vector2} force */
|
|
4871
|
-
applyForce(force)
|
|
4872
|
-
{ if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
|
|
4873
|
-
|
|
4874
|
-
/** Get the direction of the mirror
|
|
4875
|
-
* @return {number} -1 if this.mirror is true, or 1 if not mirrored */
|
|
4876
|
-
getMirrorSign() { return this.mirror ? -1 : 1; }
|
|
4877
|
-
|
|
4878
|
-
/** Attaches a child to this with a local transform, returns child for chaining
|
|
4879
|
-
* @param {EngineObject} child
|
|
4880
|
-
* @param {Vector2} [localPos=vec2()]
|
|
4881
|
-
* @param {number} [localAngle]
|
|
4882
|
-
* @return {EngineObject} The child object added */
|
|
4883
|
-
addChild(child, localPos=vec2(), localAngle=0)
|
|
4884
|
-
{
|
|
4885
|
-
ASSERT(!this.destroyed, 'cannot add child to destroyed object');
|
|
4886
|
-
if (this.destroyed) return child;
|
|
4887
|
-
ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
|
|
4888
|
-
ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4889
|
-
ASSERT(!child.destroyed, 'cannot add a destroyed child');
|
|
4890
|
-
for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
|
|
4891
|
-
ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
|
|
4892
|
-
this.children.push(child);
|
|
4893
|
-
child.parent = this;
|
|
4894
|
-
child.localPos = localPos.copy();
|
|
4895
|
-
child.localAngle = localAngle;
|
|
4896
|
-
child.updateTransforms();
|
|
4897
|
-
return child;
|
|
4898
|
-
}
|
|
4899
|
-
|
|
4900
|
-
/** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
|
|
4901
|
-
* where addChild takes one; a child of something else is moved over, returns child for chaining
|
|
4902
|
-
* @param {EngineObject} child
|
|
4903
|
-
* @return {EngineObject} The child object attached */
|
|
4904
|
-
attach(child)
|
|
4905
|
-
{
|
|
4906
|
-
ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4907
|
-
ASSERT(child !== this, 'cannot attach self');
|
|
4908
|
-
child.parent?.removeChild(child);
|
|
4909
|
-
// the local values that updateTransforms turns back into the child's current pos and angle
|
|
4910
|
-
const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
|
|
4911
|
-
local.x *= mirror;
|
|
4912
|
-
return this.addChild(child, local, mirror * (child.angle - this.angle));
|
|
4913
|
-
}
|
|
4914
|
-
|
|
4915
|
-
/** Removes a child from this one, it stays where it is in the world
|
|
4916
|
-
* @param {EngineObject} child */
|
|
4917
|
-
removeChild(child)
|
|
4918
|
-
{
|
|
4919
|
-
ASSERT(child.parent === this && this.children.includes(child));
|
|
4920
|
-
const i = this.children.indexOf(child);
|
|
4921
|
-
if (i < 0) return; // not a child of this one, release has no assert
|
|
4922
|
-
this.children.splice(i, 1);
|
|
4923
|
-
child.parent = child.localPos = undefined;
|
|
4924
|
-
}
|
|
4925
|
-
|
|
4926
|
-
/** Check if this object's box overlaps another object's box
|
|
4927
|
-
* @param {EngineObject} object
|
|
4928
|
-
* @return {boolean} */
|
|
4929
|
-
isOverlappingObject(object)
|
|
4930
|
-
{ return this.isOverlapping(object.pos, object.size); }
|
|
4931
|
-
|
|
4932
|
-
/** Check if overlapping a point or aligned bounding box
|
|
4933
|
-
* @param {Vector2} pos - Center of box
|
|
4934
|
-
* @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
|
|
4935
|
-
* @return {boolean} */
|
|
4936
|
-
isOverlapping(pos, size=vec2())
|
|
4937
|
-
{ return isOverlapping(this.pos, this.size, pos, size); }
|
|
4938
|
-
|
|
4939
|
-
/** Set how this object collides
|
|
4940
|
-
* @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
|
|
4941
|
-
* @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
|
|
4942
|
-
* @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
|
|
4943
|
-
* @param {boolean} [collideRaycast] - Does it collide with raycasts? */
|
|
4944
|
-
setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
|
|
4945
|
-
{
|
|
4946
|
-
ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
|
|
4947
|
-
|
|
4948
|
-
this.collideSolidObjects = collideSolidObjects;
|
|
4949
|
-
this.isSolid = isSolid;
|
|
4950
|
-
this.collideLevel = collideLevel;
|
|
4951
|
-
this.collideRaycast = collideRaycast;
|
|
4952
|
-
}
|
|
4953
|
-
|
|
4954
|
-
/** @deprecated since 1.20, use collideLevel
|
|
4955
|
-
* @type {boolean} */
|
|
4956
|
-
get collideTiles() { return this.collideLevel; }
|
|
4957
|
-
set collideTiles(collide) { this.collideLevel = collide; }
|
|
4958
|
-
|
|
4959
|
-
/** Returns string containing info about this object for debugging
|
|
4960
|
-
* @return {string} */
|
|
4961
|
-
toString()
|
|
4962
|
-
{
|
|
4963
|
-
let text = 'type = ' + this.constructor.name;
|
|
4964
|
-
if (this.pos.x || this.pos.y)
|
|
4965
|
-
text += '\npos = ' + this.pos;
|
|
4966
|
-
if (this.velocity.x || this.velocity.y)
|
|
4967
|
-
text += '\nvelocity = ' + this.velocity;
|
|
4968
|
-
if (this.size.x || this.size.y)
|
|
4969
|
-
text += '\nsize = ' + this.size;
|
|
4970
|
-
if (this.angle)
|
|
4971
|
-
text += '\nangle = ' + this.angle.toFixed(3);
|
|
4972
|
-
text += '\ncolor = ' + this.color;
|
|
4973
|
-
return text;
|
|
4974
|
-
}
|
|
4975
|
-
|
|
4976
|
-
/** Render debug info for this object */
|
|
4977
|
-
renderDebugInfo()
|
|
4978
|
-
{
|
|
4979
|
-
if (!debug) return;
|
|
4980
|
-
|
|
4981
|
-
// check if there is anything to show
|
|
4982
|
-
const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
|
|
4983
|
-
if (!hasPhysics && !this.parent) return;
|
|
4984
|
-
|
|
4985
|
-
// show object info for debugging
|
|
4986
|
-
const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
|
|
4987
|
-
const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
|
|
4988
|
-
debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
|
|
4989
|
-
if (this.parent)
|
|
4990
|
-
debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
|
|
4991
|
-
this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
|
|
4992
|
-
}
|
|
4320
|
+
/**
|
|
4321
|
+
* LittleJS Object System
|
|
4322
|
+
* - EngineObject is the base class for all game objects
|
|
4323
|
+
* - Handles automatic updating, rendering, physics, and collision
|
|
4324
|
+
* - Supports parent-child hierarchies with transform inheritance
|
|
4325
|
+
* - 2D physics with velocity, acceleration, damping, and gravity
|
|
4326
|
+
* - Collision system with tiles and other objects
|
|
4327
|
+
* - Renders sprites from tile sheets with color and rotation
|
|
4328
|
+
* - Objects sorted by renderOrder for layered rendering
|
|
4329
|
+
*/
|
|
4330
|
+
|
|
4331
|
+
/**
|
|
4332
|
+
* LittleJS Object Base Object Class
|
|
4333
|
+
* - Top level object class used by the engine
|
|
4334
|
+
* - Automatically adds self to object list
|
|
4335
|
+
* - Will be updated and rendered each frame
|
|
4336
|
+
* - Renders as a sprite from a tilesheet by default
|
|
4337
|
+
* - Can have color and additive color applied
|
|
4338
|
+
* - 2D Physics and collision system
|
|
4339
|
+
* - Sorted by renderOrder
|
|
4340
|
+
* - Objects can have children attached
|
|
4341
|
+
* - Parents are updated before children, and set child transform
|
|
4342
|
+
* - Call destroy() to get rid of objects
|
|
4343
|
+
*
|
|
4344
|
+
* The physics system used by objects is simple and fast with some caveats...
|
|
4345
|
+
* - Collision uses the axis aligned size, the object's rotation angle is only for rendering
|
|
4346
|
+
* - Objects are guaranteed to not intersect tile collision from physics
|
|
4347
|
+
* - If an object starts or is moved inside tile collision, it will not collide with that tile
|
|
4348
|
+
* - Collision for objects can be set to be solid to block other objects
|
|
4349
|
+
* - Objects may get pushed into overlapping other solid objects, if so they will push away
|
|
4350
|
+
* - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
|
|
4351
|
+
* - Solid objects are more performance intensive and should be used sparingly
|
|
4352
|
+
* @memberof Engine
|
|
4353
|
+
* @example
|
|
4354
|
+
* // create an engine object, normally you would first extend the class with your own
|
|
4355
|
+
* const pos = vec2(2,3);
|
|
4356
|
+
* const object = new EngineObject(pos);
|
|
4357
|
+
*/
|
|
4358
|
+
class EngineObject
|
|
4359
|
+
{
|
|
4360
|
+
/** Create an engine object and adds it to the list of objects
|
|
4361
|
+
* @param {Vector2} [pos=vec2()] - World space position of the object
|
|
4362
|
+
* @param {Vector2} [size=vec2(1)] - World space size of the object
|
|
4363
|
+
* @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
|
|
4364
|
+
* @param {number} [angle] - Angle the object is rotated by
|
|
4365
|
+
* @param {Color} [color=WHITE] - Color to apply to tile when rendered
|
|
4366
|
+
* @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
|
|
4367
|
+
*/
|
|
4368
|
+
constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
|
|
4369
|
+
{
|
|
4370
|
+
// check passed in params
|
|
4371
|
+
ASSERT(isVector2(pos), 'object pos must be a vec2');
|
|
4372
|
+
ASSERT(isVector2(size), 'object size must be a vec2');
|
|
4373
|
+
ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
|
|
4374
|
+
ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
|
|
4375
|
+
ASSERT(isColor(color), 'object color should be a valid rgba color');
|
|
4376
|
+
ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
|
|
4377
|
+
|
|
4378
|
+
/** @property {Vector2} - World space position of the object */
|
|
4379
|
+
this.pos = pos.copy();
|
|
4380
|
+
/** @property {Vector2} - World space width and height of the object */
|
|
4381
|
+
this.size = size.copy();
|
|
4382
|
+
/** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
|
|
4383
|
+
* @type {Vector2|undefined} */
|
|
4384
|
+
this.drawSize = undefined;
|
|
4385
|
+
/** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
|
|
4386
|
+
* @type {TileInfo|undefined} */
|
|
4387
|
+
this.tileInfo = tileInfo;
|
|
4388
|
+
/** @property {number} - Angle to rotate the object */
|
|
4389
|
+
this.angle = angle;
|
|
4390
|
+
/** @property {Color} - Color to apply when rendered */
|
|
4391
|
+
this.color = color.copy();
|
|
4392
|
+
/** @property {Color|undefined} - Additive color to apply when rendered
|
|
4393
|
+
* @type {Color|undefined} */
|
|
4394
|
+
this.additiveColor = undefined;
|
|
4395
|
+
/** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
|
|
4396
|
+
* @type {Shader|undefined} */
|
|
4397
|
+
this.shader = undefined;
|
|
4398
|
+
/** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
|
|
4399
|
+
this.castShadow = true;
|
|
4400
|
+
/** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
|
|
4401
|
+
* brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
|
|
4402
|
+
* renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
|
|
4403
|
+
* too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
|
|
4404
|
+
this.emissive = 0;
|
|
4405
|
+
/** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
|
|
4406
|
+
this.mirror = false;
|
|
4407
|
+
/** @property {boolean} - Has object been destroyed? */
|
|
4408
|
+
this.destroyed = false;
|
|
4409
|
+
this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
|
|
4410
|
+
|
|
4411
|
+
// physical properties
|
|
4412
|
+
/** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
|
|
4413
|
+
* collide on its own, the moving ones collide with it */
|
|
4414
|
+
this.mass = objectDefaultMass;
|
|
4415
|
+
/** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
4416
|
+
this.damping = objectDefaultDamping;
|
|
4417
|
+
/** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
4418
|
+
this.angleDamping = objectDefaultAngleDamping;
|
|
4419
|
+
/** @property {number} - How bouncy the object is when colliding (0-1) */
|
|
4420
|
+
this.restitution = objectDefaultRestitution;
|
|
4421
|
+
/** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
|
|
4422
|
+
* once, the more slippery of the object and its ground is used */
|
|
4423
|
+
this.friction = objectDefaultFriction;
|
|
4424
|
+
/** @property {number} - How much to scale gravity by for this object */
|
|
4425
|
+
this.gravityScale = 1;
|
|
4426
|
+
/** @property {number} - Objects are sorted by render order */
|
|
4427
|
+
this.renderOrder = renderOrder;
|
|
4428
|
+
/** @property {Vector2} - Velocity of the object, in world units per frame */
|
|
4429
|
+
this.velocity = vec2();
|
|
4430
|
+
/** @property {number} - Angular velocity of the object, in radians per frame */
|
|
4431
|
+
this.angleVelocity = 0;
|
|
4432
|
+
/** @property {number} - Track when object was created */
|
|
4433
|
+
this.spawnTime = time;
|
|
4434
|
+
/** @property {Array<EngineObject>} - List of children of this object
|
|
4435
|
+
* @type {Array<EngineObject>} */
|
|
4436
|
+
this.children = [];
|
|
4437
|
+
/** @property {boolean} - Limit object speed along x and y axis */
|
|
4438
|
+
this.clampSpeed = true;
|
|
4439
|
+
/** @property {EngineObject|undefined} - Object we are standing on, if any
|
|
4440
|
+
* @type {EngineObject|undefined} */
|
|
4441
|
+
this.groundObject = undefined;
|
|
4442
|
+
|
|
4443
|
+
// parent child system
|
|
4444
|
+
/** @property {EngineObject|undefined} - Parent of object if in local space
|
|
4445
|
+
* @type {EngineObject|undefined} */
|
|
4446
|
+
this.parent = undefined;
|
|
4447
|
+
/** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
|
|
4448
|
+
* @type {Vector2|undefined} */
|
|
4449
|
+
this.localPos = undefined;
|
|
4450
|
+
/** @property {number} - Local angle if child */
|
|
4451
|
+
this.localAngle = 0;
|
|
4452
|
+
|
|
4453
|
+
// collision flags
|
|
4454
|
+
/** @property {boolean} - Object collides with the level, its tile collision layers
|
|
4455
|
+
* @type {boolean} */
|
|
4456
|
+
this.collideLevel = false;
|
|
4457
|
+
/** @property {boolean} - Object collides with solid objects */
|
|
4458
|
+
this.collideSolidObjects = false;
|
|
4459
|
+
/** @property {boolean} - Object collides with and blocks other objects */
|
|
4460
|
+
this.isSolid = false;
|
|
4461
|
+
/** @property {boolean} - Object collides with raycasts */
|
|
4462
|
+
this.collideRaycast = false;
|
|
4463
|
+
|
|
4464
|
+
/** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
|
|
4465
|
+
* - Calling destroy on it still destroys it, and its children go with it either way */
|
|
4466
|
+
this.persistent = false;
|
|
4467
|
+
|
|
4468
|
+
// add to list of objects
|
|
4469
|
+
engineObjects.push(this);
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
/** Update the object transform, called automatically by engine even when paused
|
|
4473
|
+
* @param {boolean} [updateChildren] - Also update the children's transforms */
|
|
4474
|
+
updateTransforms(updateChildren=true)
|
|
4475
|
+
{
|
|
4476
|
+
const parent = this.parent;
|
|
4477
|
+
if (parent)
|
|
4478
|
+
{
|
|
4479
|
+
// compose with parent transform inline to avoid intermediate vector allocs
|
|
4480
|
+
const mirror = parent.getMirrorSign();
|
|
4481
|
+
const lp = this.localPos, pp = parent.pos;
|
|
4482
|
+
const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
|
|
4483
|
+
if (pa)
|
|
4484
|
+
{
|
|
4485
|
+
const c = cos(-pa), s = sin(-pa);
|
|
4486
|
+
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
4487
|
+
}
|
|
4488
|
+
else
|
|
4489
|
+
this.pos.set(lx + pp.x, ly + pp.y);
|
|
4490
|
+
this.angle = mirror*this.localAngle + pa;
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4493
|
+
// update children
|
|
4494
|
+
if (updateChildren)
|
|
4495
|
+
for (const child of this.children)
|
|
4496
|
+
child.updateTransforms();
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
/** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
|
|
4500
|
+
updatePhysics()
|
|
4501
|
+
{
|
|
4502
|
+
// child objects do not have physics
|
|
4503
|
+
ASSERT(!this.parent);
|
|
4504
|
+
|
|
4505
|
+
// bail if a collision callback destroyed us mid-frame
|
|
4506
|
+
if (this.destroyed) return;
|
|
4507
|
+
|
|
4508
|
+
// limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
|
|
4509
|
+
// has a mass, which tile collision needs; anything else moves as fast as it is told
|
|
4510
|
+
if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
|
|
4511
|
+
{
|
|
4512
|
+
this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
|
|
4513
|
+
this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
// physics sanity checks
|
|
4517
|
+
ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
|
|
4518
|
+
ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
|
|
4519
|
+
|
|
4520
|
+
// apply physics; only the solver needs where the object was, so only then is it copied
|
|
4521
|
+
const solve = enablePhysicsSolver && this.mass;
|
|
4522
|
+
const oldPos = solve ? this.pos.copy() : undefined;
|
|
4523
|
+
this.velocity.x *= this.damping;
|
|
4524
|
+
this.velocity.y *= this.damping;
|
|
4525
|
+
if (this.mass)
|
|
4526
|
+
{
|
|
4527
|
+
// apply gravity only if it has mass
|
|
4528
|
+
this.velocity.x += gravity.x * this.gravityScale;
|
|
4529
|
+
this.velocity.y += gravity.y * this.gravityScale;
|
|
4530
|
+
}
|
|
4531
|
+
this.pos.x += this.velocity.x;
|
|
4532
|
+
this.pos.y += this.velocity.y;
|
|
4533
|
+
this.angle += this.angleVelocity *= this.angleDamping;
|
|
4534
|
+
|
|
4535
|
+
// don't do collision for static objects or if solver disabled
|
|
4536
|
+
if (!solve) return;
|
|
4537
|
+
|
|
4538
|
+
// which way is down for this object, a negative gravityScale falls up and lands on ceilings
|
|
4539
|
+
const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
|
|
4540
|
+
const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
|
|
4541
|
+
if (this.groundObject)
|
|
4542
|
+
{
|
|
4543
|
+
// apply friction in local space of ground object
|
|
4544
|
+
const friction = max(this.friction, this.groundObject.friction);
|
|
4545
|
+
const groundSpeed = this.groundObject.velocity.x;
|
|
4546
|
+
this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
|
|
4547
|
+
this.groundObject = undefined;
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
// an object with no width or height has no box to push out of, or to be pushed out of
|
|
4551
|
+
if (this.collideSolidObjects && this.size.x && this.size.y)
|
|
4552
|
+
{
|
|
4553
|
+
// check collisions against solid objects
|
|
4554
|
+
const epsilon = .001; // necessary to push slightly outside of the collision
|
|
4555
|
+
for (const o of engineObjectsCollideStaticLast)
|
|
4556
|
+
{
|
|
4557
|
+
// skip destroyed, child objects, self collision, or objects with no box
|
|
4558
|
+
if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
|
|
4559
|
+
|
|
4560
|
+
// non solid objects don't collide with each other
|
|
4561
|
+
if (!this.isSolid && !o.isSolid) continue;
|
|
4562
|
+
|
|
4563
|
+
// check collision
|
|
4564
|
+
if (!this.isOverlappingObject(o)) continue;
|
|
4565
|
+
|
|
4566
|
+
// each moving object checks its own contacts, so a pair the other one already asked about this frame
|
|
4567
|
+
// is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
|
|
4568
|
+
// to resolve, pushed back together since, is resolved again
|
|
4569
|
+
const answer = engineObjectsCollidePairAnswer(o, this);
|
|
4570
|
+
if (answer === false) continue;
|
|
4571
|
+
if (!answer)
|
|
4572
|
+
{
|
|
4573
|
+
// notify objects of collision and check if should be resolved
|
|
4574
|
+
const collide1 = this.collideWithObject(o);
|
|
4575
|
+
const collide2 = o.collideWithObject(this);
|
|
4576
|
+
if (!collide1 || !collide2)
|
|
4577
|
+
{
|
|
4578
|
+
engineObjectsCollidePairAdd(this, o);
|
|
4579
|
+
continue;
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
|
|
4584
|
+
if (wasOverlapping && (!o.mass || o.groundObject))
|
|
4585
|
+
{
|
|
4586
|
+
// a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
|
|
4587
|
+
// at once and carries it along, it would only drift out slowly and the solid would pass through;
|
|
4588
|
+
// an object standing on something counts as fixed too, so a stack on an elevator rides together;
|
|
4589
|
+
// it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
|
|
4590
|
+
const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
|
|
4591
|
+
if (push)
|
|
4592
|
+
{
|
|
4593
|
+
this.pos.x += push.x + sign(push.x) * epsilon;
|
|
4594
|
+
this.pos.y += push.y + sign(push.y) * epsilon;
|
|
4595
|
+
const restitution = max(this.restitution, o.restitution);
|
|
4596
|
+
if (push.x)
|
|
4597
|
+
{
|
|
4598
|
+
const v = this.velocity.x - o.velocity.x;
|
|
4599
|
+
if (v * push.x < 0) // moving into it
|
|
4600
|
+
this.velocity.x = o.velocity.x - v * restitution;
|
|
4601
|
+
}
|
|
4602
|
+
else
|
|
4603
|
+
{
|
|
4604
|
+
const v = this.velocity.y - o.velocity.y;
|
|
4605
|
+
if (v * push.y < 0) // moving into it
|
|
4606
|
+
this.velocity.y = o.velocity.y - v * restitution;
|
|
4607
|
+
if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
|
|
4608
|
+
this.groundObject = o;
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
engineObjectsCollidePairAdd(this, o);
|
|
4612
|
+
|
|
4613
|
+
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
4614
|
+
continue;
|
|
4615
|
+
}
|
|
4616
|
+
if (wasOverlapping)
|
|
4617
|
+
{
|
|
4618
|
+
// if already was touching, try to push away
|
|
4619
|
+
const deltaPos = oldPos.subtract(o.pos);
|
|
4620
|
+
const length = deltaPos.length();
|
|
4621
|
+
const pushAwayAccel = .001;
|
|
4622
|
+
const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
|
|
4623
|
+
this.velocity = this.velocity.add(velocity);
|
|
4624
|
+
if (o.mass) // push away other object if not fixed
|
|
4625
|
+
o.velocity = o.velocity.subtract(velocity);
|
|
4626
|
+
engineObjectsCollidePairAdd(this, o);
|
|
4627
|
+
|
|
4628
|
+
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
4629
|
+
continue;
|
|
4630
|
+
}
|
|
4631
|
+
|
|
4632
|
+
// check for collision
|
|
4633
|
+
const sizeBoth = this.size.add(o.size);
|
|
4634
|
+
// prefer to push up if small delta, up away from this object's own gravity
|
|
4635
|
+
const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
|
|
4636
|
+
const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
|
|
4637
|
+
const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
|
|
4638
|
+
const restitution = max(this.restitution, o.restitution);
|
|
4639
|
+
|
|
4640
|
+
if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
|
|
4641
|
+
{
|
|
4642
|
+
// push outside object collision
|
|
4643
|
+
this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
|
|
4644
|
+
if ((o.groundObject && wasFalling) || !o.mass)
|
|
4645
|
+
{
|
|
4646
|
+
// set ground object if landed on something
|
|
4647
|
+
if (wasFalling)
|
|
4648
|
+
this.groundObject = o;
|
|
4649
|
+
|
|
4650
|
+
// bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
|
|
4651
|
+
// platform moving down instead of landing on it again every few frames
|
|
4652
|
+
this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
|
|
4653
|
+
}
|
|
4654
|
+
else // o has mass here, the other branch already handled the massless case
|
|
4655
|
+
{
|
|
4656
|
+
// inelastic collision
|
|
4657
|
+
const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
|
|
4658
|
+
|
|
4659
|
+
// elastic collision
|
|
4660
|
+
const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
|
|
4661
|
+
+ o.velocity.y * 2 * o.mass / (this.mass + o.mass);
|
|
4662
|
+
const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
|
|
4663
|
+
+ this.velocity.y * 2 * this.mass / (this.mass + o.mass);
|
|
4664
|
+
|
|
4665
|
+
// lerp between elastic or inelastic based on restitution
|
|
4666
|
+
this.velocity.y = lerp(inelastic, elastic0, restitution);
|
|
4667
|
+
o.velocity.y = lerp(inelastic, elastic1, restitution);
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
if (!smallStepUp && isBlockedX) // resolve x collision
|
|
4671
|
+
{
|
|
4672
|
+
// push outside collision
|
|
4673
|
+
this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
|
|
4674
|
+
if (o.mass)
|
|
4675
|
+
{
|
|
4676
|
+
// inelastic collision
|
|
4677
|
+
const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
|
|
4678
|
+
|
|
4679
|
+
// elastic collision
|
|
4680
|
+
const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
|
|
4681
|
+
+ o.velocity.x * 2 * o.mass / (this.mass + o.mass);
|
|
4682
|
+
const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
|
|
4683
|
+
+ this.velocity.x * 2 * this.mass / (this.mass + o.mass);
|
|
4684
|
+
|
|
4685
|
+
// lerp between elastic or inelastic based on restitution
|
|
4686
|
+
this.velocity.x = lerp(inelastic, elastic0, restitution);
|
|
4687
|
+
o.velocity.x = lerp(inelastic, elastic1, restitution);
|
|
4688
|
+
}
|
|
4689
|
+
else // bounce if other object is fixed, relative to it as a landing is
|
|
4690
|
+
this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
|
|
4691
|
+
}
|
|
4692
|
+
engineObjectsCollidePairAdd(this, o, true);
|
|
4693
|
+
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
if (this.collideLevel)
|
|
4697
|
+
{
|
|
4698
|
+
// check collision against tiles
|
|
4699
|
+
const hitLayer = tileCollisionTest(this.pos, this.size, this);
|
|
4700
|
+
if (hitLayer)
|
|
4701
|
+
{
|
|
4702
|
+
// if already was stuck in collision, don't do anything
|
|
4703
|
+
// this should not happen unless something starts in collision
|
|
4704
|
+
if (!tileCollisionTest(oldPos, this.size, this))
|
|
4705
|
+
{
|
|
4706
|
+
// test which side we bounced off (or both if a corner)
|
|
4707
|
+
const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
|
|
4708
|
+
const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
|
|
4709
|
+
const restitution = max(this.restitution, hitLayer.restitution);
|
|
4710
|
+
if (isBlockedX)
|
|
4711
|
+
{
|
|
4712
|
+
// a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
|
|
4713
|
+
// down off a ceiling ledge when gravity points up; zero gravity counts as down
|
|
4714
|
+
const epsilon = 1e-3;
|
|
4715
|
+
const maxMove = .1;
|
|
4716
|
+
const y = gravityY > 0 ?
|
|
4717
|
+
ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
|
|
4718
|
+
floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
|
|
4719
|
+
if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
|
|
4720
|
+
{
|
|
4721
|
+
this.pos.y = y;
|
|
4722
|
+
debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
|
|
4723
|
+
return;
|
|
4724
|
+
}
|
|
4725
|
+
|
|
4726
|
+
// move to previous X position and bounce
|
|
4727
|
+
this.pos.x = oldPos.x;
|
|
4728
|
+
this.velocity.x *= -restitution;
|
|
4729
|
+
}
|
|
4730
|
+
if (isBlockedY || !isBlockedX)
|
|
4731
|
+
{
|
|
4732
|
+
if (wasFalling)
|
|
4733
|
+
{
|
|
4734
|
+
// adjust position to slightly away from nearest tile
|
|
4735
|
+
// this prevents gap between object and ground
|
|
4736
|
+
const epsilon = .0001;
|
|
4737
|
+
const offset = this.size.y/2 + epsilon;
|
|
4738
|
+
// rounded in the layer's space as its collision test is, or a bottom a hair below a
|
|
4739
|
+
// grid line would round to the row under it, inside the floor, and fall through
|
|
4740
|
+
const layerY = hitLayer.pos.y;
|
|
4741
|
+
this.pos.y = layerY + (gravityY < 0 ?
|
|
4742
|
+
floor(oldPos.y - layerY - this.size.y/2) + offset :
|
|
4743
|
+
ceil( oldPos.y - layerY + this.size.y/2) - offset);
|
|
4744
|
+
|
|
4745
|
+
// set ground object for tile collision
|
|
4746
|
+
this.groundObject = hitLayer;
|
|
4747
|
+
}
|
|
4748
|
+
else
|
|
4749
|
+
{
|
|
4750
|
+
// move to previous Y position
|
|
4751
|
+
this.pos.y = oldPos.y;
|
|
4752
|
+
this.groundObject = undefined;
|
|
4753
|
+
}
|
|
4754
|
+
// bounce velocity
|
|
4755
|
+
this.velocity.y *= -restitution;
|
|
4756
|
+
}
|
|
4757
|
+
debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
/** Update the object, called automatically by engine once each frame. Does nothing by default. */
|
|
4764
|
+
update() {}
|
|
4765
|
+
|
|
4766
|
+
/** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
|
|
4767
|
+
render()
|
|
4768
|
+
{
|
|
4769
|
+
// default object render
|
|
4770
|
+
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
4774
|
+
renderLight() {}
|
|
4775
|
+
|
|
4776
|
+
/** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
|
|
4777
|
+
* Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
|
|
4778
|
+
* screen space WebGL draws in render() are skipped during the pass */
|
|
4779
|
+
renderShadow() { this.render(); }
|
|
4780
|
+
|
|
4781
|
+
/** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
|
|
4782
|
+
* 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
|
|
4783
|
+
* glows; override to glow a part, like a robot's eyes */
|
|
4784
|
+
renderEmissive() { this.render(); }
|
|
4785
|
+
|
|
4786
|
+
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
4787
|
+
* @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
|
|
4788
|
+
destroy(immediate=false)
|
|
4789
|
+
{
|
|
4790
|
+
if (this.destroyed) return;
|
|
4791
|
+
|
|
4792
|
+
// disconnect from parent and destroy children
|
|
4793
|
+
this.destroyed = true;
|
|
4794
|
+
this.parent?.removeChild(this);
|
|
4795
|
+
for (const child of this.children)
|
|
4796
|
+
{
|
|
4797
|
+
child.parent = undefined;
|
|
4798
|
+
child.destroy(immediate);
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
/** Convert from local space to world space
|
|
4803
|
+
* @param {Vector2} pos - local space point */
|
|
4804
|
+
localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
|
|
4805
|
+
|
|
4806
|
+
/** Convert from world space to local space
|
|
4807
|
+
* @param {Vector2} pos - world space point */
|
|
4808
|
+
worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
|
|
4809
|
+
|
|
4810
|
+
/** Convert from local space to world space for a vector (rotation only)
|
|
4811
|
+
* @param {Vector2} vec - local space vector */
|
|
4812
|
+
localToWorldVector(vec) { return vec.rotate(this.angle); }
|
|
4813
|
+
|
|
4814
|
+
/** Convert from world space to local space for a vector (rotation only)
|
|
4815
|
+
* @param {Vector2} vec - world space vector */
|
|
4816
|
+
worldToLocalVector(vec) { return vec.rotate(-this.angle); }
|
|
4817
|
+
|
|
4818
|
+
/** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
4819
|
+
* - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
|
|
4820
|
+
* positions it only tries, so keep it free of side effects or guard them to once a frame
|
|
4821
|
+
* - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
|
|
4822
|
+
* saved in update, as the platformer example does
|
|
4823
|
+
* @param {number} tileData - the value of the tile at the position
|
|
4824
|
+
* @param {Vector2} pos - the tile's bottom left corner in world space
|
|
4825
|
+
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
|
|
4826
|
+
collideWithTile(tileData, pos) { return tileData > 0; }
|
|
4827
|
+
|
|
4828
|
+
/** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
4829
|
+
* - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
|
|
4830
|
+
* destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
|
|
4831
|
+
* bullet that should hit one thing checks its own destroyed flag first
|
|
4832
|
+
* @param {EngineObject} object - the object to test against
|
|
4833
|
+
* @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
|
|
4834
|
+
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
|
|
4835
|
+
*/
|
|
4836
|
+
collideWithObject(object, push) { return true; }
|
|
4837
|
+
|
|
4838
|
+
/** Get this object's up vector
|
|
4839
|
+
* @param {number} [scale] - length of the vector
|
|
4840
|
+
* @return {Vector2} */
|
|
4841
|
+
getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
|
|
4842
|
+
|
|
4843
|
+
/** Get this object's right vector
|
|
4844
|
+
* @param {number} [scale] - length of the vector
|
|
4845
|
+
* @return {Vector2} */
|
|
4846
|
+
getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
|
|
4847
|
+
|
|
4848
|
+
/** How long since the object was created
|
|
4849
|
+
* @return {number} */
|
|
4850
|
+
getAliveTime() { return time - this.spawnTime; }
|
|
4851
|
+
|
|
4852
|
+
/** Get the speed of this object
|
|
4853
|
+
* @return {number} */
|
|
4854
|
+
getSpeed() { return this.velocity.length(); }
|
|
4855
|
+
|
|
4856
|
+
/** Apply acceleration to this object (adjust velocity, not affected by mass)
|
|
4857
|
+
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
4858
|
+
* @param {Vector2} acceleration */
|
|
4859
|
+
applyAcceleration(acceleration)
|
|
4860
|
+
{ if (this.mass) this.velocity = this.velocity.add(acceleration); }
|
|
4861
|
+
|
|
4862
|
+
/** Apply angular acceleration to this object
|
|
4863
|
+
* - Does nothing on a static object (mass 0), set its angleVelocity instead
|
|
4864
|
+
* @param {number} acceleration */
|
|
4865
|
+
applyAngularAcceleration(acceleration)
|
|
4866
|
+
{ if (this.mass) this.angleVelocity += acceleration; }
|
|
4867
|
+
|
|
4868
|
+
/** Apply force to this object (adjust velocity, affected by mass)
|
|
4869
|
+
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
4870
|
+
* @param {Vector2} force */
|
|
4871
|
+
applyForce(force)
|
|
4872
|
+
{ if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
|
|
4873
|
+
|
|
4874
|
+
/** Get the direction of the mirror
|
|
4875
|
+
* @return {number} -1 if this.mirror is true, or 1 if not mirrored */
|
|
4876
|
+
getMirrorSign() { return this.mirror ? -1 : 1; }
|
|
4877
|
+
|
|
4878
|
+
/** Attaches a child to this with a local transform, returns child for chaining
|
|
4879
|
+
* @param {EngineObject} child
|
|
4880
|
+
* @param {Vector2} [localPos=vec2()]
|
|
4881
|
+
* @param {number} [localAngle]
|
|
4882
|
+
* @return {EngineObject} The child object added */
|
|
4883
|
+
addChild(child, localPos=vec2(), localAngle=0)
|
|
4884
|
+
{
|
|
4885
|
+
ASSERT(!this.destroyed, 'cannot add child to destroyed object');
|
|
4886
|
+
if (this.destroyed) return child;
|
|
4887
|
+
ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
|
|
4888
|
+
ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4889
|
+
ASSERT(!child.destroyed, 'cannot add a destroyed child');
|
|
4890
|
+
for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
|
|
4891
|
+
ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
|
|
4892
|
+
this.children.push(child);
|
|
4893
|
+
child.parent = this;
|
|
4894
|
+
child.localPos = localPos.copy();
|
|
4895
|
+
child.localAngle = localAngle;
|
|
4896
|
+
child.updateTransforms();
|
|
4897
|
+
return child;
|
|
4898
|
+
}
|
|
4899
|
+
|
|
4900
|
+
/** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
|
|
4901
|
+
* where addChild takes one; a child of something else is moved over, returns child for chaining
|
|
4902
|
+
* @param {EngineObject} child
|
|
4903
|
+
* @return {EngineObject} The child object attached */
|
|
4904
|
+
attach(child)
|
|
4905
|
+
{
|
|
4906
|
+
ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4907
|
+
ASSERT(child !== this, 'cannot attach self');
|
|
4908
|
+
child.parent?.removeChild(child);
|
|
4909
|
+
// the local values that updateTransforms turns back into the child's current pos and angle
|
|
4910
|
+
const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
|
|
4911
|
+
local.x *= mirror;
|
|
4912
|
+
return this.addChild(child, local, mirror * (child.angle - this.angle));
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
/** Removes a child from this one, it stays where it is in the world
|
|
4916
|
+
* @param {EngineObject} child */
|
|
4917
|
+
removeChild(child)
|
|
4918
|
+
{
|
|
4919
|
+
ASSERT(child.parent === this && this.children.includes(child));
|
|
4920
|
+
const i = this.children.indexOf(child);
|
|
4921
|
+
if (i < 0) return; // not a child of this one, release has no assert
|
|
4922
|
+
this.children.splice(i, 1);
|
|
4923
|
+
child.parent = child.localPos = undefined;
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
/** Check if this object's box overlaps another object's box
|
|
4927
|
+
* @param {EngineObject} object
|
|
4928
|
+
* @return {boolean} */
|
|
4929
|
+
isOverlappingObject(object)
|
|
4930
|
+
{ return this.isOverlapping(object.pos, object.size); }
|
|
4931
|
+
|
|
4932
|
+
/** Check if overlapping a point or aligned bounding box
|
|
4933
|
+
* @param {Vector2} pos - Center of box
|
|
4934
|
+
* @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
|
|
4935
|
+
* @return {boolean} */
|
|
4936
|
+
isOverlapping(pos, size=vec2())
|
|
4937
|
+
{ return isOverlapping(this.pos, this.size, pos, size); }
|
|
4938
|
+
|
|
4939
|
+
/** Set how this object collides
|
|
4940
|
+
* @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
|
|
4941
|
+
* @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
|
|
4942
|
+
* @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
|
|
4943
|
+
* @param {boolean} [collideRaycast] - Does it collide with raycasts? */
|
|
4944
|
+
setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
|
|
4945
|
+
{
|
|
4946
|
+
ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
|
|
4947
|
+
|
|
4948
|
+
this.collideSolidObjects = collideSolidObjects;
|
|
4949
|
+
this.isSolid = isSolid;
|
|
4950
|
+
this.collideLevel = collideLevel;
|
|
4951
|
+
this.collideRaycast = collideRaycast;
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
/** @deprecated since 1.20, use collideLevel
|
|
4955
|
+
* @type {boolean} */
|
|
4956
|
+
get collideTiles() { return this.collideLevel; }
|
|
4957
|
+
set collideTiles(collide) { this.collideLevel = collide; }
|
|
4958
|
+
|
|
4959
|
+
/** Returns string containing info about this object for debugging
|
|
4960
|
+
* @return {string} */
|
|
4961
|
+
toString()
|
|
4962
|
+
{
|
|
4963
|
+
let text = 'type = ' + this.constructor.name;
|
|
4964
|
+
if (this.pos.x || this.pos.y)
|
|
4965
|
+
text += '\npos = ' + this.pos;
|
|
4966
|
+
if (this.velocity.x || this.velocity.y)
|
|
4967
|
+
text += '\nvelocity = ' + this.velocity;
|
|
4968
|
+
if (this.size.x || this.size.y)
|
|
4969
|
+
text += '\nsize = ' + this.size;
|
|
4970
|
+
if (this.angle)
|
|
4971
|
+
text += '\nangle = ' + this.angle.toFixed(3);
|
|
4972
|
+
text += '\ncolor = ' + this.color;
|
|
4973
|
+
return text;
|
|
4974
|
+
}
|
|
4975
|
+
|
|
4976
|
+
/** Render debug info for this object */
|
|
4977
|
+
renderDebugInfo()
|
|
4978
|
+
{
|
|
4979
|
+
if (!debug) return;
|
|
4980
|
+
|
|
4981
|
+
// check if there is anything to show
|
|
4982
|
+
const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
|
|
4983
|
+
if (!hasPhysics && !this.parent) return;
|
|
4984
|
+
|
|
4985
|
+
// show object info for debugging
|
|
4986
|
+
const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
|
|
4987
|
+
const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
|
|
4988
|
+
debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
|
|
4989
|
+
if (this.parent)
|
|
4990
|
+
debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
|
|
4991
|
+
this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
|
|
4992
|
+
}
|
|
4993
4993
|
}
|
|
4994
4994
|
/**
|
|
4995
4995
|
* LittleJS Drawing System
|
|
@@ -5934,7 +5934,7 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
|
|
|
5934
5934
|
// build regular polygon points
|
|
5935
5935
|
const points = [];
|
|
5936
5936
|
const sizeX = size.x/2, sizeY = size.y/2;
|
|
5937
|
-
for (let i=sides; i
|
|
5937
|
+
for (let i=sides; i-- > 0;) // a count that is not whole, or below zero, still ends
|
|
5938
5938
|
{
|
|
5939
5939
|
const a = (i/sides)*PI*2;
|
|
5940
5940
|
points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
|
|
@@ -6045,7 +6045,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
|
|
|
6045
6045
|
if (!ring)
|
|
6046
6046
|
{
|
|
6047
6047
|
const points = [];
|
|
6048
|
-
for (let i=sides; i
|
|
6048
|
+
for (let i=sides; i-- > 0;)
|
|
6049
6049
|
{
|
|
6050
6050
|
const a = (i/sides)*PI*2;
|
|
6051
6051
|
points.push(vec2(sin(a), cos(a)));
|
|
@@ -6145,7 +6145,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
|
|
|
6145
6145
|
const startA = (offset%sides)/sides*PI*2;
|
|
6146
6146
|
const points = [rim(startA)];
|
|
6147
6147
|
const colors = [outerInt];
|
|
6148
|
-
for (let i=sides; i
|
|
6148
|
+
for (let i=sides; i-- > 0;)
|
|
6149
6149
|
{
|
|
6150
6150
|
const a = ((i+offset)%sides)/sides*PI*2;
|
|
6151
6151
|
points.push(pos);
|
|
@@ -6568,7 +6568,8 @@ function isOnScreen(pos, size=0)
|
|
|
6568
6568
|
function setAdditiveBlendMode(additive=true)
|
|
6569
6569
|
{
|
|
6570
6570
|
glAdditive = additive;
|
|
6571
|
-
drawContext
|
|
6571
|
+
if (drawContext) // none headless
|
|
6572
|
+
drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
|
|
6572
6573
|
}
|
|
6573
6574
|
|
|
6574
6575
|
/** Set the Shader that 2D draws use from now on, none for the engine's own
|
|
@@ -6691,9 +6692,12 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
|
|
|
6691
6692
|
}
|
|
6692
6693
|
else
|
|
6693
6694
|
{
|
|
6694
|
-
// copy to offscreen canvas
|
|
6695
|
-
|
|
6696
|
-
workReadCanvas.height
|
|
6695
|
+
// copy to offscreen canvas, sized again only when the size changes: setting a size remakes the canvas even
|
|
6696
|
+
// to the same one, and a game's tinted tiles are mostly of one size
|
|
6697
|
+
if (workReadCanvas.width !== sWidth || workReadCanvas.height !== sHeight)
|
|
6698
|
+
workReadCanvas.width = sWidth, workReadCanvas.height = sHeight;
|
|
6699
|
+
else
|
|
6700
|
+
workReadContext.clearRect(0, 0, sWidth, sHeight);
|
|
6697
6701
|
workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
|
|
6698
6702
|
|
|
6699
6703
|
// tint image using offscreen work context
|
|
@@ -8639,7 +8643,13 @@ class Sound
|
|
|
8639
8643
|
*/
|
|
8640
8644
|
constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
|
|
8641
8645
|
{
|
|
8642
|
-
if (!soundEnable || headlessMode)
|
|
8646
|
+
if (!soundEnable || headlessMode)
|
|
8647
|
+
{
|
|
8648
|
+
// no sound is made: it counts as loaded, so a game that waits for its sounds goes on
|
|
8649
|
+
this.loadedPercent = 1;
|
|
8650
|
+
onloadCallback?.(this);
|
|
8651
|
+
return;
|
|
8652
|
+
}
|
|
8643
8653
|
const rangeIsDefault = range === undefined;
|
|
8644
8654
|
if (rangeIsDefault)
|
|
8645
8655
|
range = soundDefaultRange;
|
|
@@ -9786,7 +9796,8 @@ function objectLayersMake(tileMapData, object)
|
|
|
9786
9796
|
const name = object.type || object.class, type = objectLayersTypes.get(name);
|
|
9787
9797
|
if (!type)
|
|
9788
9798
|
{
|
|
9789
|
-
|
|
9799
|
+
// an object with no type, a shape or a text someone drew in Tiled, is not one a game makes
|
|
9800
|
+
debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
|
|
9790
9801
|
return;
|
|
9791
9802
|
}
|
|
9792
9803
|
const {height=0, tilewidth=1, tileheight=1} = tileMapData;
|
|
@@ -11870,6 +11881,9 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
|
11870
11881
|
glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
|
|
11871
11882
|
}
|
|
11872
11883
|
|
|
11884
|
+
// the list and the vectors glDrawPointsTransform writes its points into
|
|
11885
|
+
const glPointsOut = [], glPointsPool = [];
|
|
11886
|
+
|
|
11873
11887
|
/** Transform and add a polygon to the gl draw list
|
|
11874
11888
|
* @param {Array<Vector2>} points - Array of Vector2 points
|
|
11875
11889
|
* @param {number} rgba - Color of the polygon as a 32-bit integer
|
|
@@ -11882,15 +11896,21 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
|
11882
11896
|
* @memberof WebGL */
|
|
11883
11897
|
function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
|
|
11884
11898
|
{
|
|
11885
|
-
|
|
11899
|
+
// written into vectors kept for this, a circle would make one for each of its sides at every draw; they are
|
|
11900
|
+
// read into the batch before this returns
|
|
11901
|
+
const pointsOut = glPointsOut, pool = glPointsPool, count = points.length;
|
|
11902
|
+
pointsOut.length = count;
|
|
11886
11903
|
const sa = sin(-angle);
|
|
11887
11904
|
const ca = cos(-angle);
|
|
11888
|
-
for (
|
|
11905
|
+
for (let i = 0; i < count; ++i)
|
|
11889
11906
|
{
|
|
11890
11907
|
// transform the point
|
|
11908
|
+
const p = points[i], out = pool[i] ||= vec2();
|
|
11891
11909
|
const px = p.x*sx;
|
|
11892
11910
|
const py = p.y*sy;
|
|
11893
|
-
|
|
11911
|
+
out.x = x + ca*px - sa*py;
|
|
11912
|
+
out.y = y + sa*px + ca*py;
|
|
11913
|
+
pointsOut[i] = out;
|
|
11894
11914
|
}
|
|
11895
11915
|
const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
|
|
11896
11916
|
glDrawPoints(drawPoints, rgba);
|
|
@@ -13325,8 +13345,11 @@ class PostProcessPlugin
|
|
|
13325
13345
|
{
|
|
13326
13346
|
// copy main canvas to work canvas at the backing store size,
|
|
13327
13347
|
// mainCanvasSize is css pixels so it would lose resolution
|
|
13328
|
-
|
|
13329
|
-
workCanvas.height
|
|
13348
|
+
// sized again only when the canvas changed, setting a size remakes the canvas even to the same one
|
|
13349
|
+
if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height)
|
|
13350
|
+
workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height;
|
|
13351
|
+
else
|
|
13352
|
+
workContext.clearRect(0, 0, workCanvas.width, workCanvas.height);
|
|
13330
13353
|
glCopyToContext(workContext);
|
|
13331
13354
|
workContext.drawImage(mainCanvas, 0, 0);
|
|
13332
13355
|
// clear the main canvas with clearRect, resizing it would also
|
|
@@ -14420,163 +14443,165 @@ class Light extends EngineObject
|
|
|
14420
14443
|
}
|
|
14421
14444
|
}
|
|
14422
14445
|
|
|
14423
|
-
/**
|
|
14424
|
-
* LittleJS ZzFXM Plugin
|
|
14425
|
-
*
|
|
14426
|
-
|
|
14427
|
-
|
|
14428
|
-
|
|
14429
|
-
|
|
14430
|
-
|
|
14431
|
-
*
|
|
14432
|
-
*
|
|
14433
|
-
*
|
|
14434
|
-
* @
|
|
14435
|
-
*
|
|
14436
|
-
*
|
|
14437
|
-
*
|
|
14438
|
-
*
|
|
14439
|
-
*
|
|
14440
|
-
*
|
|
14441
|
-
*
|
|
14442
|
-
*
|
|
14443
|
-
*
|
|
14444
|
-
*
|
|
14445
|
-
*
|
|
14446
|
-
*
|
|
14447
|
-
*
|
|
14448
|
-
*
|
|
14449
|
-
*
|
|
14450
|
-
*
|
|
14451
|
-
*
|
|
14452
|
-
*
|
|
14453
|
-
*
|
|
14454
|
-
*
|
|
14455
|
-
* ]
|
|
14456
|
-
*
|
|
14457
|
-
*
|
|
14458
|
-
*
|
|
14459
|
-
|
|
14460
|
-
|
|
14461
|
-
|
|
14462
|
-
|
|
14463
|
-
|
|
14464
|
-
|
|
14465
|
-
|
|
14466
|
-
|
|
14467
|
-
|
|
14468
|
-
|
|
14469
|
-
|
|
14470
|
-
|
|
14471
|
-
|
|
14472
|
-
|
|
14473
|
-
|
|
14474
|
-
|
|
14475
|
-
|
|
14476
|
-
|
|
14477
|
-
|
|
14478
|
-
|
|
14479
|
-
|
|
14480
|
-
|
|
14481
|
-
|
|
14482
|
-
|
|
14483
|
-
* @param {Array}
|
|
14484
|
-
* @param {
|
|
14485
|
-
* @
|
|
14486
|
-
* @
|
|
14487
|
-
|
|
14488
|
-
|
|
14489
|
-
|
|
14490
|
-
|
|
14491
|
-
let
|
|
14492
|
-
let
|
|
14493
|
-
let
|
|
14494
|
-
let
|
|
14495
|
-
let
|
|
14496
|
-
let
|
|
14497
|
-
let
|
|
14498
|
-
let
|
|
14499
|
-
let
|
|
14500
|
-
let
|
|
14501
|
-
let
|
|
14502
|
-
let
|
|
14503
|
-
let
|
|
14504
|
-
let
|
|
14505
|
-
let
|
|
14506
|
-
let
|
|
14507
|
-
let
|
|
14508
|
-
let
|
|
14509
|
-
let
|
|
14510
|
-
|
|
14511
|
-
|
|
14512
|
-
|
|
14513
|
-
|
|
14514
|
-
|
|
14515
|
-
|
|
14516
|
-
|
|
14517
|
-
|
|
14518
|
-
|
|
14519
|
-
|
|
14520
|
-
|
|
14521
|
-
|
|
14522
|
-
|
|
14523
|
-
|
|
14524
|
-
|
|
14525
|
-
|
|
14526
|
-
|
|
14527
|
-
//
|
|
14528
|
-
|
|
14529
|
-
for
|
|
14530
|
-
|
|
14531
|
-
|
|
14532
|
-
|
|
14533
|
-
|
|
14534
|
-
|
|
14535
|
-
|
|
14536
|
-
|
|
14537
|
-
|
|
14538
|
-
|
|
14539
|
-
|
|
14540
|
-
|
|
14541
|
-
|
|
14542
|
-
|
|
14543
|
-
|
|
14544
|
-
|
|
14545
|
-
|
|
14546
|
-
|
|
14547
|
-
|
|
14548
|
-
|
|
14549
|
-
|
|
14550
|
-
|
|
14551
|
-
|
|
14552
|
-
|
|
14553
|
-
|
|
14554
|
-
|
|
14555
|
-
|
|
14556
|
-
|
|
14557
|
-
|
|
14558
|
-
|
|
14559
|
-
|
|
14560
|
-
|
|
14561
|
-
|
|
14562
|
-
|
|
14563
|
-
|
|
14564
|
-
|
|
14565
|
-
|
|
14566
|
-
|
|
14567
|
-
|
|
14568
|
-
|
|
14569
|
-
|
|
14570
|
-
|
|
14571
|
-
|
|
14572
|
-
|
|
14573
|
-
|
|
14574
|
-
|
|
14575
|
-
|
|
14576
|
-
|
|
14577
|
-
|
|
14578
|
-
|
|
14579
|
-
|
|
14446
|
+
/**
|
|
14447
|
+
* LittleJS ZzFXM Plugin
|
|
14448
|
+
* - A port of ZzFXM, the Zuper Zmall Zound Zynth music player, by Keith Clark and Frank Force, MIT licensed,
|
|
14449
|
+
* https://github.com/keithclark/ZzFXM; its notice is in COPYRIGHT.txt
|
|
14450
|
+
* @namespace ZzFXM
|
|
14451
|
+
*/
|
|
14452
|
+
|
|
14453
|
+
/**
|
|
14454
|
+
* Music Object - Stores a zzfx music track for later use
|
|
14455
|
+
*
|
|
14456
|
+
* <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
|
|
14457
|
+
* @extends Sound
|
|
14458
|
+
* @memberof ZzFXM
|
|
14459
|
+
* @example
|
|
14460
|
+
* // create some music
|
|
14461
|
+
* const music_example = new ZzFXMusic(
|
|
14462
|
+
* [
|
|
14463
|
+
* [ // instruments
|
|
14464
|
+
* [,0,400] // simple note
|
|
14465
|
+
* ],
|
|
14466
|
+
* [ // patterns
|
|
14467
|
+
* [ // pattern 1
|
|
14468
|
+
* [ // channel 0
|
|
14469
|
+
* 0, -1, // instrument 0, left speaker
|
|
14470
|
+
* 1, 0, 9, 1 // channel notes
|
|
14471
|
+
* ],
|
|
14472
|
+
* [ // channel 1
|
|
14473
|
+
* 0, 1, // instrument 0, right speaker
|
|
14474
|
+
* 0, 12, 17, -1 // channel notes
|
|
14475
|
+
* ]
|
|
14476
|
+
* ],
|
|
14477
|
+
* ],
|
|
14478
|
+
* [0, 0, 0, 0], // sequence, play pattern 0 four times
|
|
14479
|
+
* 90 // BPM
|
|
14480
|
+
* ]);
|
|
14481
|
+
*
|
|
14482
|
+
* // play the music on a loop
|
|
14483
|
+
* music_example.playMusic();
|
|
14484
|
+
*/
|
|
14485
|
+
class ZzFXMusic extends Sound
|
|
14486
|
+
{
|
|
14487
|
+
/** Create a music object and cache the zzfx music samples for later use
|
|
14488
|
+
* @param {[Array, Array, Array, number?, ...any[]]} zzfxMusic - Array of zzfx music parameters: instruments, patterns, sequence, and an optional BPM; anything after it, like the tracker's metadata, is ignored
|
|
14489
|
+
*/
|
|
14490
|
+
constructor(zzfxMusic)
|
|
14491
|
+
{
|
|
14492
|
+
super(undefined);
|
|
14493
|
+
|
|
14494
|
+
if (!soundEnable || headlessMode) return;
|
|
14495
|
+
const [instruments, patterns, sequence, BPM] = zzfxMusic;
|
|
14496
|
+
super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
|
|
14497
|
+
this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
|
|
14498
|
+
this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
|
|
14499
|
+
}
|
|
14500
|
+
}
|
|
14501
|
+
|
|
14502
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14503
|
+
// ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
|
|
14504
|
+
|
|
14505
|
+
/** Generate samples for a ZzFM song with given parameters
|
|
14506
|
+
* @param {Array} instruments - Array of ZzFX sound parameters
|
|
14507
|
+
* @param {Array} patterns - Array of pattern data
|
|
14508
|
+
* @param {Array} sequence - Array of pattern indexes
|
|
14509
|
+
* @param {number} [BPM] - Playback speed of the song in BPM
|
|
14510
|
+
* @return {Array} - Left and right channel sample data
|
|
14511
|
+
* @memberof ZzFXM */
|
|
14512
|
+
function zzfxM(instruments, patterns, sequence, BPM = 125)
|
|
14513
|
+
{
|
|
14514
|
+
let i, j, k;
|
|
14515
|
+
let instrumentParameters;
|
|
14516
|
+
let note;
|
|
14517
|
+
let sample;
|
|
14518
|
+
let patternChannel;
|
|
14519
|
+
let notFirstBeat;
|
|
14520
|
+
let stop;
|
|
14521
|
+
let instrument;
|
|
14522
|
+
let attenuation;
|
|
14523
|
+
let outSampleOffset;
|
|
14524
|
+
let isSequenceEnd;
|
|
14525
|
+
let sampleOffset = 0;
|
|
14526
|
+
let nextSampleOffset;
|
|
14527
|
+
let sampleBuffer = [];
|
|
14528
|
+
let leftChannelBuffer = [];
|
|
14529
|
+
let rightChannelBuffer = [];
|
|
14530
|
+
let channelIndex = 0;
|
|
14531
|
+
let panning = 0;
|
|
14532
|
+
let hasMore = 1;
|
|
14533
|
+
let sampleCache = {};
|
|
14534
|
+
let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
|
|
14535
|
+
|
|
14536
|
+
// for each channel in order until there are no more
|
|
14537
|
+
for (; hasMore; channelIndex++) {
|
|
14538
|
+
|
|
14539
|
+
// reset current values
|
|
14540
|
+
sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
|
|
14541
|
+
|
|
14542
|
+
// for each pattern in sequence
|
|
14543
|
+
sequence.forEach((patternIndex, sequenceIndex)=> {
|
|
14544
|
+
// get pattern for current channel, use empty 1 note pattern if none found
|
|
14545
|
+
patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
|
|
14546
|
+
|
|
14547
|
+
// check if there are more channels
|
|
14548
|
+
hasMore |= patterns[patternIndex][channelIndex]&&1;
|
|
14549
|
+
|
|
14550
|
+
// get next offset, use the length of first channel
|
|
14551
|
+
nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
|
|
14552
|
+
// for each beat in pattern, plus one extra if end of sequence
|
|
14553
|
+
isSequenceEnd = sequenceIndex === sequence.length - 1;
|
|
14554
|
+
for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
|
|
14555
|
+
|
|
14556
|
+
// <channel-note>
|
|
14557
|
+
note = patternChannel[i];
|
|
14558
|
+
|
|
14559
|
+
// stop if end, different instrument or new note
|
|
14560
|
+
stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
|
|
14561
|
+
instrument !== (patternChannel[0] || 0) || note | 0;
|
|
14562
|
+
|
|
14563
|
+
// fill buffer with samples for previous beat, most cpu intensive part
|
|
14564
|
+
for (j = 0; j < beatLength && notFirstBeat;
|
|
14565
|
+
|
|
14566
|
+
// fade off attenuation at end of beat if stopping note, prevents clicking
|
|
14567
|
+
j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
|
|
14568
|
+
) {
|
|
14569
|
+
// copy sample to stereo buffers with panning
|
|
14570
|
+
sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
|
|
14571
|
+
leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
|
|
14572
|
+
rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
|
|
14573
|
+
}
|
|
14574
|
+
|
|
14575
|
+
// set up for next note
|
|
14576
|
+
if (note) {
|
|
14577
|
+
// set attenuation
|
|
14578
|
+
attenuation = note % 1;
|
|
14579
|
+
panning = patternChannel[1] || 0;
|
|
14580
|
+
if (note |= 0) {
|
|
14581
|
+
// get cached sample
|
|
14582
|
+
sampleBuffer = sampleCache[
|
|
14583
|
+
[
|
|
14584
|
+
instrument = patternChannel[sampleOffset = 0] || 0,
|
|
14585
|
+
note
|
|
14586
|
+
]
|
|
14587
|
+
] = sampleCache[[instrument, note]] || (
|
|
14588
|
+
// add sample to cache
|
|
14589
|
+
instrumentParameters = [...instruments[instrument]],
|
|
14590
|
+
instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
|
|
14591
|
+
|
|
14592
|
+
// allow negative values to stop notes
|
|
14593
|
+
note > 0 ? zzfxG(...instrumentParameters) : []
|
|
14594
|
+
);
|
|
14595
|
+
}
|
|
14596
|
+
}
|
|
14597
|
+
}
|
|
14598
|
+
|
|
14599
|
+
// update the sample offset
|
|
14600
|
+
outSampleOffset = nextSampleOffset;
|
|
14601
|
+
});
|
|
14602
|
+
}
|
|
14603
|
+
|
|
14604
|
+
return [leftChannelBuffer, rightChannelBuffer];
|
|
14580
14605
|
}
|
|
14581
14606
|
/**
|
|
14582
14607
|
* LittleJS Audio Effects Plugin
|
|
@@ -15567,8 +15592,7 @@ class UISystemPlugin
|
|
|
15567
15592
|
}
|
|
15568
15593
|
}
|
|
15569
15594
|
|
|
15570
|
-
/** Destroy and remove all objects
|
|
15571
|
-
* @memberof UISystem */
|
|
15595
|
+
/** Destroy and remove all objects */
|
|
15572
15596
|
destroyObjects()
|
|
15573
15597
|
{
|
|
15574
15598
|
for (const o of this.uiObjects)
|
|
@@ -16089,8 +16113,10 @@ class UIObject
|
|
|
16089
16113
|
return void inputClearKey(0, 0, false, true, false);
|
|
16090
16114
|
}
|
|
16091
16115
|
}
|
|
16116
|
+
// the object that was the active one going into this update: one pressed and let go inside a
|
|
16117
|
+
// frame is clicked on the next, with its release, not on both
|
|
16092
16118
|
if (!uiSystem.activateOnPress)
|
|
16093
|
-
if (!mouseDown && this.isActiveObject() && this.interactive)
|
|
16119
|
+
if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
|
|
16094
16120
|
this.click();
|
|
16095
16121
|
if (this.destroyed) return;
|
|
16096
16122
|
}
|
|
@@ -16833,7 +16859,10 @@ class UIVideo extends UIObject
|
|
|
16833
16859
|
if (this.destroyed)
|
|
16834
16860
|
return;
|
|
16835
16861
|
|
|
16862
|
+
// let go of the media too, a paused video keeps what it has loaded
|
|
16836
16863
|
this.video.pause();
|
|
16864
|
+
this.video.removeAttribute?.('src');
|
|
16865
|
+
this.video.load?.();
|
|
16837
16866
|
this.video.remove();
|
|
16838
16867
|
super.destroy();
|
|
16839
16868
|
}
|
|
@@ -20172,6 +20201,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
|
|
|
20172
20201
|
for (const group of parseAtlas(data))
|
|
20173
20202
|
{
|
|
20174
20203
|
// reserve a block of full size cells, one per frame
|
|
20204
|
+
if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
|
|
20175
20205
|
const sourceSize = group.frames[0].sourceSize;
|
|
20176
20206
|
const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
|
|
20177
20207
|
const added = textureSheetAdd(blockSize, sourceSize, padding);
|
|
@@ -20957,45 +20987,50 @@ function tweenUpdate(gameDelta, realDelta)
|
|
|
20957
20987
|
const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
|
|
20958
20988
|
for (const t of tweenActive)
|
|
20959
20989
|
list.push(t);
|
|
20960
|
-
|
|
20990
|
+
// the list is let go however the walk ends: a callback that throws must not leave it held, or every
|
|
20991
|
+
// update after would take it for an update still going and make a list of its own
|
|
20992
|
+
try
|
|
20961
20993
|
{
|
|
20962
|
-
|
|
20963
|
-
// stopped, or started again by a callback this update, or during an update a callback ran inside it, which
|
|
20964
|
-
// counts on from this one
|
|
20965
|
-
if (!t.active || t.activePass >= pass) continue;
|
|
20966
|
-
let dt;
|
|
20967
|
-
if (enginePath)
|
|
20994
|
+
for (let i = list.length; i--;)
|
|
20968
20995
|
{
|
|
20969
|
-
|
|
20970
|
-
|
|
20971
|
-
|
|
20972
|
-
t.
|
|
20973
|
-
|
|
20974
|
-
|
|
20975
|
-
|
|
20976
|
-
|
|
20977
|
-
|
|
20996
|
+
const t = list[i];
|
|
20997
|
+
// stopped, or started again by a callback this update, or during an update a callback ran inside it, which
|
|
20998
|
+
// counts on from this one
|
|
20999
|
+
if (!t.active || t.activePass >= pass) continue;
|
|
21000
|
+
let dt;
|
|
21001
|
+
if (enginePath)
|
|
21002
|
+
{
|
|
21003
|
+
// a paused tween keeps count too, so it does not jump when resumed
|
|
21004
|
+
dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
|
|
21005
|
+
t.lastTime = time;
|
|
21006
|
+
t.lastTimeReal = timeReal;
|
|
21007
|
+
}
|
|
21008
|
+
else
|
|
21009
|
+
dt = t.useRealTime ? realDelta : gameDelta;
|
|
21010
|
+
if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
|
|
21011
|
+
if (t.paused || dt <= 0) continue;
|
|
20978
21012
|
|
|
20979
|
-
|
|
20980
|
-
|
|
20981
|
-
|
|
20982
|
-
|
|
20983
|
-
|
|
20984
|
-
|
|
20985
|
-
|
|
20986
|
-
|
|
20987
|
-
|
|
20988
|
-
|
|
20989
|
-
|
|
20990
|
-
|
|
20991
|
-
|
|
20992
|
-
|
|
20993
|
-
|
|
20994
|
-
|
|
20995
|
-
|
|
21013
|
+
t.life -= dt;
|
|
21014
|
+
if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
|
|
21015
|
+
{
|
|
21016
|
+
t.callback(t.interp(t.life));
|
|
21017
|
+
}
|
|
21018
|
+
else
|
|
21019
|
+
{
|
|
21020
|
+
// Completion: fire end value, remove from active, start the next iteration
|
|
21021
|
+
// of a loop or pingPong, or when there is none it has completed, fire onComplete
|
|
21022
|
+
t.callback(t.interp(0));
|
|
21023
|
+
if (!t.active || t.activePass >= pass)
|
|
21024
|
+
continue; // stopped or restarted by its own callback, the run it was on ends without completing
|
|
21025
|
+
tweenDeactivate(t);
|
|
21026
|
+
const next = t.thenCallback;
|
|
21027
|
+
t.thenCallback = undefined;
|
|
21028
|
+
if (!(next && next()) && t.onComplete)
|
|
21029
|
+
t.onComplete();
|
|
21030
|
+
}
|
|
20996
21031
|
}
|
|
20997
21032
|
}
|
|
20998
|
-
list.length = 0;
|
|
21033
|
+
finally { list.length = 0; }
|
|
20999
21034
|
}
|
|
21000
21035
|
|
|
21001
21036
|
/** Stop every active tween, ending loops too, without calling their then-callbacks.
|
|
@@ -23377,17 +23412,19 @@ function render3DInstance(mesh, matrix, tileInfo, color)
|
|
|
23377
23412
|
// make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
|
|
23378
23413
|
// differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
|
|
23379
23414
|
// drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
|
|
23380
|
-
// so it never sets the draw state for them, which would move the state's version at every draw
|
|
23381
|
-
|
|
23415
|
+
// so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard
|
|
23416
|
+
// passes that its batch is unlit, for the same reason
|
|
23417
|
+
function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false)
|
|
23382
23418
|
{
|
|
23383
23419
|
const r = render3D;
|
|
23384
23420
|
if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
|
|
23385
|
-
|| render3DStateChanged(mesh.instanceState)))
|
|
23421
|
+
|| mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState)))
|
|
23386
23422
|
render3DFlushInstances(mesh);
|
|
23387
23423
|
if (!mesh.instanceCount)
|
|
23388
23424
|
{
|
|
23389
23425
|
mesh.instanceTextureInfo = textureInfo;
|
|
23390
23426
|
mesh.instanceMirrored = mirrored;
|
|
23427
|
+
mesh.instanceUnlit = unlit;
|
|
23391
23428
|
mesh.instanceState = render3DCaptureBatchState();
|
|
23392
23429
|
r.instanceMeshes.push(mesh);
|
|
23393
23430
|
}
|
|
@@ -23421,6 +23458,8 @@ function render3DFlushInstances(only)
|
|
|
23421
23458
|
const state = mesh.instanceState;
|
|
23422
23459
|
if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
|
|
23423
23460
|
state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
|
|
23461
|
+
if (mesh.instanceUnlit) // a batch of sprites
|
|
23462
|
+
state.lighting = false;
|
|
23424
23463
|
render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
|
|
23425
23464
|
}
|
|
23426
23465
|
if (!only)
|
|
@@ -23777,6 +23816,7 @@ class Render3DPlugin
|
|
|
23777
23816
|
/** @type {TextureInfo|undefined} */
|
|
23778
23817
|
this.streamTileInfo = undefined;
|
|
23779
23818
|
this.streamState = undefined; // captured state the pending batch was drawn under
|
|
23819
|
+
this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are
|
|
23780
23820
|
/** @type {Mesh|undefined} */
|
|
23781
23821
|
this.capture = undefined; // the mesh a bake is filling
|
|
23782
23822
|
/** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
|
|
@@ -24167,6 +24207,8 @@ class Render3DPlugin
|
|
|
24167
24207
|
{
|
|
24168
24208
|
if (!this.streamCount || !render3DCanDraw()) return;
|
|
24169
24209
|
const gl = glContext;
|
|
24210
|
+
if (this.streamUnlit)
|
|
24211
|
+
this.streamState.lighting = false; // its own copy of the state
|
|
24170
24212
|
render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
|
|
24171
24213
|
render3DBindVertexBuffer(this.streamBuffer);
|
|
24172
24214
|
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
|
|
@@ -24401,13 +24443,34 @@ class Render3DPlugin
|
|
|
24401
24443
|
return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
|
|
24402
24444
|
}
|
|
24403
24445
|
|
|
24404
|
-
//
|
|
24405
|
-
//
|
|
24406
|
-
const
|
|
24407
|
-
this.
|
|
24408
|
-
|
|
24409
|
-
|
|
24410
|
-
|
|
24446
|
+
// unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not
|
|
24447
|
+
// set, which would move its version at every sprite
|
|
24448
|
+
const unlit = !this.shadowPass;
|
|
24449
|
+
if (!this.blend && this.depthTest && this.instancing)
|
|
24450
|
+
{
|
|
24451
|
+
// an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a
|
|
24452
|
+
// sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader
|
|
24453
|
+
// drops its see through texels, so it needs no sorting
|
|
24454
|
+
if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow
|
|
24455
|
+
if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2))
|
|
24456
|
+
return;
|
|
24457
|
+
const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo);
|
|
24458
|
+
render3DMeshUpload(quad);
|
|
24459
|
+
// the quad's half axes, doubled for its unit square
|
|
24460
|
+
const a = render3DBillboardAxes(size, angle, upright);
|
|
24461
|
+
const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData;
|
|
24462
|
+
data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
|
|
24463
|
+
data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
|
|
24464
|
+
data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
|
|
24465
|
+
data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1;
|
|
24466
|
+
data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
|
|
24467
|
+
data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
|
|
24468
|
+
return;
|
|
24469
|
+
}
|
|
24470
|
+
|
|
24471
|
+
// one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no
|
|
24472
|
+
// vectors made, for sprites that blend, and for particles when instancing is off
|
|
24473
|
+
const uvRect = render3DBeginStrip(6, tileInfo, unlit);
|
|
24411
24474
|
if (!uvRect) return;
|
|
24412
24475
|
const a = render3DBillboardAxes(size, angle, upright);
|
|
24413
24476
|
const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
|
|
@@ -25519,8 +25582,9 @@ function render3DRenderDepth()
|
|
|
25519
25582
|
// which way they face, so keeping the count even keeps every strip facing out.
|
|
25520
25583
|
|
|
25521
25584
|
// make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
|
|
25522
|
-
// differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
|
|
25523
|
-
|
|
25585
|
+
// differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws
|
|
25586
|
+
// the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw
|
|
25587
|
+
function render3DBeginStrip(count, tileInfo, unlit=false)
|
|
25524
25588
|
{
|
|
25525
25589
|
const r = render3D;
|
|
25526
25590
|
if (!render3DCanDraw()) return;
|
|
@@ -25528,12 +25592,13 @@ function render3DBeginStrip(count, tileInfo)
|
|
|
25528
25592
|
ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
|
|
25529
25593
|
if (count > RENDER3D_MAX_STREAM_VERTS) return;
|
|
25530
25594
|
const textureInfo = render3DTextureOf(tileInfo);
|
|
25531
|
-
if (r.streamCount && (textureInfo !== r.streamTileInfo ||
|
|
25532
|
-
|| r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
|
|
25595
|
+
if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit
|
|
25596
|
+
|| render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
|
|
25533
25597
|
r.flush();
|
|
25534
25598
|
render3DFlushBeforeOverlay();
|
|
25535
25599
|
if (!r.streamCount)
|
|
25536
25600
|
r.streamState = render3DCaptureBatchState();
|
|
25601
|
+
r.streamUnlit = unlit;
|
|
25537
25602
|
r.streamTileInfo = textureInfo;
|
|
25538
25603
|
return render3DGetTileUVs(tileInfo);
|
|
25539
25604
|
}
|
|
@@ -30671,7 +30736,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
|
|
|
30671
30736
|
|
|
30672
30737
|
/** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
|
|
30673
30738
|
* - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
|
|
30674
|
-
* a sheet is given here; a block's type shows that tile of the sheet on every face
|
|
30739
|
+
* a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
|
|
30740
|
+
* image gets plain blocks, a color for each type
|
|
30675
30741
|
* - setup is called with each map a level makes, to give block types their own faces or make them see-through
|
|
30676
30742
|
* - Call it before level3DLoad; with no arguments the defaults are back
|
|
30677
30743
|
* @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
|
|
@@ -30733,7 +30799,7 @@ function level3DVoxelsMake(voxels)
|
|
|
30733
30799
|
{
|
|
30734
30800
|
const shape = level3DVoxelsShape(voxels);
|
|
30735
30801
|
if (!shape || typeof VoxelMap == 'undefined') return;
|
|
30736
|
-
const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles ||
|
|
30802
|
+
const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
|
|
30737
30803
|
level3DVoxelsDecode(voxels.blocks, map.data);
|
|
30738
30804
|
level3DVoxelSetupMap?.(map);
|
|
30739
30805
|
map.rebuild();
|
|
@@ -30795,6 +30861,31 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
|
|
|
30795
30861
|
map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
|
|
30796
30862
|
}
|
|
30797
30863
|
|
|
30864
|
+
// the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
|
|
30865
|
+
// VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
|
|
30866
|
+
// block map in a game with no art can still be seen and painted
|
|
30867
|
+
let level3DVoxelPlain;
|
|
30868
|
+
function level3DVoxelPlainTiles()
|
|
30869
|
+
{
|
|
30870
|
+
if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
|
|
30871
|
+
return tile();
|
|
30872
|
+
if (!level3DVoxelPlain)
|
|
30873
|
+
{
|
|
30874
|
+
// 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
|
|
30875
|
+
const context = createCanvasContext(256);
|
|
30876
|
+
for (let i = 0; i < 256; ++i)
|
|
30877
|
+
{
|
|
30878
|
+
const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
|
|
30879
|
+
context.fillStyle = hsl(hue, .45, .4).toString();
|
|
30880
|
+
context.fillRect(x, y, 16, 16);
|
|
30881
|
+
context.fillStyle = hsl(hue, .5, .55).toString();
|
|
30882
|
+
context.fillRect(x + 1, y + 1, 14, 14);
|
|
30883
|
+
}
|
|
30884
|
+
level3DVoxelPlain = new TextureInfo(context.canvas);
|
|
30885
|
+
}
|
|
30886
|
+
return tile(0, 16, level3DVoxelPlain, 0);
|
|
30887
|
+
}
|
|
30888
|
+
|
|
30798
30889
|
// the HeightMap of a level's terrain block, undefined when it has none or it is wrong
|
|
30799
30890
|
function level3DTerrainMake(terrain)
|
|
30800
30891
|
{
|
|
@@ -32364,6 +32455,11 @@ async function parseGLTF(data, baseUrl='')
|
|
|
32364
32455
|
for (const name of json.extensionsRequired || [])
|
|
32365
32456
|
if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
|
|
32366
32457
|
throw new Error(`glTF with ${name} is not read, export it uncompressed`);
|
|
32458
|
+
else if (name === 'KHR_texture_basisu')
|
|
32459
|
+
throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg');
|
|
32460
|
+
// one that only may use them has other images to fall back on, or none: said, since it loads with them left out
|
|
32461
|
+
if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu'))
|
|
32462
|
+
console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg');
|
|
32367
32463
|
|
|
32368
32464
|
// the buffers: the GLB's own, a data uri, or a file beside the model
|
|
32369
32465
|
const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
|
|
@@ -32406,7 +32502,8 @@ async function parseGLTF(data, baseUrl='')
|
|
|
32406
32502
|
const bitmap = normalTextures.has(index) ?
|
|
32407
32503
|
await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
|
|
32408
32504
|
opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
|
|
32409
|
-
|
|
32505
|
+
// REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are
|
|
32506
|
+
return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728);
|
|
32410
32507
|
}
|
|
32411
32508
|
catch (e) { LOG('glTF image not loaded', e); }
|
|
32412
32509
|
}));
|
|
@@ -32516,17 +32613,25 @@ function gltfOpaqueImage(image)
|
|
|
32516
32613
|
{
|
|
32517
32614
|
const gl = glContext, {width, height} = image;
|
|
32518
32615
|
if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
|
|
32519
|
-
|
|
32520
|
-
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
32521
|
-
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
32522
|
-
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
32523
|
-
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
32616
|
+
// the room to read into first: for a huge image it can fail, and must not with the framebuffer bound
|
|
32524
32617
|
const data = new Uint8ClampedArray(width * height * 4);
|
|
32525
|
-
gl.
|
|
32526
|
-
|
|
32527
|
-
|
|
32528
|
-
|
|
32529
|
-
|
|
32618
|
+
const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
|
|
32619
|
+
try
|
|
32620
|
+
{
|
|
32621
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
32622
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
32623
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
32624
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
32625
|
+
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
|
|
32626
|
+
}
|
|
32627
|
+
finally
|
|
32628
|
+
{
|
|
32629
|
+
// the 2D renderer's own bindings back, whatever happened
|
|
32630
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
|
|
32631
|
+
gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
|
|
32632
|
+
gl.deleteFramebuffer(framebuffer);
|
|
32633
|
+
gl.deleteTexture(texture);
|
|
32634
|
+
}
|
|
32530
32635
|
for (let i = 3; i < data.length; i += 4)
|
|
32531
32636
|
data[i] = 255;
|
|
32532
32637
|
image.close();
|
|
@@ -32663,7 +32768,7 @@ function gltfAccessor(json, buffers, index)
|
|
|
32663
32768
|
}
|
|
32664
32769
|
if (scale !== 1)
|
|
32665
32770
|
for (let i = 0; i < out.length; ++i)
|
|
32666
|
-
out[i]
|
|
32771
|
+
out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says
|
|
32667
32772
|
return {data: out, components, count: a.count};
|
|
32668
32773
|
}
|
|
32669
32774
|
|
|
@@ -32727,8 +32832,10 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
|
|
|
32727
32832
|
const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
|
|
32728
32833
|
if (er || eg || eb)
|
|
32729
32834
|
{
|
|
32835
|
+
// KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender
|
|
32836
|
+
const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
|
|
32730
32837
|
part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
|
|
32731
|
-
part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
|
|
32838
|
+
part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength);
|
|
32732
32839
|
}
|
|
32733
32840
|
return part;
|
|
32734
32841
|
}
|
|
@@ -33632,10 +33739,10 @@ class LevelEditor
|
|
|
33632
33739
|
onPanel(element) {}
|
|
33633
33740
|
|
|
33634
33741
|
/** Called by Save with the text of the file and its name; return true when the game kept it itself, and the
|
|
33635
|
-
* editor writes no file, to set or override
|
|
33742
|
+
* editor writes no file, to set or override; it may be async
|
|
33636
33743
|
* @param {string} text
|
|
33637
33744
|
* @param {string} fileName
|
|
33638
|
-
* @return {boolean|void} */
|
|
33745
|
+
* @return {boolean|void|Promise<boolean|void>} */
|
|
33639
33746
|
onSave(text, fileName) { return false; }
|
|
33640
33747
|
|
|
33641
33748
|
/** True while the editor is open, the game is paused under it
|
|
@@ -34002,7 +34109,7 @@ function editorMapRestore(map)
|
|
|
34002
34109
|
if (!map.layers || data.some((layer)=> !Array.isArray(layer))) return map;
|
|
34003
34110
|
const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
|
|
34004
34111
|
const url = editorFetchedURLs.get(map), hash = editorMapHash(data, objects, editorMapLayout(map));
|
|
34005
|
-
const fileName = url
|
|
34112
|
+
const fileName = editorFileName(url, 'level.json');
|
|
34006
34113
|
const record = {map, url, fileName, key: editorMapKey(map, url, hash), hash,
|
|
34007
34114
|
original: data.map((layer)=> [...layer]), originalObjects: editorObjectsCopy(objects),
|
|
34008
34115
|
originalSize: {width: map.width, height: map.height}, layers: []};
|
|
@@ -34033,6 +34140,8 @@ function editorMapRestore(map)
|
|
|
34033
34140
|
editorRestoreObjects(map, saved);
|
|
34034
34141
|
console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
|
|
34035
34142
|
'Save in the editor (Esc then 0) writes them to the file');
|
|
34143
|
+
saved.stale && console.warn(`LittleJS editor: they are older than the last edits to ${record.fileName}, ` +
|
|
34144
|
+
'which storage had no room for');
|
|
34036
34145
|
}
|
|
34037
34146
|
return map;
|
|
34038
34147
|
}
|
|
@@ -34234,6 +34343,7 @@ async function editorSaveText(record, text, pickAgain)
|
|
|
34234
34343
|
if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
|
|
34235
34344
|
record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
|
|
34236
34345
|
editorFileStore.set(editorFileKey(record), undefined);
|
|
34346
|
+
console.warn(`LittleJS editor: ${record.fileName} could not be written, downloaded instead`, error);
|
|
34237
34347
|
}
|
|
34238
34348
|
}
|
|
34239
34349
|
saveText(text, record.fileName, 'application/json');
|
|
@@ -34250,15 +34360,35 @@ const editorSaves = ()=> readSaveData(editorSaveName(), {});
|
|
|
34250
34360
|
// if the last autosave did not fit in storage, the panel says so
|
|
34251
34361
|
let editorSaveFailed = false;
|
|
34252
34362
|
|
|
34253
|
-
// write the autosaves, noting whether storage took them
|
|
34254
|
-
|
|
34363
|
+
// write the autosaves, noting whether storage took them; when it did not, the autosave it still has of this map
|
|
34364
|
+
// is an older one, and is marked so, for the reload that brings it back to say so
|
|
34365
|
+
function editorWriteSaves(saves, key, name=editorSaveName())
|
|
34255
34366
|
{
|
|
34256
34367
|
try
|
|
34257
34368
|
{
|
|
34258
|
-
localStorage.setItem(
|
|
34259
|
-
editorSaveFailed = false;
|
|
34369
|
+
localStorage.setItem(name, JSON.stringify(saves));
|
|
34370
|
+
return editorSaveFailed = false;
|
|
34260
34371
|
}
|
|
34261
34372
|
catch { editorSaveFailed = true; }
|
|
34373
|
+
try
|
|
34374
|
+
{
|
|
34375
|
+
const kept = readSaveData(name, {});
|
|
34376
|
+
if (!kept[key] || kept[key].stale) return true;
|
|
34377
|
+
kept[key].stale = true;
|
|
34378
|
+
localStorage.setItem(name, JSON.stringify(kept));
|
|
34379
|
+
}
|
|
34380
|
+
catch {} // no room for the mark either
|
|
34381
|
+
return true;
|
|
34382
|
+
}
|
|
34383
|
+
|
|
34384
|
+
// the name of the file a level was fetched from, as it is on disk: without its folder and query, and with what
|
|
34385
|
+
// the url spells with a percent as it is written, so it is the name a picked file has
|
|
34386
|
+
function editorFileName(url, fallback)
|
|
34387
|
+
{
|
|
34388
|
+
const name = url?.split(/[?#]/)[0].split('/').pop();
|
|
34389
|
+
if (!name) return fallback;
|
|
34390
|
+
try { return decodeURIComponent(name); }
|
|
34391
|
+
catch { return name; }
|
|
34262
34392
|
}
|
|
34263
34393
|
|
|
34264
34394
|
// a map's name for its autosave, the file it was fetched from without a query, or its size, layer names and data as loaded; a map with
|
|
@@ -34314,12 +34444,12 @@ function editorAutosave(record)
|
|
|
34314
34444
|
else
|
|
34315
34445
|
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
|
|
34316
34446
|
layers: data, objects, nextobjectid: map.nextobjectid};
|
|
34317
|
-
editorWriteSaves(saves);
|
|
34447
|
+
editorWriteSaves(saves, record.key);
|
|
34318
34448
|
}
|
|
34319
34449
|
|
|
34320
34450
|
// paint every cell of a map's layers from a list of tile data, the tile layers of the map in order, and set its
|
|
34321
34451
|
// object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook
|
|
34322
|
-
function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height)
|
|
34452
|
+
function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height, keepUnknown=false)
|
|
34323
34453
|
{
|
|
34324
34454
|
editorStrokeEnd();
|
|
34325
34455
|
if (width !== record.map.width || height !== record.map.height)
|
|
@@ -34339,7 +34469,9 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
|
|
|
34339
34469
|
if (gids?.length === width * height)
|
|
34340
34470
|
gids.forEach((gid, i)=> editorPaint(layer, vec2(i % width, height - 1 - (i / width | 0)), gid));
|
|
34341
34471
|
}
|
|
34342
|
-
|
|
34472
|
+
// an autosave from before the file had an object layer leaves that layer as the file has it; Reset to file
|
|
34473
|
+
// empties a layer the file did not have
|
|
34474
|
+
editorObjectLayers(record).forEach((layer, i)=> objects && !(keepUnknown && i >= objects.length) &&
|
|
34343
34475
|
editorChangeObjects(layer, (list)=> list.splice(0, list.length, ...editorObjectsCopy(objects[i] ?? []))));
|
|
34344
34476
|
});
|
|
34345
34477
|
editorStroke ? editorStrokeEnd() : editorAutosave(record);
|
|
@@ -34359,7 +34491,7 @@ function editorApplyPending(record)
|
|
|
34359
34491
|
return false;
|
|
34360
34492
|
}
|
|
34361
34493
|
record.pending = record.pendingUnfit = undefined;
|
|
34362
|
-
editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height);
|
|
34494
|
+
editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height, true);
|
|
34363
34495
|
return true;
|
|
34364
34496
|
}
|
|
34365
34497
|
|
|
@@ -34464,8 +34596,7 @@ function editorClearSelections()
|
|
|
34464
34596
|
function editorMapChanged(record, before)
|
|
34465
34597
|
{
|
|
34466
34598
|
const stroke = [{resize: record, before, after: editorMapSnapshot(record)}];
|
|
34467
|
-
|
|
34468
|
-
editorRedoList.length = 0;
|
|
34599
|
+
editorUndoPush(stroke);
|
|
34469
34600
|
editorChanged(stroke);
|
|
34470
34601
|
}
|
|
34471
34602
|
|
|
@@ -34542,12 +34673,20 @@ function editorPaint(layer, pos, gid)
|
|
|
34542
34673
|
(editorStroke ||= []).push({layer, pos: pos.copy(), before, after: gid});
|
|
34543
34674
|
}
|
|
34544
34675
|
|
|
34676
|
+
// an edit goes on the undo list, which keeps the last 100, as the 3D editor's does: a fill of a large layer is
|
|
34677
|
+
// an entry for every cell, and a session of them would hold millions
|
|
34678
|
+
function editorUndoPush(stroke)
|
|
34679
|
+
{
|
|
34680
|
+
editorUndoList.push(stroke);
|
|
34681
|
+
editorUndoList.length > 100 && editorUndoList.shift();
|
|
34682
|
+
editorRedoList.length = 0;
|
|
34683
|
+
}
|
|
34684
|
+
|
|
34545
34685
|
// the stroke is done, it can be undone as one
|
|
34546
34686
|
function editorStrokeEnd()
|
|
34547
34687
|
{
|
|
34548
34688
|
if (!editorStroke) return;
|
|
34549
|
-
|
|
34550
|
-
editorRedoList.length = 0;
|
|
34689
|
+
editorUndoPush(editorStroke);
|
|
34551
34690
|
editorChanged(editorStroke);
|
|
34552
34691
|
editorStroke = undefined;
|
|
34553
34692
|
}
|
|
@@ -34957,7 +35096,7 @@ function editorUpdateObjects(space)
|
|
|
34957
35096
|
if (!editorObjectSelection.has(hit.id))
|
|
34958
35097
|
editorObjectSelection = new Set([hit.id]);
|
|
34959
35098
|
editorObjectDrag = {start: snap(mouse), from: new Map(editorSelectedObjects().map((object)=>
|
|
34960
|
-
[object.id,
|
|
35099
|
+
[object.id, {x: object.x, y: object.y}]))};
|
|
34961
35100
|
}
|
|
34962
35101
|
else if (editorObjectSelection.size)
|
|
34963
35102
|
editorObjectSelection.clear();
|
|
@@ -34972,8 +35111,10 @@ function editorUpdateObjects(space)
|
|
|
34972
35111
|
{
|
|
34973
35112
|
for (const object of list)
|
|
34974
35113
|
{
|
|
34975
|
-
|
|
34976
|
-
from
|
|
35114
|
+
// from the numbers it had, in the map's own units: through cells and back they pick up a hair
|
|
35115
|
+
const from = drag.from.get(object.id), {tilewidth=1, tileheight=1} = record.map;
|
|
35116
|
+
if (from)
|
|
35117
|
+
object.x = from.x + delta.x * tilewidth, object.y = from.y - delta.y * tileheight;
|
|
34977
35118
|
}
|
|
34978
35119
|
});
|
|
34979
35120
|
}
|
|
@@ -36868,7 +37009,7 @@ function editor3DLevelLoaded(level)
|
|
|
36868
37009
|
const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
|
|
36869
37010
|
const hash = editor3DContentHash(original, originalParts);
|
|
36870
37011
|
// sceneBase is the scene the game set, taken before the level's own block is applied
|
|
36871
|
-
const record = {fileName: url
|
|
37012
|
+
const record = {fileName: editorFileName(url, 'level3D.json'), key: url ?? 'level #' + hash, original,
|
|
36872
37013
|
originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
|
|
36873
37014
|
fileHandle: undefined, undo: [], redo: []};
|
|
36874
37015
|
editor3DRecords.set(level, record);
|
|
@@ -36969,6 +37110,7 @@ function editor3DChange(change)
|
|
|
36969
37110
|
const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
|
|
36970
37111
|
change(after);
|
|
36971
37112
|
if (editor3DSame(before, after)) return false;
|
|
37113
|
+
editor3DFixIds(after); // an object added with no id, or with one in use, gets one of its own
|
|
36972
37114
|
editor3DSetObjects(after);
|
|
36973
37115
|
editor3DStroke ||= {before, parts: editor3DLevelParts()};
|
|
36974
37116
|
return true;
|
|
@@ -37742,7 +37884,7 @@ function editor3DPrefabJSON(name)
|
|
|
37742
37884
|
|
|
37743
37885
|
// the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
|
|
37744
37886
|
// and the ids of that level's objects that had something made for them
|
|
37745
|
-
/** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string
|
|
37887
|
+
/** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string}>} */
|
|
37746
37888
|
const editor3DPrefabStack = [];
|
|
37747
37889
|
// the levels that are a prefab being edited, they have no autosave of their own
|
|
37748
37890
|
const editor3DPrefabLevels = new WeakSet;
|
|
@@ -37751,6 +37893,28 @@ const editor3DPrefabLevels = new WeakSet;
|
|
|
37751
37893
|
let editor3DPrefabEdits = {};
|
|
37752
37894
|
// the prefabs of the game's own that were edited and are not in a file yet
|
|
37753
37895
|
const editor3DPrefabDirty = new Set;
|
|
37896
|
+
// what is known of each prefab's file, by its name, so it outlasts the prefab being closed and opened again: the
|
|
37897
|
+
// save being written, for the next to wait on, what the file has since a Save, and what the prefab holds now
|
|
37898
|
+
/** @type {Map<string, {saving?: Promise<any>, saved?: string, content?: string}>} */
|
|
37899
|
+
const editor3DPrefabFiles = new Map;
|
|
37900
|
+
function editor3DPrefabFile(name)
|
|
37901
|
+
{
|
|
37902
|
+
editor3DPrefabFiles.has(name) || editor3DPrefabFiles.set(name, {});
|
|
37903
|
+
return editor3DPrefabFiles.get(name);
|
|
37904
|
+
}
|
|
37905
|
+
|
|
37906
|
+
// say if a prefab of the game's own is in its file, now that it holds this: once a Save has written it, it is
|
|
37907
|
+
// when what it holds is what was written; before any Save it is not once it was edited
|
|
37908
|
+
function editor3DPrefabFileCheck(name, content, edited=false)
|
|
37909
|
+
{
|
|
37910
|
+
const file = editor3DPrefabFile(name);
|
|
37911
|
+
file.content = content;
|
|
37912
|
+
if (level3DPrefabs.get(name)?.fromLevel) return; // one of the level's own is saved with the level
|
|
37913
|
+
if (file.saved !== undefined)
|
|
37914
|
+
content === file.saved ? editor3DPrefabDirty.delete(name) : editor3DPrefabDirty.add(name);
|
|
37915
|
+
else if (edited)
|
|
37916
|
+
editor3DPrefabDirty.add(name);
|
|
37917
|
+
}
|
|
37754
37918
|
|
|
37755
37919
|
// destroy what the level being edited has in the world, its objects' and its maps'
|
|
37756
37920
|
function editor3DPrefabClear()
|
|
@@ -37804,9 +37968,10 @@ function editor3DPrefabBack()
|
|
|
37804
37968
|
level3DPrefabSet(frame.name, prefab, fromLevel);
|
|
37805
37969
|
if (fromLevel)
|
|
37806
37970
|
editor3DPrefabEdits[frame.name] = prefab;
|
|
37807
|
-
else if (JSON.stringify(objects) !== frame.saved)
|
|
37808
|
-
editor3DPrefabDirty.add(frame.name); // not what Save wrote while it was open
|
|
37809
37971
|
}
|
|
37972
|
+
// one of the game's own is in its file or not by what it holds now, edited here or not: undone back to how it
|
|
37973
|
+
// was opened after a Save, it is not what the file has
|
|
37974
|
+
editor3DPrefabFileCheck(frame.name, JSON.stringify(objects), changed);
|
|
37810
37975
|
|
|
37811
37976
|
// the level it was opened from is the editor's again, with its own undo
|
|
37812
37977
|
const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
|
|
@@ -38134,8 +38299,10 @@ function editor3DAutosave(level=editor3DLevel, known)
|
|
|
38134
38299
|
if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
|
|
38135
38300
|
edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
|
|
38136
38301
|
});
|
|
38137
|
-
|
|
38138
|
-
|
|
38302
|
+
// with nothing edited any more, an undo back to how the prefab was, the level's autosave is the level's own
|
|
38303
|
+
// again, which takes away one of an edit that was undone and keeps the level's other edits
|
|
38304
|
+
const prefabs = {...editor3DLevelPart('prefabs', root), ...edits};
|
|
38305
|
+
root && editor3DAutosave(Object.keys(prefabs).length ? {...root, prefabs} : root, editor3DRecords.get(root));
|
|
38139
38306
|
return;
|
|
38140
38307
|
}
|
|
38141
38308
|
const record = known ?? editor3DRecords.get(level);
|
|
@@ -38149,12 +38316,9 @@ function editor3DAutosave(level=editor3DLevel, known)
|
|
|
38149
38316
|
else
|
|
38150
38317
|
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
|
|
38151
38318
|
...parts};
|
|
38152
|
-
|
|
38153
|
-
|
|
38154
|
-
|
|
38155
|
-
editor3DSaveFailed = false;
|
|
38156
|
-
}
|
|
38157
|
-
catch { editor3DSaveFailed = true; }
|
|
38319
|
+
const failed = editorSaveFailed; // the 2D editor's own flag is its own
|
|
38320
|
+
editor3DSaveFailed = editorWriteSaves(saves, record.key, editor3DSaveName());
|
|
38321
|
+
editorSaveFailed = failed;
|
|
38158
38322
|
}
|
|
38159
38323
|
|
|
38160
38324
|
// put the autosaved edits of a file that changed into the level, as one undo
|
|
@@ -38211,16 +38375,23 @@ async function editor3DSave(pickAgain=false)
|
|
|
38211
38375
|
// inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
|
|
38212
38376
|
const text = editor3DLevelJSON(level), name = open.name + '.json';
|
|
38213
38377
|
const written = JSON.stringify(editor3DObjects()); // as the file has it
|
|
38378
|
+
// its place in line is the prefab's, by its name: closed and opened again it is a new level here, and its
|
|
38379
|
+
// saves still go in the order they were asked for
|
|
38380
|
+
const file = editor3DPrefabFile(open.name), before = file.saving;
|
|
38214
38381
|
const saved = (async ()=>
|
|
38215
38382
|
{
|
|
38216
|
-
await
|
|
38383
|
+
await before;
|
|
38217
38384
|
const kept = await editorCall('onSave', text, name) === true;
|
|
38218
38385
|
kept || saveText(text, name, 'application/json');
|
|
38219
|
-
|
|
38220
|
-
|
|
38386
|
+
file.saved = written;
|
|
38387
|
+
// it is in its file when what it holds now is what was written: edited again while the save was
|
|
38388
|
+
// kept, or gone back from with more edits, it still waits
|
|
38389
|
+
const at = editor3DPrefabStack.indexOf(open);
|
|
38390
|
+
const now = at < 0 ? file.content : JSON.stringify((editor3DPrefabStack[at + 1]?.level ?? editor3DLevel).objects);
|
|
38391
|
+
editor3DPrefabFileCheck(open.name, now ?? written);
|
|
38221
38392
|
return kept ? 'kept' : 'downloaded';
|
|
38222
38393
|
})();
|
|
38223
|
-
|
|
38394
|
+
file.saving = saved.catch(()=> {});
|
|
38224
38395
|
return saved;
|
|
38225
38396
|
}
|
|
38226
38397
|
// what is written, kept as it is now: the level can change while the file is picked and written, and those
|