littlejsengine 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.23.0';
38
+ const engineVersion = '1.24.0';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -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
@@ -3440,679 +3444,679 @@ function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; }
3440
3444
  * @param {boolean} [show]
3441
3445
  * @memberof Debug */
3442
3446
  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');
4070
-
4071
- this.collideSolidObjects = collideSolidObjects;
4072
- this.isSolid = isSolid;
4073
- this.collideLevel = collideLevel;
4074
- this.collideRaycast = collideRaycast;
4075
- }
4076
-
4077
- /** @deprecated since 1.20, use collideLevel
4078
- * @type {boolean} */
4079
- get collideTiles() { return this.collideLevel; }
4080
- set collideTiles(collide) { this.collideLevel = collide; }
4081
-
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
- }
4098
-
4099
- /** Render debug info for this object */
4100
- renderDebugInfo()
4101
- {
4102
- if (!debug) return;
4103
-
4104
- // check if there is anything to show
4105
- const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
4106
- if (!hasPhysics && !this.parent) return;
4107
-
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
- }
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
@@ -7549,7 +7557,7 @@ function touchGamepadPointerUp(e)
7549
7557
  * - 2D spatial audio based on camera position with distance-based falloff
7550
7558
  * - Sound instance management (pause, resume, stop)
7551
7559
  * - Speech synthesis for text-to-speech
7552
- * - Music playback with ZzFXM support
7560
+ * - Music playback from audio files, looping by default
7553
7561
  * - Web Audio API integration with master gain control
7554
7562
  * - Sounds and the master bus can route through effects, see the audio effects plugin
7555
7563
  * @namespace Audio
@@ -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;
@@ -8421,7 +8435,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
8421
8435
  }
8422
8436
 
8423
8437
  ///////////////////////////////////////////////////////////////////////////////
8424
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
8438
+ // ZzFX - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
8425
8439
 
8426
8440
  /** Generate and play a ZzFX sound
8427
8441
  *
@@ -8706,6 +8720,10 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
8706
8720
  * Load tile layers from exported data
8707
8721
  * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
8708
8722
  * tiles continue its numbering), finite maps in the CSV or array layer format; layer offsets and parallax are not read
8723
+ * - A tileset kept in the map with a margin or a spacing, a sheet with gaps between its tiles, is read where its
8724
+ * tiles are, whatever padding the tile info has; one in a file of its own (a tsx) is not read, pass a tile info
8725
+ * with its padding
8726
+ * - An LDtk level loads through tileLayersFromLDtk, which makes it a map like these
8709
8727
  * - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices
8710
8728
  * (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and
8711
8729
  * visibility carry to the layers inside it
@@ -8738,6 +8756,18 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
8738
8756
  false&&ASSERT(tileMapData.width && tileMapData.height);
8739
8757
  false&&ASSERT(tileMapData.layers && tileMapData.layers.length);
8740
8758
 
8759
+ // a sheet with a margin around its tiles or a spacing between them: the first tile is at the margin and each
8760
+ // cell is a tile and a spacing, which is a padding of half the spacing counted from there, by the tileset's
8761
+ // own count of columns, since the image's width does not say with a spacing that is not all around
8762
+ const tileset = tileMapData.tilesets?.[0];
8763
+ if (tileInfo && tileset && (tileset.margin > 0 || tileset.spacing > 0))
8764
+ {
8765
+ const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size;
8766
+ const width = tileInfo.textureInfo?.size.x || 0;
8767
+ const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing)));
8768
+ tileInfo = new TileInfo(vec2(margin), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
8769
+ }
8770
+
8741
8771
  // flatten group layers in order, a group's color and visibility carry to the layers inside it
8742
8772
  /** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */
8743
8773
  const layers = [];
@@ -8815,6 +8845,107 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
8815
8845
  function tileLayersColor(hex)
8816
8846
  { return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); }
8817
8847
 
8848
+ // the Tiled property type of an LDtk field type, for the ones Tiled has a property for
8849
+ const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: 'string', Multilines: 'string',
8850
+ Color: 'color', FilePath: 'file'};
8851
+
8852
+ /**
8853
+ * Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad
8854
+ * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled, so the last layer
8855
+ * of the LDtk file is layer 0; where LDtk stacks tiles in a cell, an edge over a fill, each tile over another
8856
+ * goes in a layer of its own just above, named with (2), (3) and so on, so a layer of the first tiles has one
8857
+ * wherever the LDtk layer has any, which makes it the one for collision
8858
+ * - An IntGrid layer with no tiles is a hidden layer of its values, for collision: pass its index as collisionLayer
8859
+ * - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at
8860
+ * its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string)
8861
+ * - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image
8862
+ * - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of
8863
+ * another grid size or another tileset is left out, with a warning in debug builds
8864
+ * - The level editor edits the map this returns, and saves it as a Tiled map
8865
+ * @param {Object} ldtk - The LDtk project, its JSON
8866
+ * @param {number|string} [level] - Which level, by its index or its identifier
8867
+ * @return {Object} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers
8868
+ * @example
8869
+ * const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0');
8870
+ * const layers = tileLayersLoad(map, tile(0, 16), 0, 1); // layer 1 is solid
8871
+ * objectLayersLoad(map);
8872
+ * @memberof TileLayers */
8873
+ function tileLayersFromLDtk(ldtk, level=0)
8874
+ {
8875
+ const levels = ldtk?.levels || [];
8876
+ const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level];
8877
+ false&&ASSERT(data, 'LDtk level not found', level);
8878
+ false&&ASSERT(isArray(data.layerInstances), 'LDtk level has no layers: levels saved as separate files are not read');
8879
+ const instances = data.layerInstances;
8880
+ const tiled = (l)=> l.__type !== 'Entities';
8881
+ const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
8882
+ const grid = first?.__gridSize || 16;
8883
+ const width = first?.__cWid || ceil(data.pxWid / grid), height = first?.__cHei || ceil(data.pxHei / grid);
8884
+ const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down',
8885
+ infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1};
8886
+
8887
+ // the tileset of the first layer that has one, where its tiles are in its image
8888
+ const tileset = (ldtk?.defs?.tilesets || []).find((t)=> t.uid === first?.__tilesetDefUid);
8889
+ if (tileset)
8890
+ map.tilesets = [{firstgid: 1, name: String(tileset.relPath || '').replace(/^.*[\\/]/, '').replace(/\.\w+$/, ''),
8891
+ image: tileset.relPath, imagewidth: tileset.pxWid, imageheight: tileset.pxHei,
8892
+ tilewidth: tileset.tileGridSize, tileheight: tileset.tileGridSize, margin: tileset.padding || 0,
8893
+ spacing: tileset.spacing || 0, columns: tileset.__cWid, tilecount: tileset.__cWid * tileset.__cHei}];
8894
+
8895
+ // LDtk lists the top layer first, Tiled the bottom one
8896
+ for (let i = instances.length; i--;)
8897
+ {
8898
+ const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++;
8899
+ if (!tiled(instance))
8900
+ {
8901
+ const objects = (instance.entityInstances || []).map((entity)=>
8902
+ {
8903
+ // an entity is placed by its pivot, an object here by its middle
8904
+ const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0;
8905
+ const properties = [];
8906
+ for (const field of entity.fieldInstances || [])
8907
+ {
8908
+ const type = tileLayersLDtkTypes[field.__type] || (/^(Local|Extern)Enum\./.test(field.__type) && 'string');
8909
+ type && field.__value != undefined && properties.push({name: field.__identifier, type, value: field.__value});
8910
+ }
8911
+ return {id: map.nextobjectid++, name: '', type: entity.__identifier, x: entity.px[0] + (.5 - pivotX) * w,
8912
+ y: entity.px[1] + (.5 - pivotY) * h, width: w, height: h, rotation: 0, visible: true, properties};
8913
+ });
8914
+ map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0});
8915
+ continue;
8916
+ }
8917
+ const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
8918
+ const usable = instance.__gridSize === grid && instance.__cWid === width && instance.__cHei === height &&
8919
+ (!tiles.length || instance.__tilesetDefUid === tileset?.uid);
8920
+ if (!usable)
8921
+ {
8922
+ debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
8923
+ continue;
8924
+ }
8925
+ const empty = ()=> new Array(width * height).fill(0), stacks = [empty()];
8926
+ if (tiles.length)
8927
+ {
8928
+ // a tile's place in pixels, its tile in the sheet, and its flips: bit 0 across, bit 1 down, which are
8929
+ // Tiled's two top bits; LDtk lists them in the order it draws them, so the first in a cell is the
8930
+ // bottom one and each after it goes a layer higher
8931
+ const depth = new Uint16Array(width * height);
8932
+ for (const t of tiles)
8933
+ {
8934
+ const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width;
8935
+ if (x >= 0 && x < width && y >= 0 && y < height)
8936
+ (stacks[depth[cell]++] ||= empty())[cell] =
8937
+ (t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0;
8938
+ }
8939
+ }
8940
+ else
8941
+ (instance.intGridCsv || []).forEach((value, k)=> k < stacks[0].length && (stacks[0][k] = value));
8942
+ const visible = instance.__type === 'IntGrid' && !tiles.length ? false : instance.visible !== false;
8943
+ stacks.forEach((data, k)=> map.layers.push({id: k ? map.nextlayerid++ : id, name: k ? `${name} (${k + 1})` : name,
8944
+ type: 'tilelayer', width, height, data, opacity: instance.__opacity ?? 1, visible, x: 0, y: 0}));
8945
+ }
8946
+ return map;
8947
+ }
8948
+
8818
8949
  ///////////////////////////////////////////////////////////////////////////////
8819
8950
  // Object layers
8820
8951
 
@@ -8909,7 +9040,8 @@ function objectLayersMake(tileMapData, object)
8909
9040
  const name = object.type || object.class, type = objectLayersTypes.get(name);
8910
9041
  if (!type)
8911
9042
  {
8912
- debug && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
9043
+ // an object with no type, a shape or a text someone drew in Tiled, is not one a game makes
9044
+ debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
8913
9045
  return;
8914
9046
  }
8915
9047
  const {height=0, tilewidth=1, tileheight=1} = tileMapData;
@@ -10993,6 +11125,9 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
10993
11125
  glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
10994
11126
  }
10995
11127
 
11128
+ // the list and the vectors glDrawPointsTransform writes its points into
11129
+ const glPointsOut = [], glPointsPool = [];
11130
+
10996
11131
  /** Transform and add a polygon to the gl draw list
10997
11132
  * @param {Array<Vector2>} points - Array of Vector2 points
10998
11133
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -11005,15 +11140,21 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
11005
11140
  * @memberof WebGL */
11006
11141
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
11007
11142
  {
11008
- const pointsOut = [];
11143
+ // written into vectors kept for this, a circle would make one for each of its sides at every draw; they are
11144
+ // read into the batch before this returns
11145
+ const pointsOut = glPointsOut, pool = glPointsPool, count = points.length;
11146
+ pointsOut.length = count;
11009
11147
  const sa = sin(-angle);
11010
11148
  const ca = cos(-angle);
11011
- for (const p of points)
11149
+ for (let i = 0; i < count; ++i)
11012
11150
  {
11013
11151
  // transform the point
11152
+ const p = points[i], out = pool[i] ||= vec2();
11014
11153
  const px = p.x*sx;
11015
11154
  const py = p.y*sy;
11016
- pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
11155
+ out.x = x + ca*px - sa*py;
11156
+ out.y = y + sa*px + ca*py;
11157
+ pointsOut[i] = out;
11017
11158
  }
11018
11159
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
11019
11160
  glDrawPoints(drawPoints, rgba);
@@ -12448,8 +12589,11 @@ class PostProcessPlugin
12448
12589
  {
12449
12590
  // copy main canvas to work canvas at the backing store size,
12450
12591
  // mainCanvasSize is css pixels so it would lose resolution
12451
- workCanvas.width = mainCanvas.width;
12452
- workCanvas.height = mainCanvas.height;
12592
+ // sized again only when the canvas changed, setting a size remakes the canvas even to the same one
12593
+ if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height)
12594
+ workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height;
12595
+ else
12596
+ workContext.clearRect(0, 0, workCanvas.width, workCanvas.height);
12453
12597
  glCopyToContext(workContext);
12454
12598
  workContext.drawImage(mainCanvas, 0, 0);
12455
12599
  // clear the main canvas with clearRect, resizing it would also
@@ -13543,164 +13687,6 @@ class Light extends EngineObject
13543
13687
  }
13544
13688
  }
13545
13689
 
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];
13703
- }
13704
13690
  /**
13705
13691
  * LittleJS Audio Effects Plugin
13706
13692
  * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
@@ -14690,8 +14676,7 @@ class UISystemPlugin
14690
14676
  }
14691
14677
  }
14692
14678
 
14693
- /** Destroy and remove all objects
14694
- * @memberof UISystem */
14679
+ /** Destroy and remove all objects */
14695
14680
  destroyObjects()
14696
14681
  {
14697
14682
  for (const o of this.uiObjects)
@@ -15212,8 +15197,10 @@ class UIObject
15212
15197
  return void inputClearKey(0, 0, false, true, false);
15213
15198
  }
15214
15199
  }
15200
+ // the object that was the active one going into this update: one pressed and let go inside a
15201
+ // frame is clicked on the next, with its release, not on both
15215
15202
  if (!uiSystem.activateOnPress)
15216
- if (!mouseDown && this.isActiveObject() && this.interactive)
15203
+ if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
15217
15204
  this.click();
15218
15205
  if (this.destroyed) return;
15219
15206
  }
@@ -15956,7 +15943,10 @@ class UIVideo extends UIObject
15956
15943
  if (this.destroyed)
15957
15944
  return;
15958
15945
 
15946
+ // let go of the media too, a paused video keeps what it has loaded
15959
15947
  this.video.pause();
15948
+ this.video.removeAttribute?.('src');
15949
+ this.video.load?.();
15960
15950
  this.video.remove();
15961
15951
  super.destroy();
15962
15952
  }
@@ -19295,6 +19285,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
19295
19285
  for (const group of parseAtlas(data))
19296
19286
  {
19297
19287
  // reserve a block of full size cells, one per frame
19288
+ if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
19298
19289
  const sourceSize = group.frames[0].sourceSize;
19299
19290
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
19300
19291
  const added = textureSheetAdd(blockSize, sourceSize, padding);
@@ -20080,45 +20071,50 @@ function tweenUpdate(gameDelta, realDelta)
20080
20071
  const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
20081
20072
  for (const t of tweenActive)
20082
20073
  list.push(t);
20083
- for (let i = list.length; i--;)
20074
+ // the list is let go however the walk ends: a callback that throws must not leave it held, or every
20075
+ // update after would take it for an update still going and make a list of its own
20076
+ try
20084
20077
  {
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)
20078
+ for (let i = list.length; i--;)
20091
20079
  {
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;
20080
+ const t = list[i];
20081
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
20082
+ // counts on from this one
20083
+ if (!t.active || t.activePass >= pass) continue;
20084
+ let dt;
20085
+ if (enginePath)
20086
+ {
20087
+ // a paused tween keeps count too, so it does not jump when resumed
20088
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
20089
+ t.lastTime = time;
20090
+ t.lastTimeReal = timeReal;
20091
+ }
20092
+ else
20093
+ dt = t.useRealTime ? realDelta : gameDelta;
20094
+ if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
20095
+ if (t.paused || dt <= 0) continue;
20101
20096
 
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();
20097
+ t.life -= dt;
20098
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
20099
+ {
20100
+ t.callback(t.interp(t.life));
20101
+ }
20102
+ else
20103
+ {
20104
+ // Completion: fire end value, remove from active, start the next iteration
20105
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
20106
+ t.callback(t.interp(0));
20107
+ if (!t.active || t.activePass >= pass)
20108
+ continue; // stopped or restarted by its own callback, the run it was on ends without completing
20109
+ tweenDeactivate(t);
20110
+ const next = t.thenCallback;
20111
+ t.thenCallback = undefined;
20112
+ if (!(next && next()) && t.onComplete)
20113
+ t.onComplete();
20114
+ }
20119
20115
  }
20120
20116
  }
20121
- list.length = 0;
20117
+ finally { list.length = 0; }
20122
20118
  }
20123
20119
 
20124
20120
  /** Stop every active tween, ending loops too, without calling their then-callbacks.
@@ -22500,17 +22496,19 @@ function render3DInstance(mesh, matrix, tileInfo, color)
22500
22496
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
22501
22497
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
22502
22498
  // 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)
22499
+ // so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard
22500
+ // passes that its batch is unlit, for the same reason
22501
+ function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false)
22505
22502
  {
22506
22503
  const r = render3D;
22507
22504
  if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
22508
- || render3DStateChanged(mesh.instanceState)))
22505
+ || mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState)))
22509
22506
  render3DFlushInstances(mesh);
22510
22507
  if (!mesh.instanceCount)
22511
22508
  {
22512
22509
  mesh.instanceTextureInfo = textureInfo;
22513
22510
  mesh.instanceMirrored = mirrored;
22511
+ mesh.instanceUnlit = unlit;
22514
22512
  mesh.instanceState = render3DCaptureBatchState();
22515
22513
  r.instanceMeshes.push(mesh);
22516
22514
  }
@@ -22544,6 +22542,8 @@ function render3DFlushInstances(only)
22544
22542
  const state = mesh.instanceState;
22545
22543
  if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
22546
22544
  state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
22545
+ if (mesh.instanceUnlit) // a batch of sprites
22546
+ state.lighting = false;
22547
22547
  render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
22548
22548
  }
22549
22549
  if (!only)
@@ -22900,6 +22900,7 @@ class Render3DPlugin
22900
22900
  /** @type {TextureInfo|undefined} */
22901
22901
  this.streamTileInfo = undefined;
22902
22902
  this.streamState = undefined; // captured state the pending batch was drawn under
22903
+ this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are
22903
22904
  /** @type {Mesh|undefined} */
22904
22905
  this.capture = undefined; // the mesh a bake is filling
22905
22906
  /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
@@ -23108,8 +23109,8 @@ class Render3DPlugin
23108
23109
  }
23109
23110
 
23110
23111
  /** Find the nearest object under a screen position or along a ray, for clicking on things
23111
- * - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
23112
- * not triangle by triangle
23112
+ * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
23113
+ * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
23113
23114
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
23114
23115
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
23115
23116
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -23290,6 +23291,8 @@ class Render3DPlugin
23290
23291
  {
23291
23292
  if (!this.streamCount || !render3DCanDraw()) return;
23292
23293
  const gl = glContext;
23294
+ if (this.streamUnlit)
23295
+ this.streamState.lighting = false; // its own copy of the state
23293
23296
  render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
23294
23297
  render3DBindVertexBuffer(this.streamBuffer);
23295
23298
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
@@ -23524,13 +23527,34 @@ class Render3DPlugin
23524
23527
  return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
23525
23528
  }
23526
23529
 
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; }
23530
+ // unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not
23531
+ // set, which would move its version at every sprite
23532
+ const unlit = !this.shadowPass;
23533
+ if (!this.blend && this.depthTest && this.instancing)
23534
+ {
23535
+ // an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a
23536
+ // sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader
23537
+ // drops its see through texels, so it needs no sorting
23538
+ if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow
23539
+ if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2))
23540
+ return;
23541
+ const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo);
23542
+ render3DMeshUpload(quad);
23543
+ // the quad's half axes, doubled for its unit square
23544
+ const a = render3DBillboardAxes(size, angle, upright);
23545
+ const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData;
23546
+ data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
23547
+ data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
23548
+ data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
23549
+ data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1;
23550
+ data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
23551
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
23552
+ return;
23553
+ }
23554
+
23555
+ // one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no
23556
+ // vectors made, for sprites that blend, and for particles when instancing is off
23557
+ const uvRect = render3DBeginStrip(6, tileInfo, unlit);
23534
23558
  if (!uvRect) return;
23535
23559
  const a = render3DBillboardAxes(size, angle, upright);
23536
23560
  const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
@@ -24642,8 +24666,9 @@ function render3DRenderDepth()
24642
24666
  // which way they face, so keeping the count even keeps every strip facing out.
24643
24667
 
24644
24668
  // 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)
24669
+ // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws
24670
+ // the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw
24671
+ function render3DBeginStrip(count, tileInfo, unlit=false)
24647
24672
  {
24648
24673
  const r = render3D;
24649
24674
  if (!render3DCanDraw()) return;
@@ -24651,12 +24676,13 @@ function render3DBeginStrip(count, tileInfo)
24651
24676
  false&&ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
24652
24677
  if (count > RENDER3D_MAX_STREAM_VERTS) return;
24653
24678
  const textureInfo = render3DTextureOf(tileInfo);
24654
- if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
24655
- || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
24679
+ if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit
24680
+ || render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
24656
24681
  r.flush();
24657
24682
  render3DFlushBeforeOverlay();
24658
24683
  if (!r.streamCount)
24659
24684
  r.streamState = render3DCaptureBatchState();
24685
+ r.streamUnlit = unlit;
24660
24686
  r.streamTileInfo = textureInfo;
24661
24687
  return render3DGetTileUVs(tileInfo);
24662
24688
  }
@@ -26451,8 +26477,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
26451
26477
  return collected;
26452
26478
  }
26453
26479
 
26454
- // how far along a ray an object is hit, or undefined for a miss; each one is tested as the box around its mesh in
26455
- // its own space, or a sprite as the quad it draws, not triangle by triangle
26480
+ // how far along a ray an object is hit, or undefined for a miss; a mesh is hit on its triangles, in its own space,
26481
+ // and a sprite as the quad it draws
26456
26482
  function render3DRaycastObject(ray, o)
26457
26483
  {
26458
26484
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -26469,25 +26495,55 @@ function render3DRaycastObject(ray, o)
26469
26495
  const distance = raycastSphere(ray, center, radius);
26470
26496
  if (distance === undefined) return;
26471
26497
 
26472
- // the sphere is a quick reject, a mesh is hit where the ray meets its box in its own space, since a wide floor's
26473
- // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
26498
+ // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
26499
+ // against the triangles; the direction is not made unit length, so the distance holds in the world;
26474
26500
  // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
26475
26501
  if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
26476
26502
  const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
26477
26503
  const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
26478
26504
  const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
26479
- if (hit !== 0) return hit;
26480
-
26481
- // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
26482
- // inside comes first
26483
- let exit = Infinity;
26484
- for (const k of ['x', 'y', 'z'])
26505
+ if (hit === undefined || !ray.direction.lengthSquared())
26506
+ return hit; // a ray of no length is where it starts, inside the box
26507
+ return render3DRaycastMesh(local, mesh);
26508
+ }
26509
+
26510
+ // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
26511
+ // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
26512
+ // triangle i is (i-2, i-1, i), the odd ones read the other way, and a join between its pieces has no area and is
26513
+ // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
26514
+ function render3DRaycastMesh(ray, mesh)
26515
+ {
26516
+ const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
26517
+ const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
26518
+ const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
26519
+ const count = indices ? indices.length / 3 | 0 : points.length - 2;
26520
+ let nearest;
26521
+ for (let i = 0; i < count; ++i)
26485
26522
  {
26486
- const d = local.direction[k];
26487
- if (d)
26488
- exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
26523
+ // the corners clockwise from the front: an indexed list is written the other way round
26524
+ let a, b, c;
26525
+ if (indices)
26526
+ a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
26527
+ else
26528
+ a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
26529
+
26530
+ // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
26531
+ const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
26532
+ const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
26533
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
26534
+ const det = e1x * hx + e1y * hy + e1z * hz;
26535
+ if (both ? !det : !(det < 0)) continue;
26536
+ const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
26537
+ const u = (sx * hx + sy * hy + sz * hz) / det;
26538
+ if (!(u >= 0 && u <= 1)) continue;
26539
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
26540
+ const v = (dx * qx + dy * qy + dz * qz) / det;
26541
+ if (!(v >= 0 && u + v <= 1)) continue;
26542
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
26543
+ if (t >= 0 && !(t >= nearest))
26544
+ nearest = t;
26489
26545
  }
26490
- return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
26546
+ return nearest;
26491
26547
  }
26492
26548
 
26493
26549
  // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
@@ -26865,10 +26921,12 @@ class InstancedMesh3D extends EngineObject3D
26865
26921
  * - radius is a world distance, so scale3D does not change it
26866
26922
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
26867
26923
  * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
26924
+ * - addFlare gives it a lens flare, see LensFlare3D
26868
26925
  * @extends EngineObject3D
26869
26926
  * @memberof Render3D
26870
26927
  * @example
26871
26928
  * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
26929
+ * torch.addFlare(); // light in the lens when the torch is in view
26872
26930
  */
26873
26931
  class Light3D extends EngineObject3D
26874
26932
  {
@@ -26903,6 +26961,49 @@ class Light3D extends EngineObject3D
26903
26961
  * bright core */
26904
26962
  this.glowFalloff = 1;
26905
26963
  this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
26964
+ /** @type {LensFlare3D|undefined} */
26965
+ this.flareObject = undefined;
26966
+ }
26967
+
26968
+ /** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had
26969
+ * @param {number} [size] - Scales every part of it
26970
+ * @param {number} [count] - How many ghosts there are, besides the glow at the light
26971
+ * @param {number} [intensity] - How bright it is
26972
+ * @param {number} [saturation] - How colorful the ghosts are
26973
+ * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
26974
+ addFlare(size, count, intensity, saturation)
26975
+ {
26976
+ const flare = new LensFlare3D(size, count, intensity, saturation);
26977
+ this.flare = flare;
26978
+ return flare;
26979
+ }
26980
+
26981
+ /** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and
26982
+ * destroying the flare takes it away
26983
+ * - The flare is the light's: it is attached to the light as its child, so it stays through a scene change
26984
+ * when the light does, and is destroyed with the light or when another takes its place
26985
+ * @type {LensFlare3D|undefined} */
26986
+ get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; }
26987
+
26988
+ set flare(flare)
26989
+ {
26990
+ const old = this.flare; // a flare that was destroyed on its own is no flare
26991
+ if (flare === old) return;
26992
+ old && old.destroy();
26993
+ if (flare)
26994
+ {
26995
+ flare.light = this;
26996
+ flare.parent || this.addChild(flare);
26997
+ }
26998
+ this.flareObject = flare || undefined;
26999
+ }
27000
+
27001
+ /** Destroy the light, and its flare with it
27002
+ * @param {boolean} [immediate] */
27003
+ destroy(immediate)
27004
+ {
27005
+ this.flare = undefined;
27006
+ super.destroy(immediate);
26906
27007
  }
26907
27008
 
26908
27009
  /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
@@ -28421,31 +28522,74 @@ class Trail3D extends EngineObject3D
28421
28522
  // lens flare
28422
28523
 
28423
28524
  // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
28424
- // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
28525
+ // glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star
28526
+ // of six rays; undefined headless or without a canvas
28527
+ const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star'];
28425
28528
  let render3DFlareTiles;
28426
28529
  function render3DFlareTile(shape)
28427
28530
  {
28428
28531
  if (!render3DFlareTiles)
28429
28532
  {
28430
28533
  if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
28431
- const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
28534
+ const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
28432
28535
  // each shape is how see-through it is from its middle out
28433
28536
  const alpha =
28434
28537
  {
28435
28538
  glow: (t)=> engineGlowAlpha(t, 1),
28436
28539
  disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
28437
28540
  ring: (t)=> max(0, 1 - abs(t - .86) / .12),
28541
+ hex: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
28542
+ streak: (t)=> (1 - t) ** 1.5,
28438
28543
  };
28439
- shapes.forEach((shape, i)=>
28544
+ // a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon
28545
+ const fill = (shape, r, path)=>
28440
28546
  {
28441
- const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
28442
- const gradient = context.createRadialGradient(x, y, 0, x, y, r);
28547
+ const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r);
28443
28548
  for (let k = 0; k <= steps; ++k)
28444
28549
  gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
28445
28550
  context.fillStyle = gradient;
28446
28551
  context.beginPath();
28447
- context.arc(x, y, r, 0, 2 * PI);
28552
+ path ? path() : context.arc(0, 0, r, 0, 2 * PI);
28448
28553
  context.fill();
28554
+ };
28555
+ const ray = (r, angle)=>
28556
+ {
28557
+ context.save();
28558
+ context.rotate(angle);
28559
+ context.scale(1, .07);
28560
+ fill('streak', r);
28561
+ context.restore();
28562
+ };
28563
+ shapes.forEach((shape, i)=>
28564
+ {
28565
+ const r = cell / 2 - 2;
28566
+ context.save();
28567
+ context.translate(i * cell + cell / 2, cell / 2);
28568
+ if (shape == 'hex')
28569
+ {
28570
+ // a gradient is round, so the hexagon is set pixel by pixel, by how far each is toward a side
28571
+ const image = context.createImageData(cell, cell), data = image.data, apothem = r * cos(PI / 6);
28572
+ for (let k = 0; k < cell * cell; ++k)
28573
+ {
28574
+ const x = abs(k % cell + .5 - cell / 2), y = abs((k / cell | 0) + .5 - cell / 2);
28575
+ const t = max(y, x * cos(PI / 6) + y / 2) / apothem;
28576
+ data[k * 4] = data[k * 4 + 1] = data[k * 4 + 2] = 255;
28577
+ data[k * 4 + 3] = t < 1 ? clamp(alpha.hex(t)) * 255 : 0;
28578
+ }
28579
+ context.putImageData(image, i * cell, 0);
28580
+ }
28581
+ else if (shape == 'streak')
28582
+ ray(r, 0);
28583
+ else if (shape == 'star')
28584
+ {
28585
+ for (let k = 3; k--;)
28586
+ ray(r, k * PI / 3 + PI / 2);
28587
+ context.scale(.3, .3);
28588
+ fill('glow', r);
28589
+ }
28590
+ else
28591
+ fill(shape, r);
28592
+ context.restore();
28449
28593
  });
28450
28594
  const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
28451
28595
  render3DFlareTiles = new Map(shapes.map((shape, i)=>
@@ -28454,16 +28598,32 @@ function render3DFlareTile(shape)
28454
28598
  return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
28455
28599
  }
28456
28600
 
28601
+ /**
28602
+ * A part of a lens flare
28603
+ * @typedef {Object} LensFlareElement
28604
+ * @property {number} at - Where along the line: 0 the sun, 1 the middle of the screen, 2 as far past it
28605
+ * @property {number|Vector2} size - How big across, as a part of the screen's height, a vector for a part wider
28606
+ * than it is tall
28607
+ * @property {Color} color - Its color, the alpha how bright
28608
+ * @property {string} [shape] - glow, disc, ring, hex, streak or star, a glow when left out
28609
+ * @property {TileInfo} [tileInfo] - A tile of the game's own to draw in place of a shape, best white on clear
28610
+ * @property {number} [angle] - How far it is turned, in radians
28611
+ * @memberof Render3D
28612
+ */
28613
+
28457
28614
  /**
28458
28615
  * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
28459
28616
  * sizes along the line from the sun through the middle of the screen
28460
28617
  * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
28461
28618
  * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
28619
+ * - A Light3D gets one of its own with light.addFlare()
28462
28620
  * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
28463
- * with the sun's own color; or give it elements of your own
28464
- * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
28621
+ * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
28622
+ * give it elements of your own, which may be tiles of the game's
28623
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
28624
+ * sun is off the screen, where nothing is tested
28465
28625
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
28466
- * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
28626
+ * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
28467
28627
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
28468
28628
  * @extends EngineObject3D
28469
28629
  * @memberof Render3D
@@ -28493,48 +28653,61 @@ class LensFlare3D extends EngineObject3D
28493
28653
  this.saturation = saturation;
28494
28654
  /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
28495
28655
  this.seed = 1;
28496
- /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
28497
- * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
28498
- * line, 0 the sun, 1 the middle of the screen, 2 as far past it; size is across, as a part of the screen's
28499
- * height; shape is glow, disc or ring
28500
- * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
28656
+ /** @property {Array<string>|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex,
28657
+ * streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often;
28658
+ * discs, rings and glows when not set
28659
+ * @type {Array<string>|undefined} */
28660
+ this.shapes = undefined;
28661
+ /** @property {number} - Scales the glow at the sun, 0 for none */
28662
+ this.glowSize = 1;
28663
+ /** @property {number} - Scales the ghosts */
28664
+ this.ghostSize = 1;
28665
+ /** @property {Array<LensFlareElement>|undefined} - The parts of the flare, to set your own in place of the
28666
+ * ones made from count, seed, saturation, shapes, glowSize and ghostSize
28667
+ * @type {Array<LensFlareElement>|undefined} */
28501
28668
  this.elements = undefined;
28502
28669
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
28503
28670
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
28504
- * in front of the light, and a spotlight's shows from inside its beam only
28671
+ * in front of the light, and a spotlight's shows from inside its beam only; a DirectionalLight3D's is far
28672
+ * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
28673
+ * sets this
28505
28674
  * @type {Light3D|undefined} */
28506
28675
  this.light = undefined;
28507
28676
  /** @property {boolean} - Fade out when something is between the camera and the sun */
28508
28677
  this.occlusion = true;
28509
28678
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
28510
28679
  this.fadeTime = .15;
28511
- /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
28680
+ /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
28681
+ * plain view, eased */
28512
28682
  this.visible = 1;
28513
28683
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
28514
28684
  this.madeKey = '';
28515
- /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
28685
+ /** @type {Array<LensFlareElement>} */
28516
28686
  this.made = [];
28517
28687
  }
28518
28688
 
28519
- /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
28520
- * and a core at the sun and the ghosts, made again when one of those changes
28521
- * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
28689
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
28690
+ * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
28691
+ * @return {Array<LensFlareElement>} */
28522
28692
  getElements()
28523
28693
  {
28524
28694
  if (this.elements) return this.elements;
28525
- const key = [this.count, this.seed, this.saturation].join();
28695
+ const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
28696
+ const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
28526
28697
  if (key !== this.madeKey)
28527
28698
  {
28528
28699
  const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
28529
- const made = this.made = [
28530
- {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
28531
- {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
28700
+ /** @type {Array<LensFlareElement>} */
28701
+ const made = this.made = glow > 0 ? [
28702
+ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
28703
+ {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
28532
28704
  for (let i = 0; i < this.count; ++i)
28533
28705
  {
28534
28706
  // spread along the line, each a place of its own, the far ones bigger
28535
28707
  const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
28536
- const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
28537
- made.push({at, size: random.float(.04, .1) * (1 + at), shape,
28708
+ const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
28709
+ pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
28710
+ made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
28538
28711
  color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
28539
28712
  }
28540
28713
  this.madeKey = key;
@@ -28542,69 +28715,85 @@ class LensFlare3D extends EngineObject3D
28542
28715
  return this.made;
28543
28716
  }
28544
28717
 
28545
- // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
28546
- // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
28718
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
28719
+ // directional light, which shines from its place toward the origin, and a point to find it on the screen by;
28720
+ // undefined with no direction, or a light that is gone or at the camera
28547
28721
  flareSource()
28548
28722
  {
28549
28723
  const camera = render3D.camera.pos, light = this.light;
28550
- if (!light)
28724
+ if (light?.destroyed) return;
28725
+ if (!light || light.directional)
28551
28726
  {
28552
- const sun = render3D.sunDirection;
28553
- if (!sun.lengthSquared()) return;
28554
- const direction = sun.normalize();
28727
+ const from = light ? light.getWorldPos3D() : render3D.sunDirection;
28728
+ if (!from.lengthSquared()) return;
28729
+ const direction = from.normalize();
28555
28730
  return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
28556
28731
  }
28557
- if (light.destroyed) return;
28558
28732
  const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
28559
28733
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
28560
28734
  }
28561
28735
 
28562
- /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
28563
- * behind the camera
28564
- * @return {Vector2|undefined} */
28565
- getSunScreenPos()
28566
- {
28567
- const source = this.flareSource();
28568
- return source && render3D.worldToScreen(source.pos);
28569
- }
28570
-
28571
- /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
28572
- * color, dimmed by how much of the sun shows; empty when there is nothing to draw
28573
- * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
28574
- getScreenElements()
28736
+ // how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
28737
+ // fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
28738
+ // show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
28739
+ flareLook()
28575
28740
  {
28576
- const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
28577
- if (!sun || !center.x || !center.y) return [];
28741
+ const source = this.flareSource(), center = mainCanvasSize.scale(.5);
28742
+ const sun = source && render3D.worldToScreen(source.pos);
28743
+ if (!sun || !center.x || !center.y) return;
28578
28744
  // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
28579
28745
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
28580
- const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
28581
- if (!(strength > 0)) return [];
28746
+ const strength = clamp((1.3 - off) / .5) * this.intensity;
28747
+ if (!(strength > 0)) return;
28582
28748
  let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28583
- const light = this.light, source = light && this.flareSource();
28749
+ const light = this.light;
28584
28750
  if (light)
28585
28751
  {
28586
- if (!source) return [];
28587
28752
  // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
28588
28753
  const cone = render3DLightCone(light), d = source.direction;
28589
28754
  const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
28590
28755
  const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
28591
- if (!(bright > 0)) return [];
28756
+ if (!(bright > 0)) return;
28592
28757
  tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
28593
- height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
28758
+ if (!light.directional)
28759
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
28594
28760
  }
28761
+ return height > 0 ? {sun, center, strength, tint, height} : undefined;
28762
+ }
28763
+
28764
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
28765
+ * behind the camera
28766
+ * @return {Vector2|undefined} */
28767
+ getScreenPos()
28768
+ {
28769
+ const source = this.flareSource();
28770
+ return source && render3D.worldToScreen(source.pos);
28771
+ }
28772
+
28773
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector
28774
+ * when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw
28775
+ * @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined,
28776
+ * tileInfo: TileInfo|undefined, angle: number}>} */
28777
+ getScreenElements()
28778
+ {
28779
+ const look = this.visible > 0 && this.flareLook();
28780
+ if (!look) return [];
28781
+ const {sun, center, tint, height} = look, strength = look.strength * this.visible;
28595
28782
  return this.getElements().map((e)=>
28596
28783
  {
28597
28784
  const c = e.color.multiply(tint);
28598
28785
  // along the line and past the middle, a lerp would stop there
28599
- return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
28600
- color: rgb(c.r, c.g, c.b, c.a * strength)};
28786
+ const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
28787
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
28788
+ angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
28601
28789
  });
28602
28790
  }
28603
28791
 
28604
28792
  /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
28605
- * through
28793
+ * through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless
28794
+ * it is doubleSided
28606
28795
  * @return {boolean} */
28607
- isSunHidden()
28796
+ isHidden()
28608
28797
  {
28609
28798
  const source = this.flareSource();
28610
28799
  if (!source) return true;
@@ -28618,22 +28807,29 @@ class LensFlare3D extends EngineObject3D
28618
28807
  for (const map of maps)
28619
28808
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
28620
28809
  return true;
28621
- // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
28622
- // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
28623
- // one the light is inside, its lamp
28624
- const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
28625
- const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
28626
- const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
28627
- const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
28628
- const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
28810
+ // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
28811
+ // is in
28812
+ const lamp = (o)=>
28813
+ {
28814
+ if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
28815
+ const matrix = render3DObjectMatrix(o);
28816
+ if (!matrix.determinant()) return false;
28817
+ const p = matrix.copy().invert().transformPoint(source.pos), b = o.mesh.bounds || o.mesh.getBounds();
28818
+ return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
28819
+ p.z >= b.min.z && p.z <= b.max.z;
28820
+ };
28821
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
28629
28822
  return !!hit && hit.distance < reach;
28630
28823
  }
28631
28824
 
28632
- /** Ease visible toward whether the sun shows, called automatically each frame */
28825
+ /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
28826
+ * wherever the sun is, off the screen or its light off, looks for nothing in the way */
28633
28827
  update()
28634
28828
  {
28635
28829
  super.update();
28636
- const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
28830
+ if (this.light?.destroyed)
28831
+ return this.destroy(); // the flare of a light that is gone
28832
+ const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
28637
28833
  const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
28638
28834
  this.visible = clamp(this.visible + (shows ? step : -step));
28639
28835
  }
@@ -28647,8 +28843,9 @@ class LensFlare3D extends EngineObject3D
28647
28843
  setAdditiveBlendMode(true);
28648
28844
  for (const e of elements)
28649
28845
  {
28650
- const tileInfo = render3DFlareTile(e.shape);
28651
- tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
28846
+ const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
28847
+ const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
28848
+ tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
28652
28849
  }
28653
28850
  setAdditiveBlendMode(false);
28654
28851
  }
@@ -29762,8 +29959,9 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
29762
29959
  * are added before its objects are made, one the game added itself keeps its place
29763
29960
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
29764
29961
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
29765
- * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
29766
- * leaves out stays as the game set it, and a level with no block changes nothing
29962
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
29963
+ * sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
29964
+ * nothing
29767
29965
  * @param {Object} level - The level, the level editor edits this same object
29768
29966
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
29769
29967
  * @memberof Level3D */
@@ -29794,7 +29992,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
29794
29992
 
29795
29993
  /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
29796
29994
  * - 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
29995
+ * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
29996
+ * image gets plain blocks, a color for each type
29798
29997
  * - setup is called with each map a level makes, to give block types their own faces or make them see-through
29799
29998
  * - Call it before level3DLoad; with no arguments the defaults are back
29800
29999
  * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
@@ -29856,7 +30055,7 @@ function level3DVoxelsMake(voxels)
29856
30055
  {
29857
30056
  const shape = level3DVoxelsShape(voxels);
29858
30057
  if (!shape || typeof VoxelMap == 'undefined') return;
29859
- const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
30058
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
29860
30059
  level3DVoxelsDecode(voxels.blocks, map.data);
29861
30060
  level3DVoxelSetupMap?.(map);
29862
30061
  map.rebuild();
@@ -29918,6 +30117,31 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
29918
30117
  map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
29919
30118
  }
29920
30119
 
30120
+ // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
30121
+ // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
30122
+ // block map in a game with no art can still be seen and painted
30123
+ let level3DVoxelPlain;
30124
+ function level3DVoxelPlainTiles()
30125
+ {
30126
+ if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
30127
+ return tile();
30128
+ if (!level3DVoxelPlain)
30129
+ {
30130
+ // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
30131
+ const context = createCanvasContext(256);
30132
+ for (let i = 0; i < 256; ++i)
30133
+ {
30134
+ const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
30135
+ context.fillStyle = hsl(hue, .45, .4).toString();
30136
+ context.fillRect(x, y, 16, 16);
30137
+ context.fillStyle = hsl(hue, .5, .55).toString();
30138
+ context.fillRect(x + 1, y + 1, 14, 14);
30139
+ }
30140
+ level3DVoxelPlain = new TextureInfo(context.canvas);
30141
+ }
30142
+ return tile(0, 16, level3DVoxelPlain, 0);
30143
+ }
30144
+
29921
30145
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
29922
30146
  function level3DTerrainMake(terrain)
29923
30147
  {
@@ -29962,8 +30186,25 @@ function level3DSceneApply(scene)
29962
30186
  r.fogColor = color(scene.fogColor) || r.fogColor;
29963
30187
  if (typeof scene.shadows === 'boolean')
29964
30188
  r.shadows = scene.shadows;
30189
+ if (typeof scene.lensFlare === 'boolean')
30190
+ level3DSceneFlare(scene.lensFlare);
29965
30191
  }
29966
30192
 
30193
+ // the sun's lens flare as a scene sets it: on makes one unless the sun has a flare already, the game's own or an
30194
+ // earlier level's, and off takes away the one a scene made, never the game's
30195
+ let level3DSunFlare;
30196
+ function level3DSceneFlare(on)
30197
+ {
30198
+ if (!on)
30199
+ {
30200
+ level3DSunFlare && !level3DSunFlare.destroyed && level3DSunFlare.destroy();
30201
+ level3DSunFlare = undefined;
30202
+ }
30203
+ else if (!level3DSunHasFlare())
30204
+ level3DSunFlare = new LensFlare3D;
30205
+ }
30206
+ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light);
30207
+
29967
30208
  // a vec3 of an array of three numbers, as the file has them, or the fallback
29968
30209
  function level3DVector(value, fallback)
29969
30210
  {
@@ -30312,7 +30553,8 @@ level3DAddType('Cylinder', function(pos, properties)
30312
30553
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
30313
30554
  {color: WHITE, tile: -1, solid: true});
30314
30555
  // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
30315
- // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
30556
+ // object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and
30557
+ // lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare
30316
30558
  level3DAddType('Light', function(pos, properties)
30317
30559
  {
30318
30560
  const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
@@ -30320,8 +30562,9 @@ level3DAddType('Light', function(pos, properties)
30320
30562
  light.coneSoftness = properties.softness;
30321
30563
  if (properties.shadows && render3D)
30322
30564
  render3D.shadowLight = light;
30565
+ properties.lensFlare && light.addFlare();
30323
30566
  return light;
30324
- }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
30567
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false, lensFlare: false});
30325
30568
 
30326
30569
  /**
30327
30570
  * LittleJS Particle Effects Plugin
@@ -31487,6 +31730,11 @@ async function parseGLTF(data, baseUrl='')
31487
31730
  for (const name of json.extensionsRequired || [])
31488
31731
  if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
31489
31732
  throw new Error(`glTF with ${name} is not read, export it uncompressed`);
31733
+ else if (name === 'KHR_texture_basisu')
31734
+ throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg');
31735
+ // one that only may use them has other images to fall back on, or none: said, since it loads with them left out
31736
+ if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu'))
31737
+ console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg');
31490
31738
 
31491
31739
  // the buffers: the GLB's own, a data uri, or a file beside the model
31492
31740
  const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
@@ -31529,7 +31777,8 @@ async function parseGLTF(data, baseUrl='')
31529
31777
  const bitmap = normalTextures.has(index) ?
31530
31778
  await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
31531
31779
  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
31780
+ // REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are
31781
+ return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728);
31533
31782
  }
31534
31783
  catch (e) { false&&LOG('glTF image not loaded', e); }
31535
31784
  }));
@@ -31639,17 +31888,25 @@ function gltfOpaqueImage(image)
31639
31888
  {
31640
31889
  const gl = glContext, {width, height} = image;
31641
31890
  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);
31891
+ // the room to read into first: for a huge image it can fail, and must not with the framebuffer bound
31647
31892
  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);
31893
+ const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
31894
+ try
31895
+ {
31896
+ gl.bindTexture(gl.TEXTURE_2D, texture);
31897
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
31898
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
31899
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
31900
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
31901
+ }
31902
+ finally
31903
+ {
31904
+ // the 2D renderer's own bindings back, whatever happened
31905
+ gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
31906
+ gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
31907
+ gl.deleteFramebuffer(framebuffer);
31908
+ gl.deleteTexture(texture);
31909
+ }
31653
31910
  for (let i = 3; i < data.length; i += 4)
31654
31911
  data[i] = 255;
31655
31912
  image.close();
@@ -31786,7 +32043,7 @@ function gltfAccessor(json, buffers, index)
31786
32043
  }
31787
32044
  if (scale !== 1)
31788
32045
  for (let i = 0; i < out.length; ++i)
31789
- out[i] /= scale;
32046
+ out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says
31790
32047
  return {data: out, components, count: a.count};
31791
32048
  }
31792
32049
 
@@ -31850,8 +32107,10 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
31850
32107
  const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
31851
32108
  if (er || eg || eb)
31852
32109
  {
32110
+ // KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender
32111
+ const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
31853
32112
  part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
31854
- part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
32113
+ part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength);
31855
32114
  }
31856
32115
  return part;
31857
32116
  }