littlejsengine 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/littlejs.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.0';
38
+ const engineVersion = '1.24.0';
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.globalCompositeOperation = additive ? 'lighter' : 'source-over';
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
- workReadCanvas.width = sWidth;
6696
- workReadCanvas.height = sHeight;
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
@@ -8426,7 +8430,7 @@ function touchGamepadPointerUp(e)
8426
8430
  * - 2D spatial audio based on camera position with distance-based falloff
8427
8431
  * - Sound instance management (pause, resume, stop)
8428
8432
  * - Speech synthesis for text-to-speech
8429
- * - Music playback with ZzFXM support
8433
+ * - Music playback from audio files, looping by default
8430
8434
  * - Web Audio API integration with master gain control
8431
8435
  * - Sounds and the master bus can route through effects, see the audio effects plugin
8432
8436
  * @namespace Audio
@@ -8639,7 +8643,13 @@ class Sound
8639
8643
  */
8640
8644
  constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
8641
8645
  {
8642
- if (!soundEnable || headlessMode) return;
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;
@@ -9298,7 +9308,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
9298
9308
  }
9299
9309
 
9300
9310
  ///////////////////////////////////////////////////////////////////////////////
9301
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
9311
+ // ZzFX - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
9302
9312
 
9303
9313
  /** Generate and play a ZzFX sound
9304
9314
  *
@@ -9583,6 +9593,10 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
9583
9593
  * Load tile layers from exported data
9584
9594
  * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
9585
9595
  * tiles continue its numbering), finite maps in the CSV or array layer format; layer offsets and parallax are not read
9596
+ * - A tileset kept in the map with a margin or a spacing, a sheet with gaps between its tiles, is read where its
9597
+ * tiles are, whatever padding the tile info has; one in a file of its own (a tsx) is not read, pass a tile info
9598
+ * with its padding
9599
+ * - An LDtk level loads through tileLayersFromLDtk, which makes it a map like these
9586
9600
  * - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices
9587
9601
  * (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and
9588
9602
  * visibility carry to the layers inside it
@@ -9615,6 +9629,18 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
9615
9629
  ASSERT(tileMapData.width && tileMapData.height);
9616
9630
  ASSERT(tileMapData.layers && tileMapData.layers.length);
9617
9631
 
9632
+ // a sheet with a margin around its tiles or a spacing between them: the first tile is at the margin and each
9633
+ // cell is a tile and a spacing, which is a padding of half the spacing counted from there, by the tileset's
9634
+ // own count of columns, since the image's width does not say with a spacing that is not all around
9635
+ const tileset = tileMapData.tilesets?.[0];
9636
+ if (tileInfo && tileset && (tileset.margin > 0 || tileset.spacing > 0))
9637
+ {
9638
+ const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size;
9639
+ const width = tileInfo.textureInfo?.size.x || 0;
9640
+ const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing)));
9641
+ tileInfo = new TileInfo(vec2(margin), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
9642
+ }
9643
+
9618
9644
  // flatten group layers in order, a group's color and visibility carry to the layers inside it
9619
9645
  /** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */
9620
9646
  const layers = [];
@@ -9692,6 +9718,107 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
9692
9718
  function tileLayersColor(hex)
9693
9719
  { return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); }
9694
9720
 
9721
+ // the Tiled property type of an LDtk field type, for the ones Tiled has a property for
9722
+ const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: 'string', Multilines: 'string',
9723
+ Color: 'color', FilePath: 'file'};
9724
+
9725
+ /**
9726
+ * Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad
9727
+ * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled, so the last layer
9728
+ * of the LDtk file is layer 0; where LDtk stacks tiles in a cell, an edge over a fill, each tile over another
9729
+ * goes in a layer of its own just above, named with (2), (3) and so on, so a layer of the first tiles has one
9730
+ * wherever the LDtk layer has any, which makes it the one for collision
9731
+ * - An IntGrid layer with no tiles is a hidden layer of its values, for collision: pass its index as collisionLayer
9732
+ * - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at
9733
+ * its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string)
9734
+ * - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image
9735
+ * - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of
9736
+ * another grid size or another tileset is left out, with a warning in debug builds
9737
+ * - The level editor edits the map this returns, and saves it as a Tiled map
9738
+ * @param {Object} ldtk - The LDtk project, its JSON
9739
+ * @param {number|string} [level] - Which level, by its index or its identifier
9740
+ * @return {Object} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers
9741
+ * @example
9742
+ * const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0');
9743
+ * const layers = tileLayersLoad(map, tile(0, 16), 0, 1); // layer 1 is solid
9744
+ * objectLayersLoad(map);
9745
+ * @memberof TileLayers */
9746
+ function tileLayersFromLDtk(ldtk, level=0)
9747
+ {
9748
+ const levels = ldtk?.levels || [];
9749
+ const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level];
9750
+ ASSERT(data, 'LDtk level not found', level);
9751
+ ASSERT(isArray(data.layerInstances), 'LDtk level has no layers: levels saved as separate files are not read');
9752
+ const instances = data.layerInstances;
9753
+ const tiled = (l)=> l.__type !== 'Entities';
9754
+ const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
9755
+ const grid = first?.__gridSize || 16;
9756
+ const width = first?.__cWid || ceil(data.pxWid / grid), height = first?.__cHei || ceil(data.pxHei / grid);
9757
+ const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down',
9758
+ infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1};
9759
+
9760
+ // the tileset of the first layer that has one, where its tiles are in its image
9761
+ const tileset = (ldtk?.defs?.tilesets || []).find((t)=> t.uid === first?.__tilesetDefUid);
9762
+ if (tileset)
9763
+ map.tilesets = [{firstgid: 1, name: String(tileset.relPath || '').replace(/^.*[\\/]/, '').replace(/\.\w+$/, ''),
9764
+ image: tileset.relPath, imagewidth: tileset.pxWid, imageheight: tileset.pxHei,
9765
+ tilewidth: tileset.tileGridSize, tileheight: tileset.tileGridSize, margin: tileset.padding || 0,
9766
+ spacing: tileset.spacing || 0, columns: tileset.__cWid, tilecount: tileset.__cWid * tileset.__cHei}];
9767
+
9768
+ // LDtk lists the top layer first, Tiled the bottom one
9769
+ for (let i = instances.length; i--;)
9770
+ {
9771
+ const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++;
9772
+ if (!tiled(instance))
9773
+ {
9774
+ const objects = (instance.entityInstances || []).map((entity)=>
9775
+ {
9776
+ // an entity is placed by its pivot, an object here by its middle
9777
+ const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0;
9778
+ const properties = [];
9779
+ for (const field of entity.fieldInstances || [])
9780
+ {
9781
+ const type = tileLayersLDtkTypes[field.__type] || (/^(Local|Extern)Enum\./.test(field.__type) && 'string');
9782
+ type && field.__value != undefined && properties.push({name: field.__identifier, type, value: field.__value});
9783
+ }
9784
+ return {id: map.nextobjectid++, name: '', type: entity.__identifier, x: entity.px[0] + (.5 - pivotX) * w,
9785
+ y: entity.px[1] + (.5 - pivotY) * h, width: w, height: h, rotation: 0, visible: true, properties};
9786
+ });
9787
+ map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0});
9788
+ continue;
9789
+ }
9790
+ const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
9791
+ const usable = instance.__gridSize === grid && instance.__cWid === width && instance.__cHei === height &&
9792
+ (!tiles.length || instance.__tilesetDefUid === tileset?.uid);
9793
+ if (!usable)
9794
+ {
9795
+ debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
9796
+ continue;
9797
+ }
9798
+ const empty = ()=> new Array(width * height).fill(0), stacks = [empty()];
9799
+ if (tiles.length)
9800
+ {
9801
+ // a tile's place in pixels, its tile in the sheet, and its flips: bit 0 across, bit 1 down, which are
9802
+ // Tiled's two top bits; LDtk lists them in the order it draws them, so the first in a cell is the
9803
+ // bottom one and each after it goes a layer higher
9804
+ const depth = new Uint16Array(width * height);
9805
+ for (const t of tiles)
9806
+ {
9807
+ const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width;
9808
+ if (x >= 0 && x < width && y >= 0 && y < height)
9809
+ (stacks[depth[cell]++] ||= empty())[cell] =
9810
+ (t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0;
9811
+ }
9812
+ }
9813
+ else
9814
+ (instance.intGridCsv || []).forEach((value, k)=> k < stacks[0].length && (stacks[0][k] = value));
9815
+ const visible = instance.__type === 'IntGrid' && !tiles.length ? false : instance.visible !== false;
9816
+ stacks.forEach((data, k)=> map.layers.push({id: k ? map.nextlayerid++ : id, name: k ? `${name} (${k + 1})` : name,
9817
+ type: 'tilelayer', width, height, data, opacity: instance.__opacity ?? 1, visible, x: 0, y: 0}));
9818
+ }
9819
+ return map;
9820
+ }
9821
+
9695
9822
  ///////////////////////////////////////////////////////////////////////////////
9696
9823
  // Object layers
9697
9824
 
@@ -9786,7 +9913,8 @@ function objectLayersMake(tileMapData, object)
9786
9913
  const name = object.type || object.class, type = objectLayersTypes.get(name);
9787
9914
  if (!type)
9788
9915
  {
9789
- debug && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
9916
+ // an object with no type, a shape or a text someone drew in Tiled, is not one a game makes
9917
+ debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
9790
9918
  return;
9791
9919
  }
9792
9920
  const {height=0, tilewidth=1, tileheight=1} = tileMapData;
@@ -11870,6 +11998,9 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
11870
11998
  glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
11871
11999
  }
11872
12000
 
12001
+ // the list and the vectors glDrawPointsTransform writes its points into
12002
+ const glPointsOut = [], glPointsPool = [];
12003
+
11873
12004
  /** Transform and add a polygon to the gl draw list
11874
12005
  * @param {Array<Vector2>} points - Array of Vector2 points
11875
12006
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -11882,15 +12013,21 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
11882
12013
  * @memberof WebGL */
11883
12014
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
11884
12015
  {
11885
- const pointsOut = [];
12016
+ // written into vectors kept for this, a circle would make one for each of its sides at every draw; they are
12017
+ // read into the batch before this returns
12018
+ const pointsOut = glPointsOut, pool = glPointsPool, count = points.length;
12019
+ pointsOut.length = count;
11886
12020
  const sa = sin(-angle);
11887
12021
  const ca = cos(-angle);
11888
- for (const p of points)
12022
+ for (let i = 0; i < count; ++i)
11889
12023
  {
11890
12024
  // transform the point
12025
+ const p = points[i], out = pool[i] ||= vec2();
11891
12026
  const px = p.x*sx;
11892
12027
  const py = p.y*sy;
11893
- pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
12028
+ out.x = x + ca*px - sa*py;
12029
+ out.y = y + sa*px + ca*py;
12030
+ pointsOut[i] = out;
11894
12031
  }
11895
12032
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
11896
12033
  glDrawPoints(drawPoints, rgba);
@@ -13325,8 +13462,11 @@ class PostProcessPlugin
13325
13462
  {
13326
13463
  // copy main canvas to work canvas at the backing store size,
13327
13464
  // mainCanvasSize is css pixels so it would lose resolution
13328
- workCanvas.width = mainCanvas.width;
13329
- workCanvas.height = mainCanvas.height;
13465
+ // sized again only when the canvas changed, setting a size remakes the canvas even to the same one
13466
+ if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height)
13467
+ workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height;
13468
+ else
13469
+ workContext.clearRect(0, 0, workCanvas.width, workCanvas.height);
13330
13470
  glCopyToContext(workContext);
13331
13471
  workContext.drawImage(mainCanvas, 0, 0);
13332
13472
  // clear the main canvas with clearRect, resizing it would also
@@ -14420,164 +14560,6 @@ class Light extends EngineObject
14420
14560
  }
14421
14561
  }
14422
14562
 
14423
- /**
14424
- * LittleJS ZzFXM Plugin
14425
- * @namespace ZzFXM
14426
- */
14427
-
14428
- /**
14429
- * Music Object - Stores a zzfx music track for later use
14430
- *
14431
- * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
14432
- * @extends Sound
14433
- * @memberof ZzFXM
14434
- * @example
14435
- * // create some music
14436
- * const music_example = new ZzFXMusic(
14437
- * [
14438
- * [ // instruments
14439
- * [,0,400] // simple note
14440
- * ],
14441
- * [ // patterns
14442
- * [ // pattern 1
14443
- * [ // channel 0
14444
- * 0, -1, // instrument 0, left speaker
14445
- * 1, 0, 9, 1 // channel notes
14446
- * ],
14447
- * [ // channel 1
14448
- * 0, 1, // instrument 0, right speaker
14449
- * 0, 12, 17, -1 // channel notes
14450
- * ]
14451
- * ],
14452
- * ],
14453
- * [0, 0, 0, 0], // sequence, play pattern 0 four times
14454
- * 90 // BPM
14455
- * ]);
14456
- *
14457
- * // play the music on a loop
14458
- * music_example.playMusic();
14459
- */
14460
- class ZzFXMusic extends Sound
14461
- {
14462
- /** Create a music object and cache the zzfx music samples for later use
14463
- * @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
14464
- */
14465
- constructor(zzfxMusic)
14466
- {
14467
- super(undefined);
14468
-
14469
- if (!soundEnable || headlessMode) return;
14470
- const [instruments, patterns, sequence, BPM] = zzfxMusic;
14471
- super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
14472
- this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
14473
- this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
14474
- }
14475
- }
14476
-
14477
- ///////////////////////////////////////////////////////////////////////////////
14478
- // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
14479
-
14480
- /** Generate samples for a ZzFM song with given parameters
14481
- * @param {Array} instruments - Array of ZzFX sound parameters
14482
- * @param {Array} patterns - Array of pattern data
14483
- * @param {Array} sequence - Array of pattern indexes
14484
- * @param {number} [BPM] - Playback speed of the song in BPM
14485
- * @return {Array} - Left and right channel sample data
14486
- * @memberof ZzFXM */
14487
- function zzfxM(instruments, patterns, sequence, BPM = 125)
14488
- {
14489
- let i, j, k;
14490
- let instrumentParameters;
14491
- let note;
14492
- let sample;
14493
- let patternChannel;
14494
- let notFirstBeat;
14495
- let stop;
14496
- let instrument;
14497
- let attenuation;
14498
- let outSampleOffset;
14499
- let isSequenceEnd;
14500
- let sampleOffset = 0;
14501
- let nextSampleOffset;
14502
- let sampleBuffer = [];
14503
- let leftChannelBuffer = [];
14504
- let rightChannelBuffer = [];
14505
- let channelIndex = 0;
14506
- let panning = 0;
14507
- let hasMore = 1;
14508
- let sampleCache = {};
14509
- let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
14510
-
14511
- // for each channel in order until there are no more
14512
- for (; hasMore; channelIndex++) {
14513
-
14514
- // reset current values
14515
- sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
14516
-
14517
- // for each pattern in sequence
14518
- sequence.forEach((patternIndex, sequenceIndex)=> {
14519
- // get pattern for current channel, use empty 1 note pattern if none found
14520
- patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
14521
-
14522
- // check if there are more channels
14523
- hasMore |= patterns[patternIndex][channelIndex]&&1;
14524
-
14525
- // get next offset, use the length of first channel
14526
- nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
14527
- // for each beat in pattern, plus one extra if end of sequence
14528
- isSequenceEnd = sequenceIndex === sequence.length - 1;
14529
- for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
14530
-
14531
- // <channel-note>
14532
- note = patternChannel[i];
14533
-
14534
- // stop if end, different instrument or new note
14535
- stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
14536
- instrument !== (patternChannel[0] || 0) || note | 0;
14537
-
14538
- // fill buffer with samples for previous beat, most cpu intensive part
14539
- for (j = 0; j < beatLength && notFirstBeat;
14540
-
14541
- // fade off attenuation at end of beat if stopping note, prevents clicking
14542
- j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
14543
- ) {
14544
- // copy sample to stereo buffers with panning
14545
- sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
14546
- leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
14547
- rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
14548
- }
14549
-
14550
- // set up for next note
14551
- if (note) {
14552
- // set attenuation
14553
- attenuation = note % 1;
14554
- panning = patternChannel[1] || 0;
14555
- if (note |= 0) {
14556
- // get cached sample
14557
- sampleBuffer = sampleCache[
14558
- [
14559
- instrument = patternChannel[sampleOffset = 0] || 0,
14560
- note
14561
- ]
14562
- ] = sampleCache[[instrument, note]] || (
14563
- // add sample to cache
14564
- instrumentParameters = [...instruments[instrument]],
14565
- instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
14566
-
14567
- // allow negative values to stop notes
14568
- note > 0 ? zzfxG(...instrumentParameters) : []
14569
- );
14570
- }
14571
- }
14572
- }
14573
-
14574
- // update the sample offset
14575
- outSampleOffset = nextSampleOffset;
14576
- });
14577
- }
14578
-
14579
- return [leftChannelBuffer, rightChannelBuffer];
14580
- }
14581
14563
  /**
14582
14564
  * LittleJS Audio Effects Plugin
14583
14565
  * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
@@ -15567,8 +15549,7 @@ class UISystemPlugin
15567
15549
  }
15568
15550
  }
15569
15551
 
15570
- /** Destroy and remove all objects
15571
- * @memberof UISystem */
15552
+ /** Destroy and remove all objects */
15572
15553
  destroyObjects()
15573
15554
  {
15574
15555
  for (const o of this.uiObjects)
@@ -16089,8 +16070,10 @@ class UIObject
16089
16070
  return void inputClearKey(0, 0, false, true, false);
16090
16071
  }
16091
16072
  }
16073
+ // the object that was the active one going into this update: one pressed and let go inside a
16074
+ // frame is clicked on the next, with its release, not on both
16092
16075
  if (!uiSystem.activateOnPress)
16093
- if (!mouseDown && this.isActiveObject() && this.interactive)
16076
+ if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
16094
16077
  this.click();
16095
16078
  if (this.destroyed) return;
16096
16079
  }
@@ -16833,7 +16816,10 @@ class UIVideo extends UIObject
16833
16816
  if (this.destroyed)
16834
16817
  return;
16835
16818
 
16819
+ // let go of the media too, a paused video keeps what it has loaded
16836
16820
  this.video.pause();
16821
+ this.video.removeAttribute?.('src');
16822
+ this.video.load?.();
16837
16823
  this.video.remove();
16838
16824
  super.destroy();
16839
16825
  }
@@ -20172,6 +20158,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
20172
20158
  for (const group of parseAtlas(data))
20173
20159
  {
20174
20160
  // reserve a block of full size cells, one per frame
20161
+ if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
20175
20162
  const sourceSize = group.frames[0].sourceSize;
20176
20163
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
20177
20164
  const added = textureSheetAdd(blockSize, sourceSize, padding);
@@ -20957,45 +20944,50 @@ function tweenUpdate(gameDelta, realDelta)
20957
20944
  const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
20958
20945
  for (const t of tweenActive)
20959
20946
  list.push(t);
20960
- for (let i = list.length; i--;)
20947
+ // the list is let go however the walk ends: a callback that throws must not leave it held, or every
20948
+ // update after would take it for an update still going and make a list of its own
20949
+ try
20961
20950
  {
20962
- const t = list[i];
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)
20951
+ for (let i = list.length; i--;)
20968
20952
  {
20969
- // a paused tween keeps count too, so it does not jump when resumed
20970
- dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
20971
- t.lastTime = time;
20972
- t.lastTimeReal = timeReal;
20973
- }
20974
- else
20975
- dt = t.useRealTime ? realDelta : gameDelta;
20976
- if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
20977
- if (t.paused || dt <= 0) continue;
20953
+ const t = list[i];
20954
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
20955
+ // counts on from this one
20956
+ if (!t.active || t.activePass >= pass) continue;
20957
+ let dt;
20958
+ if (enginePath)
20959
+ {
20960
+ // a paused tween keeps count too, so it does not jump when resumed
20961
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
20962
+ t.lastTime = time;
20963
+ t.lastTimeReal = timeReal;
20964
+ }
20965
+ else
20966
+ dt = t.useRealTime ? realDelta : gameDelta;
20967
+ if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
20968
+ if (t.paused || dt <= 0) continue;
20978
20969
 
20979
- t.life -= dt;
20980
- if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
20981
- {
20982
- t.callback(t.interp(t.life));
20983
- }
20984
- else
20985
- {
20986
- // Completion: fire end value, remove from active, start the next iteration
20987
- // of a loop or pingPong, or when there is none it has completed, fire onComplete
20988
- t.callback(t.interp(0));
20989
- if (!t.active || t.activePass >= pass)
20990
- continue; // stopped or restarted by its own callback, the run it was on ends without completing
20991
- tweenDeactivate(t);
20992
- const next = t.thenCallback;
20993
- t.thenCallback = undefined;
20994
- if (!(next && next()) && t.onComplete)
20995
- t.onComplete();
20970
+ t.life -= dt;
20971
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
20972
+ {
20973
+ t.callback(t.interp(t.life));
20974
+ }
20975
+ else
20976
+ {
20977
+ // Completion: fire end value, remove from active, start the next iteration
20978
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
20979
+ t.callback(t.interp(0));
20980
+ if (!t.active || t.activePass >= pass)
20981
+ continue; // stopped or restarted by its own callback, the run it was on ends without completing
20982
+ tweenDeactivate(t);
20983
+ const next = t.thenCallback;
20984
+ t.thenCallback = undefined;
20985
+ if (!(next && next()) && t.onComplete)
20986
+ t.onComplete();
20987
+ }
20996
20988
  }
20997
20989
  }
20998
- list.length = 0;
20990
+ finally { list.length = 0; }
20999
20991
  }
21000
20992
 
21001
20993
  /** Stop every active tween, ending loops too, without calling their then-callbacks.
@@ -23377,17 +23369,19 @@ function render3DInstance(mesh, matrix, tileInfo, color)
23377
23369
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
23378
23370
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
23379
23371
  // 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
- function render3DInstanceSlot(mesh, textureInfo, mirrored)
23372
+ // so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard
23373
+ // passes that its batch is unlit, for the same reason
23374
+ function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false)
23382
23375
  {
23383
23376
  const r = render3D;
23384
23377
  if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
23385
- || render3DStateChanged(mesh.instanceState)))
23378
+ || mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState)))
23386
23379
  render3DFlushInstances(mesh);
23387
23380
  if (!mesh.instanceCount)
23388
23381
  {
23389
23382
  mesh.instanceTextureInfo = textureInfo;
23390
23383
  mesh.instanceMirrored = mirrored;
23384
+ mesh.instanceUnlit = unlit;
23391
23385
  mesh.instanceState = render3DCaptureBatchState();
23392
23386
  r.instanceMeshes.push(mesh);
23393
23387
  }
@@ -23421,6 +23415,8 @@ function render3DFlushInstances(only)
23421
23415
  const state = mesh.instanceState;
23422
23416
  if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
23423
23417
  state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
23418
+ if (mesh.instanceUnlit) // a batch of sprites
23419
+ state.lighting = false;
23424
23420
  render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
23425
23421
  }
23426
23422
  if (!only)
@@ -23777,6 +23773,7 @@ class Render3DPlugin
23777
23773
  /** @type {TextureInfo|undefined} */
23778
23774
  this.streamTileInfo = undefined;
23779
23775
  this.streamState = undefined; // captured state the pending batch was drawn under
23776
+ this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are
23780
23777
  /** @type {Mesh|undefined} */
23781
23778
  this.capture = undefined; // the mesh a bake is filling
23782
23779
  /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
@@ -23985,8 +23982,8 @@ class Render3DPlugin
23985
23982
  }
23986
23983
 
23987
23984
  /** Find the nearest object under a screen position or along a ray, for clicking on things
23988
- * - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
23989
- * not triangle by triangle
23985
+ * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
23986
+ * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
23990
23987
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
23991
23988
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
23992
23989
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -24167,6 +24164,8 @@ class Render3DPlugin
24167
24164
  {
24168
24165
  if (!this.streamCount || !render3DCanDraw()) return;
24169
24166
  const gl = glContext;
24167
+ if (this.streamUnlit)
24168
+ this.streamState.lighting = false; // its own copy of the state
24170
24169
  render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
24171
24170
  render3DBindVertexBuffer(this.streamBuffer);
24172
24171
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
@@ -24401,13 +24400,34 @@ class Render3DPlugin
24401
24400
  return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
24402
24401
  }
24403
24402
 
24404
- // the quad's six stream vertices written straight in with no vectors made, unlit, for sprites, and for
24405
- // particles when instancing is off
24406
- const lit = this.lighting;
24407
- this.lighting = this.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides
24408
- let uvRect;
24409
- try { uvRect = render3DBeginStrip(6, tileInfo); }
24410
- finally { this.lighting = lit; }
24403
+ // unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not
24404
+ // set, which would move its version at every sprite
24405
+ const unlit = !this.shadowPass;
24406
+ if (!this.blend && this.depthTest && this.instancing)
24407
+ {
24408
+ // an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a
24409
+ // sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader
24410
+ // drops its see through texels, so it needs no sorting
24411
+ if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow
24412
+ if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2))
24413
+ return;
24414
+ const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo);
24415
+ render3DMeshUpload(quad);
24416
+ // the quad's half axes, doubled for its unit square
24417
+ const a = render3DBillboardAxes(size, angle, upright);
24418
+ const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData;
24419
+ data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
24420
+ data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
24421
+ data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
24422
+ data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1;
24423
+ data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
24424
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
24425
+ return;
24426
+ }
24427
+
24428
+ // one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no
24429
+ // vectors made, for sprites that blend, and for particles when instancing is off
24430
+ const uvRect = render3DBeginStrip(6, tileInfo, unlit);
24411
24431
  if (!uvRect) return;
24412
24432
  const a = render3DBillboardAxes(size, angle, upright);
24413
24433
  const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
@@ -25519,8 +25539,9 @@ function render3DRenderDepth()
25519
25539
  // which way they face, so keeping the count even keeps every strip facing out.
25520
25540
 
25521
25541
  // 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
- function render3DBeginStrip(count, tileInfo)
25542
+ // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws
25543
+ // the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw
25544
+ function render3DBeginStrip(count, tileInfo, unlit=false)
25524
25545
  {
25525
25546
  const r = render3D;
25526
25547
  if (!render3DCanDraw()) return;
@@ -25528,12 +25549,13 @@ function render3DBeginStrip(count, tileInfo)
25528
25549
  ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
25529
25550
  if (count > RENDER3D_MAX_STREAM_VERTS) return;
25530
25551
  const textureInfo = render3DTextureOf(tileInfo);
25531
- if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
25532
- || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
25552
+ if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit
25553
+ || render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
25533
25554
  r.flush();
25534
25555
  render3DFlushBeforeOverlay();
25535
25556
  if (!r.streamCount)
25536
25557
  r.streamState = render3DCaptureBatchState();
25558
+ r.streamUnlit = unlit;
25537
25559
  r.streamTileInfo = textureInfo;
25538
25560
  return render3DGetTileUVs(tileInfo);
25539
25561
  }
@@ -27328,8 +27350,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
27328
27350
  return collected;
27329
27351
  }
27330
27352
 
27331
- // how far along a ray an object is hit, or undefined for a miss; each one is tested as the box around its mesh in
27332
- // its own space, or a sprite as the quad it draws, not triangle by triangle
27353
+ // how far along a ray an object is hit, or undefined for a miss; a mesh is hit on its triangles, in its own space,
27354
+ // and a sprite as the quad it draws
27333
27355
  function render3DRaycastObject(ray, o)
27334
27356
  {
27335
27357
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -27346,25 +27368,55 @@ function render3DRaycastObject(ray, o)
27346
27368
  const distance = raycastSphere(ray, center, radius);
27347
27369
  if (distance === undefined) return;
27348
27370
 
27349
- // the sphere is a quick reject, a mesh is hit where the ray meets its box in its own space, since a wide floor's
27350
- // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
27371
+ // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
27372
+ // against the triangles; the direction is not made unit length, so the distance holds in the world;
27351
27373
  // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
27352
27374
  if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
27353
27375
  const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
27354
27376
  const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
27355
27377
  const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
27356
- if (hit !== 0) return hit;
27357
-
27358
- // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
27359
- // inside comes first
27360
- let exit = Infinity;
27361
- for (const k of ['x', 'y', 'z'])
27378
+ if (hit === undefined || !ray.direction.lengthSquared())
27379
+ return hit; // a ray of no length is where it starts, inside the box
27380
+ return render3DRaycastMesh(local, mesh);
27381
+ }
27382
+
27383
+ // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
27384
+ // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
27385
+ // triangle i is (i-2, i-1, i), the odd ones read the other way, and a join between its pieces has no area and is
27386
+ // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
27387
+ function render3DRaycastMesh(ray, mesh)
27388
+ {
27389
+ const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
27390
+ const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
27391
+ const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
27392
+ const count = indices ? indices.length / 3 | 0 : points.length - 2;
27393
+ let nearest;
27394
+ for (let i = 0; i < count; ++i)
27362
27395
  {
27363
- const d = local.direction[k];
27364
- if (d)
27365
- exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
27396
+ // the corners clockwise from the front: an indexed list is written the other way round
27397
+ let a, b, c;
27398
+ if (indices)
27399
+ a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
27400
+ else
27401
+ a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
27402
+
27403
+ // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
27404
+ const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
27405
+ const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
27406
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
27407
+ const det = e1x * hx + e1y * hy + e1z * hz;
27408
+ if (both ? !det : !(det < 0)) continue;
27409
+ const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
27410
+ const u = (sx * hx + sy * hy + sz * hz) / det;
27411
+ if (!(u >= 0 && u <= 1)) continue;
27412
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
27413
+ const v = (dx * qx + dy * qy + dz * qz) / det;
27414
+ if (!(v >= 0 && u + v <= 1)) continue;
27415
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
27416
+ if (t >= 0 && !(t >= nearest))
27417
+ nearest = t;
27366
27418
  }
27367
- return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
27419
+ return nearest;
27368
27420
  }
27369
27421
 
27370
27422
  // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
@@ -27742,10 +27794,12 @@ class InstancedMesh3D extends EngineObject3D
27742
27794
  * - radius is a world distance, so scale3D does not change it
27743
27795
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
27744
27796
  * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
27797
+ * - addFlare gives it a lens flare, see LensFlare3D
27745
27798
  * @extends EngineObject3D
27746
27799
  * @memberof Render3D
27747
27800
  * @example
27748
27801
  * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
27802
+ * torch.addFlare(); // light in the lens when the torch is in view
27749
27803
  */
27750
27804
  class Light3D extends EngineObject3D
27751
27805
  {
@@ -27780,6 +27834,49 @@ class Light3D extends EngineObject3D
27780
27834
  * bright core */
27781
27835
  this.glowFalloff = 1;
27782
27836
  this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
27837
+ /** @type {LensFlare3D|undefined} */
27838
+ this.flareObject = undefined;
27839
+ }
27840
+
27841
+ /** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had
27842
+ * @param {number} [size] - Scales every part of it
27843
+ * @param {number} [count] - How many ghosts there are, besides the glow at the light
27844
+ * @param {number} [intensity] - How bright it is
27845
+ * @param {number} [saturation] - How colorful the ghosts are
27846
+ * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
27847
+ addFlare(size, count, intensity, saturation)
27848
+ {
27849
+ const flare = new LensFlare3D(size, count, intensity, saturation);
27850
+ this.flare = flare;
27851
+ return flare;
27852
+ }
27853
+
27854
+ /** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and
27855
+ * destroying the flare takes it away
27856
+ * - The flare is the light's: it is attached to the light as its child, so it stays through a scene change
27857
+ * when the light does, and is destroyed with the light or when another takes its place
27858
+ * @type {LensFlare3D|undefined} */
27859
+ get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; }
27860
+
27861
+ set flare(flare)
27862
+ {
27863
+ const old = this.flare; // a flare that was destroyed on its own is no flare
27864
+ if (flare === old) return;
27865
+ old && old.destroy();
27866
+ if (flare)
27867
+ {
27868
+ flare.light = this;
27869
+ flare.parent || this.addChild(flare);
27870
+ }
27871
+ this.flareObject = flare || undefined;
27872
+ }
27873
+
27874
+ /** Destroy the light, and its flare with it
27875
+ * @param {boolean} [immediate] */
27876
+ destroy(immediate)
27877
+ {
27878
+ this.flare = undefined;
27879
+ super.destroy(immediate);
27783
27880
  }
27784
27881
 
27785
27882
  /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
@@ -29298,31 +29395,74 @@ class Trail3D extends EngineObject3D
29298
29395
  // lens flare
29299
29396
 
29300
29397
  // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
29301
- // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
29398
+ // glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star
29399
+ // of six rays; undefined headless or without a canvas
29400
+ const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star'];
29302
29401
  let render3DFlareTiles;
29303
29402
  function render3DFlareTile(shape)
29304
29403
  {
29305
29404
  if (!render3DFlareTiles)
29306
29405
  {
29307
29406
  if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
29308
- const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
29407
+ const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
29309
29408
  // each shape is how see-through it is from its middle out
29310
29409
  const alpha =
29311
29410
  {
29312
29411
  glow: (t)=> engineGlowAlpha(t, 1),
29313
29412
  disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
29314
29413
  ring: (t)=> max(0, 1 - abs(t - .86) / .12),
29414
+ hex: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
29415
+ streak: (t)=> (1 - t) ** 1.5,
29315
29416
  };
29316
- shapes.forEach((shape, i)=>
29417
+ // a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon
29418
+ const fill = (shape, r, path)=>
29317
29419
  {
29318
- const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
29319
- const gradient = context.createRadialGradient(x, y, 0, x, y, r);
29420
+ const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r);
29320
29421
  for (let k = 0; k <= steps; ++k)
29321
29422
  gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
29322
29423
  context.fillStyle = gradient;
29323
29424
  context.beginPath();
29324
- context.arc(x, y, r, 0, 2 * PI);
29425
+ path ? path() : context.arc(0, 0, r, 0, 2 * PI);
29325
29426
  context.fill();
29427
+ };
29428
+ const ray = (r, angle)=>
29429
+ {
29430
+ context.save();
29431
+ context.rotate(angle);
29432
+ context.scale(1, .07);
29433
+ fill('streak', r);
29434
+ context.restore();
29435
+ };
29436
+ shapes.forEach((shape, i)=>
29437
+ {
29438
+ const r = cell / 2 - 2;
29439
+ context.save();
29440
+ context.translate(i * cell + cell / 2, cell / 2);
29441
+ if (shape == 'hex')
29442
+ {
29443
+ // a gradient is round, so the hexagon is set pixel by pixel, by how far each is toward a side
29444
+ const image = context.createImageData(cell, cell), data = image.data, apothem = r * cos(PI / 6);
29445
+ for (let k = 0; k < cell * cell; ++k)
29446
+ {
29447
+ const x = abs(k % cell + .5 - cell / 2), y = abs((k / cell | 0) + .5 - cell / 2);
29448
+ const t = max(y, x * cos(PI / 6) + y / 2) / apothem;
29449
+ data[k * 4] = data[k * 4 + 1] = data[k * 4 + 2] = 255;
29450
+ data[k * 4 + 3] = t < 1 ? clamp(alpha.hex(t)) * 255 : 0;
29451
+ }
29452
+ context.putImageData(image, i * cell, 0);
29453
+ }
29454
+ else if (shape == 'streak')
29455
+ ray(r, 0);
29456
+ else if (shape == 'star')
29457
+ {
29458
+ for (let k = 3; k--;)
29459
+ ray(r, k * PI / 3 + PI / 2);
29460
+ context.scale(.3, .3);
29461
+ fill('glow', r);
29462
+ }
29463
+ else
29464
+ fill(shape, r);
29465
+ context.restore();
29326
29466
  });
29327
29467
  const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
29328
29468
  render3DFlareTiles = new Map(shapes.map((shape, i)=>
@@ -29331,16 +29471,32 @@ function render3DFlareTile(shape)
29331
29471
  return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
29332
29472
  }
29333
29473
 
29474
+ /**
29475
+ * A part of a lens flare
29476
+ * @typedef {Object} LensFlareElement
29477
+ * @property {number} at - Where along the line: 0 the sun, 1 the middle of the screen, 2 as far past it
29478
+ * @property {number|Vector2} size - How big across, as a part of the screen's height, a vector for a part wider
29479
+ * than it is tall
29480
+ * @property {Color} color - Its color, the alpha how bright
29481
+ * @property {string} [shape] - glow, disc, ring, hex, streak or star, a glow when left out
29482
+ * @property {TileInfo} [tileInfo] - A tile of the game's own to draw in place of a shape, best white on clear
29483
+ * @property {number} [angle] - How far it is turned, in radians
29484
+ * @memberof Render3D
29485
+ */
29486
+
29334
29487
  /**
29335
29488
  * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
29336
29489
  * sizes along the line from the sun through the middle of the screen
29337
29490
  * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
29338
29491
  * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
29492
+ * - A Light3D gets one of its own with light.addFlare()
29339
29493
  * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
29340
- * with the sun's own color; or give it elements of your own
29341
- * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
29494
+ * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
29495
+ * give it elements of your own, which may be tiles of the game's
29496
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
29497
+ * sun is off the screen, where nothing is tested
29342
29498
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
29343
- * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
29499
+ * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
29344
29500
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
29345
29501
  * @extends EngineObject3D
29346
29502
  * @memberof Render3D
@@ -29370,48 +29526,61 @@ class LensFlare3D extends EngineObject3D
29370
29526
  this.saturation = saturation;
29371
29527
  /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
29372
29528
  this.seed = 1;
29373
- /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
29374
- * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
29375
- * line, 0 the sun, 1 the middle of the screen, 2 as far past it; size is across, as a part of the screen's
29376
- * height; shape is glow, disc or ring
29377
- * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
29529
+ /** @property {Array<string>|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex,
29530
+ * streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often;
29531
+ * discs, rings and glows when not set
29532
+ * @type {Array<string>|undefined} */
29533
+ this.shapes = undefined;
29534
+ /** @property {number} - Scales the glow at the sun, 0 for none */
29535
+ this.glowSize = 1;
29536
+ /** @property {number} - Scales the ghosts */
29537
+ this.ghostSize = 1;
29538
+ /** @property {Array<LensFlareElement>|undefined} - The parts of the flare, to set your own in place of the
29539
+ * ones made from count, seed, saturation, shapes, glowSize and ghostSize
29540
+ * @type {Array<LensFlareElement>|undefined} */
29378
29541
  this.elements = undefined;
29379
29542
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
29380
29543
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
29381
- * in front of the light, and a spotlight's shows from inside its beam only
29544
+ * in front of the light, and a spotlight's shows from inside its beam only; a DirectionalLight3D's is far
29545
+ * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
29546
+ * sets this
29382
29547
  * @type {Light3D|undefined} */
29383
29548
  this.light = undefined;
29384
29549
  /** @property {boolean} - Fade out when something is between the camera and the sun */
29385
29550
  this.occlusion = true;
29386
29551
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
29387
29552
  this.fadeTime = .15;
29388
- /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
29553
+ /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
29554
+ * plain view, eased */
29389
29555
  this.visible = 1;
29390
29556
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
29391
29557
  this.madeKey = '';
29392
- /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
29558
+ /** @type {Array<LensFlareElement>} */
29393
29559
  this.made = [];
29394
29560
  }
29395
29561
 
29396
- /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
29397
- * and a core at the sun and the ghosts, made again when one of those changes
29398
- * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
29562
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
29563
+ * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
29564
+ * @return {Array<LensFlareElement>} */
29399
29565
  getElements()
29400
29566
  {
29401
29567
  if (this.elements) return this.elements;
29402
- const key = [this.count, this.seed, this.saturation].join();
29568
+ const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
29569
+ const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
29403
29570
  if (key !== this.madeKey)
29404
29571
  {
29405
29572
  const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
29406
- const made = this.made = [
29407
- {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
29408
- {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
29573
+ /** @type {Array<LensFlareElement>} */
29574
+ const made = this.made = glow > 0 ? [
29575
+ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
29576
+ {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
29409
29577
  for (let i = 0; i < this.count; ++i)
29410
29578
  {
29411
29579
  // spread along the line, each a place of its own, the far ones bigger
29412
29580
  const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
29413
- const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
29414
- made.push({at, size: random.float(.04, .1) * (1 + at), shape,
29581
+ const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
29582
+ pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
29583
+ made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
29415
29584
  color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
29416
29585
  }
29417
29586
  this.madeKey = key;
@@ -29419,69 +29588,85 @@ class LensFlare3D extends EngineObject3D
29419
29588
  return this.made;
29420
29589
  }
29421
29590
 
29422
- // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
29423
- // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
29591
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
29592
+ // directional light, which shines from its place toward the origin, and a point to find it on the screen by;
29593
+ // undefined with no direction, or a light that is gone or at the camera
29424
29594
  flareSource()
29425
29595
  {
29426
29596
  const camera = render3D.camera.pos, light = this.light;
29427
- if (!light)
29597
+ if (light?.destroyed) return;
29598
+ if (!light || light.directional)
29428
29599
  {
29429
- const sun = render3D.sunDirection;
29430
- if (!sun.lengthSquared()) return;
29431
- const direction = sun.normalize();
29600
+ const from = light ? light.getWorldPos3D() : render3D.sunDirection;
29601
+ if (!from.lengthSquared()) return;
29602
+ const direction = from.normalize();
29432
29603
  return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
29433
29604
  }
29434
- if (light.destroyed) return;
29435
29605
  const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
29436
29606
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
29437
29607
  }
29438
29608
 
29439
- /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
29440
- * behind the camera
29441
- * @return {Vector2|undefined} */
29442
- getSunScreenPos()
29443
- {
29444
- const source = this.flareSource();
29445
- return source && render3D.worldToScreen(source.pos);
29446
- }
29447
-
29448
- /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
29449
- * color, dimmed by how much of the sun shows; empty when there is nothing to draw
29450
- * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
29451
- getScreenElements()
29609
+ // how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
29610
+ // fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
29611
+ // show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
29612
+ flareLook()
29452
29613
  {
29453
- const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
29454
- if (!sun || !center.x || !center.y) return [];
29614
+ const source = this.flareSource(), center = mainCanvasSize.scale(.5);
29615
+ const sun = source && render3D.worldToScreen(source.pos);
29616
+ if (!sun || !center.x || !center.y) return;
29455
29617
  // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
29456
29618
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
29457
- const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
29458
- if (!(strength > 0)) return [];
29619
+ const strength = clamp((1.3 - off) / .5) * this.intensity;
29620
+ if (!(strength > 0)) return;
29459
29621
  let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
29460
- const light = this.light, source = light && this.flareSource();
29622
+ const light = this.light;
29461
29623
  if (light)
29462
29624
  {
29463
- if (!source) return [];
29464
29625
  // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
29465
29626
  const cone = render3DLightCone(light), d = source.direction;
29466
29627
  const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
29467
29628
  const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
29468
- if (!(bright > 0)) return [];
29629
+ if (!(bright > 0)) return;
29469
29630
  tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
29470
- height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
29631
+ if (!light.directional)
29632
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
29471
29633
  }
29634
+ return height > 0 ? {sun, center, strength, tint, height} : undefined;
29635
+ }
29636
+
29637
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
29638
+ * behind the camera
29639
+ * @return {Vector2|undefined} */
29640
+ getScreenPos()
29641
+ {
29642
+ const source = this.flareSource();
29643
+ return source && render3D.worldToScreen(source.pos);
29644
+ }
29645
+
29646
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector
29647
+ * when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw
29648
+ * @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined,
29649
+ * tileInfo: TileInfo|undefined, angle: number}>} */
29650
+ getScreenElements()
29651
+ {
29652
+ const look = this.visible > 0 && this.flareLook();
29653
+ if (!look) return [];
29654
+ const {sun, center, tint, height} = look, strength = look.strength * this.visible;
29472
29655
  return this.getElements().map((e)=>
29473
29656
  {
29474
29657
  const c = e.color.multiply(tint);
29475
29658
  // along the line and past the middle, a lerp would stop there
29476
- return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
29477
- color: rgb(c.r, c.g, c.b, c.a * strength)};
29659
+ const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
29660
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
29661
+ angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
29478
29662
  });
29479
29663
  }
29480
29664
 
29481
29665
  /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
29482
- * through
29666
+ * through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless
29667
+ * it is doubleSided
29483
29668
  * @return {boolean} */
29484
- isSunHidden()
29669
+ isHidden()
29485
29670
  {
29486
29671
  const source = this.flareSource();
29487
29672
  if (!source) return true;
@@ -29495,22 +29680,29 @@ class LensFlare3D extends EngineObject3D
29495
29680
  for (const map of maps)
29496
29681
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
29497
29682
  return true;
29498
- // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
29499
- // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
29500
- // one the light is inside, its lamp
29501
- const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
29502
- const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
29503
- const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
29504
- const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
29505
- const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
29683
+ // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
29684
+ // is in
29685
+ const lamp = (o)=>
29686
+ {
29687
+ if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
29688
+ const matrix = render3DObjectMatrix(o);
29689
+ if (!matrix.determinant()) return false;
29690
+ const p = matrix.copy().invert().transformPoint(source.pos), b = o.mesh.bounds || o.mesh.getBounds();
29691
+ return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
29692
+ p.z >= b.min.z && p.z <= b.max.z;
29693
+ };
29694
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
29506
29695
  return !!hit && hit.distance < reach;
29507
29696
  }
29508
29697
 
29509
- /** Ease visible toward whether the sun shows, called automatically each frame */
29698
+ /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
29699
+ * wherever the sun is, off the screen or its light off, looks for nothing in the way */
29510
29700
  update()
29511
29701
  {
29512
29702
  super.update();
29513
- const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
29703
+ if (this.light?.destroyed)
29704
+ return this.destroy(); // the flare of a light that is gone
29705
+ const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
29514
29706
  const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
29515
29707
  this.visible = clamp(this.visible + (shows ? step : -step));
29516
29708
  }
@@ -29524,8 +29716,9 @@ class LensFlare3D extends EngineObject3D
29524
29716
  setAdditiveBlendMode(true);
29525
29717
  for (const e of elements)
29526
29718
  {
29527
- const tileInfo = render3DFlareTile(e.shape);
29528
- tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
29719
+ const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
29720
+ const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
29721
+ tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
29529
29722
  }
29530
29723
  setAdditiveBlendMode(false);
29531
29724
  }
@@ -30639,8 +30832,9 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
30639
30832
  * are added before its objects are made, one the game added itself keeps its place
30640
30833
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
30641
30834
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
30642
- * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
30643
- * leaves out stays as the game set it, and a level with no block changes nothing
30835
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
30836
+ * sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
30837
+ * nothing
30644
30838
  * @param {Object} level - The level, the level editor edits this same object
30645
30839
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
30646
30840
  * @memberof Level3D */
@@ -30671,7 +30865,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
30671
30865
 
30672
30866
  /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
30673
30867
  * - 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
30868
+ * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
30869
+ * image gets plain blocks, a color for each type
30675
30870
  * - setup is called with each map a level makes, to give block types their own faces or make them see-through
30676
30871
  * - Call it before level3DLoad; with no arguments the defaults are back
30677
30872
  * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
@@ -30733,7 +30928,7 @@ function level3DVoxelsMake(voxels)
30733
30928
  {
30734
30929
  const shape = level3DVoxelsShape(voxels);
30735
30930
  if (!shape || typeof VoxelMap == 'undefined') return;
30736
- const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
30931
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
30737
30932
  level3DVoxelsDecode(voxels.blocks, map.data);
30738
30933
  level3DVoxelSetupMap?.(map);
30739
30934
  map.rebuild();
@@ -30795,6 +30990,31 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
30795
30990
  map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
30796
30991
  }
30797
30992
 
30993
+ // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
30994
+ // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
30995
+ // block map in a game with no art can still be seen and painted
30996
+ let level3DVoxelPlain;
30997
+ function level3DVoxelPlainTiles()
30998
+ {
30999
+ if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
31000
+ return tile();
31001
+ if (!level3DVoxelPlain)
31002
+ {
31003
+ // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
31004
+ const context = createCanvasContext(256);
31005
+ for (let i = 0; i < 256; ++i)
31006
+ {
31007
+ const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
31008
+ context.fillStyle = hsl(hue, .45, .4).toString();
31009
+ context.fillRect(x, y, 16, 16);
31010
+ context.fillStyle = hsl(hue, .5, .55).toString();
31011
+ context.fillRect(x + 1, y + 1, 14, 14);
31012
+ }
31013
+ level3DVoxelPlain = new TextureInfo(context.canvas);
31014
+ }
31015
+ return tile(0, 16, level3DVoxelPlain, 0);
31016
+ }
31017
+
30798
31018
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
30799
31019
  function level3DTerrainMake(terrain)
30800
31020
  {
@@ -30839,8 +31059,25 @@ function level3DSceneApply(scene)
30839
31059
  r.fogColor = color(scene.fogColor) || r.fogColor;
30840
31060
  if (typeof scene.shadows === 'boolean')
30841
31061
  r.shadows = scene.shadows;
31062
+ if (typeof scene.lensFlare === 'boolean')
31063
+ level3DSceneFlare(scene.lensFlare);
30842
31064
  }
30843
31065
 
31066
+ // the sun's lens flare as a scene sets it: on makes one unless the sun has a flare already, the game's own or an
31067
+ // earlier level's, and off takes away the one a scene made, never the game's
31068
+ let level3DSunFlare;
31069
+ function level3DSceneFlare(on)
31070
+ {
31071
+ if (!on)
31072
+ {
31073
+ level3DSunFlare && !level3DSunFlare.destroyed && level3DSunFlare.destroy();
31074
+ level3DSunFlare = undefined;
31075
+ }
31076
+ else if (!level3DSunHasFlare())
31077
+ level3DSunFlare = new LensFlare3D;
31078
+ }
31079
+ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light);
31080
+
30844
31081
  // a vec3 of an array of three numbers, as the file has them, or the fallback
30845
31082
  function level3DVector(value, fallback)
30846
31083
  {
@@ -31189,7 +31426,8 @@ level3DAddType('Cylinder', function(pos, properties)
31189
31426
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
31190
31427
  {color: WHITE, tile: -1, solid: true});
31191
31428
  // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
31192
- // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
31429
+ // object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and
31430
+ // lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare
31193
31431
  level3DAddType('Light', function(pos, properties)
31194
31432
  {
31195
31433
  const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
@@ -31197,8 +31435,9 @@ level3DAddType('Light', function(pos, properties)
31197
31435
  light.coneSoftness = properties.softness;
31198
31436
  if (properties.shadows && render3D)
31199
31437
  render3D.shadowLight = light;
31438
+ properties.lensFlare && light.addFlare();
31200
31439
  return light;
31201
- }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
31440
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false, lensFlare: false});
31202
31441
 
31203
31442
  /**
31204
31443
  * LittleJS Particle Effects Plugin
@@ -32364,6 +32603,11 @@ async function parseGLTF(data, baseUrl='')
32364
32603
  for (const name of json.extensionsRequired || [])
32365
32604
  if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
32366
32605
  throw new Error(`glTF with ${name} is not read, export it uncompressed`);
32606
+ else if (name === 'KHR_texture_basisu')
32607
+ throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg');
32608
+ // one that only may use them has other images to fall back on, or none: said, since it loads with them left out
32609
+ if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu'))
32610
+ console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg');
32367
32611
 
32368
32612
  // the buffers: the GLB's own, a data uri, or a file beside the model
32369
32613
  const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
@@ -32406,7 +32650,8 @@ async function parseGLTF(data, baseUrl='')
32406
32650
  const bitmap = normalTextures.has(index) ?
32407
32651
  await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
32408
32652
  opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
32409
- return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
32653
+ // REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are
32654
+ return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728);
32410
32655
  }
32411
32656
  catch (e) { LOG('glTF image not loaded', e); }
32412
32657
  }));
@@ -32516,17 +32761,25 @@ function gltfOpaqueImage(image)
32516
32761
  {
32517
32762
  const gl = glContext, {width, height} = image;
32518
32763
  if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
32519
- const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
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);
32764
+ // the room to read into first: for a huge image it can fail, and must not with the framebuffer bound
32524
32765
  const data = new Uint8ClampedArray(width * height * 4);
32525
- gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
32526
- gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
32527
- gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
32528
- gl.deleteFramebuffer(framebuffer);
32529
- gl.deleteTexture(texture);
32766
+ const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
32767
+ try
32768
+ {
32769
+ gl.bindTexture(gl.TEXTURE_2D, texture);
32770
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
32771
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
32772
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
32773
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
32774
+ }
32775
+ finally
32776
+ {
32777
+ // the 2D renderer's own bindings back, whatever happened
32778
+ gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
32779
+ gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
32780
+ gl.deleteFramebuffer(framebuffer);
32781
+ gl.deleteTexture(texture);
32782
+ }
32530
32783
  for (let i = 3; i < data.length; i += 4)
32531
32784
  data[i] = 255;
32532
32785
  image.close();
@@ -32663,7 +32916,7 @@ function gltfAccessor(json, buffers, index)
32663
32916
  }
32664
32917
  if (scale !== 1)
32665
32918
  for (let i = 0; i < out.length; ++i)
32666
- out[i] /= scale;
32919
+ out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says
32667
32920
  return {data: out, components, count: a.count};
32668
32921
  }
32669
32922
 
@@ -32727,8 +32980,10 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
32727
32980
  const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
32728
32981
  if (er || eg || eb)
32729
32982
  {
32983
+ // KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender
32984
+ const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
32730
32985
  part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
32731
- part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
32986
+ part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength);
32732
32987
  }
32733
32988
  return part;
32734
32989
  }
@@ -33632,10 +33887,10 @@ class LevelEditor
33632
33887
  onPanel(element) {}
33633
33888
 
33634
33889
  /** 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
33890
+ * editor writes no file, to set or override; it may be async
33636
33891
  * @param {string} text
33637
33892
  * @param {string} fileName
33638
- * @return {boolean|void} */
33893
+ * @return {boolean|void|Promise<boolean|void>} */
33639
33894
  onSave(text, fileName) { return false; }
33640
33895
 
33641
33896
  /** True while the editor is open, the game is paused under it
@@ -34002,7 +34257,7 @@ function editorMapRestore(map)
34002
34257
  if (!map.layers || data.some((layer)=> !Array.isArray(layer))) return map;
34003
34258
  const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
34004
34259
  const url = editorFetchedURLs.get(map), hash = editorMapHash(data, objects, editorMapLayout(map));
34005
- const fileName = url?.split(/[?#]/)[0].split('/').pop() || 'level.json';
34260
+ const fileName = editorFileName(url, 'level.json');
34006
34261
  const record = {map, url, fileName, key: editorMapKey(map, url, hash), hash,
34007
34262
  original: data.map((layer)=> [...layer]), originalObjects: editorObjectsCopy(objects),
34008
34263
  originalSize: {width: map.width, height: map.height}, layers: []};
@@ -34033,6 +34288,8 @@ function editorMapRestore(map)
34033
34288
  editorRestoreObjects(map, saved);
34034
34289
  console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
34035
34290
  'Save in the editor (Esc then 0) writes them to the file');
34291
+ saved.stale && console.warn(`LittleJS editor: they are older than the last edits to ${record.fileName}, ` +
34292
+ 'which storage had no room for');
34036
34293
  }
34037
34294
  return map;
34038
34295
  }
@@ -34234,6 +34491,7 @@ async function editorSaveText(record, text, pickAgain)
34234
34491
  if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
34235
34492
  record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
34236
34493
  editorFileStore.set(editorFileKey(record), undefined);
34494
+ console.warn(`LittleJS editor: ${record.fileName} could not be written, downloaded instead`, error);
34237
34495
  }
34238
34496
  }
34239
34497
  saveText(text, record.fileName, 'application/json');
@@ -34250,15 +34508,35 @@ const editorSaves = ()=> readSaveData(editorSaveName(), {});
34250
34508
  // if the last autosave did not fit in storage, the panel says so
34251
34509
  let editorSaveFailed = false;
34252
34510
 
34253
- // write the autosaves, noting whether storage took them
34254
- function editorWriteSaves(saves)
34511
+ // write the autosaves, noting whether storage took them; when it did not, the autosave it still has of this map
34512
+ // is an older one, and is marked so, for the reload that brings it back to say so
34513
+ function editorWriteSaves(saves, key, name=editorSaveName())
34255
34514
  {
34256
34515
  try
34257
34516
  {
34258
- localStorage.setItem(editorSaveName(), JSON.stringify(saves));
34259
- editorSaveFailed = false;
34517
+ localStorage.setItem(name, JSON.stringify(saves));
34518
+ return editorSaveFailed = false;
34260
34519
  }
34261
34520
  catch { editorSaveFailed = true; }
34521
+ try
34522
+ {
34523
+ const kept = readSaveData(name, {});
34524
+ if (!kept[key] || kept[key].stale) return true;
34525
+ kept[key].stale = true;
34526
+ localStorage.setItem(name, JSON.stringify(kept));
34527
+ }
34528
+ catch {} // no room for the mark either
34529
+ return true;
34530
+ }
34531
+
34532
+ // the name of the file a level was fetched from, as it is on disk: without its folder and query, and with what
34533
+ // the url spells with a percent as it is written, so it is the name a picked file has
34534
+ function editorFileName(url, fallback)
34535
+ {
34536
+ const name = url?.split(/[?#]/)[0].split('/').pop();
34537
+ if (!name) return fallback;
34538
+ try { return decodeURIComponent(name); }
34539
+ catch { return name; }
34262
34540
  }
34263
34541
 
34264
34542
  // 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 +34592,12 @@ function editorAutosave(record)
34314
34592
  else
34315
34593
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
34316
34594
  layers: data, objects, nextobjectid: map.nextobjectid};
34317
- editorWriteSaves(saves);
34595
+ editorWriteSaves(saves, record.key);
34318
34596
  }
34319
34597
 
34320
34598
  // 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
34599
  // 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)
34600
+ function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height, keepUnknown=false)
34323
34601
  {
34324
34602
  editorStrokeEnd();
34325
34603
  if (width !== record.map.width || height !== record.map.height)
@@ -34339,7 +34617,9 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
34339
34617
  if (gids?.length === width * height)
34340
34618
  gids.forEach((gid, i)=> editorPaint(layer, vec2(i % width, height - 1 - (i / width | 0)), gid));
34341
34619
  }
34342
- editorObjectLayers(record).forEach((layer, i)=> objects &&
34620
+ // an autosave from before the file had an object layer leaves that layer as the file has it; Reset to file
34621
+ // empties a layer the file did not have
34622
+ editorObjectLayers(record).forEach((layer, i)=> objects && !(keepUnknown && i >= objects.length) &&
34343
34623
  editorChangeObjects(layer, (list)=> list.splice(0, list.length, ...editorObjectsCopy(objects[i] ?? []))));
34344
34624
  });
34345
34625
  editorStroke ? editorStrokeEnd() : editorAutosave(record);
@@ -34359,7 +34639,7 @@ function editorApplyPending(record)
34359
34639
  return false;
34360
34640
  }
34361
34641
  record.pending = record.pendingUnfit = undefined;
34362
- editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height);
34642
+ editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height, true);
34363
34643
  return true;
34364
34644
  }
34365
34645
 
@@ -34464,8 +34744,7 @@ function editorClearSelections()
34464
34744
  function editorMapChanged(record, before)
34465
34745
  {
34466
34746
  const stroke = [{resize: record, before, after: editorMapSnapshot(record)}];
34467
- editorUndoList.push(stroke);
34468
- editorRedoList.length = 0;
34747
+ editorUndoPush(stroke);
34469
34748
  editorChanged(stroke);
34470
34749
  }
34471
34750
 
@@ -34542,12 +34821,20 @@ function editorPaint(layer, pos, gid)
34542
34821
  (editorStroke ||= []).push({layer, pos: pos.copy(), before, after: gid});
34543
34822
  }
34544
34823
 
34824
+ // 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
34825
+ // an entry for every cell, and a session of them would hold millions
34826
+ function editorUndoPush(stroke)
34827
+ {
34828
+ editorUndoList.push(stroke);
34829
+ editorUndoList.length > 100 && editorUndoList.shift();
34830
+ editorRedoList.length = 0;
34831
+ }
34832
+
34545
34833
  // the stroke is done, it can be undone as one
34546
34834
  function editorStrokeEnd()
34547
34835
  {
34548
34836
  if (!editorStroke) return;
34549
- editorUndoList.push(editorStroke);
34550
- editorRedoList.length = 0;
34837
+ editorUndoPush(editorStroke);
34551
34838
  editorChanged(editorStroke);
34552
34839
  editorStroke = undefined;
34553
34840
  }
@@ -34957,7 +35244,7 @@ function editorUpdateObjects(space)
34957
35244
  if (!editorObjectSelection.has(hit.id))
34958
35245
  editorObjectSelection = new Set([hit.id]);
34959
35246
  editorObjectDrag = {start: snap(mouse), from: new Map(editorSelectedObjects().map((object)=>
34960
- [object.id, editorObjectPos(record, object)]))};
35247
+ [object.id, {x: object.x, y: object.y}]))};
34961
35248
  }
34962
35249
  else if (editorObjectSelection.size)
34963
35250
  editorObjectSelection.clear();
@@ -34972,8 +35259,10 @@ function editorUpdateObjects(space)
34972
35259
  {
34973
35260
  for (const object of list)
34974
35261
  {
34975
- const from = drag.from.get(object.id);
34976
- from && editorObjectSetPos(record, object, from.add(delta));
35262
+ // from the numbers it had, in the map's own units: through cells and back they pick up a hair
35263
+ const from = drag.from.get(object.id), {tilewidth=1, tileheight=1} = record.map;
35264
+ if (from)
35265
+ object.x = from.x + delta.x * tilewidth, object.y = from.y - delta.y * tileheight;
34977
35266
  }
34978
35267
  });
34979
35268
  }
@@ -36694,7 +36983,7 @@ function editor3DSceneState()
36694
36983
  const r = render3D;
36695
36984
  return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
36696
36985
  fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
36697
- sunColor: r.sunColor.copy(), shadows: r.shadows};
36986
+ sunColor: r.sunColor.copy(), shadows: r.shadows, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed};
36698
36987
  }
36699
36988
  function editor3DSceneRestore(state)
36700
36989
  {
@@ -36709,6 +36998,7 @@ function editor3DSceneRestore(state)
36709
36998
  r.sunDirection = state.sunDirection.copy();
36710
36999
  r.sunColor = state.sunColor.copy();
36711
37000
  r.shadows = state.shadows;
37001
+ level3DSceneFlare(state.sunFlare); // a flare an earlier level's scene gave the sun, never the game's own
36712
37002
  }
36713
37003
 
36714
37004
  // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
@@ -36730,6 +37020,7 @@ function editor3DSceneFromView()
36730
37020
  scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
36731
37021
  scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
36732
37022
  scene.shadows = !!r.shadows;
37023
+ scene.lensFlare = level3DSunHasFlare();
36733
37024
  return scene;
36734
37025
  }
36735
37026
 
@@ -36868,7 +37159,7 @@ function editor3DLevelLoaded(level)
36868
37159
  const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
36869
37160
  const hash = editor3DContentHash(original, originalParts);
36870
37161
  // sceneBase is the scene the game set, taken before the level's own block is applied
36871
- const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
37162
+ const record = {fileName: editorFileName(url, 'level3D.json'), key: url ?? 'level #' + hash, original,
36872
37163
  originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
36873
37164
  fileHandle: undefined, undo: [], redo: []};
36874
37165
  editor3DRecords.set(level, record);
@@ -36969,6 +37260,7 @@ function editor3DChange(change)
36969
37260
  const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
36970
37261
  change(after);
36971
37262
  if (editor3DSame(before, after)) return false;
37263
+ editor3DFixIds(after); // an object added with no id, or with one in use, gets one of its own
36972
37264
  editor3DSetObjects(after);
36973
37265
  editor3DStroke ||= {before, parts: editor3DLevelParts()};
36974
37266
  return true;
@@ -37742,7 +38034,7 @@ function editor3DPrefabJSON(name)
37742
38034
 
37743
38035
  // the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
37744
38036
  // 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, saved?: string}>} */
38037
+ /** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string}>} */
37746
38038
  const editor3DPrefabStack = [];
37747
38039
  // the levels that are a prefab being edited, they have no autosave of their own
37748
38040
  const editor3DPrefabLevels = new WeakSet;
@@ -37751,6 +38043,28 @@ const editor3DPrefabLevels = new WeakSet;
37751
38043
  let editor3DPrefabEdits = {};
37752
38044
  // the prefabs of the game's own that were edited and are not in a file yet
37753
38045
  const editor3DPrefabDirty = new Set;
38046
+ // what is known of each prefab's file, by its name, so it outlasts the prefab being closed and opened again: the
38047
+ // save being written, for the next to wait on, what the file has since a Save, and what the prefab holds now
38048
+ /** @type {Map<string, {saving?: Promise<any>, saved?: string, content?: string}>} */
38049
+ const editor3DPrefabFiles = new Map;
38050
+ function editor3DPrefabFile(name)
38051
+ {
38052
+ editor3DPrefabFiles.has(name) || editor3DPrefabFiles.set(name, {});
38053
+ return editor3DPrefabFiles.get(name);
38054
+ }
38055
+
38056
+ // 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
38057
+ // when what it holds is what was written; before any Save it is not once it was edited
38058
+ function editor3DPrefabFileCheck(name, content, edited=false)
38059
+ {
38060
+ const file = editor3DPrefabFile(name);
38061
+ file.content = content;
38062
+ if (level3DPrefabs.get(name)?.fromLevel) return; // one of the level's own is saved with the level
38063
+ if (file.saved !== undefined)
38064
+ content === file.saved ? editor3DPrefabDirty.delete(name) : editor3DPrefabDirty.add(name);
38065
+ else if (edited)
38066
+ editor3DPrefabDirty.add(name);
38067
+ }
37754
38068
 
37755
38069
  // destroy what the level being edited has in the world, its objects' and its maps'
37756
38070
  function editor3DPrefabClear()
@@ -37804,9 +38118,10 @@ function editor3DPrefabBack()
37804
38118
  level3DPrefabSet(frame.name, prefab, fromLevel);
37805
38119
  if (fromLevel)
37806
38120
  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
38121
  }
38122
+ // 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
38123
+ // was opened after a Save, it is not what the file has
38124
+ editor3DPrefabFileCheck(frame.name, JSON.stringify(objects), changed);
37810
38125
 
37811
38126
  // the level it was opened from is the editor's again, with its own undo
37812
38127
  const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
@@ -38134,8 +38449,10 @@ function editor3DAutosave(level=editor3DLevel, known)
38134
38449
  if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
38135
38450
  edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
38136
38451
  });
38137
- if (root && Object.keys(edits).length)
38138
- editor3DAutosave({...root, prefabs: {...editor3DLevelPart('prefabs', root), ...edits}}, editor3DRecords.get(root));
38452
+ // with nothing edited any more, an undo back to how the prefab was, the level's autosave is the level's own
38453
+ // again, which takes away one of an edit that was undone and keeps the level's other edits
38454
+ const prefabs = {...editor3DLevelPart('prefabs', root), ...edits};
38455
+ root && editor3DAutosave(Object.keys(prefabs).length ? {...root, prefabs} : root, editor3DRecords.get(root));
38139
38456
  return;
38140
38457
  }
38141
38458
  const record = known ?? editor3DRecords.get(level);
@@ -38149,12 +38466,9 @@ function editor3DAutosave(level=editor3DLevel, known)
38149
38466
  else
38150
38467
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
38151
38468
  ...parts};
38152
- try
38153
- {
38154
- localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
38155
- editor3DSaveFailed = false;
38156
- }
38157
- catch { editor3DSaveFailed = true; }
38469
+ const failed = editorSaveFailed; // the 2D editor's own flag is its own
38470
+ editor3DSaveFailed = editorWriteSaves(saves, record.key, editor3DSaveName());
38471
+ editorSaveFailed = failed;
38158
38472
  }
38159
38473
 
38160
38474
  // put the autosaved edits of a file that changed into the level, as one undo
@@ -38211,16 +38525,23 @@ async function editor3DSave(pickAgain=false)
38211
38525
  // inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
38212
38526
  const text = editor3DLevelJSON(level), name = open.name + '.json';
38213
38527
  const written = JSON.stringify(editor3DObjects()); // as the file has it
38528
+ // its place in line is the prefab's, by its name: closed and opened again it is a new level here, and its
38529
+ // saves still go in the order they were asked for
38530
+ const file = editor3DPrefabFile(open.name), before = file.saving;
38214
38531
  const saved = (async ()=>
38215
38532
  {
38216
- await record.saving; // in the order asked for, as a level's saves are
38533
+ await before;
38217
38534
  const kept = await editorCall('onSave', text, name) === true;
38218
38535
  kept || saveText(text, name, 'application/json');
38219
- editor3DPrefabDirty.delete(open.name);
38220
- open.saved = written;
38536
+ file.saved = written;
38537
+ // it is in its file when what it holds now is what was written: edited again while the save was
38538
+ // kept, or gone back from with more edits, it still waits
38539
+ const at = editor3DPrefabStack.indexOf(open);
38540
+ const now = at < 0 ? file.content : JSON.stringify((editor3DPrefabStack[at + 1]?.level ?? editor3DLevel).objects);
38541
+ editor3DPrefabFileCheck(open.name, now ?? written);
38221
38542
  return kept ? 'kept' : 'downloaded';
38222
38543
  })();
38223
- record.saving = saved.catch(()=> {});
38544
+ file.saving = saved.catch(()=> {});
38224
38545
  return saved;
38225
38546
  }
38226
38547
  // what is written, kept as it is now: the level can change while the file is picked and written, and those
@@ -39688,6 +40009,8 @@ function editor3DSceneUpdate(box)
39688
40009
  color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
39689
40010
  scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
39690
40011
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
40012
+ toggle('Lens flare', 'The sun flares in the lens when it is in view', !!value('lensFlare'), (on)=>
40013
+ change('lensFlare', on));
39691
40014
  }
39692
40015
 
39693
40016
  // the properties box: the position, rotation and scale of the one selected object and an input for each default