littlejsengine 1.23.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.23.1';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -984,7 +984,11 @@ function tweakDivider(){}
984
984
  function tweakEngineDefaults(){}
985
985
  class LevelEditor
986
986
  {
987
- constructor() { this.paletteTiles = this.use3D = this.tool = undefined; }
987
+ constructor()
988
+ {
989
+ this.paletteTiles = this.use3D = this.tool = undefined;
990
+ this.keys = {}, this.buttons = [], this.tools = {};
991
+ }
988
992
  get isOpen() { return false; }
989
993
  get is3D() { return false; }
990
994
  get edit2D() { return undefined; }
@@ -2292,295 +2296,295 @@ const PURPLE = debugProtectConstant(rgb(.5,0,1));
2292
2296
  * @type {Color}
2293
2297
  * @memberof Math */
2294
2298
  const MAGENTA = debugProtectConstant(rgb(1,0,1));
2295
- /**
2296
- * LittleJS Utility Classes and Functions
2297
- * - Timer - tracks time automatically with support for pause and real-time modes
2298
- * - Time formatting helper
2299
- * - JSON file fetching
2300
- * - File saving (text, canvas, data URLs)
2301
- * - Native share dialog support
2302
- * - Local storage save data management
2303
- * - Value noise (1D and 2D)
2304
- * @namespace Utilities
2305
- */
2306
-
2307
- /**
2308
- * Timer object tracks how long has passed since it was set
2309
- * @memberof Engine
2310
- * @example
2311
- * let a = new Timer; // creates a timer that is not set
2312
- * a.set(3); // sets the timer to 3 seconds
2313
- *
2314
- * let b = new Timer(1); // creates a timer with 1 second left
2315
- * b.unset(); // unset the timer
2316
- */
2317
- class Timer
2318
- {
2319
- /** Create a timer object set time passed in
2320
- * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
2321
- * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
2322
- constructor(timeLeft, useRealTime=false)
2323
- {
2324
- false&&ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
2325
- this.useRealTime = useRealTime;
2326
- const globalTime = this.getGlobalTime();
2327
- /** @type {number|undefined} */
2328
- this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
2329
- /** @type {number|undefined} */
2330
- this.setTime = timeLeft;
2331
- }
2332
-
2333
- /** Set the timer with seconds passed in
2334
- * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
2335
- set(timeLeft=0)
2336
- {
2337
- false&&ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
2338
- const globalTime = this.getGlobalTime();
2339
- this.time = globalTime + timeLeft;
2340
- this.setTime = timeLeft;
2341
- }
2342
-
2343
- /** Set if the timer should keep running even when the game is paused
2344
- * @param {boolean} [useRealTime] */
2345
- setUseRealTime(useRealTime=true)
2346
- {
2347
- false&&ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
2348
- this.useRealTime = useRealTime;
2349
- }
2350
-
2351
- /** Unset the timer */
2352
- unset() { this.time = undefined; }
2353
-
2354
- /** Returns true if set
2355
- * @return {boolean} */
2356
- isSet() { return this.time !== undefined; }
2357
-
2358
- /** Returns true if set and has not elapsed
2359
- * @return {boolean} */
2360
- active() { return this.getGlobalTime() < this.time; }
2361
-
2362
- /** Returns true if set and elapsed
2363
- * @return {boolean} */
2364
- elapsed() { return this.getGlobalTime() >= this.time; }
2365
-
2366
- /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
2367
- * @return {number} */
2368
- get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
2369
-
2370
- /** Get percentage elapsed based on time it was set to, returns 0 if not set.
2371
- * Zero-duration timers report 1 (already elapsed).
2372
- * @return {number} */
2373
- getPercent()
2374
- {
2375
- if (!this.isSet()) return 0;
2376
- if (!this.setTime) return 1;
2377
- return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
2378
- }
2379
-
2380
- /** Get the time this timer was set to, returns 0 if not set
2381
- * @return {number} */
2382
- getSetTime() { return this.isSet() ? this.setTime : 0; }
2383
-
2384
- /** Get the current global time this timer is based on
2385
- * @return {number} */
2386
- getGlobalTime() { return this.useRealTime ? timeReal : time; }
2387
-
2388
- /** Returns this timer expressed as a string
2389
- * @return {string} */
2390
- toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
2391
-
2392
- /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
2393
- * @return {number} */
2394
- valueOf() { return this.get(); }
2395
- }
2396
-
2397
- ///////////////////////////////////////////////////////////////////////////////
2398
-
2399
- /** Formats seconds to mm:ss style for display purposes
2400
- * @param {number} t - time in seconds
2401
- * @return {string}
2402
- * @memberof Utilities */
2403
- function formatTime(t)
2404
- {
2405
- const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
2406
- t = abs(t)|0;
2407
- return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2408
- }
2409
-
2410
- /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
2411
- * @param {string} url - URL of JSON file
2412
- * @return {Promise<object>}
2413
- * @memberof Utilities */
2414
- async function fetchJSON(url)
2415
- {
2416
- const response = await fetch(url);
2417
- if (!response.ok)
2418
- throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
2419
- const json = await response.json();
2420
- editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
2421
- return json;
2422
- }
2423
-
2424
- ///////////////////////////////////////////////////////////////////////////////
2425
-
2426
- /** Save a text file to disk
2427
- * @param {string} text
2428
- * @param {string} [filename]
2429
- * @param {string} [type]
2430
- * @memberof Utilities */
2431
- function saveText(text, filename='text', type='text/plain')
2432
- { saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
2433
-
2434
- /** Create an offscreen canvas to draw into, and return its 2D context
2435
- * - The canvas is context.canvas, which is what TextureInfo and the like take
2436
- * @param {number} width - In pixels
2437
- * @param {number} [height] - In pixels, defaults to the width for a square
2438
- * @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
2439
- * @return {OffscreenCanvasRenderingContext2D}
2440
- * @memberof Utilities */
2441
- function createCanvasContext(width, height=width, willReadFrequently=false)
2442
- {
2443
- false&&ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
2444
- return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
2445
- }
2446
-
2447
- /** Save a canvas to disk
2448
- * @param {HTMLCanvasElement|OffscreenCanvas} canvas
2449
- * @param {string} [filename]
2450
- * @param {string} [type]
2451
- * @memberof Utilities */
2452
- function saveCanvas(canvas, filename='screenshot', type='image/png')
2453
- {
2454
- if (canvas instanceof OffscreenCanvas)
2455
- {
2456
- // copy to temporary canvas and save
2457
- const saveCanvas = document.createElement('canvas');
2458
- saveCanvas.width = canvas.width;
2459
- saveCanvas.height = canvas.height;
2460
- saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
2461
- saveDataURL(saveCanvas.toDataURL(type), filename);
2462
- }
2463
- else
2464
- saveDataURL(canvas.toDataURL(type), filename);
2465
- }
2466
-
2467
- /** Save a data url to disk
2468
- * @param {string} url
2469
- * @param {string} [filename]
2470
- * @param {number} [revokeTime] - how long before revoking the url
2471
- * @memberof Utilities */
2472
- function saveDataURL(url, filename='download', revokeTime)
2473
- {
2474
- false&&ASSERT(isStringLike(url), 'saveDataURL requires url string');
2475
- false&&ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
2476
-
2477
- // create link for saving screenshots
2478
- const link = document.createElement('a');
2479
- link.download = filename;
2480
- link.href = url;
2481
- link.click();
2482
- if (revokeTime !== undefined)
2483
- setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
2484
- }
2485
-
2486
- /** Share content using the native share dialog if available
2487
- * @param {string} title - title of the share
2488
- * @param {string} url - url to share
2489
- * @param {Function} [callback] - Called when share is complete
2490
- * @memberof Utilities */
2491
- function shareURL(title, url, callback)
2492
- {
2493
- false&&ASSERT(isStringLike(title), 'shareURL requires title string');
2494
- false&&ASSERT(isStringLike(url), 'shareURL requires url string');
2495
- navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
2496
- }
2497
-
2498
- ///////////////////////////////////////////////////////////////////////////////
2499
-
2500
- /** Read save data from local storage
2501
- * - The result has the type of defaultSaveData, or any when there is none
2502
- * @template {Object<string, any>} [T=any]
2503
- * @param {string} saveName - unique name for the game/save
2504
- * @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
2505
- * @return {T}
2506
- * @memberof Utilities */
2507
- function readSaveData(saveName, defaultSaveData)
2508
- {
2509
- false&&ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
2510
- false&&ASSERT(defaultSaveData === undefined ||
2511
- (typeof defaultSaveData === 'object' && defaultSaveData !== null),
2512
- 'readSaveData: default must be an object - the result is ' +
2513
- '{...default, ...loaded}, so a scalar default yields {}. ' +
2514
- 'Use readSaveData(key, {best:0}).best');
2515
-
2516
- // tolerate localStorage being unavailable (iOS private mode, sandboxed
2517
- // iframes) and corrupt JSON in stored data
2518
- let loadedData = {};
2519
- try
2520
- {
2521
- const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
2522
- if (data)
2523
- {
2524
- try { loadedData = JSON.parse(data); }
2525
- catch { false&&LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2526
- }
2527
- }
2528
- catch { false&&LOG('readSaveData: localStorage unavailable — using defaults'); }
2529
- return { .../** @type {object} */ (defaultSaveData), ...loadedData };
2530
- }
2531
-
2532
- /** Write save data to local storage
2533
- * @param {string} saveName - unique name for the game/save
2534
- * @param {object} saveData - object containing data to be saved
2535
- * @memberof Utilities */
2536
- function writeSaveData(saveName, saveData)
2537
- {
2538
- false&&ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
2539
- // tolerate localStorage being unavailable or quota exceeded
2540
- try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
2541
- catch { false&&LOG('writeSaveData: failed to write', saveName); }
2542
- }
2543
-
2544
- ///////////////////////////////////////////////////////////////////////////////
2545
-
2546
- // Deterministic well-distributed hash of an integer lattice index to [0, 1).
2547
- // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
2548
- function noiseHash(i)
2549
- {
2550
- let h = (i | 0) ^ 0x9e3779b9;
2551
- h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
2552
- h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
2553
- h ^= h >>> 16;
2554
- return (h >>> 0) / 2**32;
2555
- }
2556
-
2557
- /** 1D value noise — returns a smooth value in [0, 1] for any real x.
2558
- * Integer inputs land on deterministic lattice values; non-integer inputs
2559
- * are interpolated with smoothStep for C1 continuity.
2560
- * @param {number} x
2561
- * @return {number}
2562
- * @memberof Utilities */
2563
- function noise1D(x)
2564
- {
2565
- const i = floor(x);
2566
- return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
2567
- }
2568
-
2569
- /** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
2570
- * @param {number} x
2571
- * @param {number} y
2572
- * @return {number}
2573
- * @memberof Utilities */
2574
- function noise2D(x, y)
2575
- {
2576
- const ix = floor(x), iy = floor(y);
2577
- const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
2578
- // large prime decorrelates neighboring rows
2579
- const h = (a, b) => noiseHash(a + b * 374761393);
2580
- return lerp(
2581
- lerp(h(ix, iy ), h(ix + 1, iy ), fx),
2582
- lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
2583
- fy);
2299
+ /**
2300
+ * LittleJS Utility Classes and Functions
2301
+ * - Timer - tracks time automatically with support for pause and real-time modes
2302
+ * - Time formatting helper
2303
+ * - JSON file fetching
2304
+ * - File saving (text, canvas, data URLs)
2305
+ * - Native share dialog support
2306
+ * - Local storage save data management
2307
+ * - Value noise (1D and 2D)
2308
+ * @namespace Utilities
2309
+ */
2310
+
2311
+ /**
2312
+ * Timer object tracks how long has passed since it was set
2313
+ * @memberof Engine
2314
+ * @example
2315
+ * let a = new Timer; // creates a timer that is not set
2316
+ * a.set(3); // sets the timer to 3 seconds
2317
+ *
2318
+ * let b = new Timer(1); // creates a timer with 1 second left
2319
+ * b.unset(); // unset the timer
2320
+ */
2321
+ class Timer
2322
+ {
2323
+ /** Create a timer object set time passed in
2324
+ * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
2325
+ * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
2326
+ constructor(timeLeft, useRealTime=false)
2327
+ {
2328
+ false&&ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
2329
+ this.useRealTime = useRealTime;
2330
+ const globalTime = this.getGlobalTime();
2331
+ /** @type {number|undefined} */
2332
+ this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
2333
+ /** @type {number|undefined} */
2334
+ this.setTime = timeLeft;
2335
+ }
2336
+
2337
+ /** Set the timer with seconds passed in
2338
+ * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
2339
+ set(timeLeft=0)
2340
+ {
2341
+ false&&ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
2342
+ const globalTime = this.getGlobalTime();
2343
+ this.time = globalTime + timeLeft;
2344
+ this.setTime = timeLeft;
2345
+ }
2346
+
2347
+ /** Set if the timer should keep running even when the game is paused
2348
+ * @param {boolean} [useRealTime] */
2349
+ setUseRealTime(useRealTime=true)
2350
+ {
2351
+ false&&ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
2352
+ this.useRealTime = useRealTime;
2353
+ }
2354
+
2355
+ /** Unset the timer */
2356
+ unset() { this.time = undefined; }
2357
+
2358
+ /** Returns true if set
2359
+ * @return {boolean} */
2360
+ isSet() { return this.time !== undefined; }
2361
+
2362
+ /** Returns true if set and has not elapsed
2363
+ * @return {boolean} */
2364
+ active() { return this.getGlobalTime() < this.time; }
2365
+
2366
+ /** Returns true if set and elapsed
2367
+ * @return {boolean} */
2368
+ elapsed() { return this.getGlobalTime() >= this.time; }
2369
+
2370
+ /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
2371
+ * @return {number} */
2372
+ get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
2373
+
2374
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
2375
+ * Zero-duration timers report 1 (already elapsed).
2376
+ * @return {number} */
2377
+ getPercent()
2378
+ {
2379
+ if (!this.isSet()) return 0;
2380
+ if (!(this.setTime > 0)) return 1;
2381
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
2382
+ }
2383
+
2384
+ /** Get the time this timer was set to, returns 0 if not set
2385
+ * @return {number} */
2386
+ getSetTime() { return this.isSet() ? this.setTime : 0; }
2387
+
2388
+ /** Get the current global time this timer is based on
2389
+ * @return {number} */
2390
+ getGlobalTime() { return this.useRealTime ? timeReal : time; }
2391
+
2392
+ /** Returns this timer expressed as a string
2393
+ * @return {string} */
2394
+ toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
2395
+
2396
+ /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
2397
+ * @return {number} */
2398
+ valueOf() { return this.get(); }
2399
+ }
2400
+
2401
+ ///////////////////////////////////////////////////////////////////////////////
2402
+
2403
+ /** Formats seconds to mm:ss style for display purposes
2404
+ * @param {number} t - time in seconds
2405
+ * @return {string}
2406
+ * @memberof Utilities */
2407
+ function formatTime(t)
2408
+ {
2409
+ const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
2410
+ t = abs(t)|0;
2411
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2412
+ }
2413
+
2414
+ /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
2415
+ * @param {string} url - URL of JSON file
2416
+ * @return {Promise<object>}
2417
+ * @memberof Utilities */
2418
+ async function fetchJSON(url)
2419
+ {
2420
+ const response = await fetch(url);
2421
+ if (!response.ok)
2422
+ throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
2423
+ const json = await response.json();
2424
+ editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
2425
+ return json;
2426
+ }
2427
+
2428
+ ///////////////////////////////////////////////////////////////////////////////
2429
+
2430
+ /** Save a text file to disk
2431
+ * @param {string} text
2432
+ * @param {string} [filename]
2433
+ * @param {string} [type]
2434
+ * @memberof Utilities */
2435
+ function saveText(text, filename='text', type='text/plain')
2436
+ { saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
2437
+
2438
+ /** Create an offscreen canvas to draw into, and return its 2D context
2439
+ * - The canvas is context.canvas, which is what TextureInfo and the like take
2440
+ * @param {number} width - In pixels
2441
+ * @param {number} [height] - In pixels, defaults to the width for a square
2442
+ * @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
2443
+ * @return {OffscreenCanvasRenderingContext2D}
2444
+ * @memberof Utilities */
2445
+ function createCanvasContext(width, height=width, willReadFrequently=false)
2446
+ {
2447
+ false&&ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
2448
+ return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
2449
+ }
2450
+
2451
+ /** Save a canvas to disk
2452
+ * @param {HTMLCanvasElement|OffscreenCanvas} canvas
2453
+ * @param {string} [filename]
2454
+ * @param {string} [type]
2455
+ * @memberof Utilities */
2456
+ function saveCanvas(canvas, filename='screenshot', type='image/png')
2457
+ {
2458
+ if (canvas instanceof OffscreenCanvas)
2459
+ {
2460
+ // copy to temporary canvas and save
2461
+ const saveCanvas = document.createElement('canvas');
2462
+ saveCanvas.width = canvas.width;
2463
+ saveCanvas.height = canvas.height;
2464
+ saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
2465
+ saveDataURL(saveCanvas.toDataURL(type), filename);
2466
+ }
2467
+ else
2468
+ saveDataURL(canvas.toDataURL(type), filename);
2469
+ }
2470
+
2471
+ /** Save a data url to disk
2472
+ * @param {string} url
2473
+ * @param {string} [filename]
2474
+ * @param {number} [revokeTime] - how long before revoking the url
2475
+ * @memberof Utilities */
2476
+ function saveDataURL(url, filename='download', revokeTime)
2477
+ {
2478
+ false&&ASSERT(isStringLike(url), 'saveDataURL requires url string');
2479
+ false&&ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
2480
+
2481
+ // create link for saving screenshots
2482
+ const link = document.createElement('a');
2483
+ link.download = filename;
2484
+ link.href = url;
2485
+ link.click();
2486
+ if (revokeTime !== undefined)
2487
+ setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
2488
+ }
2489
+
2490
+ /** Share content using the native share dialog if available
2491
+ * @param {string} title - title of the share
2492
+ * @param {string} url - url to share
2493
+ * @param {Function} [callback] - Called when share is complete
2494
+ * @memberof Utilities */
2495
+ function shareURL(title, url, callback)
2496
+ {
2497
+ false&&ASSERT(isStringLike(title), 'shareURL requires title string');
2498
+ false&&ASSERT(isStringLike(url), 'shareURL requires url string');
2499
+ navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
2500
+ }
2501
+
2502
+ ///////////////////////////////////////////////////////////////////////////////
2503
+
2504
+ /** Read save data from local storage
2505
+ * - The result has the type of defaultSaveData, or any when there is none
2506
+ * @template {Object<string, any>} [T=any]
2507
+ * @param {string} saveName - unique name for the game/save
2508
+ * @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
2509
+ * @return {T}
2510
+ * @memberof Utilities */
2511
+ function readSaveData(saveName, defaultSaveData)
2512
+ {
2513
+ false&&ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
2514
+ false&&ASSERT(defaultSaveData === undefined ||
2515
+ (typeof defaultSaveData === 'object' && defaultSaveData !== null),
2516
+ 'readSaveData: default must be an object - the result is ' +
2517
+ '{...default, ...loaded}, so a scalar default yields {}. ' +
2518
+ 'Use readSaveData(key, {best:0}).best');
2519
+
2520
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
2521
+ // iframes) and corrupt JSON in stored data
2522
+ let loadedData = {};
2523
+ try
2524
+ {
2525
+ const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
2526
+ if (data)
2527
+ {
2528
+ try { loadedData = JSON.parse(data); }
2529
+ catch { false&&LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2530
+ }
2531
+ }
2532
+ catch { false&&LOG('readSaveData: localStorage unavailable — using defaults'); }
2533
+ return { .../** @type {object} */ (defaultSaveData), ...loadedData };
2534
+ }
2535
+
2536
+ /** Write save data to local storage
2537
+ * @param {string} saveName - unique name for the game/save
2538
+ * @param {object} saveData - object containing data to be saved
2539
+ * @memberof Utilities */
2540
+ function writeSaveData(saveName, saveData)
2541
+ {
2542
+ false&&ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
2543
+ // tolerate localStorage being unavailable or quota exceeded
2544
+ try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
2545
+ catch { false&&LOG('writeSaveData: failed to write', saveName); }
2546
+ }
2547
+
2548
+ ///////////////////////////////////////////////////////////////////////////////
2549
+
2550
+ // Deterministic well-distributed hash of an integer lattice index to [0, 1).
2551
+ // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
2552
+ function noiseHash(i)
2553
+ {
2554
+ let h = (i | 0) ^ 0x9e3779b9;
2555
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
2556
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
2557
+ h ^= h >>> 16;
2558
+ return (h >>> 0) / 2**32;
2559
+ }
2560
+
2561
+ /** 1D value noise — returns a smooth value in [0, 1] for any real x.
2562
+ * Integer inputs land on deterministic lattice values; non-integer inputs
2563
+ * are interpolated with smoothStep for C1 continuity.
2564
+ * @param {number} x
2565
+ * @return {number}
2566
+ * @memberof Utilities */
2567
+ function noise1D(x)
2568
+ {
2569
+ const i = floor(x);
2570
+ return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
2571
+ }
2572
+
2573
+ /** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
2574
+ * @param {number} x
2575
+ * @param {number} y
2576
+ * @return {number}
2577
+ * @memberof Utilities */
2578
+ function noise2D(x, y)
2579
+ {
2580
+ const ix = floor(x), iy = floor(y);
2581
+ const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
2582
+ // large prime decorrelates neighboring rows
2583
+ const h = (a, b) => noiseHash(a + b * 374761393);
2584
+ return lerp(
2585
+ lerp(h(ix, iy ), h(ix + 1, iy ), fx),
2586
+ lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
2587
+ fy);
2584
2588
  }
2585
2589
  /**
2586
2590
  * LittleJS Engine Settings
@@ -3407,712 +3411,712 @@ function setSoundVolume(volume)
3407
3411
  }
3408
3412
 
3409
3413
  /** Set default range where sound no longer plays
3410
- * @param {number} range
3411
- * @memberof Settings */
3412
- function setSoundDefaultRange(range) { soundDefaultRange = range; }
3413
-
3414
- /** Set default range percent to start tapering off sound
3415
- * @param {number} taper
3416
- * @memberof Settings */
3417
- function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
3418
-
3419
- /** Set if all sound pauses while the page is hidden
3420
- * @param {boolean} pause
3421
- * @memberof Settings */
3422
- function setSoundPauseWhenHidden(pause) { soundPauseWhenHidden = pause; }
3423
-
3424
- /** Set if watermark with FPS should be shown
3425
- * @param {boolean} show
3426
- * @memberof Debug */
3427
- function setDebugWatermark(show) { debugWatermark = show; }
3428
-
3429
- /** Set key code used to toggle debug mode, Esc by default
3430
- * @param {string} key
3431
- * @memberof Debug */
3432
- function setDebugKey(key) { debugKey = key; }
3433
-
3434
- /** Set if the debug keys work while the overlay is closed, the number keys and the +/- time keys
3435
- * @param {boolean} [enable]
3436
- * @memberof Debug */
3437
- function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; }
3438
-
3439
- /** Set if the tweakables panel is shown, as 9 does while the debug overlay is open
3440
- * @param {boolean} [show]
3441
- * @memberof Debug */
3442
- function setDebugTweakables(show=true) { debugTweakables = show; }
3443
- /**
3444
- * LittleJS Object System
3445
- * - EngineObject is the base class for all game objects
3446
- * - Handles automatic updating, rendering, physics, and collision
3447
- * - Supports parent-child hierarchies with transform inheritance
3448
- * - 2D physics with velocity, acceleration, damping, and gravity
3449
- * - Collision system with tiles and other objects
3450
- * - Renders sprites from tile sheets with color and rotation
3451
- * - Objects sorted by renderOrder for layered rendering
3452
- */
3453
-
3454
- /**
3455
- * LittleJS Object Base Object Class
3456
- * - Top level object class used by the engine
3457
- * - Automatically adds self to object list
3458
- * - Will be updated and rendered each frame
3459
- * - Renders as a sprite from a tilesheet by default
3460
- * - Can have color and additive color applied
3461
- * - 2D Physics and collision system
3462
- * - Sorted by renderOrder
3463
- * - Objects can have children attached
3464
- * - Parents are updated before children, and set child transform
3465
- * - Call destroy() to get rid of objects
3466
- *
3467
- * The physics system used by objects is simple and fast with some caveats...
3468
- * - Collision uses the axis aligned size, the object's rotation angle is only for rendering
3469
- * - Objects are guaranteed to not intersect tile collision from physics
3470
- * - If an object starts or is moved inside tile collision, it will not collide with that tile
3471
- * - Collision for objects can be set to be solid to block other objects
3472
- * - Objects may get pushed into overlapping other solid objects, if so they will push away
3473
- * - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
3474
- * - Solid objects are more performance intensive and should be used sparingly
3475
- * @memberof Engine
3476
- * @example
3477
- * // create an engine object, normally you would first extend the class with your own
3478
- * const pos = vec2(2,3);
3479
- * const object = new EngineObject(pos);
3480
- */
3481
- class EngineObject
3482
- {
3483
- /** Create an engine object and adds it to the list of objects
3484
- * @param {Vector2} [pos=vec2()] - World space position of the object
3485
- * @param {Vector2} [size=vec2(1)] - World space size of the object
3486
- * @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
3487
- * @param {number} [angle] - Angle the object is rotated by
3488
- * @param {Color} [color=WHITE] - Color to apply to tile when rendered
3489
- * @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
3490
- */
3491
- constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
3492
- {
3493
- // check passed in params
3494
- false&&ASSERT(isVector2(pos), 'object pos must be a vec2');
3495
- false&&ASSERT(isVector2(size), 'object size must be a vec2');
3496
- false&&ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
3497
- false&&ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
3498
- false&&ASSERT(isColor(color), 'object color should be a valid rgba color');
3499
- false&&ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
3500
-
3501
- /** @property {Vector2} - World space position of the object */
3502
- this.pos = pos.copy();
3503
- /** @property {Vector2} - World space width and height of the object */
3504
- this.size = size.copy();
3505
- /** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
3506
- * @type {Vector2|undefined} */
3507
- this.drawSize = undefined;
3508
- /** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
3509
- * @type {TileInfo|undefined} */
3510
- this.tileInfo = tileInfo;
3511
- /** @property {number} - Angle to rotate the object */
3512
- this.angle = angle;
3513
- /** @property {Color} - Color to apply when rendered */
3514
- this.color = color.copy();
3515
- /** @property {Color|undefined} - Additive color to apply when rendered
3516
- * @type {Color|undefined} */
3517
- this.additiveColor = undefined;
3518
- /** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
3519
- * @type {Shader|undefined} */
3520
- this.shader = undefined;
3521
- /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
3522
- this.castShadow = true;
3523
- /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
3524
- * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
3525
- * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
3526
- * too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
3527
- this.emissive = 0;
3528
- /** @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. */
3529
- this.mirror = false;
3530
- /** @property {boolean} - Has object been destroyed? */
3531
- this.destroyed = false;
3532
- this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
3533
-
3534
- // physical properties
3535
- /** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
3536
- * collide on its own, the moving ones collide with it */
3537
- this.mass = objectDefaultMass;
3538
- /** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
3539
- this.damping = objectDefaultDamping;
3540
- /** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
3541
- this.angleDamping = objectDefaultAngleDamping;
3542
- /** @property {number} - How bouncy the object is when colliding (0-1) */
3543
- this.restitution = objectDefaultRestitution;
3544
- /** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
3545
- * once, the more slippery of the object and its ground is used */
3546
- this.friction = objectDefaultFriction;
3547
- /** @property {number} - How much to scale gravity by for this object */
3548
- this.gravityScale = 1;
3549
- /** @property {number} - Objects are sorted by render order */
3550
- this.renderOrder = renderOrder;
3551
- /** @property {Vector2} - Velocity of the object, in world units per frame */
3552
- this.velocity = vec2();
3553
- /** @property {number} - Angular velocity of the object, in radians per frame */
3554
- this.angleVelocity = 0;
3555
- /** @property {number} - Track when object was created */
3556
- this.spawnTime = time;
3557
- /** @property {Array<EngineObject>} - List of children of this object
3558
- * @type {Array<EngineObject>} */
3559
- this.children = [];
3560
- /** @property {boolean} - Limit object speed along x and y axis */
3561
- this.clampSpeed = true;
3562
- /** @property {EngineObject|undefined} - Object we are standing on, if any
3563
- * @type {EngineObject|undefined} */
3564
- this.groundObject = undefined;
3565
-
3566
- // parent child system
3567
- /** @property {EngineObject|undefined} - Parent of object if in local space
3568
- * @type {EngineObject|undefined} */
3569
- this.parent = undefined;
3570
- /** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
3571
- * @type {Vector2|undefined} */
3572
- this.localPos = undefined;
3573
- /** @property {number} - Local angle if child */
3574
- this.localAngle = 0;
3575
-
3576
- // collision flags
3577
- /** @property {boolean} - Object collides with the level, its tile collision layers
3578
- * @type {boolean} */
3579
- this.collideLevel = false;
3580
- /** @property {boolean} - Object collides with solid objects */
3581
- this.collideSolidObjects = false;
3582
- /** @property {boolean} - Object collides with and blocks other objects */
3583
- this.isSolid = false;
3584
- /** @property {boolean} - Object collides with raycasts */
3585
- this.collideRaycast = false;
3586
-
3587
- /** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
3588
- * - Calling destroy on it still destroys it, and its children go with it either way */
3589
- this.persistent = false;
3590
-
3591
- // add to list of objects
3592
- engineObjects.push(this);
3593
- }
3594
-
3595
- /** Update the object transform, called automatically by engine even when paused
3596
- * @param {boolean} [updateChildren] - Also update the children's transforms */
3597
- updateTransforms(updateChildren=true)
3598
- {
3599
- const parent = this.parent;
3600
- if (parent)
3601
- {
3602
- // compose with parent transform inline to avoid intermediate vector allocs
3603
- const mirror = parent.getMirrorSign();
3604
- const lp = this.localPos, pp = parent.pos;
3605
- const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
3606
- if (pa)
3607
- {
3608
- const c = cos(-pa), s = sin(-pa);
3609
- this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3610
- }
3611
- else
3612
- this.pos.set(lx + pp.x, ly + pp.y);
3613
- this.angle = mirror*this.localAngle + pa;
3614
- }
3615
-
3616
- // update children
3617
- if (updateChildren)
3618
- for (const child of this.children)
3619
- child.updateTransforms();
3620
- }
3621
-
3622
- /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
3623
- updatePhysics()
3624
- {
3625
- // child objects do not have physics
3626
- false&&ASSERT(!this.parent);
3627
-
3628
- // bail if a collision callback destroyed us mid-frame
3629
- if (this.destroyed) return;
3630
-
3631
- // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
3632
- // has a mass, which tile collision needs; anything else moves as fast as it is told
3633
- if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
3634
- {
3635
- this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
3636
- this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
3637
- }
3638
-
3639
- // physics sanity checks
3640
- false&&ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
3641
- false&&ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
3642
-
3643
- // apply physics; only the solver needs where the object was, so only then is it copied
3644
- const solve = enablePhysicsSolver && this.mass;
3645
- const oldPos = solve ? this.pos.copy() : undefined;
3646
- this.velocity.x *= this.damping;
3647
- this.velocity.y *= this.damping;
3648
- if (this.mass)
3649
- {
3650
- // apply gravity only if it has mass
3651
- this.velocity.x += gravity.x * this.gravityScale;
3652
- this.velocity.y += gravity.y * this.gravityScale;
3653
- }
3654
- this.pos.x += this.velocity.x;
3655
- this.pos.y += this.velocity.y;
3656
- this.angle += this.angleVelocity *= this.angleDamping;
3657
-
3658
- // don't do collision for static objects or if solver disabled
3659
- if (!solve) return;
3660
-
3661
- // which way is down for this object, a negative gravityScale falls up and lands on ceilings
3662
- const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
3663
- const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
3664
- if (this.groundObject)
3665
- {
3666
- // apply friction in local space of ground object
3667
- const friction = max(this.friction, this.groundObject.friction);
3668
- const groundSpeed = this.groundObject.velocity.x;
3669
- this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
3670
- this.groundObject = undefined;
3671
- }
3672
-
3673
- // an object with no width or height has no box to push out of, or to be pushed out of
3674
- if (this.collideSolidObjects && this.size.x && this.size.y)
3675
- {
3676
- // check collisions against solid objects
3677
- const epsilon = .001; // necessary to push slightly outside of the collision
3678
- for (const o of engineObjectsCollideStaticLast)
3679
- {
3680
- // skip destroyed, child objects, self collision, or objects with no box
3681
- if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
3682
-
3683
- // non solid objects don't collide with each other
3684
- if (!this.isSolid && !o.isSolid) continue;
3685
-
3686
- // check collision
3687
- if (!this.isOverlappingObject(o)) continue;
3688
-
3689
- // each moving object checks its own contacts, so a pair the other one already asked about this frame
3690
- // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
3691
- // to resolve, pushed back together since, is resolved again
3692
- const answer = engineObjectsCollidePairAnswer(o, this);
3693
- if (answer === false) continue;
3694
- if (!answer)
3695
- {
3696
- // notify objects of collision and check if should be resolved
3697
- const collide1 = this.collideWithObject(o);
3698
- const collide2 = o.collideWithObject(this);
3699
- if (!collide1 || !collide2)
3700
- {
3701
- engineObjectsCollidePairAdd(this, o);
3702
- continue;
3703
- }
3704
- }
3705
-
3706
- const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
3707
- if (wasOverlapping && (!o.mass || o.groundObject))
3708
- {
3709
- // a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
3710
- // at once and carries it along, it would only drift out slowly and the solid would pass through;
3711
- // an object standing on something counts as fixed too, so a stack on an elevator rides together;
3712
- // it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
3713
- const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
3714
- if (push)
3715
- {
3716
- this.pos.x += push.x + sign(push.x) * epsilon;
3717
- this.pos.y += push.y + sign(push.y) * epsilon;
3718
- const restitution = max(this.restitution, o.restitution);
3719
- if (push.x)
3720
- {
3721
- const v = this.velocity.x - o.velocity.x;
3722
- if (v * push.x < 0) // moving into it
3723
- this.velocity.x = o.velocity.x - v * restitution;
3724
- }
3725
- else
3726
- {
3727
- const v = this.velocity.y - o.velocity.y;
3728
- if (v * push.y < 0) // moving into it
3729
- this.velocity.y = o.velocity.y - v * restitution;
3730
- if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
3731
- this.groundObject = o;
3732
- }
3733
- }
3734
- engineObjectsCollidePairAdd(this, o);
3735
-
3736
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3737
- continue;
3738
- }
3739
- if (wasOverlapping)
3740
- {
3741
- // if already was touching, try to push away
3742
- const deltaPos = oldPos.subtract(o.pos);
3743
- const length = deltaPos.length();
3744
- const pushAwayAccel = .001;
3745
- const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
3746
- this.velocity = this.velocity.add(velocity);
3747
- if (o.mass) // push away other object if not fixed
3748
- o.velocity = o.velocity.subtract(velocity);
3749
- engineObjectsCollidePairAdd(this, o);
3750
-
3751
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3752
- continue;
3753
- }
3754
-
3755
- // check for collision
3756
- const sizeBoth = this.size.add(o.size);
3757
- // prefer to push up if small delta, up away from this object's own gravity
3758
- const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
3759
- const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
3760
- const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
3761
- const restitution = max(this.restitution, o.restitution);
3762
-
3763
- if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
3764
- {
3765
- // push outside object collision
3766
- this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
3767
- if ((o.groundObject && wasFalling) || !o.mass)
3768
- {
3769
- // set ground object if landed on something
3770
- if (wasFalling)
3771
- this.groundObject = o;
3772
-
3773
- // bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
3774
- // platform moving down instead of landing on it again every few frames
3775
- this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
3776
- }
3777
- else // o has mass here, the other branch already handled the massless case
3778
- {
3779
- // inelastic collision
3780
- const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
3781
-
3782
- // elastic collision
3783
- const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
3784
- + o.velocity.y * 2 * o.mass / (this.mass + o.mass);
3785
- const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
3786
- + this.velocity.y * 2 * this.mass / (this.mass + o.mass);
3787
-
3788
- // lerp between elastic or inelastic based on restitution
3789
- this.velocity.y = lerp(inelastic, elastic0, restitution);
3790
- o.velocity.y = lerp(inelastic, elastic1, restitution);
3791
- }
3792
- }
3793
- if (!smallStepUp && isBlockedX) // resolve x collision
3794
- {
3795
- // push outside collision
3796
- this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
3797
- if (o.mass)
3798
- {
3799
- // inelastic collision
3800
- const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
3801
-
3802
- // elastic collision
3803
- const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
3804
- + o.velocity.x * 2 * o.mass / (this.mass + o.mass);
3805
- const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
3806
- + this.velocity.x * 2 * this.mass / (this.mass + o.mass);
3807
-
3808
- // lerp between elastic or inelastic based on restitution
3809
- this.velocity.x = lerp(inelastic, elastic0, restitution);
3810
- o.velocity.x = lerp(inelastic, elastic1, restitution);
3811
- }
3812
- else // bounce if other object is fixed, relative to it as a landing is
3813
- this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
3814
- }
3815
- engineObjectsCollidePairAdd(this, o, true);
3816
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
3817
- }
3818
- }
3819
- if (this.collideLevel)
3820
- {
3821
- // check collision against tiles
3822
- const hitLayer = tileCollisionTest(this.pos, this.size, this);
3823
- if (hitLayer)
3824
- {
3825
- // if already was stuck in collision, don't do anything
3826
- // this should not happen unless something starts in collision
3827
- if (!tileCollisionTest(oldPos, this.size, this))
3828
- {
3829
- // test which side we bounced off (or both if a corner)
3830
- const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
3831
- const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
3832
- const restitution = max(this.restitution, hitLayer.restitution);
3833
- if (isBlockedX)
3834
- {
3835
- // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
3836
- // down off a ceiling ledge when gravity points up; zero gravity counts as down
3837
- const epsilon = 1e-3;
3838
- const maxMove = .1;
3839
- const y = gravityY > 0 ?
3840
- ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
3841
- floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
3842
- if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
3843
- {
3844
- this.pos.y = y;
3845
- debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
3846
- return;
3847
- }
3848
-
3849
- // move to previous X position and bounce
3850
- this.pos.x = oldPos.x;
3851
- this.velocity.x *= -restitution;
3852
- }
3853
- if (isBlockedY || !isBlockedX)
3854
- {
3855
- if (wasFalling)
3856
- {
3857
- // adjust position to slightly away from nearest tile
3858
- // this prevents gap between object and ground
3859
- const epsilon = .0001;
3860
- const offset = this.size.y/2 + epsilon;
3861
- // rounded in the layer's space as its collision test is, or a bottom a hair below a
3862
- // grid line would round to the row under it, inside the floor, and fall through
3863
- const layerY = hitLayer.pos.y;
3864
- this.pos.y = layerY + (gravityY < 0 ?
3865
- floor(oldPos.y - layerY - this.size.y/2) + offset :
3866
- ceil( oldPos.y - layerY + this.size.y/2) - offset);
3867
-
3868
- // set ground object for tile collision
3869
- this.groundObject = hitLayer;
3870
- }
3871
- else
3872
- {
3873
- // move to previous Y position
3874
- this.pos.y = oldPos.y;
3875
- this.groundObject = undefined;
3876
- }
3877
- // bounce velocity
3878
- this.velocity.y *= -restitution;
3879
- }
3880
- debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
3881
- }
3882
- }
3883
- }
3884
- }
3885
-
3886
- /** Update the object, called automatically by engine once each frame. Does nothing by default. */
3887
- update() {}
3888
-
3889
- /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
3890
- render()
3891
- {
3892
- // default object render
3893
- drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
3894
- }
3895
-
3896
- /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3897
- renderLight() {}
3898
-
3899
- /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
3900
- * 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;
3901
- * screen space WebGL draws in render() are skipped during the pass */
3902
- renderShadow() { this.render(); }
3903
-
3904
- /** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
3905
- * 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
3906
- * glows; override to glow a part, like a robot's eyes */
3907
- renderEmissive() { this.render(); }
3908
-
3909
- /** Destroy this object, destroy its children, detach its parent, and mark it for removal
3910
- * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
3911
- destroy(immediate=false)
3912
- {
3913
- if (this.destroyed) return;
3914
-
3915
- // disconnect from parent and destroy children
3916
- this.destroyed = true;
3917
- this.parent?.removeChild(this);
3918
- for (const child of this.children)
3919
- {
3920
- child.parent = undefined;
3921
- child.destroy(immediate);
3922
- }
3923
- }
3924
-
3925
- /** Convert from local space to world space
3926
- * @param {Vector2} pos - local space point */
3927
- localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
3928
-
3929
- /** Convert from world space to local space
3930
- * @param {Vector2} pos - world space point */
3931
- worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
3932
-
3933
- /** Convert from local space to world space for a vector (rotation only)
3934
- * @param {Vector2} vec - local space vector */
3935
- localToWorldVector(vec) { return vec.rotate(this.angle); }
3936
-
3937
- /** Convert from world space to local space for a vector (rotation only)
3938
- * @param {Vector2} vec - world space vector */
3939
- worldToLocalVector(vec) { return vec.rotate(-this.angle); }
3940
-
3941
- /** 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.
3942
- * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
3943
- * positions it only tries, so keep it free of side effects or guard them to once a frame
3944
- * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
3945
- * saved in update, as the platformer example does
3946
- * @param {number} tileData - the value of the tile at the position
3947
- * @param {Vector2} pos - the tile's bottom left corner in world space
3948
- * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
3949
- collideWithTile(tileData, pos) { return tileData > 0; }
3950
-
3951
- /** 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.
3952
- * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
3953
- * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
3954
- * bullet that should hit one thing checks its own destroyed flag first
3955
- * @param {EngineObject} object - the object to test against
3956
- * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
3957
- * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
3958
- */
3959
- collideWithObject(object, push) { return true; }
3960
-
3961
- /** Get this object's up vector
3962
- * @param {number} [scale] - length of the vector
3963
- * @return {Vector2} */
3964
- getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
3965
-
3966
- /** Get this object's right vector
3967
- * @param {number} [scale] - length of the vector
3968
- * @return {Vector2} */
3969
- getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
3970
-
3971
- /** How long since the object was created
3972
- * @return {number} */
3973
- getAliveTime() { return time - this.spawnTime; }
3974
-
3975
- /** Get the speed of this object
3976
- * @return {number} */
3977
- getSpeed() { return this.velocity.length(); }
3978
-
3979
- /** Apply acceleration to this object (adjust velocity, not affected by mass)
3980
- * - Does nothing on a static object (mass 0), set its velocity instead
3981
- * @param {Vector2} acceleration */
3982
- applyAcceleration(acceleration)
3983
- { if (this.mass) this.velocity = this.velocity.add(acceleration); }
3984
-
3985
- /** Apply angular acceleration to this object
3986
- * - Does nothing on a static object (mass 0), set its angleVelocity instead
3987
- * @param {number} acceleration */
3988
- applyAngularAcceleration(acceleration)
3989
- { if (this.mass) this.angleVelocity += acceleration; }
3990
-
3991
- /** Apply force to this object (adjust velocity, affected by mass)
3992
- * - Does nothing on a static object (mass 0), set its velocity instead
3993
- * @param {Vector2} force */
3994
- applyForce(force)
3995
- { if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
3996
-
3997
- /** Get the direction of the mirror
3998
- * @return {number} -1 if this.mirror is true, or 1 if not mirrored */
3999
- getMirrorSign() { return this.mirror ? -1 : 1; }
4000
-
4001
- /** Attaches a child to this with a local transform, returns child for chaining
4002
- * @param {EngineObject} child
4003
- * @param {Vector2} [localPos=vec2()]
4004
- * @param {number} [localAngle]
4005
- * @return {EngineObject} The child object added */
4006
- addChild(child, localPos=vec2(), localAngle=0)
4007
- {
4008
- false&&ASSERT(!this.destroyed, 'cannot add child to destroyed object');
4009
- if (this.destroyed) return child;
4010
- false&&ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
4011
- false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
4012
- false&&ASSERT(!child.destroyed, 'cannot add a destroyed child');
4013
- for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
4014
- false&&ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
4015
- this.children.push(child);
4016
- child.parent = this;
4017
- child.localPos = localPos.copy();
4018
- child.localAngle = localAngle;
4019
- child.updateTransforms();
4020
- return child;
4021
- }
4022
-
4023
- /** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
4024
- * where addChild takes one; a child of something else is moved over, returns child for chaining
4025
- * @param {EngineObject} child
4026
- * @return {EngineObject} The child object attached */
4027
- attach(child)
4028
- {
4029
- false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
4030
- false&&ASSERT(child !== this, 'cannot attach self');
4031
- child.parent?.removeChild(child);
4032
- // the local values that updateTransforms turns back into the child's current pos and angle
4033
- const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
4034
- local.x *= mirror;
4035
- return this.addChild(child, local, mirror * (child.angle - this.angle));
4036
- }
4037
-
4038
- /** Removes a child from this one, it stays where it is in the world
4039
- * @param {EngineObject} child */
4040
- removeChild(child)
4041
- {
4042
- false&&ASSERT(child.parent === this && this.children.includes(child));
4043
- const i = this.children.indexOf(child);
4044
- if (i < 0) return; // not a child of this one, release has no assert
4045
- this.children.splice(i, 1);
4046
- child.parent = child.localPos = undefined;
4047
- }
4048
-
4049
- /** Check if this object's box overlaps another object's box
4050
- * @param {EngineObject} object
4051
- * @return {boolean} */
4052
- isOverlappingObject(object)
4053
- { return this.isOverlapping(object.pos, object.size); }
4054
-
4055
- /** Check if overlapping a point or aligned bounding box
4056
- * @param {Vector2} pos - Center of box
4057
- * @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
4058
- * @return {boolean} */
4059
- isOverlapping(pos, size=vec2())
4060
- { return isOverlapping(this.pos, this.size, pos, size); }
4061
-
4062
- /** Set how this object collides
4063
- * @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
4064
- * @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
4065
- * @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
4066
- * @param {boolean} [collideRaycast] - Does it collide with raycasts? */
4067
- setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
4068
- {
4069
- false&&ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
3414
+ * @param {number} range
3415
+ * @memberof Settings */
3416
+ function setSoundDefaultRange(range) { soundDefaultRange = range; }
4070
3417
 
4071
- this.collideSolidObjects = collideSolidObjects;
4072
- this.isSolid = isSolid;
4073
- this.collideLevel = collideLevel;
4074
- this.collideRaycast = collideRaycast;
4075
- }
3418
+ /** Set default range percent to start tapering off sound
3419
+ * @param {number} taper
3420
+ * @memberof Settings */
3421
+ function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
4076
3422
 
4077
- /** @deprecated since 1.20, use collideLevel
4078
- * @type {boolean} */
4079
- get collideTiles() { return this.collideLevel; }
4080
- set collideTiles(collide) { this.collideLevel = collide; }
3423
+ /** Set if all sound pauses while the page is hidden
3424
+ * @param {boolean} pause
3425
+ * @memberof Settings */
3426
+ function setSoundPauseWhenHidden(pause) { soundPauseWhenHidden = pause; }
4081
3427
 
4082
- /** Returns string containing info about this object for debugging
4083
- * @return {string} */
4084
- toString()
4085
- {
4086
- let text = 'type = ' + this.constructor.name;
4087
- if (this.pos.x || this.pos.y)
4088
- text += '\npos = ' + this.pos;
4089
- if (this.velocity.x || this.velocity.y)
4090
- text += '\nvelocity = ' + this.velocity;
4091
- if (this.size.x || this.size.y)
4092
- text += '\nsize = ' + this.size;
4093
- if (this.angle)
4094
- text += '\nangle = ' + this.angle.toFixed(3);
4095
- text += '\ncolor = ' + this.color;
4096
- return text;
4097
- }
3428
+ /** Set if watermark with FPS should be shown
3429
+ * @param {boolean} show
3430
+ * @memberof Debug */
3431
+ function setDebugWatermark(show) { debugWatermark = show; }
4098
3432
 
4099
- /** Render debug info for this object */
4100
- renderDebugInfo()
4101
- {
4102
- if (!debug) return;
3433
+ /** Set key code used to toggle debug mode, Esc by default
3434
+ * @param {string} key
3435
+ * @memberof Debug */
3436
+ function setDebugKey(key) { debugKey = key; }
4103
3437
 
4104
- // check if there is anything to show
4105
- const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
4106
- if (!hasPhysics && !this.parent) return;
3438
+ /** Set if the debug keys work while the overlay is closed, the number keys and the +/- time keys
3439
+ * @param {boolean} [enable]
3440
+ * @memberof Debug */
3441
+ function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; }
4107
3442
 
4108
- // show object info for debugging
4109
- const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
4110
- const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
4111
- debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
4112
- if (this.parent)
4113
- debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
4114
- this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
4115
- }
3443
+ /** Set if the tweakables panel is shown, as 9 does while the debug overlay is open
3444
+ * @param {boolean} [show]
3445
+ * @memberof Debug */
3446
+ function setDebugTweakables(show=true) { debugTweakables = show; }
3447
+ /**
3448
+ * LittleJS Object System
3449
+ * - EngineObject is the base class for all game objects
3450
+ * - Handles automatic updating, rendering, physics, and collision
3451
+ * - Supports parent-child hierarchies with transform inheritance
3452
+ * - 2D physics with velocity, acceleration, damping, and gravity
3453
+ * - Collision system with tiles and other objects
3454
+ * - Renders sprites from tile sheets with color and rotation
3455
+ * - Objects sorted by renderOrder for layered rendering
3456
+ */
3457
+
3458
+ /**
3459
+ * LittleJS Object Base Object Class
3460
+ * - Top level object class used by the engine
3461
+ * - Automatically adds self to object list
3462
+ * - Will be updated and rendered each frame
3463
+ * - Renders as a sprite from a tilesheet by default
3464
+ * - Can have color and additive color applied
3465
+ * - 2D Physics and collision system
3466
+ * - Sorted by renderOrder
3467
+ * - Objects can have children attached
3468
+ * - Parents are updated before children, and set child transform
3469
+ * - Call destroy() to get rid of objects
3470
+ *
3471
+ * The physics system used by objects is simple and fast with some caveats...
3472
+ * - Collision uses the axis aligned size, the object's rotation angle is only for rendering
3473
+ * - Objects are guaranteed to not intersect tile collision from physics
3474
+ * - If an object starts or is moved inside tile collision, it will not collide with that tile
3475
+ * - Collision for objects can be set to be solid to block other objects
3476
+ * - Objects may get pushed into overlapping other solid objects, if so they will push away
3477
+ * - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
3478
+ * - Solid objects are more performance intensive and should be used sparingly
3479
+ * @memberof Engine
3480
+ * @example
3481
+ * // create an engine object, normally you would first extend the class with your own
3482
+ * const pos = vec2(2,3);
3483
+ * const object = new EngineObject(pos);
3484
+ */
3485
+ class EngineObject
3486
+ {
3487
+ /** Create an engine object and adds it to the list of objects
3488
+ * @param {Vector2} [pos=vec2()] - World space position of the object
3489
+ * @param {Vector2} [size=vec2(1)] - World space size of the object
3490
+ * @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
3491
+ * @param {number} [angle] - Angle the object is rotated by
3492
+ * @param {Color} [color=WHITE] - Color to apply to tile when rendered
3493
+ * @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
3494
+ */
3495
+ constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
3496
+ {
3497
+ // check passed in params
3498
+ false&&ASSERT(isVector2(pos), 'object pos must be a vec2');
3499
+ false&&ASSERT(isVector2(size), 'object size must be a vec2');
3500
+ false&&ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
3501
+ false&&ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
3502
+ false&&ASSERT(isColor(color), 'object color should be a valid rgba color');
3503
+ false&&ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
3504
+
3505
+ /** @property {Vector2} - World space position of the object */
3506
+ this.pos = pos.copy();
3507
+ /** @property {Vector2} - World space width and height of the object */
3508
+ this.size = size.copy();
3509
+ /** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
3510
+ * @type {Vector2|undefined} */
3511
+ this.drawSize = undefined;
3512
+ /** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
3513
+ * @type {TileInfo|undefined} */
3514
+ this.tileInfo = tileInfo;
3515
+ /** @property {number} - Angle to rotate the object */
3516
+ this.angle = angle;
3517
+ /** @property {Color} - Color to apply when rendered */
3518
+ this.color = color.copy();
3519
+ /** @property {Color|undefined} - Additive color to apply when rendered
3520
+ * @type {Color|undefined} */
3521
+ this.additiveColor = undefined;
3522
+ /** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
3523
+ * @type {Shader|undefined} */
3524
+ this.shader = undefined;
3525
+ /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
3526
+ this.castShadow = true;
3527
+ /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
3528
+ * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
3529
+ * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
3530
+ * too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
3531
+ this.emissive = 0;
3532
+ /** @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. */
3533
+ this.mirror = false;
3534
+ /** @property {boolean} - Has object been destroyed? */
3535
+ this.destroyed = false;
3536
+ this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
3537
+
3538
+ // physical properties
3539
+ /** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
3540
+ * collide on its own, the moving ones collide with it */
3541
+ this.mass = objectDefaultMass;
3542
+ /** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
3543
+ this.damping = objectDefaultDamping;
3544
+ /** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
3545
+ this.angleDamping = objectDefaultAngleDamping;
3546
+ /** @property {number} - How bouncy the object is when colliding (0-1) */
3547
+ this.restitution = objectDefaultRestitution;
3548
+ /** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
3549
+ * once, the more slippery of the object and its ground is used */
3550
+ this.friction = objectDefaultFriction;
3551
+ /** @property {number} - How much to scale gravity by for this object */
3552
+ this.gravityScale = 1;
3553
+ /** @property {number} - Objects are sorted by render order */
3554
+ this.renderOrder = renderOrder;
3555
+ /** @property {Vector2} - Velocity of the object, in world units per frame */
3556
+ this.velocity = vec2();
3557
+ /** @property {number} - Angular velocity of the object, in radians per frame */
3558
+ this.angleVelocity = 0;
3559
+ /** @property {number} - Track when object was created */
3560
+ this.spawnTime = time;
3561
+ /** @property {Array<EngineObject>} - List of children of this object
3562
+ * @type {Array<EngineObject>} */
3563
+ this.children = [];
3564
+ /** @property {boolean} - Limit object speed along x and y axis */
3565
+ this.clampSpeed = true;
3566
+ /** @property {EngineObject|undefined} - Object we are standing on, if any
3567
+ * @type {EngineObject|undefined} */
3568
+ this.groundObject = undefined;
3569
+
3570
+ // parent child system
3571
+ /** @property {EngineObject|undefined} - Parent of object if in local space
3572
+ * @type {EngineObject|undefined} */
3573
+ this.parent = undefined;
3574
+ /** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
3575
+ * @type {Vector2|undefined} */
3576
+ this.localPos = undefined;
3577
+ /** @property {number} - Local angle if child */
3578
+ this.localAngle = 0;
3579
+
3580
+ // collision flags
3581
+ /** @property {boolean} - Object collides with the level, its tile collision layers
3582
+ * @type {boolean} */
3583
+ this.collideLevel = false;
3584
+ /** @property {boolean} - Object collides with solid objects */
3585
+ this.collideSolidObjects = false;
3586
+ /** @property {boolean} - Object collides with and blocks other objects */
3587
+ this.isSolid = false;
3588
+ /** @property {boolean} - Object collides with raycasts */
3589
+ this.collideRaycast = false;
3590
+
3591
+ /** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
3592
+ * - Calling destroy on it still destroys it, and its children go with it either way */
3593
+ this.persistent = false;
3594
+
3595
+ // add to list of objects
3596
+ engineObjects.push(this);
3597
+ }
3598
+
3599
+ /** Update the object transform, called automatically by engine even when paused
3600
+ * @param {boolean} [updateChildren] - Also update the children's transforms */
3601
+ updateTransforms(updateChildren=true)
3602
+ {
3603
+ const parent = this.parent;
3604
+ if (parent)
3605
+ {
3606
+ // compose with parent transform inline to avoid intermediate vector allocs
3607
+ const mirror = parent.getMirrorSign();
3608
+ const lp = this.localPos, pp = parent.pos;
3609
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
3610
+ if (pa)
3611
+ {
3612
+ const c = cos(-pa), s = sin(-pa);
3613
+ this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3614
+ }
3615
+ else
3616
+ this.pos.set(lx + pp.x, ly + pp.y);
3617
+ this.angle = mirror*this.localAngle + pa;
3618
+ }
3619
+
3620
+ // update children
3621
+ if (updateChildren)
3622
+ for (const child of this.children)
3623
+ child.updateTransforms();
3624
+ }
3625
+
3626
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
3627
+ updatePhysics()
3628
+ {
3629
+ // child objects do not have physics
3630
+ false&&ASSERT(!this.parent);
3631
+
3632
+ // bail if a collision callback destroyed us mid-frame
3633
+ if (this.destroyed) return;
3634
+
3635
+ // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
3636
+ // has a mass, which tile collision needs; anything else moves as fast as it is told
3637
+ if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
3638
+ {
3639
+ this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
3640
+ this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
3641
+ }
3642
+
3643
+ // physics sanity checks
3644
+ false&&ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
3645
+ false&&ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
3646
+
3647
+ // apply physics; only the solver needs where the object was, so only then is it copied
3648
+ const solve = enablePhysicsSolver && this.mass;
3649
+ const oldPos = solve ? this.pos.copy() : undefined;
3650
+ this.velocity.x *= this.damping;
3651
+ this.velocity.y *= this.damping;
3652
+ if (this.mass)
3653
+ {
3654
+ // apply gravity only if it has mass
3655
+ this.velocity.x += gravity.x * this.gravityScale;
3656
+ this.velocity.y += gravity.y * this.gravityScale;
3657
+ }
3658
+ this.pos.x += this.velocity.x;
3659
+ this.pos.y += this.velocity.y;
3660
+ this.angle += this.angleVelocity *= this.angleDamping;
3661
+
3662
+ // don't do collision for static objects or if solver disabled
3663
+ if (!solve) return;
3664
+
3665
+ // which way is down for this object, a negative gravityScale falls up and lands on ceilings
3666
+ const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
3667
+ const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
3668
+ if (this.groundObject)
3669
+ {
3670
+ // apply friction in local space of ground object
3671
+ const friction = max(this.friction, this.groundObject.friction);
3672
+ const groundSpeed = this.groundObject.velocity.x;
3673
+ this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
3674
+ this.groundObject = undefined;
3675
+ }
3676
+
3677
+ // an object with no width or height has no box to push out of, or to be pushed out of
3678
+ if (this.collideSolidObjects && this.size.x && this.size.y)
3679
+ {
3680
+ // check collisions against solid objects
3681
+ const epsilon = .001; // necessary to push slightly outside of the collision
3682
+ for (const o of engineObjectsCollideStaticLast)
3683
+ {
3684
+ // skip destroyed, child objects, self collision, or objects with no box
3685
+ if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
3686
+
3687
+ // non solid objects don't collide with each other
3688
+ if (!this.isSolid && !o.isSolid) continue;
3689
+
3690
+ // check collision
3691
+ if (!this.isOverlappingObject(o)) continue;
3692
+
3693
+ // each moving object checks its own contacts, so a pair the other one already asked about this frame
3694
+ // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
3695
+ // to resolve, pushed back together since, is resolved again
3696
+ const answer = engineObjectsCollidePairAnswer(o, this);
3697
+ if (answer === false) continue;
3698
+ if (!answer)
3699
+ {
3700
+ // notify objects of collision and check if should be resolved
3701
+ const collide1 = this.collideWithObject(o);
3702
+ const collide2 = o.collideWithObject(this);
3703
+ if (!collide1 || !collide2)
3704
+ {
3705
+ engineObjectsCollidePairAdd(this, o);
3706
+ continue;
3707
+ }
3708
+ }
3709
+
3710
+ const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
3711
+ if (wasOverlapping && (!o.mass || o.groundObject))
3712
+ {
3713
+ // a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
3714
+ // at once and carries it along, it would only drift out slowly and the solid would pass through;
3715
+ // an object standing on something counts as fixed too, so a stack on an elevator rides together;
3716
+ // it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
3717
+ const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
3718
+ if (push)
3719
+ {
3720
+ this.pos.x += push.x + sign(push.x) * epsilon;
3721
+ this.pos.y += push.y + sign(push.y) * epsilon;
3722
+ const restitution = max(this.restitution, o.restitution);
3723
+ if (push.x)
3724
+ {
3725
+ const v = this.velocity.x - o.velocity.x;
3726
+ if (v * push.x < 0) // moving into it
3727
+ this.velocity.x = o.velocity.x - v * restitution;
3728
+ }
3729
+ else
3730
+ {
3731
+ const v = this.velocity.y - o.velocity.y;
3732
+ if (v * push.y < 0) // moving into it
3733
+ this.velocity.y = o.velocity.y - v * restitution;
3734
+ if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
3735
+ this.groundObject = o;
3736
+ }
3737
+ }
3738
+ engineObjectsCollidePairAdd(this, o);
3739
+
3740
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3741
+ continue;
3742
+ }
3743
+ if (wasOverlapping)
3744
+ {
3745
+ // if already was touching, try to push away
3746
+ const deltaPos = oldPos.subtract(o.pos);
3747
+ const length = deltaPos.length();
3748
+ const pushAwayAccel = .001;
3749
+ const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
3750
+ this.velocity = this.velocity.add(velocity);
3751
+ if (o.mass) // push away other object if not fixed
3752
+ o.velocity = o.velocity.subtract(velocity);
3753
+ engineObjectsCollidePairAdd(this, o);
3754
+
3755
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3756
+ continue;
3757
+ }
3758
+
3759
+ // check for collision
3760
+ const sizeBoth = this.size.add(o.size);
3761
+ // prefer to push up if small delta, up away from this object's own gravity
3762
+ const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
3763
+ const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
3764
+ const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
3765
+ const restitution = max(this.restitution, o.restitution);
3766
+
3767
+ if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
3768
+ {
3769
+ // push outside object collision
3770
+ this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
3771
+ if ((o.groundObject && wasFalling) || !o.mass)
3772
+ {
3773
+ // set ground object if landed on something
3774
+ if (wasFalling)
3775
+ this.groundObject = o;
3776
+
3777
+ // bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
3778
+ // platform moving down instead of landing on it again every few frames
3779
+ this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
3780
+ }
3781
+ else // o has mass here, the other branch already handled the massless case
3782
+ {
3783
+ // inelastic collision
3784
+ const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
3785
+
3786
+ // elastic collision
3787
+ const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
3788
+ + o.velocity.y * 2 * o.mass / (this.mass + o.mass);
3789
+ const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
3790
+ + this.velocity.y * 2 * this.mass / (this.mass + o.mass);
3791
+
3792
+ // lerp between elastic or inelastic based on restitution
3793
+ this.velocity.y = lerp(inelastic, elastic0, restitution);
3794
+ o.velocity.y = lerp(inelastic, elastic1, restitution);
3795
+ }
3796
+ }
3797
+ if (!smallStepUp && isBlockedX) // resolve x collision
3798
+ {
3799
+ // push outside collision
3800
+ this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
3801
+ if (o.mass)
3802
+ {
3803
+ // inelastic collision
3804
+ const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
3805
+
3806
+ // elastic collision
3807
+ const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
3808
+ + o.velocity.x * 2 * o.mass / (this.mass + o.mass);
3809
+ const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
3810
+ + this.velocity.x * 2 * this.mass / (this.mass + o.mass);
3811
+
3812
+ // lerp between elastic or inelastic based on restitution
3813
+ this.velocity.x = lerp(inelastic, elastic0, restitution);
3814
+ o.velocity.x = lerp(inelastic, elastic1, restitution);
3815
+ }
3816
+ else // bounce if other object is fixed, relative to it as a landing is
3817
+ this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
3818
+ }
3819
+ engineObjectsCollidePairAdd(this, o, true);
3820
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
3821
+ }
3822
+ }
3823
+ if (this.collideLevel)
3824
+ {
3825
+ // check collision against tiles
3826
+ const hitLayer = tileCollisionTest(this.pos, this.size, this);
3827
+ if (hitLayer)
3828
+ {
3829
+ // if already was stuck in collision, don't do anything
3830
+ // this should not happen unless something starts in collision
3831
+ if (!tileCollisionTest(oldPos, this.size, this))
3832
+ {
3833
+ // test which side we bounced off (or both if a corner)
3834
+ const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
3835
+ const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
3836
+ const restitution = max(this.restitution, hitLayer.restitution);
3837
+ if (isBlockedX)
3838
+ {
3839
+ // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
3840
+ // down off a ceiling ledge when gravity points up; zero gravity counts as down
3841
+ const epsilon = 1e-3;
3842
+ const maxMove = .1;
3843
+ const y = gravityY > 0 ?
3844
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
3845
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
3846
+ if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
3847
+ {
3848
+ this.pos.y = y;
3849
+ debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
3850
+ return;
3851
+ }
3852
+
3853
+ // move to previous X position and bounce
3854
+ this.pos.x = oldPos.x;
3855
+ this.velocity.x *= -restitution;
3856
+ }
3857
+ if (isBlockedY || !isBlockedX)
3858
+ {
3859
+ if (wasFalling)
3860
+ {
3861
+ // adjust position to slightly away from nearest tile
3862
+ // this prevents gap between object and ground
3863
+ const epsilon = .0001;
3864
+ const offset = this.size.y/2 + epsilon;
3865
+ // rounded in the layer's space as its collision test is, or a bottom a hair below a
3866
+ // grid line would round to the row under it, inside the floor, and fall through
3867
+ const layerY = hitLayer.pos.y;
3868
+ this.pos.y = layerY + (gravityY < 0 ?
3869
+ floor(oldPos.y - layerY - this.size.y/2) + offset :
3870
+ ceil( oldPos.y - layerY + this.size.y/2) - offset);
3871
+
3872
+ // set ground object for tile collision
3873
+ this.groundObject = hitLayer;
3874
+ }
3875
+ else
3876
+ {
3877
+ // move to previous Y position
3878
+ this.pos.y = oldPos.y;
3879
+ this.groundObject = undefined;
3880
+ }
3881
+ // bounce velocity
3882
+ this.velocity.y *= -restitution;
3883
+ }
3884
+ debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
3885
+ }
3886
+ }
3887
+ }
3888
+ }
3889
+
3890
+ /** Update the object, called automatically by engine once each frame. Does nothing by default. */
3891
+ update() {}
3892
+
3893
+ /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
3894
+ render()
3895
+ {
3896
+ // default object render
3897
+ drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
3898
+ }
3899
+
3900
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3901
+ renderLight() {}
3902
+
3903
+ /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
3904
+ * 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;
3905
+ * screen space WebGL draws in render() are skipped during the pass */
3906
+ renderShadow() { this.render(); }
3907
+
3908
+ /** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
3909
+ * 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
3910
+ * glows; override to glow a part, like a robot's eyes */
3911
+ renderEmissive() { this.render(); }
3912
+
3913
+ /** Destroy this object, destroy its children, detach its parent, and mark it for removal
3914
+ * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
3915
+ destroy(immediate=false)
3916
+ {
3917
+ if (this.destroyed) return;
3918
+
3919
+ // disconnect from parent and destroy children
3920
+ this.destroyed = true;
3921
+ this.parent?.removeChild(this);
3922
+ for (const child of this.children)
3923
+ {
3924
+ child.parent = undefined;
3925
+ child.destroy(immediate);
3926
+ }
3927
+ }
3928
+
3929
+ /** Convert from local space to world space
3930
+ * @param {Vector2} pos - local space point */
3931
+ localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
3932
+
3933
+ /** Convert from world space to local space
3934
+ * @param {Vector2} pos - world space point */
3935
+ worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
3936
+
3937
+ /** Convert from local space to world space for a vector (rotation only)
3938
+ * @param {Vector2} vec - local space vector */
3939
+ localToWorldVector(vec) { return vec.rotate(this.angle); }
3940
+
3941
+ /** Convert from world space to local space for a vector (rotation only)
3942
+ * @param {Vector2} vec - world space vector */
3943
+ worldToLocalVector(vec) { return vec.rotate(-this.angle); }
3944
+
3945
+ /** 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.
3946
+ * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
3947
+ * positions it only tries, so keep it free of side effects or guard them to once a frame
3948
+ * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
3949
+ * saved in update, as the platformer example does
3950
+ * @param {number} tileData - the value of the tile at the position
3951
+ * @param {Vector2} pos - the tile's bottom left corner in world space
3952
+ * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
3953
+ collideWithTile(tileData, pos) { return tileData > 0; }
3954
+
3955
+ /** 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.
3956
+ * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
3957
+ * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
3958
+ * bullet that should hit one thing checks its own destroyed flag first
3959
+ * @param {EngineObject} object - the object to test against
3960
+ * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
3961
+ * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
3962
+ */
3963
+ collideWithObject(object, push) { return true; }
3964
+
3965
+ /** Get this object's up vector
3966
+ * @param {number} [scale] - length of the vector
3967
+ * @return {Vector2} */
3968
+ getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
3969
+
3970
+ /** Get this object's right vector
3971
+ * @param {number} [scale] - length of the vector
3972
+ * @return {Vector2} */
3973
+ getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
3974
+
3975
+ /** How long since the object was created
3976
+ * @return {number} */
3977
+ getAliveTime() { return time - this.spawnTime; }
3978
+
3979
+ /** Get the speed of this object
3980
+ * @return {number} */
3981
+ getSpeed() { return this.velocity.length(); }
3982
+
3983
+ /** Apply acceleration to this object (adjust velocity, not affected by mass)
3984
+ * - Does nothing on a static object (mass 0), set its velocity instead
3985
+ * @param {Vector2} acceleration */
3986
+ applyAcceleration(acceleration)
3987
+ { if (this.mass) this.velocity = this.velocity.add(acceleration); }
3988
+
3989
+ /** Apply angular acceleration to this object
3990
+ * - Does nothing on a static object (mass 0), set its angleVelocity instead
3991
+ * @param {number} acceleration */
3992
+ applyAngularAcceleration(acceleration)
3993
+ { if (this.mass) this.angleVelocity += acceleration; }
3994
+
3995
+ /** Apply force to this object (adjust velocity, affected by mass)
3996
+ * - Does nothing on a static object (mass 0), set its velocity instead
3997
+ * @param {Vector2} force */
3998
+ applyForce(force)
3999
+ { if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
4000
+
4001
+ /** Get the direction of the mirror
4002
+ * @return {number} -1 if this.mirror is true, or 1 if not mirrored */
4003
+ getMirrorSign() { return this.mirror ? -1 : 1; }
4004
+
4005
+ /** Attaches a child to this with a local transform, returns child for chaining
4006
+ * @param {EngineObject} child
4007
+ * @param {Vector2} [localPos=vec2()]
4008
+ * @param {number} [localAngle]
4009
+ * @return {EngineObject} The child object added */
4010
+ addChild(child, localPos=vec2(), localAngle=0)
4011
+ {
4012
+ false&&ASSERT(!this.destroyed, 'cannot add child to destroyed object');
4013
+ if (this.destroyed) return child;
4014
+ false&&ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
4015
+ false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
4016
+ false&&ASSERT(!child.destroyed, 'cannot add a destroyed child');
4017
+ for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
4018
+ false&&ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
4019
+ this.children.push(child);
4020
+ child.parent = this;
4021
+ child.localPos = localPos.copy();
4022
+ child.localAngle = localAngle;
4023
+ child.updateTransforms();
4024
+ return child;
4025
+ }
4026
+
4027
+ /** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
4028
+ * where addChild takes one; a child of something else is moved over, returns child for chaining
4029
+ * @param {EngineObject} child
4030
+ * @return {EngineObject} The child object attached */
4031
+ attach(child)
4032
+ {
4033
+ false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
4034
+ false&&ASSERT(child !== this, 'cannot attach self');
4035
+ child.parent?.removeChild(child);
4036
+ // the local values that updateTransforms turns back into the child's current pos and angle
4037
+ const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
4038
+ local.x *= mirror;
4039
+ return this.addChild(child, local, mirror * (child.angle - this.angle));
4040
+ }
4041
+
4042
+ /** Removes a child from this one, it stays where it is in the world
4043
+ * @param {EngineObject} child */
4044
+ removeChild(child)
4045
+ {
4046
+ false&&ASSERT(child.parent === this && this.children.includes(child));
4047
+ const i = this.children.indexOf(child);
4048
+ if (i < 0) return; // not a child of this one, release has no assert
4049
+ this.children.splice(i, 1);
4050
+ child.parent = child.localPos = undefined;
4051
+ }
4052
+
4053
+ /** Check if this object's box overlaps another object's box
4054
+ * @param {EngineObject} object
4055
+ * @return {boolean} */
4056
+ isOverlappingObject(object)
4057
+ { return this.isOverlapping(object.pos, object.size); }
4058
+
4059
+ /** Check if overlapping a point or aligned bounding box
4060
+ * @param {Vector2} pos - Center of box
4061
+ * @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
4062
+ * @return {boolean} */
4063
+ isOverlapping(pos, size=vec2())
4064
+ { return isOverlapping(this.pos, this.size, pos, size); }
4065
+
4066
+ /** Set how this object collides
4067
+ * @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
4068
+ * @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
4069
+ * @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
4070
+ * @param {boolean} [collideRaycast] - Does it collide with raycasts? */
4071
+ setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
4072
+ {
4073
+ false&&ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
4074
+
4075
+ this.collideSolidObjects = collideSolidObjects;
4076
+ this.isSolid = isSolid;
4077
+ this.collideLevel = collideLevel;
4078
+ this.collideRaycast = collideRaycast;
4079
+ }
4080
+
4081
+ /** @deprecated since 1.20, use collideLevel
4082
+ * @type {boolean} */
4083
+ get collideTiles() { return this.collideLevel; }
4084
+ set collideTiles(collide) { this.collideLevel = collide; }
4085
+
4086
+ /** Returns string containing info about this object for debugging
4087
+ * @return {string} */
4088
+ toString()
4089
+ {
4090
+ let text = 'type = ' + this.constructor.name;
4091
+ if (this.pos.x || this.pos.y)
4092
+ text += '\npos = ' + this.pos;
4093
+ if (this.velocity.x || this.velocity.y)
4094
+ text += '\nvelocity = ' + this.velocity;
4095
+ if (this.size.x || this.size.y)
4096
+ text += '\nsize = ' + this.size;
4097
+ if (this.angle)
4098
+ text += '\nangle = ' + this.angle.toFixed(3);
4099
+ text += '\ncolor = ' + this.color;
4100
+ return text;
4101
+ }
4102
+
4103
+ /** Render debug info for this object */
4104
+ renderDebugInfo()
4105
+ {
4106
+ if (!debug) return;
4107
+
4108
+ // check if there is anything to show
4109
+ const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
4110
+ if (!hasPhysics && !this.parent) return;
4111
+
4112
+ // show object info for debugging
4113
+ const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
4114
+ const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
4115
+ debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
4116
+ if (this.parent)
4117
+ debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
4118
+ this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
4119
+ }
4116
4120
  }
4117
4121
  /**
4118
4122
  * LittleJS Drawing System
@@ -5057,7 +5061,7 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
5057
5061
  // build regular polygon points
5058
5062
  const points = [];
5059
5063
  const sizeX = size.x/2, sizeY = size.y/2;
5060
- for (let i=sides; i--;)
5064
+ for (let i=sides; i-- > 0;) // a count that is not whole, or below zero, still ends
5061
5065
  {
5062
5066
  const a = (i/sides)*PI*2;
5063
5067
  points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
@@ -5168,7 +5172,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
5168
5172
  if (!ring)
5169
5173
  {
5170
5174
  const points = [];
5171
- for (let i=sides; i--;)
5175
+ for (let i=sides; i-- > 0;)
5172
5176
  {
5173
5177
  const a = (i/sides)*PI*2;
5174
5178
  points.push(vec2(sin(a), cos(a)));
@@ -5268,7 +5272,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
5268
5272
  const startA = (offset%sides)/sides*PI*2;
5269
5273
  const points = [rim(startA)];
5270
5274
  const colors = [outerInt];
5271
- for (let i=sides; i--;)
5275
+ for (let i=sides; i-- > 0;)
5272
5276
  {
5273
5277
  const a = ((i+offset)%sides)/sides*PI*2;
5274
5278
  points.push(pos);
@@ -5691,7 +5695,8 @@ function isOnScreen(pos, size=0)
5691
5695
  function setAdditiveBlendMode(additive=true)
5692
5696
  {
5693
5697
  glAdditive = additive;
5694
- drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5698
+ if (drawContext) // none headless
5699
+ drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5695
5700
  }
5696
5701
 
5697
5702
  /** Set the Shader that 2D draws use from now on, none for the engine's own
@@ -5814,9 +5819,12 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
5814
5819
  }
5815
5820
  else
5816
5821
  {
5817
- // copy to offscreen canvas
5818
- workReadCanvas.width = sWidth;
5819
- workReadCanvas.height = sHeight;
5822
+ // copy to offscreen canvas, sized again only when the size changes: setting a size remakes the canvas even
5823
+ // to the same one, and a game's tinted tiles are mostly of one size
5824
+ if (workReadCanvas.width !== sWidth || workReadCanvas.height !== sHeight)
5825
+ workReadCanvas.width = sWidth, workReadCanvas.height = sHeight;
5826
+ else
5827
+ workReadContext.clearRect(0, 0, sWidth, sHeight);
5820
5828
  workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
5821
5829
 
5822
5830
  // tint image using offscreen work context
@@ -7762,7 +7770,13 @@ class Sound
7762
7770
  */
7763
7771
  constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
7764
7772
  {
7765
- if (!soundEnable || headlessMode) return;
7773
+ if (!soundEnable || headlessMode)
7774
+ {
7775
+ // no sound is made: it counts as loaded, so a game that waits for its sounds goes on
7776
+ this.loadedPercent = 1;
7777
+ onloadCallback?.(this);
7778
+ return;
7779
+ }
7766
7780
  const rangeIsDefault = range === undefined;
7767
7781
  if (rangeIsDefault)
7768
7782
  range = soundDefaultRange;
@@ -8909,7 +8923,8 @@ function objectLayersMake(tileMapData, object)
8909
8923
  const name = object.type || object.class, type = objectLayersTypes.get(name);
8910
8924
  if (!type)
8911
8925
  {
8912
- debug && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
8926
+ // an object with no type, a shape or a text someone drew in Tiled, is not one a game makes
8927
+ debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
8913
8928
  return;
8914
8929
  }
8915
8930
  const {height=0, tilewidth=1, tileheight=1} = tileMapData;
@@ -10993,6 +11008,9 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
10993
11008
  glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
10994
11009
  }
10995
11010
 
11011
+ // the list and the vectors glDrawPointsTransform writes its points into
11012
+ const glPointsOut = [], glPointsPool = [];
11013
+
10996
11014
  /** Transform and add a polygon to the gl draw list
10997
11015
  * @param {Array<Vector2>} points - Array of Vector2 points
10998
11016
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -11005,15 +11023,21 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
11005
11023
  * @memberof WebGL */
11006
11024
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
11007
11025
  {
11008
- const pointsOut = [];
11026
+ // written into vectors kept for this, a circle would make one for each of its sides at every draw; they are
11027
+ // read into the batch before this returns
11028
+ const pointsOut = glPointsOut, pool = glPointsPool, count = points.length;
11029
+ pointsOut.length = count;
11009
11030
  const sa = sin(-angle);
11010
11031
  const ca = cos(-angle);
11011
- for (const p of points)
11032
+ for (let i = 0; i < count; ++i)
11012
11033
  {
11013
11034
  // transform the point
11035
+ const p = points[i], out = pool[i] ||= vec2();
11014
11036
  const px = p.x*sx;
11015
11037
  const py = p.y*sy;
11016
- pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
11038
+ out.x = x + ca*px - sa*py;
11039
+ out.y = y + sa*px + ca*py;
11040
+ pointsOut[i] = out;
11017
11041
  }
11018
11042
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
11019
11043
  glDrawPoints(drawPoints, rgba);
@@ -12448,8 +12472,11 @@ class PostProcessPlugin
12448
12472
  {
12449
12473
  // copy main canvas to work canvas at the backing store size,
12450
12474
  // mainCanvasSize is css pixels so it would lose resolution
12451
- workCanvas.width = mainCanvas.width;
12452
- workCanvas.height = mainCanvas.height;
12475
+ // sized again only when the canvas changed, setting a size remakes the canvas even to the same one
12476
+ if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height)
12477
+ workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height;
12478
+ else
12479
+ workContext.clearRect(0, 0, workCanvas.width, workCanvas.height);
12453
12480
  glCopyToContext(workContext);
12454
12481
  workContext.drawImage(mainCanvas, 0, 0);
12455
12482
  // clear the main canvas with clearRect, resizing it would also
@@ -13543,163 +13570,165 @@ class Light extends EngineObject
13543
13570
  }
13544
13571
  }
13545
13572
 
13546
- /**
13547
- * LittleJS ZzFXM Plugin
13548
- * @namespace ZzFXM
13549
- */
13550
-
13551
- /**
13552
- * Music Object - Stores a zzfx music track for later use
13553
- *
13554
- * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
13555
- * @extends Sound
13556
- * @memberof ZzFXM
13557
- * @example
13558
- * // create some music
13559
- * const music_example = new ZzFXMusic(
13560
- * [
13561
- * [ // instruments
13562
- * [,0,400] // simple note
13563
- * ],
13564
- * [ // patterns
13565
- * [ // pattern 1
13566
- * [ // channel 0
13567
- * 0, -1, // instrument 0, left speaker
13568
- * 1, 0, 9, 1 // channel notes
13569
- * ],
13570
- * [ // channel 1
13571
- * 0, 1, // instrument 0, right speaker
13572
- * 0, 12, 17, -1 // channel notes
13573
- * ]
13574
- * ],
13575
- * ],
13576
- * [0, 0, 0, 0], // sequence, play pattern 0 four times
13577
- * 90 // BPM
13578
- * ]);
13579
- *
13580
- * // play the music on a loop
13581
- * music_example.playMusic();
13582
- */
13583
- class ZzFXMusic extends Sound
13584
- {
13585
- /** Create a music object and cache the zzfx music samples for later use
13586
- * @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
13587
- */
13588
- constructor(zzfxMusic)
13589
- {
13590
- super(undefined);
13591
-
13592
- if (!soundEnable || headlessMode) return;
13593
- const [instruments, patterns, sequence, BPM] = zzfxMusic;
13594
- super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
13595
- this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
13596
- this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
13597
- }
13598
- }
13599
-
13600
- ///////////////////////////////////////////////////////////////////////////////
13601
- // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
13602
-
13603
- /** Generate samples for a ZzFM song with given parameters
13604
- * @param {Array} instruments - Array of ZzFX sound parameters
13605
- * @param {Array} patterns - Array of pattern data
13606
- * @param {Array} sequence - Array of pattern indexes
13607
- * @param {number} [BPM] - Playback speed of the song in BPM
13608
- * @return {Array} - Left and right channel sample data
13609
- * @memberof ZzFXM */
13610
- function zzfxM(instruments, patterns, sequence, BPM = 125)
13611
- {
13612
- let i, j, k;
13613
- let instrumentParameters;
13614
- let note;
13615
- let sample;
13616
- let patternChannel;
13617
- let notFirstBeat;
13618
- let stop;
13619
- let instrument;
13620
- let attenuation;
13621
- let outSampleOffset;
13622
- let isSequenceEnd;
13623
- let sampleOffset = 0;
13624
- let nextSampleOffset;
13625
- let sampleBuffer = [];
13626
- let leftChannelBuffer = [];
13627
- let rightChannelBuffer = [];
13628
- let channelIndex = 0;
13629
- let panning = 0;
13630
- let hasMore = 1;
13631
- let sampleCache = {};
13632
- let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
13633
-
13634
- // for each channel in order until there are no more
13635
- for (; hasMore; channelIndex++) {
13636
-
13637
- // reset current values
13638
- sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
13639
-
13640
- // for each pattern in sequence
13641
- sequence.forEach((patternIndex, sequenceIndex)=> {
13642
- // get pattern for current channel, use empty 1 note pattern if none found
13643
- patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
13644
-
13645
- // check if there are more channels
13646
- hasMore |= patterns[patternIndex][channelIndex]&&1;
13647
-
13648
- // get next offset, use the length of first channel
13649
- nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
13650
- // for each beat in pattern, plus one extra if end of sequence
13651
- isSequenceEnd = sequenceIndex === sequence.length - 1;
13652
- for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
13653
-
13654
- // <channel-note>
13655
- note = patternChannel[i];
13656
-
13657
- // stop if end, different instrument or new note
13658
- stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
13659
- instrument !== (patternChannel[0] || 0) || note | 0;
13660
-
13661
- // fill buffer with samples for previous beat, most cpu intensive part
13662
- for (j = 0; j < beatLength && notFirstBeat;
13663
-
13664
- // fade off attenuation at end of beat if stopping note, prevents clicking
13665
- j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
13666
- ) {
13667
- // copy sample to stereo buffers with panning
13668
- sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
13669
- leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
13670
- rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
13671
- }
13672
-
13673
- // set up for next note
13674
- if (note) {
13675
- // set attenuation
13676
- attenuation = note % 1;
13677
- panning = patternChannel[1] || 0;
13678
- if (note |= 0) {
13679
- // get cached sample
13680
- sampleBuffer = sampleCache[
13681
- [
13682
- instrument = patternChannel[sampleOffset = 0] || 0,
13683
- note
13684
- ]
13685
- ] = sampleCache[[instrument, note]] || (
13686
- // add sample to cache
13687
- instrumentParameters = [...instruments[instrument]],
13688
- instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
13689
-
13690
- // allow negative values to stop notes
13691
- note > 0 ? zzfxG(...instrumentParameters) : []
13692
- );
13693
- }
13694
- }
13695
- }
13696
-
13697
- // update the sample offset
13698
- outSampleOffset = nextSampleOffset;
13699
- });
13700
- }
13701
-
13702
- return [leftChannelBuffer, rightChannelBuffer];
13573
+ /**
13574
+ * LittleJS ZzFXM Plugin
13575
+ * - A port of ZzFXM, the Zuper Zmall Zound Zynth music player, by Keith Clark and Frank Force, MIT licensed,
13576
+ * https://github.com/keithclark/ZzFXM; its notice is in COPYRIGHT.txt
13577
+ * @namespace ZzFXM
13578
+ */
13579
+
13580
+ /**
13581
+ * Music Object - Stores a zzfx music track for later use
13582
+ *
13583
+ * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
13584
+ * @extends Sound
13585
+ * @memberof ZzFXM
13586
+ * @example
13587
+ * // create some music
13588
+ * const music_example = new ZzFXMusic(
13589
+ * [
13590
+ * [ // instruments
13591
+ * [,0,400] // simple note
13592
+ * ],
13593
+ * [ // patterns
13594
+ * [ // pattern 1
13595
+ * [ // channel 0
13596
+ * 0, -1, // instrument 0, left speaker
13597
+ * 1, 0, 9, 1 // channel notes
13598
+ * ],
13599
+ * [ // channel 1
13600
+ * 0, 1, // instrument 0, right speaker
13601
+ * 0, 12, 17, -1 // channel notes
13602
+ * ]
13603
+ * ],
13604
+ * ],
13605
+ * [0, 0, 0, 0], // sequence, play pattern 0 four times
13606
+ * 90 // BPM
13607
+ * ]);
13608
+ *
13609
+ * // play the music on a loop
13610
+ * music_example.playMusic();
13611
+ */
13612
+ class ZzFXMusic extends Sound
13613
+ {
13614
+ /** Create a music object and cache the zzfx music samples for later use
13615
+ * @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
13616
+ */
13617
+ constructor(zzfxMusic)
13618
+ {
13619
+ super(undefined);
13620
+
13621
+ if (!soundEnable || headlessMode) return;
13622
+ const [instruments, patterns, sequence, BPM] = zzfxMusic;
13623
+ super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
13624
+ this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
13625
+ this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
13626
+ }
13627
+ }
13628
+
13629
+ ///////////////////////////////////////////////////////////////////////////////
13630
+ // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
13631
+
13632
+ /** Generate samples for a ZzFM song with given parameters
13633
+ * @param {Array} instruments - Array of ZzFX sound parameters
13634
+ * @param {Array} patterns - Array of pattern data
13635
+ * @param {Array} sequence - Array of pattern indexes
13636
+ * @param {number} [BPM] - Playback speed of the song in BPM
13637
+ * @return {Array} - Left and right channel sample data
13638
+ * @memberof ZzFXM */
13639
+ function zzfxM(instruments, patterns, sequence, BPM = 125)
13640
+ {
13641
+ let i, j, k;
13642
+ let instrumentParameters;
13643
+ let note;
13644
+ let sample;
13645
+ let patternChannel;
13646
+ let notFirstBeat;
13647
+ let stop;
13648
+ let instrument;
13649
+ let attenuation;
13650
+ let outSampleOffset;
13651
+ let isSequenceEnd;
13652
+ let sampleOffset = 0;
13653
+ let nextSampleOffset;
13654
+ let sampleBuffer = [];
13655
+ let leftChannelBuffer = [];
13656
+ let rightChannelBuffer = [];
13657
+ let channelIndex = 0;
13658
+ let panning = 0;
13659
+ let hasMore = 1;
13660
+ let sampleCache = {};
13661
+ let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
13662
+
13663
+ // for each channel in order until there are no more
13664
+ for (; hasMore; channelIndex++) {
13665
+
13666
+ // reset current values
13667
+ sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
13668
+
13669
+ // for each pattern in sequence
13670
+ sequence.forEach((patternIndex, sequenceIndex)=> {
13671
+ // get pattern for current channel, use empty 1 note pattern if none found
13672
+ patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
13673
+
13674
+ // check if there are more channels
13675
+ hasMore |= patterns[patternIndex][channelIndex]&&1;
13676
+
13677
+ // get next offset, use the length of first channel
13678
+ nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
13679
+ // for each beat in pattern, plus one extra if end of sequence
13680
+ isSequenceEnd = sequenceIndex === sequence.length - 1;
13681
+ for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
13682
+
13683
+ // <channel-note>
13684
+ note = patternChannel[i];
13685
+
13686
+ // stop if end, different instrument or new note
13687
+ stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
13688
+ instrument !== (patternChannel[0] || 0) || note | 0;
13689
+
13690
+ // fill buffer with samples for previous beat, most cpu intensive part
13691
+ for (j = 0; j < beatLength && notFirstBeat;
13692
+
13693
+ // fade off attenuation at end of beat if stopping note, prevents clicking
13694
+ j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
13695
+ ) {
13696
+ // copy sample to stereo buffers with panning
13697
+ sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
13698
+ leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
13699
+ rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
13700
+ }
13701
+
13702
+ // set up for next note
13703
+ if (note) {
13704
+ // set attenuation
13705
+ attenuation = note % 1;
13706
+ panning = patternChannel[1] || 0;
13707
+ if (note |= 0) {
13708
+ // get cached sample
13709
+ sampleBuffer = sampleCache[
13710
+ [
13711
+ instrument = patternChannel[sampleOffset = 0] || 0,
13712
+ note
13713
+ ]
13714
+ ] = sampleCache[[instrument, note]] || (
13715
+ // add sample to cache
13716
+ instrumentParameters = [...instruments[instrument]],
13717
+ instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
13718
+
13719
+ // allow negative values to stop notes
13720
+ note > 0 ? zzfxG(...instrumentParameters) : []
13721
+ );
13722
+ }
13723
+ }
13724
+ }
13725
+
13726
+ // update the sample offset
13727
+ outSampleOffset = nextSampleOffset;
13728
+ });
13729
+ }
13730
+
13731
+ return [leftChannelBuffer, rightChannelBuffer];
13703
13732
  }
13704
13733
  /**
13705
13734
  * LittleJS Audio Effects Plugin
@@ -14690,8 +14719,7 @@ class UISystemPlugin
14690
14719
  }
14691
14720
  }
14692
14721
 
14693
- /** Destroy and remove all objects
14694
- * @memberof UISystem */
14722
+ /** Destroy and remove all objects */
14695
14723
  destroyObjects()
14696
14724
  {
14697
14725
  for (const o of this.uiObjects)
@@ -15212,8 +15240,10 @@ class UIObject
15212
15240
  return void inputClearKey(0, 0, false, true, false);
15213
15241
  }
15214
15242
  }
15243
+ // the object that was the active one going into this update: one pressed and let go inside a
15244
+ // frame is clicked on the next, with its release, not on both
15215
15245
  if (!uiSystem.activateOnPress)
15216
- if (!mouseDown && this.isActiveObject() && this.interactive)
15246
+ if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
15217
15247
  this.click();
15218
15248
  if (this.destroyed) return;
15219
15249
  }
@@ -15956,7 +15986,10 @@ class UIVideo extends UIObject
15956
15986
  if (this.destroyed)
15957
15987
  return;
15958
15988
 
15989
+ // let go of the media too, a paused video keeps what it has loaded
15959
15990
  this.video.pause();
15991
+ this.video.removeAttribute?.('src');
15992
+ this.video.load?.();
15960
15993
  this.video.remove();
15961
15994
  super.destroy();
15962
15995
  }
@@ -19295,6 +19328,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
19295
19328
  for (const group of parseAtlas(data))
19296
19329
  {
19297
19330
  // reserve a block of full size cells, one per frame
19331
+ if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
19298
19332
  const sourceSize = group.frames[0].sourceSize;
19299
19333
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
19300
19334
  const added = textureSheetAdd(blockSize, sourceSize, padding);
@@ -20080,45 +20114,50 @@ function tweenUpdate(gameDelta, realDelta)
20080
20114
  const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
20081
20115
  for (const t of tweenActive)
20082
20116
  list.push(t);
20083
- for (let i = list.length; i--;)
20117
+ // the list is let go however the walk ends: a callback that throws must not leave it held, or every
20118
+ // update after would take it for an update still going and make a list of its own
20119
+ try
20084
20120
  {
20085
- const t = list[i];
20086
- // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
20087
- // counts on from this one
20088
- if (!t.active || t.activePass >= pass) continue;
20089
- let dt;
20090
- if (enginePath)
20121
+ for (let i = list.length; i--;)
20091
20122
  {
20092
- // a paused tween keeps count too, so it does not jump when resumed
20093
- dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
20094
- t.lastTime = time;
20095
- t.lastTimeReal = timeReal;
20096
- }
20097
- else
20098
- dt = t.useRealTime ? realDelta : gameDelta;
20099
- if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
20100
- if (t.paused || dt <= 0) continue;
20123
+ const t = list[i];
20124
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
20125
+ // counts on from this one
20126
+ if (!t.active || t.activePass >= pass) continue;
20127
+ let dt;
20128
+ if (enginePath)
20129
+ {
20130
+ // a paused tween keeps count too, so it does not jump when resumed
20131
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
20132
+ t.lastTime = time;
20133
+ t.lastTimeReal = timeReal;
20134
+ }
20135
+ else
20136
+ dt = t.useRealTime ? realDelta : gameDelta;
20137
+ if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
20138
+ if (t.paused || dt <= 0) continue;
20101
20139
 
20102
- t.life -= dt;
20103
- if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
20104
- {
20105
- t.callback(t.interp(t.life));
20106
- }
20107
- else
20108
- {
20109
- // Completion: fire end value, remove from active, start the next iteration
20110
- // of a loop or pingPong, or when there is none it has completed, fire onComplete
20111
- t.callback(t.interp(0));
20112
- if (!t.active || t.activePass >= pass)
20113
- continue; // stopped or restarted by its own callback, the run it was on ends without completing
20114
- tweenDeactivate(t);
20115
- const next = t.thenCallback;
20116
- t.thenCallback = undefined;
20117
- if (!(next && next()) && t.onComplete)
20118
- t.onComplete();
20140
+ t.life -= dt;
20141
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
20142
+ {
20143
+ t.callback(t.interp(t.life));
20144
+ }
20145
+ else
20146
+ {
20147
+ // Completion: fire end value, remove from active, start the next iteration
20148
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
20149
+ t.callback(t.interp(0));
20150
+ if (!t.active || t.activePass >= pass)
20151
+ continue; // stopped or restarted by its own callback, the run it was on ends without completing
20152
+ tweenDeactivate(t);
20153
+ const next = t.thenCallback;
20154
+ t.thenCallback = undefined;
20155
+ if (!(next && next()) && t.onComplete)
20156
+ t.onComplete();
20157
+ }
20119
20158
  }
20120
20159
  }
20121
- list.length = 0;
20160
+ finally { list.length = 0; }
20122
20161
  }
20123
20162
 
20124
20163
  /** Stop every active tween, ending loops too, without calling their then-callbacks.
@@ -22500,17 +22539,19 @@ function render3DInstance(mesh, matrix, tileInfo, color)
22500
22539
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
22501
22540
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
22502
22541
  // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
22503
- // so it never sets the draw state for them, which would move the state's version at every draw
22504
- function render3DInstanceSlot(mesh, textureInfo, mirrored)
22542
+ // so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard
22543
+ // passes that its batch is unlit, for the same reason
22544
+ function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false)
22505
22545
  {
22506
22546
  const r = render3D;
22507
22547
  if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
22508
- || render3DStateChanged(mesh.instanceState)))
22548
+ || mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState)))
22509
22549
  render3DFlushInstances(mesh);
22510
22550
  if (!mesh.instanceCount)
22511
22551
  {
22512
22552
  mesh.instanceTextureInfo = textureInfo;
22513
22553
  mesh.instanceMirrored = mirrored;
22554
+ mesh.instanceUnlit = unlit;
22514
22555
  mesh.instanceState = render3DCaptureBatchState();
22515
22556
  r.instanceMeshes.push(mesh);
22516
22557
  }
@@ -22544,6 +22585,8 @@ function render3DFlushInstances(only)
22544
22585
  const state = mesh.instanceState;
22545
22586
  if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
22546
22587
  state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
22588
+ if (mesh.instanceUnlit) // a batch of sprites
22589
+ state.lighting = false;
22547
22590
  render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
22548
22591
  }
22549
22592
  if (!only)
@@ -22900,6 +22943,7 @@ class Render3DPlugin
22900
22943
  /** @type {TextureInfo|undefined} */
22901
22944
  this.streamTileInfo = undefined;
22902
22945
  this.streamState = undefined; // captured state the pending batch was drawn under
22946
+ this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are
22903
22947
  /** @type {Mesh|undefined} */
22904
22948
  this.capture = undefined; // the mesh a bake is filling
22905
22949
  /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
@@ -23290,6 +23334,8 @@ class Render3DPlugin
23290
23334
  {
23291
23335
  if (!this.streamCount || !render3DCanDraw()) return;
23292
23336
  const gl = glContext;
23337
+ if (this.streamUnlit)
23338
+ this.streamState.lighting = false; // its own copy of the state
23293
23339
  render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
23294
23340
  render3DBindVertexBuffer(this.streamBuffer);
23295
23341
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
@@ -23524,13 +23570,34 @@ class Render3DPlugin
23524
23570
  return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
23525
23571
  }
23526
23572
 
23527
- // the quad's six stream vertices written straight in with no vectors made, unlit, for sprites, and for
23528
- // particles when instancing is off
23529
- const lit = this.lighting;
23530
- this.lighting = this.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides
23531
- let uvRect;
23532
- try { uvRect = render3DBeginStrip(6, tileInfo); }
23533
- finally { this.lighting = lit; }
23573
+ // unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not
23574
+ // set, which would move its version at every sprite
23575
+ const unlit = !this.shadowPass;
23576
+ if (!this.blend && this.depthTest && this.instancing)
23577
+ {
23578
+ // an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a
23579
+ // sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader
23580
+ // drops its see through texels, so it needs no sorting
23581
+ if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow
23582
+ if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2))
23583
+ return;
23584
+ const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo);
23585
+ render3DMeshUpload(quad);
23586
+ // the quad's half axes, doubled for its unit square
23587
+ const a = render3DBillboardAxes(size, angle, upright);
23588
+ const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData;
23589
+ data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
23590
+ data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
23591
+ data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
23592
+ data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1;
23593
+ data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
23594
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
23595
+ return;
23596
+ }
23597
+
23598
+ // one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no
23599
+ // vectors made, for sprites that blend, and for particles when instancing is off
23600
+ const uvRect = render3DBeginStrip(6, tileInfo, unlit);
23534
23601
  if (!uvRect) return;
23535
23602
  const a = render3DBillboardAxes(size, angle, upright);
23536
23603
  const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
@@ -24642,8 +24709,9 @@ function render3DRenderDepth()
24642
24709
  // which way they face, so keeping the count even keeps every strip facing out.
24643
24710
 
24644
24711
  // make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
24645
- // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
24646
- function render3DBeginStrip(count, tileInfo)
24712
+ // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws
24713
+ // the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw
24714
+ function render3DBeginStrip(count, tileInfo, unlit=false)
24647
24715
  {
24648
24716
  const r = render3D;
24649
24717
  if (!render3DCanDraw()) return;
@@ -24651,12 +24719,13 @@ function render3DBeginStrip(count, tileInfo)
24651
24719
  false&&ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
24652
24720
  if (count > RENDER3D_MAX_STREAM_VERTS) return;
24653
24721
  const textureInfo = render3DTextureOf(tileInfo);
24654
- if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
24655
- || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
24722
+ if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit
24723
+ || render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
24656
24724
  r.flush();
24657
24725
  render3DFlushBeforeOverlay();
24658
24726
  if (!r.streamCount)
24659
24727
  r.streamState = render3DCaptureBatchState();
24728
+ r.streamUnlit = unlit;
24660
24729
  r.streamTileInfo = textureInfo;
24661
24730
  return render3DGetTileUVs(tileInfo);
24662
24731
  }
@@ -29794,7 +29863,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
29794
29863
 
29795
29864
  /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
29796
29865
  * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
29797
- * a sheet is given here; a block's type shows that tile of the sheet on every face
29866
+ * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
29867
+ * image gets plain blocks, a color for each type
29798
29868
  * - setup is called with each map a level makes, to give block types their own faces or make them see-through
29799
29869
  * - Call it before level3DLoad; with no arguments the defaults are back
29800
29870
  * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
@@ -29856,7 +29926,7 @@ function level3DVoxelsMake(voxels)
29856
29926
  {
29857
29927
  const shape = level3DVoxelsShape(voxels);
29858
29928
  if (!shape || typeof VoxelMap == 'undefined') return;
29859
- const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
29929
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
29860
29930
  level3DVoxelsDecode(voxels.blocks, map.data);
29861
29931
  level3DVoxelSetupMap?.(map);
29862
29932
  map.rebuild();
@@ -29918,6 +29988,31 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
29918
29988
  map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
29919
29989
  }
29920
29990
 
29991
+ // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
29992
+ // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
29993
+ // block map in a game with no art can still be seen and painted
29994
+ let level3DVoxelPlain;
29995
+ function level3DVoxelPlainTiles()
29996
+ {
29997
+ if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
29998
+ return tile();
29999
+ if (!level3DVoxelPlain)
30000
+ {
30001
+ // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
30002
+ const context = createCanvasContext(256);
30003
+ for (let i = 0; i < 256; ++i)
30004
+ {
30005
+ const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
30006
+ context.fillStyle = hsl(hue, .45, .4).toString();
30007
+ context.fillRect(x, y, 16, 16);
30008
+ context.fillStyle = hsl(hue, .5, .55).toString();
30009
+ context.fillRect(x + 1, y + 1, 14, 14);
30010
+ }
30011
+ level3DVoxelPlain = new TextureInfo(context.canvas);
30012
+ }
30013
+ return tile(0, 16, level3DVoxelPlain, 0);
30014
+ }
30015
+
29921
30016
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
29922
30017
  function level3DTerrainMake(terrain)
29923
30018
  {
@@ -31487,6 +31582,11 @@ async function parseGLTF(data, baseUrl='')
31487
31582
  for (const name of json.extensionsRequired || [])
31488
31583
  if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
31489
31584
  throw new Error(`glTF with ${name} is not read, export it uncompressed`);
31585
+ else if (name === 'KHR_texture_basisu')
31586
+ throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg');
31587
+ // one that only may use them has other images to fall back on, or none: said, since it loads with them left out
31588
+ if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu'))
31589
+ console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg');
31490
31590
 
31491
31591
  // the buffers: the GLB's own, a data uri, or a file beside the model
31492
31592
  const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
@@ -31529,7 +31629,8 @@ async function parseGLTF(data, baseUrl='')
31529
31629
  const bitmap = normalTextures.has(index) ?
31530
31630
  await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
31531
31631
  opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
31532
- return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
31632
+ // REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are
31633
+ return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728);
31533
31634
  }
31534
31635
  catch (e) { false&&LOG('glTF image not loaded', e); }
31535
31636
  }));
@@ -31639,17 +31740,25 @@ function gltfOpaqueImage(image)
31639
31740
  {
31640
31741
  const gl = glContext, {width, height} = image;
31641
31742
  if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
31642
- const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
31643
- gl.bindTexture(gl.TEXTURE_2D, texture);
31644
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
31645
- gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
31646
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
31743
+ // the room to read into first: for a huge image it can fail, and must not with the framebuffer bound
31647
31744
  const data = new Uint8ClampedArray(width * height * 4);
31648
- gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
31649
- gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
31650
- gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
31651
- gl.deleteFramebuffer(framebuffer);
31652
- gl.deleteTexture(texture);
31745
+ const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
31746
+ try
31747
+ {
31748
+ gl.bindTexture(gl.TEXTURE_2D, texture);
31749
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
31750
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
31751
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
31752
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
31753
+ }
31754
+ finally
31755
+ {
31756
+ // the 2D renderer's own bindings back, whatever happened
31757
+ gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
31758
+ gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
31759
+ gl.deleteFramebuffer(framebuffer);
31760
+ gl.deleteTexture(texture);
31761
+ }
31653
31762
  for (let i = 3; i < data.length; i += 4)
31654
31763
  data[i] = 255;
31655
31764
  image.close();
@@ -31786,7 +31895,7 @@ function gltfAccessor(json, buffers, index)
31786
31895
  }
31787
31896
  if (scale !== 1)
31788
31897
  for (let i = 0; i < out.length; ++i)
31789
- out[i] /= scale;
31898
+ out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says
31790
31899
  return {data: out, components, count: a.count};
31791
31900
  }
31792
31901
 
@@ -31850,8 +31959,10 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
31850
31959
  const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
31851
31960
  if (er || eg || eb)
31852
31961
  {
31962
+ // KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender
31963
+ const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
31853
31964
  part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
31854
- part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
31965
+ part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength);
31855
31966
  }
31856
31967
  return part;
31857
31968
  }