littlejsengine 1.22.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.22.0';
38
+ const engineVersion = '1.23.1';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -982,8 +982,23 @@ function tweak(){}
982
982
  function tweakButton(){}
983
983
  function tweakDivider(){}
984
984
  function tweakEngineDefaults(){}
985
- const levelEditor = {isOpen: false, open(){}, close(){}, onTile: undefined, onRestart: undefined, onPlayFrom: undefined,
986
- paletteTiles: undefined, use3D: undefined};
985
+ class LevelEditor
986
+ {
987
+ constructor()
988
+ {
989
+ this.paletteTiles = this.use3D = this.tool = undefined;
990
+ this.keys = {}, this.buttons = [], this.tools = {};
991
+ }
992
+ get isOpen() { return false; }
993
+ get is3D() { return false; }
994
+ get edit2D() { return undefined; }
995
+ get edit3D() { return undefined; }
996
+ open(){} close(){} addKey(){} addButton(){} addTool(){}
997
+ onTile(){} onRestart(){} onPlayFrom(){} onOpen(){} onClose(){} onUpdate(){} onDraw(){} onPanel(){}
998
+ onSave(){ return false; }
999
+ }
1000
+ let levelEditor = new LevelEditor;
1001
+ function setLevelEditor(editor){ levelEditor = editor; }
987
1002
  function editorMapRestore(map){ return map; }
988
1003
  function editorMapLoaded(){}
989
1004
  function editorJSONFetched(){}
@@ -2281,295 +2296,295 @@ const PURPLE = debugProtectConstant(rgb(.5,0,1));
2281
2296
  * @type {Color}
2282
2297
  * @memberof Math */
2283
2298
  const MAGENTA = debugProtectConstant(rgb(1,0,1));
2284
- /**
2285
- * LittleJS Utility Classes and Functions
2286
- * - Timer - tracks time automatically with support for pause and real-time modes
2287
- * - Time formatting helper
2288
- * - JSON file fetching
2289
- * - File saving (text, canvas, data URLs)
2290
- * - Native share dialog support
2291
- * - Local storage save data management
2292
- * - Value noise (1D and 2D)
2293
- * @namespace Utilities
2294
- */
2295
-
2296
- /**
2297
- * Timer object tracks how long has passed since it was set
2298
- * @memberof Engine
2299
- * @example
2300
- * let a = new Timer; // creates a timer that is not set
2301
- * a.set(3); // sets the timer to 3 seconds
2302
- *
2303
- * let b = new Timer(1); // creates a timer with 1 second left
2304
- * b.unset(); // unset the timer
2305
- */
2306
- class Timer
2307
- {
2308
- /** Create a timer object set time passed in
2309
- * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
2310
- * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
2311
- constructor(timeLeft, useRealTime=false)
2312
- {
2313
- false&&ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
2314
- this.useRealTime = useRealTime;
2315
- const globalTime = this.getGlobalTime();
2316
- /** @type {number|undefined} */
2317
- this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
2318
- /** @type {number|undefined} */
2319
- this.setTime = timeLeft;
2320
- }
2321
-
2322
- /** Set the timer with seconds passed in
2323
- * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
2324
- set(timeLeft=0)
2325
- {
2326
- false&&ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
2327
- const globalTime = this.getGlobalTime();
2328
- this.time = globalTime + timeLeft;
2329
- this.setTime = timeLeft;
2330
- }
2331
-
2332
- /** Set if the timer should keep running even when the game is paused
2333
- * @param {boolean} [useRealTime] */
2334
- setUseRealTime(useRealTime=true)
2335
- {
2336
- false&&ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
2337
- this.useRealTime = useRealTime;
2338
- }
2339
-
2340
- /** Unset the timer */
2341
- unset() { this.time = undefined; }
2342
-
2343
- /** Returns true if set
2344
- * @return {boolean} */
2345
- isSet() { return this.time !== undefined; }
2346
-
2347
- /** Returns true if set and has not elapsed
2348
- * @return {boolean} */
2349
- active() { return this.getGlobalTime() < this.time; }
2350
-
2351
- /** Returns true if set and elapsed
2352
- * @return {boolean} */
2353
- elapsed() { return this.getGlobalTime() >= this.time; }
2354
-
2355
- /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
2356
- * @return {number} */
2357
- get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
2358
-
2359
- /** Get percentage elapsed based on time it was set to, returns 0 if not set.
2360
- * Zero-duration timers report 1 (already elapsed).
2361
- * @return {number} */
2362
- getPercent()
2363
- {
2364
- if (!this.isSet()) return 0;
2365
- if (!this.setTime) return 1;
2366
- return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
2367
- }
2368
-
2369
- /** Get the time this timer was set to, returns 0 if not set
2370
- * @return {number} */
2371
- getSetTime() { return this.isSet() ? this.setTime : 0; }
2372
-
2373
- /** Get the current global time this timer is based on
2374
- * @return {number} */
2375
- getGlobalTime() { return this.useRealTime ? timeReal : time; }
2376
-
2377
- /** Returns this timer expressed as a string
2378
- * @return {string} */
2379
- toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
2380
-
2381
- /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
2382
- * @return {number} */
2383
- valueOf() { return this.get(); }
2384
- }
2385
-
2386
- ///////////////////////////////////////////////////////////////////////////////
2387
-
2388
- /** Formats seconds to mm:ss style for display purposes
2389
- * @param {number} t - time in seconds
2390
- * @return {string}
2391
- * @memberof Utilities */
2392
- function formatTime(t)
2393
- {
2394
- const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
2395
- t = abs(t)|0;
2396
- return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2397
- }
2398
-
2399
- /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
2400
- * @param {string} url - URL of JSON file
2401
- * @return {Promise<object>}
2402
- * @memberof Utilities */
2403
- async function fetchJSON(url)
2404
- {
2405
- const response = await fetch(url);
2406
- if (!response.ok)
2407
- throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
2408
- const json = await response.json();
2409
- editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
2410
- return json;
2411
- }
2412
-
2413
- ///////////////////////////////////////////////////////////////////////////////
2414
-
2415
- /** Save a text file to disk
2416
- * @param {string} text
2417
- * @param {string} [filename]
2418
- * @param {string} [type]
2419
- * @memberof Utilities */
2420
- function saveText(text, filename='text', type='text/plain')
2421
- { saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
2422
-
2423
- /** Create an offscreen canvas to draw into, and return its 2D context
2424
- * - The canvas is context.canvas, which is what TextureInfo and the like take
2425
- * @param {number} width - In pixels
2426
- * @param {number} [height] - In pixels, defaults to the width for a square
2427
- * @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
2428
- * @return {OffscreenCanvasRenderingContext2D}
2429
- * @memberof Utilities */
2430
- function createCanvasContext(width, height=width, willReadFrequently=false)
2431
- {
2432
- false&&ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
2433
- return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
2434
- }
2435
-
2436
- /** Save a canvas to disk
2437
- * @param {HTMLCanvasElement|OffscreenCanvas} canvas
2438
- * @param {string} [filename]
2439
- * @param {string} [type]
2440
- * @memberof Utilities */
2441
- function saveCanvas(canvas, filename='screenshot', type='image/png')
2442
- {
2443
- if (canvas instanceof OffscreenCanvas)
2444
- {
2445
- // copy to temporary canvas and save
2446
- const saveCanvas = document.createElement('canvas');
2447
- saveCanvas.width = canvas.width;
2448
- saveCanvas.height = canvas.height;
2449
- saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
2450
- saveDataURL(saveCanvas.toDataURL(type), filename);
2451
- }
2452
- else
2453
- saveDataURL(canvas.toDataURL(type), filename);
2454
- }
2455
-
2456
- /** Save a data url to disk
2457
- * @param {string} url
2458
- * @param {string} [filename]
2459
- * @param {number} [revokeTime] - how long before revoking the url
2460
- * @memberof Utilities */
2461
- function saveDataURL(url, filename='download', revokeTime)
2462
- {
2463
- false&&ASSERT(isStringLike(url), 'saveDataURL requires url string');
2464
- false&&ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
2465
-
2466
- // create link for saving screenshots
2467
- const link = document.createElement('a');
2468
- link.download = filename;
2469
- link.href = url;
2470
- link.click();
2471
- if (revokeTime !== undefined)
2472
- setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
2473
- }
2474
-
2475
- /** Share content using the native share dialog if available
2476
- * @param {string} title - title of the share
2477
- * @param {string} url - url to share
2478
- * @param {Function} [callback] - Called when share is complete
2479
- * @memberof Utilities */
2480
- function shareURL(title, url, callback)
2481
- {
2482
- false&&ASSERT(isStringLike(title), 'shareURL requires title string');
2483
- false&&ASSERT(isStringLike(url), 'shareURL requires url string');
2484
- navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
2485
- }
2486
-
2487
- ///////////////////////////////////////////////////////////////////////////////
2488
-
2489
- /** Read save data from local storage
2490
- * - The result has the type of defaultSaveData, or any when there is none
2491
- * @template {Object<string, any>} [T=any]
2492
- * @param {string} saveName - unique name for the game/save
2493
- * @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
2494
- * @return {T}
2495
- * @memberof Utilities */
2496
- function readSaveData(saveName, defaultSaveData)
2497
- {
2498
- false&&ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
2499
- false&&ASSERT(defaultSaveData === undefined ||
2500
- (typeof defaultSaveData === 'object' && defaultSaveData !== null),
2501
- 'readSaveData: default must be an object - the result is ' +
2502
- '{...default, ...loaded}, so a scalar default yields {}. ' +
2503
- 'Use readSaveData(key, {best:0}).best');
2504
-
2505
- // tolerate localStorage being unavailable (iOS private mode, sandboxed
2506
- // iframes) and corrupt JSON in stored data
2507
- let loadedData = {};
2508
- try
2509
- {
2510
- const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
2511
- if (data)
2512
- {
2513
- try { loadedData = JSON.parse(data); }
2514
- catch { false&&LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2515
- }
2516
- }
2517
- catch { false&&LOG('readSaveData: localStorage unavailable — using defaults'); }
2518
- return { .../** @type {object} */ (defaultSaveData), ...loadedData };
2519
- }
2520
-
2521
- /** Write save data to local storage
2522
- * @param {string} saveName - unique name for the game/save
2523
- * @param {object} saveData - object containing data to be saved
2524
- * @memberof Utilities */
2525
- function writeSaveData(saveName, saveData)
2526
- {
2527
- false&&ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
2528
- // tolerate localStorage being unavailable or quota exceeded
2529
- try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
2530
- catch { false&&LOG('writeSaveData: failed to write', saveName); }
2531
- }
2532
-
2533
- ///////////////////////////////////////////////////////////////////////////////
2534
-
2535
- // Deterministic well-distributed hash of an integer lattice index to [0, 1).
2536
- // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
2537
- function noiseHash(i)
2538
- {
2539
- let h = (i | 0) ^ 0x9e3779b9;
2540
- h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
2541
- h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
2542
- h ^= h >>> 16;
2543
- return (h >>> 0) / 2**32;
2544
- }
2545
-
2546
- /** 1D value noise — returns a smooth value in [0, 1] for any real x.
2547
- * Integer inputs land on deterministic lattice values; non-integer inputs
2548
- * are interpolated with smoothStep for C1 continuity.
2549
- * @param {number} x
2550
- * @return {number}
2551
- * @memberof Utilities */
2552
- function noise1D(x)
2553
- {
2554
- const i = floor(x);
2555
- return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
2556
- }
2557
-
2558
- /** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
2559
- * @param {number} x
2560
- * @param {number} y
2561
- * @return {number}
2562
- * @memberof Utilities */
2563
- function noise2D(x, y)
2564
- {
2565
- const ix = floor(x), iy = floor(y);
2566
- const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
2567
- // large prime decorrelates neighboring rows
2568
- const h = (a, b) => noiseHash(a + b * 374761393);
2569
- return lerp(
2570
- lerp(h(ix, iy ), h(ix + 1, iy ), fx),
2571
- lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
2572
- 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);
2573
2588
  }
2574
2589
  /**
2575
2590
  * LittleJS Engine Settings
@@ -2884,6 +2899,15 @@ let touchGamepadEnable = false;
2884
2899
  * @memberof Settings */
2885
2900
  let touchGamepadPassthrough = false;
2886
2901
 
2902
+ /** True if a pinch of two fingers turns the mouse wheel: touchPinch is added to mouseWheel, so what zooms with
2903
+ * the wheel zooms with a pinch, the mouse is between the two fingers, and a second finger lets go of the button
2904
+ * the first pressed, since a pinch is not a drag; turn it off for a game with its own use for two fingers, the
2905
+ * first finger stays the mouse and touchPinch can still be read
2906
+ * @type {boolean}
2907
+ * @default
2908
+ * @memberof Settings */
2909
+ let touchPinchWheel = true;
2910
+
2887
2911
  /** Size of center button if touch gamepad should have start button in the center
2888
2912
  * - Prevents activating when pressed near virtual stick or face buttons
2889
2913
  * - When the game is paused, any touch will press the button
@@ -3279,6 +3303,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
3279
3303
  * @memberof Settings */
3280
3304
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3281
3305
 
3306
+ /** Set if a pinch of two fingers turns the mouse wheel
3307
+ * @param {boolean} enable
3308
+ * @memberof Settings */
3309
+ function setTouchPinchWheel(enable) { touchPinchWheel = enable; }
3310
+
3282
3311
  /** Set if touches outside the gamepad controls should still drive mouse/touch input
3283
3312
  * @param {boolean} passthrough
3284
3313
  * @memberof Settings */
@@ -3318,776 +3347,776 @@ function setTouchGamepadLeftStick(enable)
3318
3347
  function setTouchGamepadLeftButtonCount(count)
3319
3348
  {
3320
3349
  touchGamepadLeftButtonCount = count;
3321
- if (count > 0)
3322
- touchGamepadLeftStick = false;
3323
- }
3324
-
3325
- /** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
3326
- * @param {boolean} rightStick
3327
- * @memberof Settings */
3328
- function setTouchGamepadRightStick(rightStick)
3329
- {
3330
- touchGamepadRightStick = rightStick;
3331
- if (rightStick)
3332
- touchGamepadButtonCount = 0;
3333
- }
3334
-
3335
- /** Set if touch gamepad should be analog stick or 8 way dpad
3336
- * @param {boolean} analog
3337
- * @memberof Settings */
3338
- function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
3339
-
3340
- /** Set if touch gamepad directional controls should float to where you press
3341
- * @param {boolean} floating
3342
- * @memberof Settings */
3343
- function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
3344
-
3345
- /** Set size of virtual gamepad for touch devices in viewport CSS pixels
3346
- * @param {number} size
3347
- * @memberof Settings */
3348
- function setTouchGamepadSize(size) { touchGamepadSize = size; }
3349
-
3350
- /** Set transparency of touch gamepad overlay
3351
- * @param {number} alpha
3352
- * @memberof Settings */
3353
- function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
3354
-
3355
- /** Set how long to display the touch gamepad on screen in seconds, set to 0 to always display
3356
- * @param {number} time
3357
- * @memberof Settings */
3358
- function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
3359
-
3360
- /** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
3361
- * @param {number} ms
3362
- * @memberof Settings */
3363
- function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
3364
-
3365
- /** Set to allow vibration hardware if it exists
3366
- * @param {boolean} enable
3367
- * @memberof Settings */
3368
- function setVibrateEnable(enable) { vibrateEnable = enable; }
3369
-
3370
- /** Set if audio is enabled, false turns all sound off
3371
- * @param {boolean} enable
3372
- * @memberof Settings */
3373
- function setSoundEnable(enable) { soundEnable = enable; }
3374
-
3375
- /** Set volume scale to apply to all sound, music and speech
3376
- * @param {number} volume
3377
- * @memberof Settings */
3378
- function setSoundVolume(volume)
3379
- {
3380
- soundVolume = volume;
3381
- audioUpdateVolume(); // update gain immediately, sound off or not
3382
- }
3383
-
3384
- /** Set default range where sound no longer plays
3385
- * @param {number} range
3386
- * @memberof Settings */
3387
- function setSoundDefaultRange(range) { soundDefaultRange = range; }
3388
-
3389
- /** Set default range percent to start tapering off sound
3390
- * @param {number} taper
3391
- * @memberof Settings */
3392
- function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
3393
-
3394
- /** Set if all sound pauses while the page is hidden
3395
- * @param {boolean} pause
3396
- * @memberof Settings */
3397
- function setSoundPauseWhenHidden(pause) { soundPauseWhenHidden = pause; }
3398
-
3399
- /** Set if watermark with FPS should be shown
3400
- * @param {boolean} show
3401
- * @memberof Debug */
3402
- function setDebugWatermark(show) { debugWatermark = show; }
3403
-
3404
- /** Set key code used to toggle debug mode, Esc by default
3405
- * @param {string} key
3406
- * @memberof Debug */
3407
- function setDebugKey(key) { debugKey = key; }
3408
-
3409
- /** Set if the debug keys work while the overlay is closed, the number keys and the +/- time keys
3410
- * @param {boolean} [enable]
3411
- * @memberof Debug */
3412
- function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; }
3413
-
3414
- /** Set if the tweakables panel is shown, as 9 does while the debug overlay is open
3415
- * @param {boolean} [show]
3416
- * @memberof Debug */
3417
- function setDebugTweakables(show=true) { debugTweakables = show; }
3418
- /**
3419
- * LittleJS Object System
3420
- * - EngineObject is the base class for all game objects
3421
- * - Handles automatic updating, rendering, physics, and collision
3422
- * - Supports parent-child hierarchies with transform inheritance
3423
- * - 2D physics with velocity, acceleration, damping, and gravity
3424
- * - Collision system with tiles and other objects
3425
- * - Renders sprites from tile sheets with color and rotation
3426
- * - Objects sorted by renderOrder for layered rendering
3427
- */
3428
-
3429
- /**
3430
- * LittleJS Object Base Object Class
3431
- * - Top level object class used by the engine
3432
- * - Automatically adds self to object list
3433
- * - Will be updated and rendered each frame
3434
- * - Renders as a sprite from a tilesheet by default
3435
- * - Can have color and additive color applied
3436
- * - 2D Physics and collision system
3437
- * - Sorted by renderOrder
3438
- * - Objects can have children attached
3439
- * - Parents are updated before children, and set child transform
3440
- * - Call destroy() to get rid of objects
3441
- *
3442
- * The physics system used by objects is simple and fast with some caveats...
3443
- * - Collision uses the axis aligned size, the object's rotation angle is only for rendering
3444
- * - Objects are guaranteed to not intersect tile collision from physics
3445
- * - If an object starts or is moved inside tile collision, it will not collide with that tile
3446
- * - Collision for objects can be set to be solid to block other objects
3447
- * - Objects may get pushed into overlapping other solid objects, if so they will push away
3448
- * - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
3449
- * - Solid objects are more performance intensive and should be used sparingly
3450
- * @memberof Engine
3451
- * @example
3452
- * // create an engine object, normally you would first extend the class with your own
3453
- * const pos = vec2(2,3);
3454
- * const object = new EngineObject(pos);
3455
- */
3456
- class EngineObject
3457
- {
3458
- /** Create an engine object and adds it to the list of objects
3459
- * @param {Vector2} [pos=vec2()] - World space position of the object
3460
- * @param {Vector2} [size=vec2(1)] - World space size of the object
3461
- * @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
3462
- * @param {number} [angle] - Angle the object is rotated by
3463
- * @param {Color} [color=WHITE] - Color to apply to tile when rendered
3464
- * @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
3465
- */
3466
- constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
3467
- {
3468
- // check passed in params
3469
- false&&ASSERT(isVector2(pos), 'object pos must be a vec2');
3470
- false&&ASSERT(isVector2(size), 'object size must be a vec2');
3471
- false&&ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
3472
- false&&ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
3473
- false&&ASSERT(isColor(color), 'object color should be a valid rgba color');
3474
- false&&ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
3475
-
3476
- /** @property {Vector2} - World space position of the object */
3477
- this.pos = pos.copy();
3478
- /** @property {Vector2} - World space width and height of the object */
3479
- this.size = size.copy();
3480
- /** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
3481
- * @type {Vector2|undefined} */
3482
- this.drawSize = undefined;
3483
- /** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
3484
- * @type {TileInfo|undefined} */
3485
- this.tileInfo = tileInfo;
3486
- /** @property {number} - Angle to rotate the object */
3487
- this.angle = angle;
3488
- /** @property {Color} - Color to apply when rendered */
3489
- this.color = color.copy();
3490
- /** @property {Color|undefined} - Additive color to apply when rendered
3491
- * @type {Color|undefined} */
3492
- this.additiveColor = undefined;
3493
- /** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
3494
- * @type {Shader|undefined} */
3495
- this.shader = undefined;
3496
- /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
3497
- this.castShadow = true;
3498
- /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
3499
- * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
3500
- * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
3501
- * too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
3502
- this.emissive = 0;
3503
- /** @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. */
3504
- this.mirror = false;
3505
- /** @property {boolean} - Has object been destroyed? */
3506
- this.destroyed = false;
3507
- this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
3508
-
3509
- // physical properties
3510
- /** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
3511
- * collide on its own, the moving ones collide with it */
3512
- this.mass = objectDefaultMass;
3513
- /** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
3514
- this.damping = objectDefaultDamping;
3515
- /** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
3516
- this.angleDamping = objectDefaultAngleDamping;
3517
- /** @property {number} - How bouncy the object is when colliding (0-1) */
3518
- this.restitution = objectDefaultRestitution;
3519
- /** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
3520
- * once, the more slippery of the object and its ground is used */
3521
- this.friction = objectDefaultFriction;
3522
- /** @property {number} - How much to scale gravity by for this object */
3523
- this.gravityScale = 1;
3524
- /** @property {number} - Objects are sorted by render order */
3525
- this.renderOrder = renderOrder;
3526
- /** @property {Vector2} - Velocity of the object, in world units per frame */
3527
- this.velocity = vec2();
3528
- /** @property {number} - Angular velocity of the object, in radians per frame */
3529
- this.angleVelocity = 0;
3530
- /** @property {number} - Track when object was created */
3531
- this.spawnTime = time;
3532
- /** @property {Array<EngineObject>} - List of children of this object
3533
- * @type {Array<EngineObject>} */
3534
- this.children = [];
3535
- /** @property {boolean} - Limit object speed along x and y axis */
3536
- this.clampSpeed = true;
3537
- /** @property {EngineObject|undefined} - Object we are standing on, if any
3538
- * @type {EngineObject|undefined} */
3539
- this.groundObject = undefined;
3540
-
3541
- // parent child system
3542
- /** @property {EngineObject|undefined} - Parent of object if in local space
3543
- * @type {EngineObject|undefined} */
3544
- this.parent = undefined;
3545
- /** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
3546
- * @type {Vector2|undefined} */
3547
- this.localPos = undefined;
3548
- /** @property {number} - Local angle if child */
3549
- this.localAngle = 0;
3550
-
3551
- // collision flags
3552
- /** @property {boolean} - Object collides with the level, its tile collision layers
3553
- * @type {boolean} */
3554
- this.collideLevel = false;
3555
- /** @property {boolean} - Object collides with solid objects */
3556
- this.collideSolidObjects = false;
3557
- /** @property {boolean} - Object collides with and blocks other objects */
3558
- this.isSolid = false;
3559
- /** @property {boolean} - Object collides with raycasts */
3560
- this.collideRaycast = false;
3561
-
3562
- /** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
3563
- * - Calling destroy on it still destroys it, and its children go with it either way */
3564
- this.persistent = false;
3565
-
3566
- // add to list of objects
3567
- engineObjects.push(this);
3568
- }
3569
-
3570
- /** Update the object transform, called automatically by engine even when paused
3571
- * @param {boolean} [updateChildren] - Also update the children's transforms */
3572
- updateTransforms(updateChildren=true)
3573
- {
3574
- const parent = this.parent;
3575
- if (parent)
3576
- {
3577
- // compose with parent transform inline to avoid intermediate vector allocs
3578
- const mirror = parent.getMirrorSign();
3579
- const lp = this.localPos, pp = parent.pos;
3580
- const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
3581
- if (pa)
3582
- {
3583
- const c = cos(-pa), s = sin(-pa);
3584
- this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3585
- }
3586
- else
3587
- this.pos.set(lx + pp.x, ly + pp.y);
3588
- this.angle = mirror*this.localAngle + pa;
3589
- }
3590
-
3591
- // update children
3592
- if (updateChildren)
3593
- for (const child of this.children)
3594
- child.updateTransforms();
3595
- }
3596
-
3597
- /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
3598
- updatePhysics()
3599
- {
3600
- // child objects do not have physics
3601
- false&&ASSERT(!this.parent);
3602
-
3603
- // bail if a collision callback destroyed us mid-frame
3604
- if (this.destroyed) return;
3605
-
3606
- // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
3607
- // has a mass, which tile collision needs; anything else moves as fast as it is told
3608
- if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
3609
- {
3610
- this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
3611
- this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
3612
- }
3613
-
3614
- // physics sanity checks
3615
- false&&ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
3616
- false&&ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
3617
-
3618
- // apply physics; only the solver needs where the object was, so only then is it copied
3619
- const solve = enablePhysicsSolver && this.mass;
3620
- const oldPos = solve ? this.pos.copy() : undefined;
3621
- this.velocity.x *= this.damping;
3622
- this.velocity.y *= this.damping;
3623
- if (this.mass)
3624
- {
3625
- // apply gravity only if it has mass
3626
- this.velocity.x += gravity.x * this.gravityScale;
3627
- this.velocity.y += gravity.y * this.gravityScale;
3628
- }
3629
- this.pos.x += this.velocity.x;
3630
- this.pos.y += this.velocity.y;
3631
- this.angle += this.angleVelocity *= this.angleDamping;
3632
-
3633
- // don't do collision for static objects or if solver disabled
3634
- if (!solve) return;
3635
-
3636
- // which way is down for this object, a negative gravityScale falls up and lands on ceilings
3637
- const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
3638
- const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
3639
- if (this.groundObject)
3640
- {
3641
- // apply friction in local space of ground object
3642
- const friction = max(this.friction, this.groundObject.friction);
3643
- const groundSpeed = this.groundObject.velocity.x;
3644
- this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
3645
- this.groundObject = undefined;
3646
- }
3647
-
3648
- // an object with no width or height has no box to push out of, or to be pushed out of
3649
- if (this.collideSolidObjects && this.size.x && this.size.y)
3650
- {
3651
- // check collisions against solid objects
3652
- const epsilon = .001; // necessary to push slightly outside of the collision
3653
- for (const o of engineObjectsCollideStaticLast)
3654
- {
3655
- // skip destroyed, child objects, self collision, or objects with no box
3656
- if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
3657
-
3658
- // non solid objects don't collide with each other
3659
- if (!this.isSolid && !o.isSolid) continue;
3660
-
3661
- // check collision
3662
- if (!this.isOverlappingObject(o)) continue;
3663
-
3664
- // each moving object checks its own contacts, so a pair the other one already asked about this frame
3665
- // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
3666
- // to resolve, pushed back together since, is resolved again
3667
- const answer = engineObjectsCollidePairAnswer(o, this);
3668
- if (answer === false) continue;
3669
- if (!answer)
3670
- {
3671
- // notify objects of collision and check if should be resolved
3672
- const collide1 = this.collideWithObject(o);
3673
- const collide2 = o.collideWithObject(this);
3674
- if (!collide1 || !collide2)
3675
- {
3676
- engineObjectsCollidePairAdd(this, o);
3677
- continue;
3678
- }
3679
- }
3680
-
3681
- const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
3682
- if (wasOverlapping && (!o.mass || o.groundObject))
3683
- {
3684
- // a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
3685
- // at once and carries it along, it would only drift out slowly and the solid would pass through;
3686
- // an object standing on something counts as fixed too, so a stack on an elevator rides together;
3687
- // it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
3688
- const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
3689
- if (push)
3690
- {
3691
- this.pos.x += push.x + sign(push.x) * epsilon;
3692
- this.pos.y += push.y + sign(push.y) * epsilon;
3693
- const restitution = max(this.restitution, o.restitution);
3694
- if (push.x)
3695
- {
3696
- const v = this.velocity.x - o.velocity.x;
3697
- if (v * push.x < 0) // moving into it
3698
- this.velocity.x = o.velocity.x - v * restitution;
3699
- }
3700
- else
3701
- {
3702
- const v = this.velocity.y - o.velocity.y;
3703
- if (v * push.y < 0) // moving into it
3704
- this.velocity.y = o.velocity.y - v * restitution;
3705
- if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
3706
- this.groundObject = o;
3707
- }
3708
- }
3709
- engineObjectsCollidePairAdd(this, o);
3710
-
3711
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3712
- continue;
3713
- }
3714
- if (wasOverlapping)
3715
- {
3716
- // if already was touching, try to push away
3717
- const deltaPos = oldPos.subtract(o.pos);
3718
- const length = deltaPos.length();
3719
- const pushAwayAccel = .001;
3720
- const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
3721
- this.velocity = this.velocity.add(velocity);
3722
- if (o.mass) // push away other object if not fixed
3723
- o.velocity = o.velocity.subtract(velocity);
3724
- engineObjectsCollidePairAdd(this, o);
3725
-
3726
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3727
- continue;
3728
- }
3729
-
3730
- // check for collision
3731
- const sizeBoth = this.size.add(o.size);
3732
- // prefer to push up if small delta, up away from this object's own gravity
3733
- const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
3734
- const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
3735
- const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
3736
- const restitution = max(this.restitution, o.restitution);
3737
-
3738
- if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
3739
- {
3740
- // push outside object collision
3741
- this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
3742
- if ((o.groundObject && wasFalling) || !o.mass)
3743
- {
3744
- // set ground object if landed on something
3745
- if (wasFalling)
3746
- this.groundObject = o;
3747
-
3748
- // bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
3749
- // platform moving down instead of landing on it again every few frames
3750
- this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
3751
- }
3752
- else // o has mass here, the other branch already handled the massless case
3753
- {
3754
- // inelastic collision
3755
- const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
3756
-
3757
- // elastic collision
3758
- const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
3759
- + o.velocity.y * 2 * o.mass / (this.mass + o.mass);
3760
- const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
3761
- + this.velocity.y * 2 * this.mass / (this.mass + o.mass);
3762
-
3763
- // lerp between elastic or inelastic based on restitution
3764
- this.velocity.y = lerp(inelastic, elastic0, restitution);
3765
- o.velocity.y = lerp(inelastic, elastic1, restitution);
3766
- }
3767
- }
3768
- if (!smallStepUp && isBlockedX) // resolve x collision
3769
- {
3770
- // push outside collision
3771
- this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
3772
- if (o.mass)
3773
- {
3774
- // inelastic collision
3775
- const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
3776
-
3777
- // elastic collision
3778
- const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
3779
- + o.velocity.x * 2 * o.mass / (this.mass + o.mass);
3780
- const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
3781
- + this.velocity.x * 2 * this.mass / (this.mass + o.mass);
3782
-
3783
- // lerp between elastic or inelastic based on restitution
3784
- this.velocity.x = lerp(inelastic, elastic0, restitution);
3785
- o.velocity.x = lerp(inelastic, elastic1, restitution);
3786
- }
3787
- else // bounce if other object is fixed, relative to it as a landing is
3788
- this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
3789
- }
3790
- engineObjectsCollidePairAdd(this, o, true);
3791
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
3792
- }
3793
- }
3794
- if (this.collideLevel)
3795
- {
3796
- // check collision against tiles
3797
- const hitLayer = tileCollisionTest(this.pos, this.size, this);
3798
- if (hitLayer)
3799
- {
3800
- // if already was stuck in collision, don't do anything
3801
- // this should not happen unless something starts in collision
3802
- if (!tileCollisionTest(oldPos, this.size, this))
3803
- {
3804
- // test which side we bounced off (or both if a corner)
3805
- const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
3806
- const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
3807
- const restitution = max(this.restitution, hitLayer.restitution);
3808
- if (isBlockedX)
3809
- {
3810
- // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
3811
- // down off a ceiling ledge when gravity points up; zero gravity counts as down
3812
- const epsilon = 1e-3;
3813
- const maxMove = .1;
3814
- const y = gravityY > 0 ?
3815
- ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
3816
- floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
3817
- if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
3818
- {
3819
- this.pos.y = y;
3820
- debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
3821
- return;
3822
- }
3823
-
3824
- // move to previous X position and bounce
3825
- this.pos.x = oldPos.x;
3826
- this.velocity.x *= -restitution;
3827
- }
3828
- if (isBlockedY || !isBlockedX)
3829
- {
3830
- if (wasFalling)
3831
- {
3832
- // adjust position to slightly away from nearest tile
3833
- // this prevents gap between object and ground
3834
- const epsilon = .0001;
3835
- const offset = this.size.y/2 + epsilon;
3836
- // rounded in the layer's space as its collision test is, or a bottom a hair below a
3837
- // grid line would round to the row under it, inside the floor, and fall through
3838
- const layerY = hitLayer.pos.y;
3839
- this.pos.y = layerY + (gravityY < 0 ?
3840
- floor(oldPos.y - layerY - this.size.y/2) + offset :
3841
- ceil( oldPos.y - layerY + this.size.y/2) - offset);
3842
-
3843
- // set ground object for tile collision
3844
- this.groundObject = hitLayer;
3845
- }
3846
- else
3847
- {
3848
- // move to previous Y position
3849
- this.pos.y = oldPos.y;
3850
- this.groundObject = undefined;
3851
- }
3852
- // bounce velocity
3853
- this.velocity.y *= -restitution;
3854
- }
3855
- debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
3856
- }
3857
- }
3858
- }
3859
- }
3860
-
3861
- /** Update the object, called automatically by engine once each frame. Does nothing by default. */
3862
- update() {}
3863
-
3864
- /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
3865
- render()
3866
- {
3867
- // default object render
3868
- drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
3869
- }
3870
-
3871
- /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3872
- renderLight() {}
3873
-
3874
- /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
3875
- * 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;
3876
- * screen space WebGL draws in render() are skipped during the pass */
3877
- renderShadow() { this.render(); }
3878
-
3879
- /** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
3880
- * 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
3881
- * glows; override to glow a part, like a robot's eyes */
3882
- renderEmissive() { this.render(); }
3883
-
3884
- /** Destroy this object, destroy its children, detach its parent, and mark it for removal
3885
- * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
3886
- destroy(immediate=false)
3887
- {
3888
- if (this.destroyed) return;
3889
-
3890
- // disconnect from parent and destroy children
3891
- this.destroyed = true;
3892
- this.parent?.removeChild(this);
3893
- for (const child of this.children)
3894
- {
3895
- child.parent = undefined;
3896
- child.destroy(immediate);
3897
- }
3898
- }
3899
-
3900
- /** Convert from local space to world space
3901
- * @param {Vector2} pos - local space point */
3902
- localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
3903
-
3904
- /** Convert from world space to local space
3905
- * @param {Vector2} pos - world space point */
3906
- worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
3907
-
3908
- /** Convert from local space to world space for a vector (rotation only)
3909
- * @param {Vector2} vec - local space vector */
3910
- localToWorldVector(vec) { return vec.rotate(this.angle); }
3911
-
3912
- /** Convert from world space to local space for a vector (rotation only)
3913
- * @param {Vector2} vec - world space vector */
3914
- worldToLocalVector(vec) { return vec.rotate(-this.angle); }
3915
-
3916
- /** 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.
3917
- * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
3918
- * positions it only tries, so keep it free of side effects or guard them to once a frame
3919
- * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
3920
- * saved in update, as the platformer example does
3921
- * @param {number} tileData - the value of the tile at the position
3922
- * @param {Vector2} pos - the tile's bottom left corner in world space
3923
- * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
3924
- collideWithTile(tileData, pos) { return tileData > 0; }
3925
-
3926
- /** 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.
3927
- * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
3928
- * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
3929
- * bullet that should hit one thing checks its own destroyed flag first
3930
- * @param {EngineObject} object - the object to test against
3931
- * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
3932
- * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
3933
- */
3934
- collideWithObject(object, push) { return true; }
3935
-
3936
- /** Get this object's up vector
3937
- * @param {number} [scale] - length of the vector
3938
- * @return {Vector2} */
3939
- getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
3940
-
3941
- /** Get this object's right vector
3942
- * @param {number} [scale] - length of the vector
3943
- * @return {Vector2} */
3944
- getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
3945
-
3946
- /** How long since the object was created
3947
- * @return {number} */
3948
- getAliveTime() { return time - this.spawnTime; }
3350
+ if (count > 0)
3351
+ touchGamepadLeftStick = false;
3352
+ }
3949
3353
 
3950
- /** Get the speed of this object
3951
- * @return {number} */
3952
- getSpeed() { return this.velocity.length(); }
3354
+ /** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
3355
+ * @param {boolean} rightStick
3356
+ * @memberof Settings */
3357
+ function setTouchGamepadRightStick(rightStick)
3358
+ {
3359
+ touchGamepadRightStick = rightStick;
3360
+ if (rightStick)
3361
+ touchGamepadButtonCount = 0;
3362
+ }
3953
3363
 
3954
- /** Apply acceleration to this object (adjust velocity, not affected by mass)
3955
- * - Does nothing on a static object (mass 0), set its velocity instead
3956
- * @param {Vector2} acceleration */
3957
- applyAcceleration(acceleration)
3958
- { if (this.mass) this.velocity = this.velocity.add(acceleration); }
3364
+ /** Set if touch gamepad should be analog stick or 8 way dpad
3365
+ * @param {boolean} analog
3366
+ * @memberof Settings */
3367
+ function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
3959
3368
 
3960
- /** Apply angular acceleration to this object
3961
- * - Does nothing on a static object (mass 0), set its angleVelocity instead
3962
- * @param {number} acceleration */
3963
- applyAngularAcceleration(acceleration)
3964
- { if (this.mass) this.angleVelocity += acceleration; }
3369
+ /** Set if touch gamepad directional controls should float to where you press
3370
+ * @param {boolean} floating
3371
+ * @memberof Settings */
3372
+ function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
3965
3373
 
3966
- /** Apply force to this object (adjust velocity, affected by mass)
3967
- * - Does nothing on a static object (mass 0), set its velocity instead
3968
- * @param {Vector2} force */
3969
- applyForce(force)
3970
- { if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
3374
+ /** Set size of virtual gamepad for touch devices in viewport CSS pixels
3375
+ * @param {number} size
3376
+ * @memberof Settings */
3377
+ function setTouchGamepadSize(size) { touchGamepadSize = size; }
3971
3378
 
3972
- /** Get the direction of the mirror
3973
- * @return {number} -1 if this.mirror is true, or 1 if not mirrored */
3974
- getMirrorSign() { return this.mirror ? -1 : 1; }
3379
+ /** Set transparency of touch gamepad overlay
3380
+ * @param {number} alpha
3381
+ * @memberof Settings */
3382
+ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
3975
3383
 
3976
- /** Attaches a child to this with a local transform, returns child for chaining
3977
- * @param {EngineObject} child
3978
- * @param {Vector2} [localPos=vec2()]
3979
- * @param {number} [localAngle]
3980
- * @return {EngineObject} The child object added */
3981
- addChild(child, localPos=vec2(), localAngle=0)
3982
- {
3983
- false&&ASSERT(!this.destroyed, 'cannot add child to destroyed object');
3984
- if (this.destroyed) return child;
3985
- false&&ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
3986
- false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3987
- false&&ASSERT(!child.destroyed, 'cannot add a destroyed child');
3988
- for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
3989
- false&&ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
3990
- this.children.push(child);
3991
- child.parent = this;
3992
- child.localPos = localPos.copy();
3993
- child.localAngle = localAngle;
3994
- child.updateTransforms();
3995
- return child;
3996
- }
3384
+ /** Set how long to display the touch gamepad on screen in seconds, set to 0 to always display
3385
+ * @param {number} time
3386
+ * @memberof Settings */
3387
+ function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
3997
3388
 
3998
- /** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
3999
- * where addChild takes one; a child of something else is moved over, returns child for chaining
4000
- * @param {EngineObject} child
4001
- * @return {EngineObject} The child object attached */
4002
- attach(child)
4003
- {
4004
- false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
4005
- false&&ASSERT(child !== this, 'cannot attach self');
4006
- child.parent?.removeChild(child);
4007
- // the local values that updateTransforms turns back into the child's current pos and angle
4008
- const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
4009
- local.x *= mirror;
4010
- return this.addChild(child, local, mirror * (child.angle - this.angle));
4011
- }
3389
+ /** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
3390
+ * @param {number} ms
3391
+ * @memberof Settings */
3392
+ function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
4012
3393
 
4013
- /** Removes a child from this one, it stays where it is in the world
4014
- * @param {EngineObject} child */
4015
- removeChild(child)
4016
- {
4017
- false&&ASSERT(child.parent === this && this.children.includes(child));
4018
- const i = this.children.indexOf(child);
4019
- if (i < 0) return; // not a child of this one, release has no assert
4020
- this.children.splice(i, 1);
4021
- child.parent = child.localPos = undefined;
4022
- }
3394
+ /** Set to allow vibration hardware if it exists
3395
+ * @param {boolean} enable
3396
+ * @memberof Settings */
3397
+ function setVibrateEnable(enable) { vibrateEnable = enable; }
4023
3398
 
4024
- /** Check if this object's box overlaps another object's box
4025
- * @param {EngineObject} object
4026
- * @return {boolean} */
4027
- isOverlappingObject(object)
4028
- { return this.isOverlapping(object.pos, object.size); }
3399
+ /** Set if audio is enabled, false turns all sound off
3400
+ * @param {boolean} enable
3401
+ * @memberof Settings */
3402
+ function setSoundEnable(enable) { soundEnable = enable; }
4029
3403
 
4030
- /** Check if overlapping a point or aligned bounding box
4031
- * @param {Vector2} pos - Center of box
4032
- * @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
4033
- * @return {boolean} */
4034
- isOverlapping(pos, size=vec2())
4035
- { return isOverlapping(this.pos, this.size, pos, size); }
3404
+ /** Set volume scale to apply to all sound, music and speech
3405
+ * @param {number} volume
3406
+ * @memberof Settings */
3407
+ function setSoundVolume(volume)
3408
+ {
3409
+ soundVolume = volume;
3410
+ audioUpdateVolume(); // update gain immediately, sound off or not
3411
+ }
4036
3412
 
4037
- /** Set how this object collides
4038
- * @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
4039
- * @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
4040
- * @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
4041
- * @param {boolean} [collideRaycast] - Does it collide with raycasts? */
4042
- setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
4043
- {
4044
- false&&ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
3413
+ /** Set default range where sound no longer plays
3414
+ * @param {number} range
3415
+ * @memberof Settings */
3416
+ function setSoundDefaultRange(range) { soundDefaultRange = range; }
4045
3417
 
4046
- this.collideSolidObjects = collideSolidObjects;
4047
- this.isSolid = isSolid;
4048
- this.collideLevel = collideLevel;
4049
- this.collideRaycast = collideRaycast;
4050
- }
3418
+ /** Set default range percent to start tapering off sound
3419
+ * @param {number} taper
3420
+ * @memberof Settings */
3421
+ function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
4051
3422
 
4052
- /** @deprecated since 1.20, use collideLevel
4053
- * @type {boolean} */
4054
- get collideTiles() { return this.collideLevel; }
4055
- set collideTiles(collide) { this.collideLevel = collide; }
3423
+ /** Set if all sound pauses while the page is hidden
3424
+ * @param {boolean} pause
3425
+ * @memberof Settings */
3426
+ function setSoundPauseWhenHidden(pause) { soundPauseWhenHidden = pause; }
4056
3427
 
4057
- /** Returns string containing info about this object for debugging
4058
- * @return {string} */
4059
- toString()
4060
- {
4061
- let text = 'type = ' + this.constructor.name;
4062
- if (this.pos.x || this.pos.y)
4063
- text += '\npos = ' + this.pos;
4064
- if (this.velocity.x || this.velocity.y)
4065
- text += '\nvelocity = ' + this.velocity;
4066
- if (this.size.x || this.size.y)
4067
- text += '\nsize = ' + this.size;
4068
- if (this.angle)
4069
- text += '\nangle = ' + this.angle.toFixed(3);
4070
- text += '\ncolor = ' + this.color;
4071
- return text;
4072
- }
3428
+ /** Set if watermark with FPS should be shown
3429
+ * @param {boolean} show
3430
+ * @memberof Debug */
3431
+ function setDebugWatermark(show) { debugWatermark = show; }
4073
3432
 
4074
- /** Render debug info for this object */
4075
- renderDebugInfo()
4076
- {
4077
- if (!debug) return;
3433
+ /** Set key code used to toggle debug mode, Esc by default
3434
+ * @param {string} key
3435
+ * @memberof Debug */
3436
+ function setDebugKey(key) { debugKey = key; }
4078
3437
 
4079
- // check if there is anything to show
4080
- const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
4081
- if (!hasPhysics && !this.parent) return;
3438
+ /** Set if the debug keys work while the overlay is closed, the number keys and the +/- time keys
3439
+ * @param {boolean} [enable]
3440
+ * @memberof Debug */
3441
+ function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; }
4082
3442
 
4083
- // show object info for debugging
4084
- const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
4085
- const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
4086
- debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
4087
- if (this.parent)
4088
- debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
4089
- this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
4090
- }
3443
+ /** Set if the tweakables panel is shown, as 9 does while the debug overlay is open
3444
+ * @param {boolean} [show]
3445
+ * @memberof Debug */
3446
+ function setDebugTweakables(show=true) { debugTweakables = show; }
3447
+ /**
3448
+ * LittleJS Object System
3449
+ * - EngineObject is the base class for all game objects
3450
+ * - Handles automatic updating, rendering, physics, and collision
3451
+ * - Supports parent-child hierarchies with transform inheritance
3452
+ * - 2D physics with velocity, acceleration, damping, and gravity
3453
+ * - Collision system with tiles and other objects
3454
+ * - Renders sprites from tile sheets with color and rotation
3455
+ * - Objects sorted by renderOrder for layered rendering
3456
+ */
3457
+
3458
+ /**
3459
+ * LittleJS Object Base Object Class
3460
+ * - Top level object class used by the engine
3461
+ * - Automatically adds self to object list
3462
+ * - Will be updated and rendered each frame
3463
+ * - Renders as a sprite from a tilesheet by default
3464
+ * - Can have color and additive color applied
3465
+ * - 2D Physics and collision system
3466
+ * - Sorted by renderOrder
3467
+ * - Objects can have children attached
3468
+ * - Parents are updated before children, and set child transform
3469
+ * - Call destroy() to get rid of objects
3470
+ *
3471
+ * The physics system used by objects is simple and fast with some caveats...
3472
+ * - Collision uses the axis aligned size, the object's rotation angle is only for rendering
3473
+ * - Objects are guaranteed to not intersect tile collision from physics
3474
+ * - If an object starts or is moved inside tile collision, it will not collide with that tile
3475
+ * - Collision for objects can be set to be solid to block other objects
3476
+ * - Objects may get pushed into overlapping other solid objects, if so they will push away
3477
+ * - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
3478
+ * - Solid objects are more performance intensive and should be used sparingly
3479
+ * @memberof Engine
3480
+ * @example
3481
+ * // create an engine object, normally you would first extend the class with your own
3482
+ * const pos = vec2(2,3);
3483
+ * const object = new EngineObject(pos);
3484
+ */
3485
+ class EngineObject
3486
+ {
3487
+ /** Create an engine object and adds it to the list of objects
3488
+ * @param {Vector2} [pos=vec2()] - World space position of the object
3489
+ * @param {Vector2} [size=vec2(1)] - World space size of the object
3490
+ * @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
3491
+ * @param {number} [angle] - Angle the object is rotated by
3492
+ * @param {Color} [color=WHITE] - Color to apply to tile when rendered
3493
+ * @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
3494
+ */
3495
+ constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
3496
+ {
3497
+ // check passed in params
3498
+ false&&ASSERT(isVector2(pos), 'object pos must be a vec2');
3499
+ false&&ASSERT(isVector2(size), 'object size must be a vec2');
3500
+ false&&ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
3501
+ false&&ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
3502
+ false&&ASSERT(isColor(color), 'object color should be a valid rgba color');
3503
+ false&&ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
3504
+
3505
+ /** @property {Vector2} - World space position of the object */
3506
+ this.pos = pos.copy();
3507
+ /** @property {Vector2} - World space width and height of the object */
3508
+ this.size = size.copy();
3509
+ /** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
3510
+ * @type {Vector2|undefined} */
3511
+ this.drawSize = undefined;
3512
+ /** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
3513
+ * @type {TileInfo|undefined} */
3514
+ this.tileInfo = tileInfo;
3515
+ /** @property {number} - Angle to rotate the object */
3516
+ this.angle = angle;
3517
+ /** @property {Color} - Color to apply when rendered */
3518
+ this.color = color.copy();
3519
+ /** @property {Color|undefined} - Additive color to apply when rendered
3520
+ * @type {Color|undefined} */
3521
+ this.additiveColor = undefined;
3522
+ /** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
3523
+ * @type {Shader|undefined} */
3524
+ this.shader = undefined;
3525
+ /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
3526
+ this.castShadow = true;
3527
+ /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
3528
+ * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
3529
+ * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
3530
+ * too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
3531
+ this.emissive = 0;
3532
+ /** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
3533
+ this.mirror = false;
3534
+ /** @property {boolean} - Has object been destroyed? */
3535
+ this.destroyed = false;
3536
+ this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
3537
+
3538
+ // physical properties
3539
+ /** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
3540
+ * collide on its own, the moving ones collide with it */
3541
+ this.mass = objectDefaultMass;
3542
+ /** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
3543
+ this.damping = objectDefaultDamping;
3544
+ /** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
3545
+ this.angleDamping = objectDefaultAngleDamping;
3546
+ /** @property {number} - How bouncy the object is when colliding (0-1) */
3547
+ this.restitution = objectDefaultRestitution;
3548
+ /** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
3549
+ * once, the more slippery of the object and its ground is used */
3550
+ this.friction = objectDefaultFriction;
3551
+ /** @property {number} - How much to scale gravity by for this object */
3552
+ this.gravityScale = 1;
3553
+ /** @property {number} - Objects are sorted by render order */
3554
+ this.renderOrder = renderOrder;
3555
+ /** @property {Vector2} - Velocity of the object, in world units per frame */
3556
+ this.velocity = vec2();
3557
+ /** @property {number} - Angular velocity of the object, in radians per frame */
3558
+ this.angleVelocity = 0;
3559
+ /** @property {number} - Track when object was created */
3560
+ this.spawnTime = time;
3561
+ /** @property {Array<EngineObject>} - List of children of this object
3562
+ * @type {Array<EngineObject>} */
3563
+ this.children = [];
3564
+ /** @property {boolean} - Limit object speed along x and y axis */
3565
+ this.clampSpeed = true;
3566
+ /** @property {EngineObject|undefined} - Object we are standing on, if any
3567
+ * @type {EngineObject|undefined} */
3568
+ this.groundObject = undefined;
3569
+
3570
+ // parent child system
3571
+ /** @property {EngineObject|undefined} - Parent of object if in local space
3572
+ * @type {EngineObject|undefined} */
3573
+ this.parent = undefined;
3574
+ /** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
3575
+ * @type {Vector2|undefined} */
3576
+ this.localPos = undefined;
3577
+ /** @property {number} - Local angle if child */
3578
+ this.localAngle = 0;
3579
+
3580
+ // collision flags
3581
+ /** @property {boolean} - Object collides with the level, its tile collision layers
3582
+ * @type {boolean} */
3583
+ this.collideLevel = false;
3584
+ /** @property {boolean} - Object collides with solid objects */
3585
+ this.collideSolidObjects = false;
3586
+ /** @property {boolean} - Object collides with and blocks other objects */
3587
+ this.isSolid = false;
3588
+ /** @property {boolean} - Object collides with raycasts */
3589
+ this.collideRaycast = false;
3590
+
3591
+ /** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
3592
+ * - Calling destroy on it still destroys it, and its children go with it either way */
3593
+ this.persistent = false;
3594
+
3595
+ // add to list of objects
3596
+ engineObjects.push(this);
3597
+ }
3598
+
3599
+ /** Update the object transform, called automatically by engine even when paused
3600
+ * @param {boolean} [updateChildren] - Also update the children's transforms */
3601
+ updateTransforms(updateChildren=true)
3602
+ {
3603
+ const parent = this.parent;
3604
+ if (parent)
3605
+ {
3606
+ // compose with parent transform inline to avoid intermediate vector allocs
3607
+ const mirror = parent.getMirrorSign();
3608
+ const lp = this.localPos, pp = parent.pos;
3609
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
3610
+ if (pa)
3611
+ {
3612
+ const c = cos(-pa), s = sin(-pa);
3613
+ this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3614
+ }
3615
+ else
3616
+ this.pos.set(lx + pp.x, ly + pp.y);
3617
+ this.angle = mirror*this.localAngle + pa;
3618
+ }
3619
+
3620
+ // update children
3621
+ if (updateChildren)
3622
+ for (const child of this.children)
3623
+ child.updateTransforms();
3624
+ }
3625
+
3626
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
3627
+ updatePhysics()
3628
+ {
3629
+ // child objects do not have physics
3630
+ false&&ASSERT(!this.parent);
3631
+
3632
+ // bail if a collision callback destroyed us mid-frame
3633
+ if (this.destroyed) return;
3634
+
3635
+ // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
3636
+ // has a mass, which tile collision needs; anything else moves as fast as it is told
3637
+ if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
3638
+ {
3639
+ this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
3640
+ this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
3641
+ }
3642
+
3643
+ // physics sanity checks
3644
+ false&&ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
3645
+ false&&ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
3646
+
3647
+ // apply physics; only the solver needs where the object was, so only then is it copied
3648
+ const solve = enablePhysicsSolver && this.mass;
3649
+ const oldPos = solve ? this.pos.copy() : undefined;
3650
+ this.velocity.x *= this.damping;
3651
+ this.velocity.y *= this.damping;
3652
+ if (this.mass)
3653
+ {
3654
+ // apply gravity only if it has mass
3655
+ this.velocity.x += gravity.x * this.gravityScale;
3656
+ this.velocity.y += gravity.y * this.gravityScale;
3657
+ }
3658
+ this.pos.x += this.velocity.x;
3659
+ this.pos.y += this.velocity.y;
3660
+ this.angle += this.angleVelocity *= this.angleDamping;
3661
+
3662
+ // don't do collision for static objects or if solver disabled
3663
+ if (!solve) return;
3664
+
3665
+ // which way is down for this object, a negative gravityScale falls up and lands on ceilings
3666
+ const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
3667
+ const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
3668
+ if (this.groundObject)
3669
+ {
3670
+ // apply friction in local space of ground object
3671
+ const friction = max(this.friction, this.groundObject.friction);
3672
+ const groundSpeed = this.groundObject.velocity.x;
3673
+ this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
3674
+ this.groundObject = undefined;
3675
+ }
3676
+
3677
+ // an object with no width or height has no box to push out of, or to be pushed out of
3678
+ if (this.collideSolidObjects && this.size.x && this.size.y)
3679
+ {
3680
+ // check collisions against solid objects
3681
+ const epsilon = .001; // necessary to push slightly outside of the collision
3682
+ for (const o of engineObjectsCollideStaticLast)
3683
+ {
3684
+ // skip destroyed, child objects, self collision, or objects with no box
3685
+ if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
3686
+
3687
+ // non solid objects don't collide with each other
3688
+ if (!this.isSolid && !o.isSolid) continue;
3689
+
3690
+ // check collision
3691
+ if (!this.isOverlappingObject(o)) continue;
3692
+
3693
+ // each moving object checks its own contacts, so a pair the other one already asked about this frame
3694
+ // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
3695
+ // to resolve, pushed back together since, is resolved again
3696
+ const answer = engineObjectsCollidePairAnswer(o, this);
3697
+ if (answer === false) continue;
3698
+ if (!answer)
3699
+ {
3700
+ // notify objects of collision and check if should be resolved
3701
+ const collide1 = this.collideWithObject(o);
3702
+ const collide2 = o.collideWithObject(this);
3703
+ if (!collide1 || !collide2)
3704
+ {
3705
+ engineObjectsCollidePairAdd(this, o);
3706
+ continue;
3707
+ }
3708
+ }
3709
+
3710
+ const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
3711
+ if (wasOverlapping && (!o.mass || o.groundObject))
3712
+ {
3713
+ // a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
3714
+ // at once and carries it along, it would only drift out slowly and the solid would pass through;
3715
+ // an object standing on something counts as fixed too, so a stack on an elevator rides together;
3716
+ // it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
3717
+ const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
3718
+ if (push)
3719
+ {
3720
+ this.pos.x += push.x + sign(push.x) * epsilon;
3721
+ this.pos.y += push.y + sign(push.y) * epsilon;
3722
+ const restitution = max(this.restitution, o.restitution);
3723
+ if (push.x)
3724
+ {
3725
+ const v = this.velocity.x - o.velocity.x;
3726
+ if (v * push.x < 0) // moving into it
3727
+ this.velocity.x = o.velocity.x - v * restitution;
3728
+ }
3729
+ else
3730
+ {
3731
+ const v = this.velocity.y - o.velocity.y;
3732
+ if (v * push.y < 0) // moving into it
3733
+ this.velocity.y = o.velocity.y - v * restitution;
3734
+ if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
3735
+ this.groundObject = o;
3736
+ }
3737
+ }
3738
+ engineObjectsCollidePairAdd(this, o);
3739
+
3740
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3741
+ continue;
3742
+ }
3743
+ if (wasOverlapping)
3744
+ {
3745
+ // if already was touching, try to push away
3746
+ const deltaPos = oldPos.subtract(o.pos);
3747
+ const length = deltaPos.length();
3748
+ const pushAwayAccel = .001;
3749
+ const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
3750
+ this.velocity = this.velocity.add(velocity);
3751
+ if (o.mass) // push away other object if not fixed
3752
+ o.velocity = o.velocity.subtract(velocity);
3753
+ engineObjectsCollidePairAdd(this, o);
3754
+
3755
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
3756
+ continue;
3757
+ }
3758
+
3759
+ // check for collision
3760
+ const sizeBoth = this.size.add(o.size);
3761
+ // prefer to push up if small delta, up away from this object's own gravity
3762
+ const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
3763
+ const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
3764
+ const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
3765
+ const restitution = max(this.restitution, o.restitution);
3766
+
3767
+ if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
3768
+ {
3769
+ // push outside object collision
3770
+ this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
3771
+ if ((o.groundObject && wasFalling) || !o.mass)
3772
+ {
3773
+ // set ground object if landed on something
3774
+ if (wasFalling)
3775
+ this.groundObject = o;
3776
+
3777
+ // bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
3778
+ // platform moving down instead of landing on it again every few frames
3779
+ this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
3780
+ }
3781
+ else // o has mass here, the other branch already handled the massless case
3782
+ {
3783
+ // inelastic collision
3784
+ const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
3785
+
3786
+ // elastic collision
3787
+ const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
3788
+ + o.velocity.y * 2 * o.mass / (this.mass + o.mass);
3789
+ const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
3790
+ + this.velocity.y * 2 * this.mass / (this.mass + o.mass);
3791
+
3792
+ // lerp between elastic or inelastic based on restitution
3793
+ this.velocity.y = lerp(inelastic, elastic0, restitution);
3794
+ o.velocity.y = lerp(inelastic, elastic1, restitution);
3795
+ }
3796
+ }
3797
+ if (!smallStepUp && isBlockedX) // resolve x collision
3798
+ {
3799
+ // push outside collision
3800
+ this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
3801
+ if (o.mass)
3802
+ {
3803
+ // inelastic collision
3804
+ const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
3805
+
3806
+ // elastic collision
3807
+ const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
3808
+ + o.velocity.x * 2 * o.mass / (this.mass + o.mass);
3809
+ const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
3810
+ + this.velocity.x * 2 * this.mass / (this.mass + o.mass);
3811
+
3812
+ // lerp between elastic or inelastic based on restitution
3813
+ this.velocity.x = lerp(inelastic, elastic0, restitution);
3814
+ o.velocity.x = lerp(inelastic, elastic1, restitution);
3815
+ }
3816
+ else // bounce if other object is fixed, relative to it as a landing is
3817
+ this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
3818
+ }
3819
+ engineObjectsCollidePairAdd(this, o, true);
3820
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
3821
+ }
3822
+ }
3823
+ if (this.collideLevel)
3824
+ {
3825
+ // check collision against tiles
3826
+ const hitLayer = tileCollisionTest(this.pos, this.size, this);
3827
+ if (hitLayer)
3828
+ {
3829
+ // if already was stuck in collision, don't do anything
3830
+ // this should not happen unless something starts in collision
3831
+ if (!tileCollisionTest(oldPos, this.size, this))
3832
+ {
3833
+ // test which side we bounced off (or both if a corner)
3834
+ const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
3835
+ const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
3836
+ const restitution = max(this.restitution, hitLayer.restitution);
3837
+ if (isBlockedX)
3838
+ {
3839
+ // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
3840
+ // down off a ceiling ledge when gravity points up; zero gravity counts as down
3841
+ const epsilon = 1e-3;
3842
+ const maxMove = .1;
3843
+ const y = gravityY > 0 ?
3844
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
3845
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
3846
+ if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
3847
+ {
3848
+ this.pos.y = y;
3849
+ debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
3850
+ return;
3851
+ }
3852
+
3853
+ // move to previous X position and bounce
3854
+ this.pos.x = oldPos.x;
3855
+ this.velocity.x *= -restitution;
3856
+ }
3857
+ if (isBlockedY || !isBlockedX)
3858
+ {
3859
+ if (wasFalling)
3860
+ {
3861
+ // adjust position to slightly away from nearest tile
3862
+ // this prevents gap between object and ground
3863
+ const epsilon = .0001;
3864
+ const offset = this.size.y/2 + epsilon;
3865
+ // rounded in the layer's space as its collision test is, or a bottom a hair below a
3866
+ // grid line would round to the row under it, inside the floor, and fall through
3867
+ const layerY = hitLayer.pos.y;
3868
+ this.pos.y = layerY + (gravityY < 0 ?
3869
+ floor(oldPos.y - layerY - this.size.y/2) + offset :
3870
+ ceil( oldPos.y - layerY + this.size.y/2) - offset);
3871
+
3872
+ // set ground object for tile collision
3873
+ this.groundObject = hitLayer;
3874
+ }
3875
+ else
3876
+ {
3877
+ // move to previous Y position
3878
+ this.pos.y = oldPos.y;
3879
+ this.groundObject = undefined;
3880
+ }
3881
+ // bounce velocity
3882
+ this.velocity.y *= -restitution;
3883
+ }
3884
+ debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
3885
+ }
3886
+ }
3887
+ }
3888
+ }
3889
+
3890
+ /** Update the object, called automatically by engine once each frame. Does nothing by default. */
3891
+ update() {}
3892
+
3893
+ /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
3894
+ render()
3895
+ {
3896
+ // default object render
3897
+ drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
3898
+ }
3899
+
3900
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3901
+ renderLight() {}
3902
+
3903
+ /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
3904
+ * Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
3905
+ * screen space WebGL draws in render() are skipped during the pass */
3906
+ renderShadow() { this.render(); }
3907
+
3908
+ /** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
3909
+ * 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
3910
+ * glows; override to glow a part, like a robot's eyes */
3911
+ renderEmissive() { this.render(); }
3912
+
3913
+ /** Destroy this object, destroy its children, detach its parent, and mark it for removal
3914
+ * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
3915
+ destroy(immediate=false)
3916
+ {
3917
+ if (this.destroyed) return;
3918
+
3919
+ // disconnect from parent and destroy children
3920
+ this.destroyed = true;
3921
+ this.parent?.removeChild(this);
3922
+ for (const child of this.children)
3923
+ {
3924
+ child.parent = undefined;
3925
+ child.destroy(immediate);
3926
+ }
3927
+ }
3928
+
3929
+ /** Convert from local space to world space
3930
+ * @param {Vector2} pos - local space point */
3931
+ localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
3932
+
3933
+ /** Convert from world space to local space
3934
+ * @param {Vector2} pos - world space point */
3935
+ worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
3936
+
3937
+ /** Convert from local space to world space for a vector (rotation only)
3938
+ * @param {Vector2} vec - local space vector */
3939
+ localToWorldVector(vec) { return vec.rotate(this.angle); }
3940
+
3941
+ /** Convert from world space to local space for a vector (rotation only)
3942
+ * @param {Vector2} vec - world space vector */
3943
+ worldToLocalVector(vec) { return vec.rotate(-this.angle); }
3944
+
3945
+ /** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
3946
+ * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
3947
+ * positions it only tries, so keep it free of side effects or guard them to once a frame
3948
+ * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
3949
+ * saved in update, as the platformer example does
3950
+ * @param {number} tileData - the value of the tile at the position
3951
+ * @param {Vector2} pos - the tile's bottom left corner in world space
3952
+ * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
3953
+ collideWithTile(tileData, pos) { return tileData > 0; }
3954
+
3955
+ /** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
3956
+ * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
3957
+ * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
3958
+ * bullet that should hit one thing checks its own destroyed flag first
3959
+ * @param {EngineObject} object - the object to test against
3960
+ * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
3961
+ * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
3962
+ */
3963
+ collideWithObject(object, push) { return true; }
3964
+
3965
+ /** Get this object's up vector
3966
+ * @param {number} [scale] - length of the vector
3967
+ * @return {Vector2} */
3968
+ getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
3969
+
3970
+ /** Get this object's right vector
3971
+ * @param {number} [scale] - length of the vector
3972
+ * @return {Vector2} */
3973
+ getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
3974
+
3975
+ /** How long since the object was created
3976
+ * @return {number} */
3977
+ getAliveTime() { return time - this.spawnTime; }
3978
+
3979
+ /** Get the speed of this object
3980
+ * @return {number} */
3981
+ getSpeed() { return this.velocity.length(); }
3982
+
3983
+ /** Apply acceleration to this object (adjust velocity, not affected by mass)
3984
+ * - Does nothing on a static object (mass 0), set its velocity instead
3985
+ * @param {Vector2} acceleration */
3986
+ applyAcceleration(acceleration)
3987
+ { if (this.mass) this.velocity = this.velocity.add(acceleration); }
3988
+
3989
+ /** Apply angular acceleration to this object
3990
+ * - Does nothing on a static object (mass 0), set its angleVelocity instead
3991
+ * @param {number} acceleration */
3992
+ applyAngularAcceleration(acceleration)
3993
+ { if (this.mass) this.angleVelocity += acceleration; }
3994
+
3995
+ /** Apply force to this object (adjust velocity, affected by mass)
3996
+ * - Does nothing on a static object (mass 0), set its velocity instead
3997
+ * @param {Vector2} force */
3998
+ applyForce(force)
3999
+ { if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
4000
+
4001
+ /** Get the direction of the mirror
4002
+ * @return {number} -1 if this.mirror is true, or 1 if not mirrored */
4003
+ getMirrorSign() { return this.mirror ? -1 : 1; }
4004
+
4005
+ /** Attaches a child to this with a local transform, returns child for chaining
4006
+ * @param {EngineObject} child
4007
+ * @param {Vector2} [localPos=vec2()]
4008
+ * @param {number} [localAngle]
4009
+ * @return {EngineObject} The child object added */
4010
+ addChild(child, localPos=vec2(), localAngle=0)
4011
+ {
4012
+ false&&ASSERT(!this.destroyed, 'cannot add child to destroyed object');
4013
+ if (this.destroyed) return child;
4014
+ false&&ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
4015
+ false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
4016
+ false&&ASSERT(!child.destroyed, 'cannot add a destroyed child');
4017
+ for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
4018
+ false&&ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
4019
+ this.children.push(child);
4020
+ child.parent = this;
4021
+ child.localPos = localPos.copy();
4022
+ child.localAngle = localAngle;
4023
+ child.updateTransforms();
4024
+ return child;
4025
+ }
4026
+
4027
+ /** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
4028
+ * where addChild takes one; a child of something else is moved over, returns child for chaining
4029
+ * @param {EngineObject} child
4030
+ * @return {EngineObject} The child object attached */
4031
+ attach(child)
4032
+ {
4033
+ false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
4034
+ false&&ASSERT(child !== this, 'cannot attach self');
4035
+ child.parent?.removeChild(child);
4036
+ // the local values that updateTransforms turns back into the child's current pos and angle
4037
+ const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
4038
+ local.x *= mirror;
4039
+ return this.addChild(child, local, mirror * (child.angle - this.angle));
4040
+ }
4041
+
4042
+ /** Removes a child from this one, it stays where it is in the world
4043
+ * @param {EngineObject} child */
4044
+ removeChild(child)
4045
+ {
4046
+ false&&ASSERT(child.parent === this && this.children.includes(child));
4047
+ const i = this.children.indexOf(child);
4048
+ if (i < 0) return; // not a child of this one, release has no assert
4049
+ this.children.splice(i, 1);
4050
+ child.parent = child.localPos = undefined;
4051
+ }
4052
+
4053
+ /** Check if this object's box overlaps another object's box
4054
+ * @param {EngineObject} object
4055
+ * @return {boolean} */
4056
+ isOverlappingObject(object)
4057
+ { return this.isOverlapping(object.pos, object.size); }
4058
+
4059
+ /** Check if overlapping a point or aligned bounding box
4060
+ * @param {Vector2} pos - Center of box
4061
+ * @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
4062
+ * @return {boolean} */
4063
+ isOverlapping(pos, size=vec2())
4064
+ { return isOverlapping(this.pos, this.size, pos, size); }
4065
+
4066
+ /** Set how this object collides
4067
+ * @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
4068
+ * @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
4069
+ * @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
4070
+ * @param {boolean} [collideRaycast] - Does it collide with raycasts? */
4071
+ setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
4072
+ {
4073
+ false&&ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
4074
+
4075
+ this.collideSolidObjects = collideSolidObjects;
4076
+ this.isSolid = isSolid;
4077
+ this.collideLevel = collideLevel;
4078
+ this.collideRaycast = collideRaycast;
4079
+ }
4080
+
4081
+ /** @deprecated since 1.20, use collideLevel
4082
+ * @type {boolean} */
4083
+ get collideTiles() { return this.collideLevel; }
4084
+ set collideTiles(collide) { this.collideLevel = collide; }
4085
+
4086
+ /** Returns string containing info about this object for debugging
4087
+ * @return {string} */
4088
+ toString()
4089
+ {
4090
+ let text = 'type = ' + this.constructor.name;
4091
+ if (this.pos.x || this.pos.y)
4092
+ text += '\npos = ' + this.pos;
4093
+ if (this.velocity.x || this.velocity.y)
4094
+ text += '\nvelocity = ' + this.velocity;
4095
+ if (this.size.x || this.size.y)
4096
+ text += '\nsize = ' + this.size;
4097
+ if (this.angle)
4098
+ text += '\nangle = ' + this.angle.toFixed(3);
4099
+ text += '\ncolor = ' + this.color;
4100
+ return text;
4101
+ }
4102
+
4103
+ /** Render debug info for this object */
4104
+ renderDebugInfo()
4105
+ {
4106
+ if (!debug) return;
4107
+
4108
+ // check if there is anything to show
4109
+ const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
4110
+ if (!hasPhysics && !this.parent) return;
4111
+
4112
+ // show object info for debugging
4113
+ const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
4114
+ const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
4115
+ debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
4116
+ if (this.parent)
4117
+ debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
4118
+ this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
4119
+ }
4091
4120
  }
4092
4121
  /**
4093
4122
  * LittleJS Drawing System
@@ -4585,17 +4614,29 @@ class SpriteAnimator
4585
4614
  * @return {SpriteAnimation} */
4586
4615
  get clip() { return this.clips[this.name]; }
4587
4616
 
4617
+ /** The clip showing now, once a clip that has ended has called its onEnd: the callback may switch clips, and a
4618
+ * read gives the clip it switched to, not the one that ended
4619
+ * @return {SpriteAnimation}
4620
+ * @ignore */
4621
+ get settledClip()
4622
+ {
4623
+ // each clip's own isDone calls its onEnd once; a few switches in a row at most
4624
+ for (let i = 4, clip; i-- && clip !== this.clip;)
4625
+ (clip = this.clip).isDone;
4626
+ return this.clip;
4627
+ }
4628
+
4588
4629
  /** The tile of the frame showing now
4589
4630
  * @return {TileInfo} */
4590
- get tileInfo() { return this.clip.tileInfo; }
4631
+ get tileInfo() { return this.settledClip.tileInfo; }
4591
4632
 
4592
4633
  /** The frame of the clip showing now
4593
4634
  * @return {number} */
4594
- get frame() { return this.clip.frame; }
4635
+ get frame() { return this.settledClip.frame; }
4595
4636
 
4596
4637
  /** True once the clip showing now is a play that has ended
4597
4638
  * @return {boolean} */
4598
- get isDone() { return this.clip.isDone; }
4639
+ get isDone() { return this.settledClip.isDone; }
4599
4640
  }
4600
4641
 
4601
4642
  ///////////////////////////////////////////////////////////////////////////////
@@ -5020,7 +5061,7 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
5020
5061
  // build regular polygon points
5021
5062
  const points = [];
5022
5063
  const sizeX = size.x/2, sizeY = size.y/2;
5023
- for (let i=sides; i--;)
5064
+ for (let i=sides; i-- > 0;) // a count that is not whole, or below zero, still ends
5024
5065
  {
5025
5066
  const a = (i/sides)*PI*2;
5026
5067
  points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
@@ -5131,7 +5172,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
5131
5172
  if (!ring)
5132
5173
  {
5133
5174
  const points = [];
5134
- for (let i=sides; i--;)
5175
+ for (let i=sides; i-- > 0;)
5135
5176
  {
5136
5177
  const a = (i/sides)*PI*2;
5137
5178
  points.push(vec2(sin(a), cos(a)));
@@ -5231,7 +5272,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
5231
5272
  const startA = (offset%sides)/sides*PI*2;
5232
5273
  const points = [rim(startA)];
5233
5274
  const colors = [outerInt];
5234
- for (let i=sides; i--;)
5275
+ for (let i=sides; i-- > 0;)
5235
5276
  {
5236
5277
  const a = ((i+offset)%sides)/sides*PI*2;
5237
5278
  points.push(pos);
@@ -5654,7 +5695,8 @@ function isOnScreen(pos, size=0)
5654
5695
  function setAdditiveBlendMode(additive=true)
5655
5696
  {
5656
5697
  glAdditive = additive;
5657
- drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5698
+ if (drawContext) // none headless
5699
+ drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5658
5700
  }
5659
5701
 
5660
5702
  /** Set the Shader that 2D draws use from now on, none for the engine's own
@@ -5777,9 +5819,12 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
5777
5819
  }
5778
5820
  else
5779
5821
  {
5780
- // copy to offscreen canvas
5781
- workReadCanvas.width = sWidth;
5782
- 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);
5783
5828
  workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
5784
5829
 
5785
5830
  // tint image using offscreen work context
@@ -6034,6 +6079,13 @@ let mouseDeltaScreen = vec2();
6034
6079
  * @memberof Input */
6035
6080
  let mouseWheel = 0;
6036
6081
 
6082
+ /** Touch pinch delta this frame, in the mouse wheel's units: two fingers moving apart are negative, as the wheel
6083
+ * turned up is, and moving together positive, 1 for every 50 pixels; added to mouseWheel while touchPinchWheel
6084
+ * is on, so a game that zooms with the wheel zooms with a pinch
6085
+ * @type {number}
6086
+ * @memberof Input */
6087
+ let touchPinch = 0;
6088
+
6037
6089
  /** True if mouse was inside the document window, set to false when mouse leaves
6038
6090
  * @type {boolean}
6039
6091
  * @memberof Input */
@@ -6122,6 +6174,7 @@ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearRe
6122
6174
  const inputWASDToArrow = {KeyW:'ArrowUp', KeyS:'ArrowDown', KeyA:'ArrowLeft', KeyD:'ArrowRight'};
6123
6175
  const inputArrowToWASD = {ArrowUp:'KeyW', ArrowDown:'KeyS', ArrowLeft:'KeyA', ArrowRight:'KeyD'};
6124
6176
  const inputKeysHeld = new Set; // the keys physically down, since an arrow's slot is shared with its alias
6177
+ let inputPinch; // the two fingers pinching and how far apart they were, undefined with no pinch
6125
6178
  let inputWasTouching = 0, inputTouchIdentifier; // the touch driving the mouse, cleared with the input so only a new touch presses
6126
6179
  let inputLastTouchTime = -1e9; // when a touch event last came, the mouse events a browser makes from a tap follow it
6127
6180
 
@@ -6164,6 +6217,7 @@ function inputClear()
6164
6217
  if (!i || inputData[i]) inputData[i] = [];
6165
6218
  inputKeysHeld.clear();
6166
6219
  inputWasTouching = 0;
6220
+ inputPinch = undefined;
6167
6221
  // release the touch gamepad, a finger still on it has to lift before it can take a control again
6168
6222
  touchGamepadPointerRole.clear();
6169
6223
  touchGamepadButtons.length = 0;
@@ -6691,19 +6745,52 @@ function inputInit()
6691
6745
  const pressTouch = !inputWasTouching && e.type == 'touchstart' &&
6692
6746
  [...e.changedTouches].find(t=> !isGamepadTouch(t));
6693
6747
  const touch = pressTouch || gameTouches[0];
6748
+ let pos = vec2(touch.clientX, touch.clientY);
6749
+
6750
+ // two fingers pinch: how far they moved together or apart is the touch screen's wheel, a
6751
+ // click for every 50 pixels, and the mouse is between them, the place to zoom about; with
6752
+ // touchPinchWheel off the pinch is only measured, and the first finger is the mouse as ever
6753
+ const pinching = touching > 1 && touchPinchWheel;
6754
+ if (touching > 1)
6755
+ {
6756
+ const [a, b] = gameTouches, key = a.identifier + ' ' + b.identifier;
6757
+ const distance = hypot(a.clientX - b.clientX, a.clientY - b.clientY);
6758
+ if (inputPinch && inputPinch.key === key)
6759
+ {
6760
+ const delta = (inputPinch.distance - distance) / 50;
6761
+ touchPinch += delta;
6762
+ if (touchPinchWheel)
6763
+ mouseWheel += delta;
6764
+ }
6765
+ inputPinch = {key, distance}; // other fingers start it over, with no jump
6766
+ if (pinching)
6767
+ pos = vec2((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
6768
+ }
6769
+ else
6770
+ inputPinch = undefined;
6694
6771
 
6695
6772
  // set event pos and pass it along
6696
- const pos = vec2(touch.clientX, touch.clientY);
6697
6773
  const mousePosScreenLast = mousePosScreen;
6698
6774
  mousePosScreen = mouseEventToScreen(pos);
6699
- if (inputWasTouching && touch.identifier === inputTouchIdentifier)
6775
+ if (pinching)
6776
+ {
6777
+ // a pinch is not a press or a drag, the finger that pressed lets go
6778
+ if (inputData[0][button] & 1)
6779
+ inputData[0][button] = inputData[0][button] & 2 | 4;
6780
+ }
6781
+ else if (inputWasTouching && touch.identifier === inputTouchIdentifier)
6700
6782
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
6701
6783
  else if (pressTouch)
6702
6784
  inputData[0][button] = 3;
6703
- inputTouchIdentifier = touch.identifier;
6785
+ // the finger left after a pinch moves the mouse from where it is, with no jump and no press
6786
+ inputTouchIdentifier = pinching ? undefined : touch.identifier;
6787
+ }
6788
+ else
6789
+ {
6790
+ inputPinch = undefined;
6791
+ if (inputWasTouching && (inputData[0][button] & 1))
6792
+ inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
6704
6793
  }
6705
- else if (inputWasTouching && (inputData[0][button] & 1))
6706
- inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
6707
6794
 
6708
6795
  // set was touching
6709
6796
  inputWasTouching = touching;
@@ -6962,7 +7049,7 @@ function inputUpdatePost()
6962
7049
  for (const deviceInputData of inputData)
6963
7050
  for (const i in deviceInputData)
6964
7051
  deviceInputData[i] &= 1;
6965
- mouseWheel = 0;
7052
+ mouseWheel = touchPinch = 0;
6966
7053
  mouseDelta = vec2();
6967
7054
  mouseDeltaScreen = vec2();
6968
7055
  }
@@ -7683,7 +7770,13 @@ class Sound
7683
7770
  */
7684
7771
  constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
7685
7772
  {
7686
- 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
+ }
7687
7780
  const rangeIsDefault = range === undefined;
7688
7781
  if (rangeIsDefault)
7689
7782
  range = soundDefaultRange;
@@ -7694,10 +7787,9 @@ class Sound
7694
7787
  false&&ASSERT(isNumber(range), 'range must be a number');
7695
7788
  false&&ASSERT(isNumber(taper), 'taper must be a number');
7696
7789
 
7697
- /** @property {number} - World space max range of sound */
7698
7790
  this.range = range;
7699
7791
  /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
7700
- * to render3D.soundDefaultRange instead, which is further */
7792
+ * to render3D.soundDefaultRange instead, which is further; setting range makes it false */
7701
7793
  this.rangeIsDefault = rangeIsDefault;
7702
7794
  /** @property {number} - At what percentage of range should it start tapering */
7703
7795
  this.taper = taper;
@@ -7753,6 +7845,15 @@ class Sound
7753
7845
  }
7754
7846
  }
7755
7847
 
7848
+ /** World space max range of sound, 0 for no limit; a range that is set is the sound's own, in 3D too
7849
+ * @type {number} */
7850
+ get range() { return this.rangeValue; }
7851
+ set range(range)
7852
+ {
7853
+ this.rangeValue = range;
7854
+ this.rangeIsDefault = false;
7855
+ }
7856
+
7756
7857
  /** Sample data for each channel
7757
7858
  * Sounds keep their samples in an audio buffer, so reading this rebuilds
7758
7859
  * the arrays from it and caches them. The copies are safe to hold onto,
@@ -8822,7 +8923,8 @@ function objectLayersMake(tileMapData, object)
8822
8923
  const name = object.type || object.class, type = objectLayersTypes.get(name);
8823
8924
  if (!type)
8824
8925
  {
8825
- debug && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
8926
+ // an object with no type, a shape or a text someone drew in Tiled, is not one a game makes
8927
+ debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
8826
8928
  return;
8827
8929
  }
8828
8930
  const {height=0, tilewidth=1, tileheight=1} = tileMapData;
@@ -10906,6 +11008,9 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
10906
11008
  glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
10907
11009
  }
10908
11010
 
11011
+ // the list and the vectors glDrawPointsTransform writes its points into
11012
+ const glPointsOut = [], glPointsPool = [];
11013
+
10909
11014
  /** Transform and add a polygon to the gl draw list
10910
11015
  * @param {Array<Vector2>} points - Array of Vector2 points
10911
11016
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -10918,15 +11023,21 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
10918
11023
  * @memberof WebGL */
10919
11024
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
10920
11025
  {
10921
- const pointsOut = [];
11026
+ // written into vectors kept for this, a circle would make one for each of its sides at every draw; they are
11027
+ // read into the batch before this returns
11028
+ const pointsOut = glPointsOut, pool = glPointsPool, count = points.length;
11029
+ pointsOut.length = count;
10922
11030
  const sa = sin(-angle);
10923
11031
  const ca = cos(-angle);
10924
- for (const p of points)
11032
+ for (let i = 0; i < count; ++i)
10925
11033
  {
10926
11034
  // transform the point
11035
+ const p = points[i], out = pool[i] ||= vec2();
10927
11036
  const px = p.x*sx;
10928
11037
  const py = p.y*sy;
10929
- pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
11038
+ out.x = x + ca*px - sa*py;
11039
+ out.y = y + sa*px + ca*py;
11040
+ pointsOut[i] = out;
10930
11041
  }
10931
11042
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
10932
11043
  glDrawPoints(drawPoints, rgba);
@@ -12361,8 +12472,11 @@ class PostProcessPlugin
12361
12472
  {
12362
12473
  // copy main canvas to work canvas at the backing store size,
12363
12474
  // mainCanvasSize is css pixels so it would lose resolution
12364
- workCanvas.width = mainCanvas.width;
12365
- workCanvas.height = mainCanvas.height;
12475
+ // sized again only when the canvas changed, setting a size remakes the canvas even to the same one
12476
+ if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height)
12477
+ workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height;
12478
+ else
12479
+ workContext.clearRect(0, 0, workCanvas.width, workCanvas.height);
12366
12480
  glCopyToContext(workContext);
12367
12481
  workContext.drawImage(mainCanvas, 0, 0);
12368
12482
  // clear the main canvas with clearRect, resizing it would also
@@ -13118,13 +13232,13 @@ class LightSystemPlugin
13118
13232
  // an automatic size follows the canvas, so reallocate the lightmap when
13119
13233
  // the canvas changed size, after the flush so the batch keeps its texture
13120
13234
  const size = lightSystem.textureSize;
13121
- if (lightSystem.textureSizeAuto &&
13122
- (size.x !== mainCanvasSize.x || size.y !== mainCanvasSize.y))
13235
+ const wantX = glClampTextureSize(mainCanvasSize.x), wantY = glClampTextureSize(mainCanvasSize.y);
13236
+ if (lightSystem.textureSizeAuto && (size.x !== wantX || size.y !== wantY))
13123
13237
  {
13124
- lightSystem.textureSize = mainCanvasSize.copy();
13238
+ lightSystem.textureSize = vec2(wantX, wantY);
13125
13239
  glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
13126
13240
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
13127
- mainCanvasSize.x, mainCanvasSize.y, 0,
13241
+ wantX, wantY, 0,
13128
13242
  glContext.RGBA, glContext.UNSIGNED_BYTE, null);
13129
13243
  // put back the texture the engine tracks, a draw in renderLight must not sample the lightmap
13130
13244
  if (glActiveTexture)
@@ -13264,8 +13378,10 @@ class LightSystemPlugin
13264
13378
  this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
13265
13379
  this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
13266
13380
  const size = this.textureSize;
13267
- if (size)
13268
- this.textureSize = vec2(glClampTextureSize(size.x), glClampTextureSize(size.y));
13381
+ if (!size) return;
13382
+ const x = glClampTextureSize(size.x), y = glClampTextureSize(size.y);
13383
+ if (x !== size.x || y !== size.y)
13384
+ this.textureSize = vec2(x, y); // only when it is too large, this runs every frame
13269
13385
  }
13270
13386
 
13271
13387
  /** Draw a single Light's falloff blob into the currently bound lightmap.
@@ -13454,163 +13570,165 @@ class Light extends EngineObject
13454
13570
  }
13455
13571
  }
13456
13572
 
13457
- /**
13458
- * LittleJS ZzFXM Plugin
13459
- * @namespace ZzFXM
13460
- */
13461
-
13462
- /**
13463
- * Music Object - Stores a zzfx music track for later use
13464
- *
13465
- * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
13466
- * @extends Sound
13467
- * @memberof ZzFXM
13468
- * @example
13469
- * // create some music
13470
- * const music_example = new ZzFXMusic(
13471
- * [
13472
- * [ // instruments
13473
- * [,0,400] // simple note
13474
- * ],
13475
- * [ // patterns
13476
- * [ // pattern 1
13477
- * [ // channel 0
13478
- * 0, -1, // instrument 0, left speaker
13479
- * 1, 0, 9, 1 // channel notes
13480
- * ],
13481
- * [ // channel 1
13482
- * 0, 1, // instrument 0, right speaker
13483
- * 0, 12, 17, -1 // channel notes
13484
- * ]
13485
- * ],
13486
- * ],
13487
- * [0, 0, 0, 0], // sequence, play pattern 0 four times
13488
- * 90 // BPM
13489
- * ]);
13490
- *
13491
- * // play the music on a loop
13492
- * music_example.playMusic();
13493
- */
13494
- class ZzFXMusic extends Sound
13495
- {
13496
- /** Create a music object and cache the zzfx music samples for later use
13497
- * @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
13498
- */
13499
- constructor(zzfxMusic)
13500
- {
13501
- super(undefined);
13502
-
13503
- if (!soundEnable || headlessMode) return;
13504
- const [instruments, patterns, sequence, BPM] = zzfxMusic;
13505
- super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
13506
- this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
13507
- this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
13508
- }
13509
- }
13510
-
13511
- ///////////////////////////////////////////////////////////////////////////////
13512
- // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
13513
-
13514
- /** Generate samples for a ZzFM song with given parameters
13515
- * @param {Array} instruments - Array of ZzFX sound parameters
13516
- * @param {Array} patterns - Array of pattern data
13517
- * @param {Array} sequence - Array of pattern indexes
13518
- * @param {number} [BPM] - Playback speed of the song in BPM
13519
- * @return {Array} - Left and right channel sample data
13520
- * @memberof ZzFXM */
13521
- function zzfxM(instruments, patterns, sequence, BPM = 125)
13522
- {
13523
- let i, j, k;
13524
- let instrumentParameters;
13525
- let note;
13526
- let sample;
13527
- let patternChannel;
13528
- let notFirstBeat;
13529
- let stop;
13530
- let instrument;
13531
- let attenuation;
13532
- let outSampleOffset;
13533
- let isSequenceEnd;
13534
- let sampleOffset = 0;
13535
- let nextSampleOffset;
13536
- let sampleBuffer = [];
13537
- let leftChannelBuffer = [];
13538
- let rightChannelBuffer = [];
13539
- let channelIndex = 0;
13540
- let panning = 0;
13541
- let hasMore = 1;
13542
- let sampleCache = {};
13543
- let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
13544
-
13545
- // for each channel in order until there are no more
13546
- for (; hasMore; channelIndex++) {
13547
-
13548
- // reset current values
13549
- sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
13550
-
13551
- // for each pattern in sequence
13552
- sequence.forEach((patternIndex, sequenceIndex)=> {
13553
- // get pattern for current channel, use empty 1 note pattern if none found
13554
- patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
13555
-
13556
- // check if there are more channels
13557
- hasMore |= patterns[patternIndex][channelIndex]&&1;
13558
-
13559
- // get next offset, use the length of first channel
13560
- nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
13561
- // for each beat in pattern, plus one extra if end of sequence
13562
- isSequenceEnd = sequenceIndex === sequence.length - 1;
13563
- for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
13564
-
13565
- // <channel-note>
13566
- note = patternChannel[i];
13567
-
13568
- // stop if end, different instrument or new note
13569
- stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
13570
- instrument !== (patternChannel[0] || 0) || note | 0;
13571
-
13572
- // fill buffer with samples for previous beat, most cpu intensive part
13573
- for (j = 0; j < beatLength && notFirstBeat;
13574
-
13575
- // fade off attenuation at end of beat if stopping note, prevents clicking
13576
- j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
13577
- ) {
13578
- // copy sample to stereo buffers with panning
13579
- sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
13580
- leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
13581
- rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
13582
- }
13583
-
13584
- // set up for next note
13585
- if (note) {
13586
- // set attenuation
13587
- attenuation = note % 1;
13588
- panning = patternChannel[1] || 0;
13589
- if (note |= 0) {
13590
- // get cached sample
13591
- sampleBuffer = sampleCache[
13592
- [
13593
- instrument = patternChannel[sampleOffset = 0] || 0,
13594
- note
13595
- ]
13596
- ] = sampleCache[[instrument, note]] || (
13597
- // add sample to cache
13598
- instrumentParameters = [...instruments[instrument]],
13599
- instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
13600
-
13601
- // allow negative values to stop notes
13602
- note > 0 ? zzfxG(...instrumentParameters) : []
13603
- );
13604
- }
13605
- }
13606
- }
13607
-
13608
- // update the sample offset
13609
- outSampleOffset = nextSampleOffset;
13610
- });
13611
- }
13612
-
13613
- return [leftChannelBuffer, rightChannelBuffer];
13573
+ /**
13574
+ * LittleJS ZzFXM Plugin
13575
+ * - A port of ZzFXM, the Zuper Zmall Zound Zynth music player, by Keith Clark and Frank Force, MIT licensed,
13576
+ * https://github.com/keithclark/ZzFXM; its notice is in COPYRIGHT.txt
13577
+ * @namespace ZzFXM
13578
+ */
13579
+
13580
+ /**
13581
+ * Music Object - Stores a zzfx music track for later use
13582
+ *
13583
+ * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
13584
+ * @extends Sound
13585
+ * @memberof ZzFXM
13586
+ * @example
13587
+ * // create some music
13588
+ * const music_example = new ZzFXMusic(
13589
+ * [
13590
+ * [ // instruments
13591
+ * [,0,400] // simple note
13592
+ * ],
13593
+ * [ // patterns
13594
+ * [ // pattern 1
13595
+ * [ // channel 0
13596
+ * 0, -1, // instrument 0, left speaker
13597
+ * 1, 0, 9, 1 // channel notes
13598
+ * ],
13599
+ * [ // channel 1
13600
+ * 0, 1, // instrument 0, right speaker
13601
+ * 0, 12, 17, -1 // channel notes
13602
+ * ]
13603
+ * ],
13604
+ * ],
13605
+ * [0, 0, 0, 0], // sequence, play pattern 0 four times
13606
+ * 90 // BPM
13607
+ * ]);
13608
+ *
13609
+ * // play the music on a loop
13610
+ * music_example.playMusic();
13611
+ */
13612
+ class ZzFXMusic extends Sound
13613
+ {
13614
+ /** Create a music object and cache the zzfx music samples for later use
13615
+ * @param {[Array, Array, Array, number?, ...any[]]} zzfxMusic - Array of zzfx music parameters: instruments, patterns, sequence, and an optional BPM; anything after it, like the tracker's metadata, is ignored
13616
+ */
13617
+ constructor(zzfxMusic)
13618
+ {
13619
+ super(undefined);
13620
+
13621
+ if (!soundEnable || headlessMode) return;
13622
+ const [instruments, patterns, sequence, BPM] = zzfxMusic;
13623
+ super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
13624
+ this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
13625
+ this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
13626
+ }
13627
+ }
13628
+
13629
+ ///////////////////////////////////////////////////////////////////////////////
13630
+ // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
13631
+
13632
+ /** Generate samples for a ZzFM song with given parameters
13633
+ * @param {Array} instruments - Array of ZzFX sound parameters
13634
+ * @param {Array} patterns - Array of pattern data
13635
+ * @param {Array} sequence - Array of pattern indexes
13636
+ * @param {number} [BPM] - Playback speed of the song in BPM
13637
+ * @return {Array} - Left and right channel sample data
13638
+ * @memberof ZzFXM */
13639
+ function zzfxM(instruments, patterns, sequence, BPM = 125)
13640
+ {
13641
+ let i, j, k;
13642
+ let instrumentParameters;
13643
+ let note;
13644
+ let sample;
13645
+ let patternChannel;
13646
+ let notFirstBeat;
13647
+ let stop;
13648
+ let instrument;
13649
+ let attenuation;
13650
+ let outSampleOffset;
13651
+ let isSequenceEnd;
13652
+ let sampleOffset = 0;
13653
+ let nextSampleOffset;
13654
+ let sampleBuffer = [];
13655
+ let leftChannelBuffer = [];
13656
+ let rightChannelBuffer = [];
13657
+ let channelIndex = 0;
13658
+ let panning = 0;
13659
+ let hasMore = 1;
13660
+ let sampleCache = {};
13661
+ let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
13662
+
13663
+ // for each channel in order until there are no more
13664
+ for (; hasMore; channelIndex++) {
13665
+
13666
+ // reset current values
13667
+ sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
13668
+
13669
+ // for each pattern in sequence
13670
+ sequence.forEach((patternIndex, sequenceIndex)=> {
13671
+ // get pattern for current channel, use empty 1 note pattern if none found
13672
+ patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
13673
+
13674
+ // check if there are more channels
13675
+ hasMore |= patterns[patternIndex][channelIndex]&&1;
13676
+
13677
+ // get next offset, use the length of first channel
13678
+ nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
13679
+ // for each beat in pattern, plus one extra if end of sequence
13680
+ isSequenceEnd = sequenceIndex === sequence.length - 1;
13681
+ for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
13682
+
13683
+ // <channel-note>
13684
+ note = patternChannel[i];
13685
+
13686
+ // stop if end, different instrument or new note
13687
+ stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
13688
+ instrument !== (patternChannel[0] || 0) || note | 0;
13689
+
13690
+ // fill buffer with samples for previous beat, most cpu intensive part
13691
+ for (j = 0; j < beatLength && notFirstBeat;
13692
+
13693
+ // fade off attenuation at end of beat if stopping note, prevents clicking
13694
+ j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
13695
+ ) {
13696
+ // copy sample to stereo buffers with panning
13697
+ sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
13698
+ leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
13699
+ rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
13700
+ }
13701
+
13702
+ // set up for next note
13703
+ if (note) {
13704
+ // set attenuation
13705
+ attenuation = note % 1;
13706
+ panning = patternChannel[1] || 0;
13707
+ if (note |= 0) {
13708
+ // get cached sample
13709
+ sampleBuffer = sampleCache[
13710
+ [
13711
+ instrument = patternChannel[sampleOffset = 0] || 0,
13712
+ note
13713
+ ]
13714
+ ] = sampleCache[[instrument, note]] || (
13715
+ // add sample to cache
13716
+ instrumentParameters = [...instruments[instrument]],
13717
+ instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
13718
+
13719
+ // allow negative values to stop notes
13720
+ note > 0 ? zzfxG(...instrumentParameters) : []
13721
+ );
13722
+ }
13723
+ }
13724
+ }
13725
+
13726
+ // update the sample offset
13727
+ outSampleOffset = nextSampleOffset;
13728
+ });
13729
+ }
13730
+
13731
+ return [leftChannelBuffer, rightChannelBuffer];
13614
13732
  }
13615
13733
  /**
13616
13734
  * LittleJS Audio Effects Plugin
@@ -14601,8 +14719,7 @@ class UISystemPlugin
14601
14719
  }
14602
14720
  }
14603
14721
 
14604
- /** Destroy and remove all objects
14605
- * @memberof UISystem */
14722
+ /** Destroy and remove all objects */
14606
14723
  destroyObjects()
14607
14724
  {
14608
14725
  for (const o of this.uiObjects)
@@ -15123,8 +15240,10 @@ class UIObject
15123
15240
  return void inputClearKey(0, 0, false, true, false);
15124
15241
  }
15125
15242
  }
15243
+ // the object that was the active one going into this update: one pressed and let go inside a
15244
+ // frame is clicked on the next, with its release, not on both
15126
15245
  if (!uiSystem.activateOnPress)
15127
- if (!mouseDown && this.isActiveObject() && this.interactive)
15246
+ if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
15128
15247
  this.click();
15129
15248
  if (this.destroyed) return;
15130
15249
  }
@@ -15867,7 +15986,10 @@ class UIVideo extends UIObject
15867
15986
  if (this.destroyed)
15868
15987
  return;
15869
15988
 
15989
+ // let go of the media too, a paused video keeps what it has loaded
15870
15990
  this.video.pause();
15991
+ this.video.removeAttribute?.('src');
15992
+ this.video.load?.();
15871
15993
  this.video.remove();
15872
15994
  super.destroy();
15873
15995
  }
@@ -19206,6 +19328,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
19206
19328
  for (const group of parseAtlas(data))
19207
19329
  {
19208
19330
  // reserve a block of full size cells, one per frame
19331
+ if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
19209
19332
  const sourceSize = group.frames[0].sourceSize;
19210
19333
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
19211
19334
  const added = textureSheetAdd(blockSize, sourceSize, padding);
@@ -19991,45 +20114,50 @@ function tweenUpdate(gameDelta, realDelta)
19991
20114
  const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
19992
20115
  for (const t of tweenActive)
19993
20116
  list.push(t);
19994
- for (let i = list.length; i--;)
20117
+ // the list is let go however the walk ends: a callback that throws must not leave it held, or every
20118
+ // update after would take it for an update still going and make a list of its own
20119
+ try
19995
20120
  {
19996
- const t = list[i];
19997
- // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
19998
- // counts on from this one
19999
- if (!t.active || t.activePass >= pass) continue;
20000
- let dt;
20001
- if (enginePath)
20121
+ for (let i = list.length; i--;)
20002
20122
  {
20003
- // a paused tween keeps count too, so it does not jump when resumed
20004
- dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
20005
- t.lastTime = time;
20006
- t.lastTimeReal = timeReal;
20007
- }
20008
- else
20009
- dt = t.useRealTime ? realDelta : gameDelta;
20010
- if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
20011
- if (t.paused || dt <= 0) continue;
20123
+ const t = list[i];
20124
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
20125
+ // counts on from this one
20126
+ if (!t.active || t.activePass >= pass) continue;
20127
+ let dt;
20128
+ if (enginePath)
20129
+ {
20130
+ // a paused tween keeps count too, so it does not jump when resumed
20131
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
20132
+ t.lastTime = time;
20133
+ t.lastTimeReal = timeReal;
20134
+ }
20135
+ else
20136
+ dt = t.useRealTime ? realDelta : gameDelta;
20137
+ if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
20138
+ if (t.paused || dt <= 0) continue;
20012
20139
 
20013
- t.life -= dt;
20014
- if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
20015
- {
20016
- t.callback(t.interp(t.life));
20017
- }
20018
- else
20019
- {
20020
- // Completion: fire end value, remove from active, start the next iteration
20021
- // of a loop or pingPong, or when there is none it has completed, fire onComplete
20022
- t.callback(t.interp(0));
20023
- if (!t.active || t.activePass >= pass)
20024
- continue; // stopped or restarted by its own callback, the run it was on ends without completing
20025
- tweenDeactivate(t);
20026
- const next = t.thenCallback;
20027
- t.thenCallback = undefined;
20028
- if (!(next && next()) && t.onComplete)
20029
- t.onComplete();
20140
+ t.life -= dt;
20141
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
20142
+ {
20143
+ t.callback(t.interp(t.life));
20144
+ }
20145
+ else
20146
+ {
20147
+ // Completion: fire end value, remove from active, start the next iteration
20148
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
20149
+ t.callback(t.interp(0));
20150
+ if (!t.active || t.activePass >= pass)
20151
+ continue; // stopped or restarted by its own callback, the run it was on ends without completing
20152
+ tweenDeactivate(t);
20153
+ const next = t.thenCallback;
20154
+ t.thenCallback = undefined;
20155
+ if (!(next && next()) && t.onComplete)
20156
+ t.onComplete();
20157
+ }
20030
20158
  }
20031
20159
  }
20032
- list.length = 0;
20160
+ finally { list.length = 0; }
20033
20161
  }
20034
20162
 
20035
20163
  /** Stop every active tween, ending loops too, without calling their then-callbacks.
@@ -20103,6 +20231,179 @@ function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); }
20103
20231
  function sceneRender() { sceneCurrent?.render?.(); }
20104
20232
  function sceneRenderPost() { sceneCurrent?.renderPost?.(); }
20105
20233
 
20234
+ /**
20235
+ * LittleJS Parallax Plugin
20236
+ * - A background layer that follows the camera by a part of its movement, so it looks far away
20237
+ * - Repeats across the view, so a level of any width has a background
20238
+ * - Draws mountains by default, or anything a game draws into it
20239
+ * @namespace Parallax
20240
+ */
20241
+
20242
+ ///////////////////////////////////////////////////////////////////////////////
20243
+
20244
+ /**
20245
+ * A background layer with parallax: an image that follows the camera by a part of its movement and repeats across
20246
+ * the view, drawn once into a canvas by a function, mountains when none is given
20247
+ * - A far layer has a parallax near 1 and a low renderOrder, a near one a smaller parallax and a higher renderOrder
20248
+ * - The image should meet itself at its left and right edges to repeat without a seam, as the mountains do
20249
+ * @memberof Parallax
20250
+ * @extends CanvasLayer
20251
+ * @example
20252
+ * // three ranges of mountains, the far ones lighter and slower
20253
+ * for (let i = 3; i--;)
20254
+ * new ParallaxLayer(vec2(0, i*2), vec2(40, 20), .9 - i*.2, -1e3 + i,
20255
+ * parallaxMountains(hsl(.6, .3, .7 - i*.2), hsl(.6, .5, .2), i));
20256
+ *
20257
+ * // or draw your own image into the layer
20258
+ * new ParallaxLayer(vec2(), vec2(40, 20), .5, -1e3, (context, size)=>
20259
+ * {
20260
+ * context.fillStyle = '#fff';
20261
+ * context.fillRect(size.x/2 - 20, size.y/2 - 20, 40, 40);
20262
+ * });
20263
+ */
20264
+ class ParallaxLayer extends CanvasLayer
20265
+ {
20266
+ /** Create a parallax layer and draw its image
20267
+ * @param {Vector2} [pos] - Where the middle of the image is in the world when the camera is there too
20268
+ * @param {Vector2} [size] - World size of one copy of the image
20269
+ * @param {number|Vector2} [parallax] - How much of the camera's movement it follows, 0 stays with the world
20270
+ * and 1 with the screen, a Vector2 to follow x and y by different amounts
20271
+ * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
20272
+ * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
20273
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given
20274
+ * @param {Vector2} [canvasSize] - Size of the image in pixels */
20275
+ constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
20276
+ canvasSize=vec2(512, 256))
20277
+ {
20278
+ false&&ASSERT(isNumber(parallax) || isVector2(parallax), 'parallax must be a number or a Vector2');
20279
+ false&&ASSERT(typeof drawFunction == 'function', 'drawFunction must be a function');
20280
+ super(pos, size, 0, renderOrder, canvasSize);
20281
+
20282
+ /** @property {Vector2} - How much of the camera's movement it follows on each axis, 0 stays with the world
20283
+ * and 1 with the screen */
20284
+ this.parallax = isVector2(parallax) ? /** @type {Vector2} */ (parallax).copy() :
20285
+ vec2(/** @type {number} */ (parallax));
20286
+ /** @property {boolean} - Repeat the image across the view, left and right */
20287
+ this.wrapX = true;
20288
+ /** @property {boolean} - Repeat the image up and down the view */
20289
+ this.wrapY = false;
20290
+ /** @property {number} - How much the camera's zoom changes its size on the screen: 1 like the world, 0 not
20291
+ * at all, the same on the screen at any zoom as it is at zoomScale */
20292
+ this.zoomFollow = 1;
20293
+ /** @property {number} - The camera scale its size is given for, used when zoomFollow is under 1 */
20294
+ this.zoomScale = cameraScale;
20295
+ /** @property {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} - Draws the
20296
+ * image, redraw() after changing it */
20297
+ this.drawFunction = drawFunction;
20298
+ this.redraw();
20299
+ }
20300
+
20301
+ /** Draw the image again with drawFunction, after changing it or what it draws from */
20302
+ redraw()
20303
+ {
20304
+ const context = this.context;
20305
+ if (!context) return; // headless
20306
+ const size = vec2(this.canvas.width, this.canvas.height);
20307
+ context.clearRect(0, 0, size.x, size.y);
20308
+ this.drawFunction(context, size, this);
20309
+ this.updateWebGL();
20310
+ }
20311
+
20312
+ // how much smaller it is drawn than its size, from the camera's zoom and how much of it the layer follows
20313
+ zoomFactor()
20314
+ { return this.zoomFollow < 1 && cameraScale > 0 ? (this.zoomScale / cameraScale) ** (1 - this.zoomFollow) : 1; }
20315
+
20316
+ /** World size one copy of the image is drawn at now, its size unless zoomFollow is under 1
20317
+ * @return {Vector2} */
20318
+ getDrawSize() { return this.size.scale(this.zoomFactor()); }
20319
+
20320
+ /** Where each copy of the image is drawn now, in the world: one place, or with wrapping every place that
20321
+ * shows in the view
20322
+ * @return {Array<Vector2>} */
20323
+ getDrawPositions()
20324
+ {
20325
+ // its place seen from the camera is cut down by the parallax, and by the zoom it does not follow
20326
+ const zoom = this.zoomFactor(), size = this.size.scale(zoom), p = this.parallax;
20327
+ const offset = this.pos.subtract(cameraPos);
20328
+ const center = cameraPos.add(vec2(offset.x * (1 - p.x) * zoom, offset.y * (1 - p.y) * zoom));
20329
+
20330
+ // the copies that reach into the view; a turned camera sees as far as its corner each way
20331
+ const view = mainCanvasSize.scale(.5 / (cameraScale || 1));
20332
+ const reach = cameraAngle ? vec2(view.length()) : view;
20333
+ const range = (wrap, axis)=>
20334
+ {
20335
+ if (!wrap || !(size[axis] > 0)) return [0, 0];
20336
+ const from = cameraPos[axis] - reach[axis] - size[axis] / 2 - center[axis];
20337
+ const to = cameraPos[axis] + reach[axis] + size[axis] / 2 - center[axis];
20338
+ const first = ceil(from / size[axis]);
20339
+ return [first, min(floor(to / size[axis]), first + 99)]; // a tiny image does not draw without end
20340
+ };
20341
+ const [x0, x1] = range(this.wrapX, 'x'), [y0, y1] = range(this.wrapY, 'y'), positions = [];
20342
+ for (let y = y0; y <= y1; ++y)
20343
+ for (let x = x0; x <= x1; ++x)
20344
+ positions.push(vec2(center.x + x * size.x, center.y + y * size.y));
20345
+ return positions;
20346
+ }
20347
+
20348
+ // Render the layer, called automatically by the engine
20349
+ render()
20350
+ {
20351
+ const size = this.getDrawSize();
20352
+ for (const pos of this.getDrawPositions())
20353
+ this.draw(pos, size, this.color, 0, this.mirror, this.additiveColor, false);
20354
+ }
20355
+ }
20356
+
20357
+ ///////////////////////////////////////////////////////////////////////////////
20358
+
20359
+ // the ridge of a range of mountains: a height for each column of an image and one more, the last the same as the
20360
+ // first so the image repeats with no step; it wanders about the middle, turning now and then
20361
+ function parallaxRidge(width, height, random)
20362
+ {
20363
+ const pointiness = .2, levelness = .005, slopeRange = height / 256; // how often it turns, how hard it is
20364
+ const ridge = new Float32Array(width + 1), middle = height / 2; // pulled level, how steep it gets
20365
+ let y = middle, slope = random.float(-slopeRange, slopeRange);
20366
+ for (let x = 0; x <= width; ++x)
20367
+ {
20368
+ ridge[x] = y;
20369
+ y += slope -= (y - middle) * levelness;
20370
+ if (random.float() < pointiness)
20371
+ slope = random.float(-slopeRange, slopeRange);
20372
+ }
20373
+ // take out the difference between its ends, a little from each column, and keep it inside the image
20374
+ const drift = ridge[width] - ridge[0];
20375
+ for (let x = 0; x <= width; ++x)
20376
+ ridge[x] = clamp(ridge[x] - drift * x / width, 0, height);
20377
+ return ridge;
20378
+ }
20379
+
20380
+ /** A draw function for a ParallaxLayer: a range of mountains across the image, shaded from the color of their
20381
+ * peaks down to the color of their feet, that repeats with no seam
20382
+ * @param {Color} [topColor] - Color at the peaks
20383
+ * @param {Color} [bottomColor] - Color at the bottom of the image
20384
+ * @param {number} [seed] - The same seed gives the same mountains, random when not given
20385
+ * @return {function(OffscreenCanvasRenderingContext2D, Vector2): void}
20386
+ * @memberof Parallax */
20387
+ function parallaxMountains(topColor=hsl(.6, .2, .6), bottomColor=hsl(.6, .3, .25), seed)
20388
+ {
20389
+ return (context, size)=>
20390
+ {
20391
+ const w = size.x, h = size.y;
20392
+ const random = new RandomGenerator(seed === undefined ? randInt(1e9) + 1 : seed * 7919 + 1);
20393
+ const ridge = parallaxRidge(w, h, random);
20394
+
20395
+ // a strip one pixel wide with the shading, stretched down each column from its peak
20396
+ const strip = createCanvasContext(1, h);
20397
+ for (let i = h; i--;)
20398
+ {
20399
+ strip.fillStyle = topColor.lerp(bottomColor, i / h).toString();
20400
+ strip.fillRect(0, i, 1, 1);
20401
+ }
20402
+ for (let x = w; x--;)
20403
+ ridge[x] < h && context.drawImage(strip.canvas, 0, 0, 1, h, x, ridge[x], 1, h - ridge[x]);
20404
+ };
20405
+ }
20406
+
20106
20407
  /**
20107
20408
  * LittleJS PathFinder Plugin
20108
20409
  * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
@@ -22238,17 +22539,19 @@ function render3DInstance(mesh, matrix, tileInfo, color)
22238
22539
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
22239
22540
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
22240
22541
  // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
22241
- // so it never sets the draw state for them, which would move the state's version at every draw
22242
- function render3DInstanceSlot(mesh, textureInfo, mirrored)
22542
+ // so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard
22543
+ // passes that its batch is unlit, for the same reason
22544
+ function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false)
22243
22545
  {
22244
22546
  const r = render3D;
22245
22547
  if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
22246
- || render3DStateChanged(mesh.instanceState)))
22548
+ || mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState)))
22247
22549
  render3DFlushInstances(mesh);
22248
22550
  if (!mesh.instanceCount)
22249
22551
  {
22250
22552
  mesh.instanceTextureInfo = textureInfo;
22251
22553
  mesh.instanceMirrored = mirrored;
22554
+ mesh.instanceUnlit = unlit;
22252
22555
  mesh.instanceState = render3DCaptureBatchState();
22253
22556
  r.instanceMeshes.push(mesh);
22254
22557
  }
@@ -22282,6 +22585,8 @@ function render3DFlushInstances(only)
22282
22585
  const state = mesh.instanceState;
22283
22586
  if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
22284
22587
  state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
22588
+ if (mesh.instanceUnlit) // a batch of sprites
22589
+ state.lighting = false;
22285
22590
  render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
22286
22591
  }
22287
22592
  if (!only)
@@ -22369,12 +22674,17 @@ function render3DShadowCaster()
22369
22674
  light.intensity > 0 && light.color.a > 0 ? light : undefined;
22370
22675
  }
22371
22676
 
22677
+ // the widest cone a shadow map can look down, to each side; the light that casts the shadows lights no wider, or
22678
+ // what is past the map's edge would be lit with nothing to shade it
22679
+ const RENDER3D_SHADOW_CONE_MAX = 1.35;
22680
+
22372
22681
  // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
22373
22682
  // a point, less the fourth number, is 0 at the edge of the cone and 1 where its fade starts; a light with no cone
22374
22683
  // gets numbers that make it 1 every way
22375
22684
  function render3DLightCone(light)
22376
22685
  {
22377
- const angle = light.directional ? 0 : min(light.coneAngle, PI);
22686
+ const widest = light === render3DShadowCaster() ? RENDER3D_SHADOW_CONE_MAX : PI;
22687
+ const angle = light.directional ? 0 : min(light.coneAngle, widest);
22378
22688
  if (!(angle > 0)) return [0, 0, 0, -1];
22379
22689
  const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
22380
22690
  const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
@@ -22633,6 +22943,7 @@ class Render3DPlugin
22633
22943
  /** @type {TextureInfo|undefined} */
22634
22944
  this.streamTileInfo = undefined;
22635
22945
  this.streamState = undefined; // captured state the pending batch was drawn under
22946
+ this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are
22636
22947
  /** @type {Mesh|undefined} */
22637
22948
  this.capture = undefined; // the mesh a bake is filling
22638
22949
  /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
@@ -23023,6 +23334,8 @@ class Render3DPlugin
23023
23334
  {
23024
23335
  if (!this.streamCount || !render3DCanDraw()) return;
23025
23336
  const gl = glContext;
23337
+ if (this.streamUnlit)
23338
+ this.streamState.lighting = false; // its own copy of the state
23026
23339
  render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
23027
23340
  render3DBindVertexBuffer(this.streamBuffer);
23028
23341
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
@@ -23161,8 +23474,9 @@ class Render3DPlugin
23161
23474
  // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
23162
23475
  const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
23163
23476
  const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
23164
- const far = caster.radius, near = max(far / 500, .02);
23165
- const fov = min(min(caster.coneAngle, PI) * 2 + .1, 2.8);
23477
+ // the near plane stays in front of the far one however small the light
23478
+ const far = caster.radius, near = min(max(far / 500, .02), far / 2);
23479
+ const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
23166
23480
  this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
23167
23481
  Matrix4.lookAt(pos, pos.add(forward), up).invert());
23168
23482
  this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
@@ -23256,13 +23570,34 @@ class Render3DPlugin
23256
23570
  return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
23257
23571
  }
23258
23572
 
23259
- // the quad's six stream vertices written straight in with no vectors made, unlit, for sprites, and for
23260
- // particles when instancing is off
23261
- const lit = this.lighting;
23262
- this.lighting = this.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides
23263
- let uvRect;
23264
- try { uvRect = render3DBeginStrip(6, tileInfo); }
23265
- finally { this.lighting = lit; }
23573
+ // unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not
23574
+ // set, which would move its version at every sprite
23575
+ const unlit = !this.shadowPass;
23576
+ if (!this.blend && this.depthTest && this.instancing)
23577
+ {
23578
+ // an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a
23579
+ // sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader
23580
+ // drops its see through texels, so it needs no sorting
23581
+ if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow
23582
+ if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2))
23583
+ return;
23584
+ const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo);
23585
+ render3DMeshUpload(quad);
23586
+ // the quad's half axes, doubled for its unit square
23587
+ const a = render3DBillboardAxes(size, angle, upright);
23588
+ const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData;
23589
+ data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
23590
+ data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
23591
+ data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
23592
+ data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1;
23593
+ data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
23594
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
23595
+ return;
23596
+ }
23597
+
23598
+ // one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no
23599
+ // vectors made, for sprites that blend, and for particles when instancing is off
23600
+ const uvRect = render3DBeginStrip(6, tileInfo, unlit);
23266
23601
  if (!uvRect) return;
23267
23602
  const a = render3DBillboardAxes(size, angle, upright);
23268
23603
  const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
@@ -24374,8 +24709,9 @@ function render3DRenderDepth()
24374
24709
  // which way they face, so keeping the count even keeps every strip facing out.
24375
24710
 
24376
24711
  // make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
24377
- // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
24378
- function render3DBeginStrip(count, tileInfo)
24712
+ // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws
24713
+ // the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw
24714
+ function render3DBeginStrip(count, tileInfo, unlit=false)
24379
24715
  {
24380
24716
  const r = render3D;
24381
24717
  if (!render3DCanDraw()) return;
@@ -24383,12 +24719,13 @@ function render3DBeginStrip(count, tileInfo)
24383
24719
  false&&ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
24384
24720
  if (count > RENDER3D_MAX_STREAM_VERTS) return;
24385
24721
  const textureInfo = render3DTextureOf(tileInfo);
24386
- if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
24387
- || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
24722
+ if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit
24723
+ || render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
24388
24724
  r.flush();
24389
24725
  render3DFlushBeforeOverlay();
24390
24726
  if (!r.streamCount)
24391
24727
  r.streamState = render3DCaptureBatchState();
24728
+ r.streamUnlit = unlit;
24392
24729
  r.streamTileInfo = textureInfo;
24393
24730
  return render3DGetTileUVs(tileInfo);
24394
24731
  }
@@ -26596,7 +26933,7 @@ class InstancedMesh3D extends EngineObject3D
26596
26933
  * - intensity multiplies the color, above 1 for a light brighter than white
26597
26934
  * - radius is a world distance, so scale3D does not change it
26598
26935
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
26599
- * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
26936
+ * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
26600
26937
  * @extends EngineObject3D
26601
26938
  * @memberof Render3D
26602
26939
  * @example
@@ -26645,8 +26982,10 @@ class Light3D extends EngineObject3D
26645
26982
  const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
26646
26983
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
26647
26984
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
26648
- const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
26649
- r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
26985
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1)), texture = engineGlowTexture(this.glowFalloff);
26986
+ // a browser that can not make the glow's texture draws no glow, not a plain square
26987
+ if (texture || !glContext)
26988
+ r.drawBillboard(at, vec2(this.glow), texture, color);
26650
26989
  }
26651
26990
  }
26652
26991
 
@@ -26656,7 +26995,7 @@ class Light3D extends EngineObject3D
26656
26995
  * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
26657
26996
  * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
26658
26997
  * - It cannot sit on the origin, since that leaves no direction
26659
- * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
26998
+ * - It casts no shadow: the sun, render3D.sunDirection, does, or a spotlight set as render3D.shadowLight
26660
26999
  * @extends Light3D
26661
27000
  * @memberof Render3D
26662
27001
  * @example
@@ -27733,6 +28072,9 @@ class ParticleEmitter3D extends EngineObject3D
27733
28072
  /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
27734
28073
  * sliding along by friction; off by default, it tests each particle's move against the level every frame */
27735
28074
  this.collideLevel = false;
28075
+ /** @property {number} - How much a particle grips where it lands, 0 to 1: its speed along the surface is
28076
+ * cut by this much on each hit, on top of the friction, 1 stops it there */
28077
+ this.stick = 0;
27736
28078
  /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
27737
28079
  * @type {Particle3DCallback|undefined} */
27738
28080
  this.particleCreateCallback = undefined;
@@ -27752,7 +28094,7 @@ class ParticleEmitter3D extends EngineObject3D
27752
28094
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
27753
28095
  * @type {Vector3|undefined} */
27754
28096
  this.worldPos3D = undefined;
27755
- this.emitTimeBuffer = 0;
28097
+ this.emitTimeBuffer = 1; // the first particle comes at once, as the 2D emitter's does
27756
28098
  }
27757
28099
 
27758
28100
  /** Spawn new particles, move the live ones, and go away when done */
@@ -27769,9 +28111,10 @@ class ParticleEmitter3D extends EngineObject3D
27769
28111
  {
27770
28112
  // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
27771
28113
  // neither is a reason to stop counting down the emit time
27772
- if (this.emitRate && particleEmitRateScale)
28114
+ const rate = this.emitRate * particleEmitRateScale;
28115
+ if (rate > 0 && rate < Infinity)
27773
28116
  {
27774
- this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
28117
+ this.emitTimeBuffer += rate * timeDelta;
27775
28118
  for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
27776
28119
  this.emitParticle();
27777
28120
  }
@@ -27811,7 +28154,9 @@ class ParticleEmitter3D extends EngineObject3D
27811
28154
  const vz = data[k+5] = data[k+5] * damping + gravityZ;
27812
28155
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
27813
28156
  data[k+18] += data[k+19] *= angleDamping;
27814
- collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
28157
+ // true when its collide callback destroyed it, it has had its last callback but the destroy one
28158
+ const gone = collideLevel && this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz);
28159
+ collideLevel && reread();
27815
28160
  const t = i * trailMax * 3;
27816
28161
  if (trailMax)
27817
28162
  {
@@ -27823,7 +28168,7 @@ class ParticleEmitter3D extends EngineObject3D
27823
28168
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
27824
28169
  data[k+20] = n + 1;
27825
28170
  }
27826
- updateCallback && (this.particleCall(updateCallback, k), reread());
28171
+ updateCallback && !gone && (this.particleCall(updateCallback, k), reread());
27827
28172
  if ((data[k+17] += timeDelta) >= data[k+16])
27828
28173
  {
27829
28174
  // dead: the last particle takes its slot, trail and all, order does not matter
@@ -27925,13 +28270,14 @@ class ParticleEmitter3D extends EngineObject3D
27925
28270
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
27926
28271
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
27927
28272
  if (view.destroyed)
27928
- out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
28273
+ out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
27929
28274
  return result;
27930
28275
  }
27931
28276
 
27932
28277
  // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
27933
28278
  // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
27934
- // collide callback lets it through; one that starts inside is let go, as a 2D one is
28279
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is; true when the callback
28280
+ // destroyed it
27935
28281
  particleCollide(k, x, y, z)
27936
28282
  {
27937
28283
  let data = this.particleData;
@@ -27944,18 +28290,21 @@ class ParticleEmitter3D extends EngineObject3D
27944
28290
  if (h && (!hit || h.distance < hit.distance))
27945
28291
  hit = h, level = l;
27946
28292
  }
27947
- if (!hit) return;
28293
+ if (!hit) return false;
27948
28294
  const point = from.lerp(to, hit.distance);
27949
28295
  const callback = this.particleCollideCallback;
27950
28296
  if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
27951
- return; // let through, or destroyed by the callback
28297
+ return this.particleView.destroyed; // let through, or destroyed by the callback
27952
28298
  data = this.particleData; // as the callback left it, an emit may have grown it
27953
28299
 
27954
28300
  const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
27955
- const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
28301
+ // the larger friction of the two, as objects take it, then the emitter's own grip, which a level can not undo
28302
+ const restitution = max(this.restitution, level.restitution);
28303
+ const friction = max(this.friction, level.friction) * (1 - clamp(this.stick));
27956
28304
  const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
27957
28305
  data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
27958
28306
  data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
28307
+ return false;
27959
28308
  }
27960
28309
 
27961
28310
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -28219,6 +28568,11 @@ class LensFlare3D extends EngineObject3D
28219
28568
  * height; shape is glow, disc or ring
28220
28569
  * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
28221
28570
  this.elements = undefined;
28571
+ /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
28572
+ * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
28573
+ * in front of the light, and a spotlight's shows from inside its beam only
28574
+ * @type {Light3D|undefined} */
28575
+ this.light = undefined;
28222
28576
  /** @property {boolean} - Fade out when something is between the camera and the sun */
28223
28577
  this.occlusion = true;
28224
28578
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
@@ -28257,13 +28611,30 @@ class LensFlare3D extends EngineObject3D
28257
28611
  return this.made;
28258
28612
  }
28259
28613
 
28260
- /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
28614
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
28615
+ // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
28616
+ flareSource()
28617
+ {
28618
+ const camera = render3D.camera.pos, light = this.light;
28619
+ if (!light)
28620
+ {
28621
+ const sun = render3D.sunDirection;
28622
+ if (!sun.lengthSquared()) return;
28623
+ const direction = sun.normalize();
28624
+ return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
28625
+ }
28626
+ if (light.destroyed) return;
28627
+ const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
28628
+ return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
28629
+ }
28630
+
28631
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
28632
+ * behind the camera
28261
28633
  * @return {Vector2|undefined} */
28262
28634
  getSunScreenPos()
28263
28635
  {
28264
- const direction = render3D.sunDirection;
28265
- if (!direction.lengthSquared()) return;
28266
- return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
28636
+ const source = this.flareSource();
28637
+ return source && render3D.worldToScreen(source.pos);
28267
28638
  }
28268
28639
 
28269
28640
  /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
@@ -28277,7 +28648,19 @@ class LensFlare3D extends EngineObject3D
28277
28648
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
28278
28649
  const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
28279
28650
  if (!(strength > 0)) return [];
28280
- const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28651
+ let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28652
+ const light = this.light, source = light && this.flareSource();
28653
+ if (light)
28654
+ {
28655
+ if (!source) return [];
28656
+ // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
28657
+ const cone = render3DLightCone(light), d = source.direction;
28658
+ const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
28659
+ const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
28660
+ if (!(bright > 0)) return [];
28661
+ tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
28662
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
28663
+ }
28281
28664
  return this.getElements().map((e)=>
28282
28665
  {
28283
28666
  const c = e.color.multiply(tint);
@@ -28287,15 +28670,32 @@ class LensFlare3D extends EngineObject3D
28287
28670
  });
28288
28671
  }
28289
28672
 
28290
- /** Is something between the camera and the sun: the level, or an object that is not see through
28673
+ /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
28674
+ * through
28291
28675
  * @return {boolean} */
28292
28676
  isSunHidden()
28293
28677
  {
28294
- const direction = render3D.sunDirection;
28295
- if (!direction.lengthSquared()) return true;
28296
- const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
28297
- !o.additive);
28298
- return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
28678
+ const source = this.flareSource();
28679
+ if (!source) return true;
28680
+ const ray = new Ray3D(render3D.camera.pos, source.direction), reach = source.distance;
28681
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.destroyed &&
28682
+ !o.transparent && !o.additive);
28683
+ // a voxel map says block by block what is see through: glass, water and leaves let the sun by, and the
28684
+ // ray goes on to the blocks behind them
28685
+ const maps = /** @type {Array<VoxelMap>} */ (typeof VoxelMap == 'undefined' ? [] :
28686
+ blockers.filter((o)=> o instanceof VoxelMap));
28687
+ for (const map of maps)
28688
+ if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
28689
+ return true;
28690
+ // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
28691
+ // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
28692
+ // one the light is inside, its lamp
28693
+ const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
28694
+ const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
28695
+ const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
28696
+ const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
28697
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
28698
+ return !!hit && hit.distance < reach;
28299
28699
  }
28300
28700
 
28301
28701
  /** Ease visible toward whether the sun shows, called automatically each frame */
@@ -29425,7 +29825,10 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
29425
29825
  * returned, see level3DVoxelSetup for its sheet
29426
29826
  * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
29427
29827
  * height, how tall a full height is, color, and heights, rows of 0 to 1 from -z to +z, each from -x to +x; it
29428
- * is made a HeightMap, returned with what else was made
29828
+ * is made a HeightMap, returned with what else was made; paint, when it has it, colors its samples: colors, a
29829
+ * list, and cells, runs of a count and a color along the rows, 0 for the terrain's own and 1 the list's first
29830
+ * - A level may hold prefabs of its own, in a prefabs block, each by its name as level3DAddPrefab takes it; they
29831
+ * are added before its objects are made, one the game added itself keeps its place
29429
29832
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
29430
29833
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
29431
29834
  * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
@@ -29438,6 +29841,7 @@ function level3DLoad(level)
29438
29841
  false&&ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
29439
29842
  false&&ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
29440
29843
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
29844
+ level3DPrefabsAdd(level.prefabs);
29441
29845
  level3DSceneApply(level.scene);
29442
29846
  const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
29443
29847
  map && made.push(map);
@@ -29459,7 +29863,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
29459
29863
 
29460
29864
  /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
29461
29865
  * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
29462
- * a sheet is given here; a block's type shows that tile of the sheet on every face
29866
+ * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
29867
+ * image gets plain blocks, a color for each type
29463
29868
  * - setup is called with each map a level makes, to give block types their own faces or make them see-through
29464
29869
  * - Call it before level3DLoad; with no arguments the defaults are back
29465
29870
  * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
@@ -29521,7 +29926,7 @@ function level3DVoxelsMake(voxels)
29521
29926
  {
29522
29927
  const shape = level3DVoxelsShape(voxels);
29523
29928
  if (!shape || typeof VoxelMap == 'undefined') return;
29524
- const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
29929
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
29525
29930
  level3DVoxelsDecode(voxels.blocks, map.data);
29526
29931
  level3DVoxelSetupMap?.(map);
29527
29932
  map.rebuild();
@@ -29548,14 +29953,74 @@ function level3DTerrainShape(terrain)
29548
29953
  height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
29549
29954
  }
29550
29955
 
29956
+ // a hex color of a level as a Color, undefined when it is not one
29957
+ function level3DHexColor(hex)
29958
+ { return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
29959
+
29960
+ // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
29961
+ // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
29962
+ // it does not fit the terrain
29963
+ function level3DTerrainPaint(terrain, samples)
29964
+ {
29965
+ const paint = terrain?.paint, colors = paint?.colors, runs = paint?.cells;
29966
+ if (!isArray(colors) || !isArray(runs) || colors.length > 255 || !colors.every(level3DHexColor)) return;
29967
+ const cells = new Uint8Array(samples);
29968
+ let at = 0;
29969
+ for (let i = 0; i < runs.length; i += 2)
29970
+ {
29971
+ const count = runs[i], color = runs[i + 1];
29972
+ if (!(count > 0 && count % 1 === 0 && color >= 0 && color % 1 === 0) || color > colors.length ||
29973
+ at + count > samples) return;
29974
+ cells.fill(color, at, at += count);
29975
+ }
29976
+ return at === samples ? {colors: colors.slice(), cells} : undefined;
29977
+ }
29978
+
29979
+ // give a terrain's map the colors of its block: its own color over the whole of it, or with paint a color for
29980
+ // each sample, the terrain's own where it is not painted; the caller builds the map again
29981
+ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain, map.rows * map.columns))
29982
+ {
29983
+ const base = level3DHexColor(terrain.color) || WHITE;
29984
+ map.color = base, map.colors = undefined;
29985
+ if (!paint || !paint.cells.some((v)=> v)) return;
29986
+ const palette = [base, ...paint.colors.map(level3DHexColor)];
29987
+ map.color = WHITE;
29988
+ map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
29989
+ }
29990
+
29991
+ // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
29992
+ // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
29993
+ // block map in a game with no art can still be seen and painted
29994
+ let level3DVoxelPlain;
29995
+ function level3DVoxelPlainTiles()
29996
+ {
29997
+ if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
29998
+ return tile();
29999
+ if (!level3DVoxelPlain)
30000
+ {
30001
+ // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
30002
+ const context = createCanvasContext(256);
30003
+ for (let i = 0; i < 256; ++i)
30004
+ {
30005
+ const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
30006
+ context.fillStyle = hsl(hue, .45, .4).toString();
30007
+ context.fillRect(x, y, 16, 16);
30008
+ context.fillStyle = hsl(hue, .5, .55).toString();
30009
+ context.fillRect(x + 1, y + 1, 14, 14);
30010
+ }
30011
+ level3DVoxelPlain = new TextureInfo(context.canvas);
30012
+ }
30013
+ return tile(0, 16, level3DVoxelPlain, 0);
30014
+ }
30015
+
29551
30016
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
29552
30017
  function level3DTerrainMake(terrain)
29553
30018
  {
29554
30019
  const shape = level3DTerrainShape(terrain);
29555
30020
  if (!shape || typeof HeightMap == 'undefined') return;
29556
30021
  const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
29557
- if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color))
29558
- map.color = new Color().setHex(terrain.color);
30022
+ level3DTerrainSetColors(map, terrain);
30023
+ map.colors && map.rebuild();
29559
30024
  return map;
29560
30025
  }
29561
30026
 
@@ -29628,6 +30093,14 @@ function level3DProperties(type, object)
29628
30093
  // make one object of a level from the type added for its name, undefined when there is no such type or it made
29629
30094
  // nothing
29630
30095
  function level3DMake(object)
30096
+ {
30097
+ return level3DMakeAt(object, level3DVector(object.pos, vec3()),
30098
+ level3DVector(object.rotation, vec3()).scale(PI / 180), level3DVector(object.scale, vec3(1)));
30099
+ }
30100
+
30101
+ // make a level object's game object at a place, a rotation in radians and a scale, its own as level3DMake gives
30102
+ // them or the ones a prefab's instance puts it at
30103
+ function level3DMakeAt(object, pos, rotation, scale)
29631
30104
  {
29632
30105
  const type = level3DTypes.get(object.type);
29633
30106
  if (!type)
@@ -29635,7 +30108,7 @@ function level3DMake(object)
29635
30108
  debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
29636
30109
  return;
29637
30110
  }
29638
- const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
30111
+ const properties = level3DProperties(type, object);
29639
30112
  const {make} = type, count = engineObjects.length;
29640
30113
  let result;
29641
30114
  try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
@@ -29653,16 +30126,263 @@ function level3DMake(object)
29653
30126
  if (result instanceof EngineObject3D)
29654
30127
  {
29655
30128
  level3DBaseScale.set(result, result.scale3D.copy());
29656
- result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
29657
- result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
30129
+ result.rotation3D = rotation.copy();
30130
+ result.scale3D = result.scale3D.multiply(scale);
29658
30131
  }
29659
30132
 
29660
30133
  // the type's own properties are set on it, one the type has no default for could be a field of the engine's
29661
30134
  for (const key in type.defaults)
29662
30135
  result[key] = properties[key];
30136
+ // a prefab's instance makes its parts now that it is where it goes
30137
+ result instanceof Prefab3D && result.placeAt(pos);
29663
30138
  return result;
29664
30139
  }
29665
30140
 
30141
+ /** Make one object of a type added with level3DAddType, level3DAddMesh or level3DAddPrefab, from code, with no
30142
+ * level: a prefab's instance, or a plain type
30143
+ * @param {string} type - The type's name
30144
+ * @param {Vector3} [pos3D]
30145
+ * @param {Vector3} [rotation3D] - In radians, as an object has it
30146
+ * @param {Vector3} [scale3D] - Times the scale the type makes it with
30147
+ * @param {Object} [properties] - Over the type's defaults
30148
+ * @return {any} - What the type made, a Prefab3D for a prefab, undefined when there is no such type
30149
+ * @memberof Level3D
30150
+ * @example
30151
+ * level3DAddPrefab('House', await fetchJSON('house.json'));
30152
+ * level3DSpawn('House', vec3(10, 0, 0), vec3(0, PI/2, 0)); */
30153
+ function level3DSpawn(type, pos3D=vec3(), rotation3D=vec3(), scale3D=vec3(1), properties)
30154
+ {
30155
+ false&&ASSERT(isVector3(pos3D) && isVector3(rotation3D) && isVector3(scale3D), 'pos3D, rotation3D and scale3D are vec3');
30156
+ return level3DMakeAt(properties ? {type, properties} : {type}, pos3D.copy(), rotation3D, scale3D);
30157
+ }
30158
+
30159
+ ///////////////////////////////////////////////////////////////////////////////
30160
+ // prefabs: a prefab is a small level, a list of objects about its own origin, placed many times
30161
+
30162
+ // the prefabs by name: how an instance is built, its objects, fromLevel, true for one a level's own prefabs
30163
+ // block added, which one the game adds takes the place of, and source, the object it was added from, which the
30164
+ // level editor knows a level that is a prefab by
30165
+ /** @type {Map<string, {attached: boolean, objects: Array<Object>, fromLevel: boolean, source: Object}>} */
30166
+ const level3DPrefabs = new Map;
30167
+
30168
+ // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
30169
+ // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
30170
+ let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
30171
+
30172
+ /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
30173
+ * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
30174
+ * an instance, a Prefab3D; an instance is made of the prefab as it is then, so the level editor, where a prefab
30175
+ * changes, makes its instances again
30176
+ * - The prefab is a level as the level editor saves it, {objects: [...]}, so the editor is the prefab editor too;
30177
+ * only its objects are used, and they may be of other prefabs
30178
+ * - With attached true in it the parts are children of the instance and move with it as one body, without
30179
+ * collision of their own, a prefab inside it too; its handle is at the middle of the box around them and its
30180
+ * size3D is that box, so setCollision makes the body solid where it is seen; otherwise each part is an object of its own in the world and collides as one placed
30181
+ * by hand does
30182
+ * - Adding a name again replaces it
30183
+ * @param {string} name - The type its instances have in a level
30184
+ * @param {Object} prefab - {objects, attached}
30185
+ * @memberof Level3D
30186
+ * @example
30187
+ * level3DAddPrefab('Tower', {objects: [{type: 'Box', pos: [0, 1, 0], scale: [2, 2, 2]},
30188
+ * {type: 'Cylinder', pos: [0, 3, 0]}]});
30189
+ * level3DLoad({objects: [{type: 'Tower', pos: [5, 0, 5]}, {type: 'Tower', pos: [-5, 0, 5], rotation: [0, 45, 0]}]}); */
30190
+ function level3DAddPrefab(name, prefab) { level3DPrefabSet(name, prefab, false); }
30191
+
30192
+ // add a prefab, the game's or a level's own
30193
+ function level3DPrefabSet(name, prefab, fromLevel)
30194
+ {
30195
+ false&&ASSERT(!!prefab && typeof prefab === 'object', 'a prefab is an object, {objects: [...]}');
30196
+ name = String(name);
30197
+ const objects = isArray(prefab?.objects) ? prefab.objects.filter((o)=> o && typeof o === 'object') : [];
30198
+ // a copy, what the game or the editor does to its own afterwards does not change the instances made later
30199
+ level3DPrefabs.set(name, {attached: !!prefab?.attached, objects: JSON.parse(JSON.stringify(objects)), fromLevel,
30200
+ source: prefab});
30201
+ // a class of its own, so the level editor makes its instances again as it does a class's
30202
+ level3DTypes.set(name, {make: class extends Prefab3D { constructor(pos) { super(pos, name); } }, defaults: {},
30203
+ tileInfo: undefined});
30204
+ }
30205
+
30206
+ // add a level's own prefabs, each unless the game added one of that name or it is a plain type's name
30207
+ function level3DPrefabsAdd(prefabs)
30208
+ {
30209
+ if (!prefabs || typeof prefabs !== 'object' || isArray(prefabs)) return;
30210
+ for (const name in prefabs)
30211
+ {
30212
+ const known = level3DPrefabs.get(name);
30213
+ if (known ? !known.fromLevel : level3DTypes.has(name)) continue;
30214
+ prefabs[name] && typeof prefabs[name] === 'object' && level3DPrefabSet(name, prefabs[name], true);
30215
+ }
30216
+ }
30217
+
30218
+ /** Load a prefab from a file the level editor saved and add it
30219
+ * @param {string} name - The type its instances have in a level
30220
+ * @param {string} url
30221
+ * @return {Promise<void>}
30222
+ * @memberof Level3D */
30223
+ async function level3DLoadPrefab(name, url) { level3DAddPrefab(name, await fetchJSON(url)); }
30224
+
30225
+ // where an instance puts one of its prefab's objects: the object's own place, turn and size inside the instance's
30226
+ function level3DPrefabPartTransform(instance, object)
30227
+ {
30228
+ const pos = level3DVector(object.pos, vec3()), scale = level3DVector(object.scale, vec3(1));
30229
+ const rotation = level3DVector(object.rotation, vec3()).scale(PI / 180);
30230
+ // the two turns one after the other; the sizes multiply along each axis, which is exact for an instance sized
30231
+ // evenly or a part that is not turned, and as near as a box can be otherwise
30232
+ const turn = buildMatrix(vec3(), instance.rotation3D).multiply(buildMatrix(vec3(), rotation));
30233
+ return {pos: buildMatrix(instance.pos3D, instance.rotation3D, instance.scale3D).transformPoint(pos),
30234
+ rotation: turn.getRotation(), scale: scale.multiply(instance.scale3D)};
30235
+ }
30236
+
30237
+ /**
30238
+ * An instance of a prefab, what a prefab's type makes: a handle with no shape of its own, and its parts, what the
30239
+ * prefab's objects made
30240
+ * - Parts of a prefab that is not attached are objects of their own in the world: moving, turning or sizing the
30241
+ * handle puts them where it now says with its next update, or call placeParts; destroying it destroys them
30242
+ * - Parts of an attached prefab are its children and move with it as one body
30243
+ * @extends EngineObject3D
30244
+ * @memberof Level3D
30245
+ */
30246
+ class Prefab3D extends EngineObject3D
30247
+ {
30248
+ /** Create an instance of a prefab, made by its type: place one with a level or level3DSpawn
30249
+ * @param {Vector3} [pos3D]
30250
+ * @param {string} [prefabName] - A prefab added with level3DAddPrefab */
30251
+ constructor(pos3D, prefabName='')
30252
+ {
30253
+ super(pos3D);
30254
+ /** @property {string} - The prefab it is an instance of */
30255
+ this.prefabName = prefabName;
30256
+ /** @property {boolean} - Are its parts its children, moving with it as one body */
30257
+ this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0;
30258
+ /** @property {Array<any>} - What the prefab's objects made, in the prefab's order */
30259
+ this.parts = [];
30260
+ /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for
30261
+ * separate parts, and for an attached prefab the middle of the box around its parts, where its handle
30262
+ * is, as an object's place is the middle of its body */
30263
+ this.originOffset = vec3();
30264
+ this.partObjects = []; // the prefab's object of each part
30265
+ this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made
30266
+ this.partsMade = false;
30267
+ }
30268
+
30269
+ /** Put the instance with its prefab's origin at a place, turned and sized as the handle is: where a level's
30270
+ * object or level3DSpawn says it goes; the handle of an attached prefab is then at the middle of its body
30271
+ * @param {Vector3} pos3D */
30272
+ placeAt(pos3D)
30273
+ {
30274
+ this.pos3D = pos3D.copy();
30275
+ this.placeParts();
30276
+ const o = this.originOffset;
30277
+ if (o.x || o.y || o.z)
30278
+ this.pos3D = buildMatrix(pos3D, this.rotation3D, this.scale3D).transformPoint(o);
30279
+ }
30280
+
30281
+ /** Put the parts where the handle is now, making them the first time; called by the handle's update when it
30282
+ * has moved, turned or changed size */
30283
+ placeParts()
30284
+ {
30285
+ if (this.destroyed) return;
30286
+ this.partsMade ||= (this.makeParts(), true);
30287
+ this.partsPlaced = this.placeKey();
30288
+ if (this.attached) return; // children follow by themselves
30289
+ this.parts.forEach((part, i)=>
30290
+ {
30291
+ if (!(part instanceof EngineObject3D)) return; // what a function made is where it was made
30292
+ const to = level3DPrefabPartTransform(this, this.partObjects[i]);
30293
+ part.rotation3D = to.rotation;
30294
+ part.scale3D = (level3DBaseScale.get(part) ?? vec3(1)).multiply(to.scale);
30295
+ // a prefab inside this one goes by its origin, which is not where an attached one's handle is
30296
+ part instanceof Prefab3D ? part.placeAt(to.pos) : part.pos3D = to.pos;
30297
+ });
30298
+ }
30299
+
30300
+ // the handle's place, turn and size as one text, to tell when it has changed
30301
+ placeKey()
30302
+ {
30303
+ const p = this.pos3D, r = this.rotation3D, s = this.scale3D;
30304
+ return [p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z].join();
30305
+ }
30306
+
30307
+ // make the parts, each where the instance puts it, or attached at its own place inside the instance
30308
+ makeParts()
30309
+ {
30310
+ const prefab = level3DPrefabs.get(this.prefabName);
30311
+ if (!prefab) return;
30312
+ if (level3DPrefabDepth >= 8)
30313
+ {
30314
+ debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
30315
+ return;
30316
+ }
30317
+ ++level3DPrefabDepth;
30318
+ this.attached && ++level3DPrefabAttached;
30319
+ try
30320
+ {
30321
+ const low = vec3(Infinity), high = vec3(-Infinity), children = [];
30322
+ for (const object of prefab.objects)
30323
+ {
30324
+ const local = {pos: level3DVector(object.pos, vec3()), scale: level3DVector(object.scale, vec3(1)),
30325
+ rotation: level3DVector(object.rotation, vec3()).scale(PI / 180)};
30326
+ const at = this.attached ? local : level3DPrefabPartTransform(this, object);
30327
+ const part = level3DMakeAt(object, at.pos, at.rotation, at.scale);
30328
+ if (!part) continue; // level3DMakeAt gives an object or nothing
30329
+ this.parts.push(part);
30330
+ this.partObjects.push(object);
30331
+ if (!this.attached || !(part instanceof EngineObject3D)) continue;
30332
+ // a child rides with its parent and has no collision of its own
30333
+ part.setCollision(false, false, false);
30334
+ children.push(part);
30335
+ // the corners of its box as it is turned; its place is its middle, a prefab inside this one too
30336
+ const box = buildMatrix(part.pos3D, part.rotation3D, part.size3D.multiply(part.scale3D));
30337
+ for (let i = 8; i--;)
30338
+ {
30339
+ const p = box.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
30340
+ for (const k of ['x', 'y', 'z'])
30341
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
30342
+ }
30343
+ }
30344
+ // an attached instance is the box around its parts, its handle at the middle of it as an object's
30345
+ // place is the middle of its body, so a game that makes it solid has it solid where it is seen
30346
+ if (children.length)
30347
+ {
30348
+ this.size3D = high.subtract(low);
30349
+ this.originOffset = low.add(high).scale(.5);
30350
+ }
30351
+ for (const part of children)
30352
+ {
30353
+ part.pos3D = part.pos3D.subtract(this.originOffset);
30354
+ this.addChild(part);
30355
+ }
30356
+ }
30357
+ finally
30358
+ {
30359
+ --level3DPrefabDepth;
30360
+ this.attached && --level3DPrefabAttached;
30361
+ }
30362
+ }
30363
+
30364
+ /** Keep the parts with the handle, called automatically each frame */
30365
+ update()
30366
+ {
30367
+ super.update();
30368
+ if (!this.attached && this.partsPlaced !== this.placeKey())
30369
+ this.placeParts();
30370
+ }
30371
+
30372
+ /** Destroy the instance and its parts
30373
+ * @param {boolean} [immediate] */
30374
+ destroy(immediate)
30375
+ {
30376
+ if (this.destroyed) return;
30377
+ for (const part of this.parts)
30378
+ part.parent === this || part.destroy?.(immediate); // children go with their parent
30379
+ super.destroy(immediate);
30380
+ }
30381
+
30382
+ /** A prefab's instance has nothing of its own to draw */
30383
+ render3D() {}
30384
+ }
30385
+
29666
30386
  ///////////////////////////////////////////////////////////////////////////////
29667
30387
  // the built-in types
29668
30388
 
@@ -29876,7 +30596,9 @@ function particleEffectSanitize(raw)
29876
30596
  settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
29877
30597
  else if (isNumber(value))
29878
30598
  {
29879
- const clamped = clamp(value, setting.hardMin, setting.hardMax);
30599
+ // an angle past half a turn comes around, a clamp would point it the other way
30600
+ const wraps = setting.name === 'angle' && isFinite(value) && abs(value) > PI;
30601
+ const clamped = clamp(wraps ? mod(value + PI, 2*PI) - PI : value, setting.hardMin, setting.hardMax);
29880
30602
  settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
29881
30603
  }
29882
30604
  else
@@ -29955,9 +30677,11 @@ function particleEffectsGet(name) { return particleEffectsByName.get(String(name
29955
30677
  * @param {string} text
29956
30678
  * @return {Array<Object>}
29957
30679
  * @memberof ParticleEffects */
29958
- function particleEffectsParse(text)
30680
+ function particleEffectsParse(text) { return particleEffectsFromData(JSON.parse(text)); }
30681
+
30682
+ // a library file's data, or one effect's, into sanitized effects
30683
+ function particleEffectsFromData(data)
29959
30684
  {
29960
- const data = JSON.parse(text);
29961
30685
  const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
29962
30686
  if (!list || !list.length)
29963
30687
  throw new Error('no effects in this file');
@@ -29976,7 +30700,7 @@ function particleEffectsText(effects) { return JSON.stringify({version:1, effect
29976
30700
  * @memberof ParticleEffects */
29977
30701
  async function particleEffectsLoad(url)
29978
30702
  {
29979
- const effects = particleEffectsParse(JSON.stringify(await fetchJSON(url)));
30703
+ const effects = particleEffectsFromData(await fetchJSON(url));
29980
30704
  particleEffectsAdd(effects);
29981
30705
  return effects;
29982
30706
  }
@@ -30154,7 +30878,13 @@ function particleEffectTileInfo(s)
30154
30878
  * @memberof ParticleEffects */
30155
30879
  function particleEffectApply(emitter, effect)
30156
30880
  {
30157
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
30881
+ // an effect written by hand or changed since it was added
30882
+ particleEffectSet(emitter, particleEffectSanitize(effect));
30883
+ }
30884
+
30885
+ // set a 2D emitter to an effect that is sanitized already, as a play's is
30886
+ function particleEffectSet(emitter, effect)
30887
+ {
30158
30888
  const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
30159
30889
  for (const setting of particleEffectSettings)
30160
30890
  if (!particleEffectIndirect.includes(setting.name))
@@ -30190,15 +30920,14 @@ function particleEffectResolve(nameOrEffect, options)
30190
30920
  * - Attach it to an object with addChild to follow it
30191
30921
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30192
30922
  * @param {Vector2} [pos]
30193
- * @param {Object} [options]
30194
- * @param {number} [options.scale] - Grows the whole effect, the built-ins fit a one unit object at 1
30195
- * @param {number} [options.hue] - Turns its colors around the color wheel, 1 is all the way
30196
- * @param {number} [options.saturation] - Multiplies its saturation, 0 is grey
30197
- * @param {number} [options.angle] - Direction, 0 is up; the effect's own angle when not given
30198
- * @param {TileInfo|TextureInfo} [options.tileInfo] - The game's own art to draw with in place of the effect's shape,
30199
- * tinted by its colors; a whole texture draws as one tile
30200
- * @param {*} [options.settings] - Any effect setting by its name, emitTime, emitRate, speed and the rest, replacing
30201
- * the effect's own for this play
30923
+ * @param {Object} [options] - What to change for this play, each left out when not wanted:
30924
+ * scale grows the whole effect, the built-ins fit a one unit object at 1;
30925
+ * hue turns its colors around the color wheel, 1 is all the way;
30926
+ * saturation multiplies its saturation, 0 is grey;
30927
+ * angle is its direction, 0 is up, the effect's own angle when not given;
30928
+ * tileInfo, a TileInfo or a TextureInfo, is the game's own art to draw with in place of the effect's shape,
30929
+ * tinted by its colors, a whole texture drawn as one tile;
30930
+ * and any effect setting by its name, emitTime, emitRate, speed and the rest, replaces the effect's own
30202
30931
  * @return {ParticleEmitter|undefined} - undefined when there is no such effect
30203
30932
  * @memberof ParticleEffects */
30204
30933
  function particleEffect(nameOrEffect, pos=vec2(), options={})
@@ -30206,7 +30935,7 @@ function particleEffect(nameOrEffect, pos=vec2(), options={})
30206
30935
  const effect = particleEffectResolve(nameOrEffect, options);
30207
30936
  if (!effect) return;
30208
30937
  const emitter = new ParticleEmitter(pos.copy());
30209
- particleEffectApply(emitter, effect);
30938
+ particleEffectSet(emitter, effect);
30210
30939
  emitter.scale = options.scale ?? 1;
30211
30940
  if (options.angle !== undefined)
30212
30941
  emitter.angle = options.angle;
@@ -30270,7 +30999,7 @@ function particleEffectFromEmitter(emitter, name='Effect')
30270
30999
  p.velocity.y += (p.pos.x - c.x) * s * .002;
30271
31000
  };
30272
31001
  b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
30273
- // stick has no 3D push: particleEffect3D makes it the friction a particle lands with
31002
+ // stick has no 3D push: particleEffectApply3D makes it the emitter's stick, its grip on landing
30274
31003
  }
30275
31004
 
30276
31005
  /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
@@ -30279,7 +31008,13 @@ function particleEffectFromEmitter(emitter, name='Effect')
30279
31008
  * @memberof ParticleEffects */
30280
31009
  function particleEffectApply3D(emitter, effect)
30281
31010
  {
30282
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
31011
+ // an effect written by hand or changed since it was added
31012
+ particleEffectSet3D(emitter, particleEffectSanitize(effect));
31013
+ }
31014
+
31015
+ // set a 3D emitter to an effect that is sanitized already, as a play's is
31016
+ function particleEffectSet3D(emitter, effect)
31017
+ {
30283
31018
  const s = effect.settings, e = emitter;
30284
31019
  // a circle is a sphere and a rectangle a box as deep as it is wide
30285
31020
  e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
@@ -30292,8 +31027,8 @@ function particleEffectApply3D(emitter, effect)
30292
31027
  'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
30293
31028
  e[name] = s[name];
30294
31029
  e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
30295
- const stick = effect.behaviors.find(b=> b.name == 'stick'); // in 3D, a grip on landing is less sliding
30296
- e.friction = stick ? min(s.friction, 1 - stick.strength) : s.friction;
31030
+ e.friction = s.friction;
31031
+ e.stick = effect.behaviors.find(b=> b.name == 'stick')?.strength ?? 0; // in 3D the emitter grips on landing
30297
31032
  // a 2D emitter at angle a shoots along (sin a, cos a), a z turn r takes up to (-sin r, cos r), so r is -a
30298
31033
  e.rotation3D = vec3(0, 0, -s.angle);
30299
31034
  e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
@@ -30303,8 +31038,8 @@ function particleEffectApply3D(emitter, effect)
30303
31038
  * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
30304
31039
  * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
30305
31040
  * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
30306
- * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the friction a
30307
- * particle lands with
31041
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the emitter's
31042
+ * stick, its grip where a particle lands
30308
31043
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30309
31044
  * @param {Vector3} [pos3D]
30310
31045
  * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
@@ -30316,7 +31051,7 @@ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
30316
31051
  const effect = particleEffectResolve(nameOrEffect, options);
30317
31052
  if (!effect) return;
30318
31053
  const e = new ParticleEmitter3D(pos3D.copy());
30319
- particleEffectApply3D(e, effect);
31054
+ particleEffectSet3D(e, effect);
30320
31055
  if (options.angle !== undefined)
30321
31056
  e.rotation3D = vec3(0, 0, -options.angle);
30322
31057
  e.scale3D = vec3(options.scale ?? 1);
@@ -30847,6 +31582,11 @@ async function parseGLTF(data, baseUrl='')
30847
31582
  for (const name of json.extensionsRequired || [])
30848
31583
  if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
30849
31584
  throw new Error(`glTF with ${name} is not read, export it uncompressed`);
31585
+ else if (name === 'KHR_texture_basisu')
31586
+ throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg');
31587
+ // one that only may use them has other images to fall back on, or none: said, since it loads with them left out
31588
+ if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu'))
31589
+ console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg');
30850
31590
 
30851
31591
  // the buffers: the GLB's own, a data uri, or a file beside the model
30852
31592
  const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
@@ -30889,7 +31629,8 @@ async function parseGLTF(data, baseUrl='')
30889
31629
  const bitmap = normalTextures.has(index) ?
30890
31630
  await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
30891
31631
  opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
30892
- return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
31632
+ // REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are
31633
+ return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728);
30893
31634
  }
30894
31635
  catch (e) { false&&LOG('glTF image not loaded', e); }
30895
31636
  }));
@@ -30999,17 +31740,25 @@ function gltfOpaqueImage(image)
30999
31740
  {
31000
31741
  const gl = glContext, {width, height} = image;
31001
31742
  if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
31002
- const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
31003
- gl.bindTexture(gl.TEXTURE_2D, texture);
31004
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
31005
- gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
31006
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
31743
+ // the room to read into first: for a huge image it can fail, and must not with the framebuffer bound
31007
31744
  const data = new Uint8ClampedArray(width * height * 4);
31008
- gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
31009
- gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
31010
- gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
31011
- gl.deleteFramebuffer(framebuffer);
31012
- gl.deleteTexture(texture);
31745
+ const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
31746
+ try
31747
+ {
31748
+ gl.bindTexture(gl.TEXTURE_2D, texture);
31749
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
31750
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
31751
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
31752
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
31753
+ }
31754
+ finally
31755
+ {
31756
+ // the 2D renderer's own bindings back, whatever happened
31757
+ gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
31758
+ gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
31759
+ gl.deleteFramebuffer(framebuffer);
31760
+ gl.deleteTexture(texture);
31761
+ }
31013
31762
  for (let i = 3; i < data.length; i += 4)
31014
31763
  data[i] = 255;
31015
31764
  image.close();
@@ -31146,7 +31895,7 @@ function gltfAccessor(json, buffers, index)
31146
31895
  }
31147
31896
  if (scale !== 1)
31148
31897
  for (let i = 0; i < out.length; ++i)
31149
- out[i] /= scale;
31898
+ out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says
31150
31899
  return {data: out, components, count: a.count};
31151
31900
  }
31152
31901
 
@@ -31210,8 +31959,10 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
31210
31959
  const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
31211
31960
  if (er || eg || eb)
31212
31961
  {
31962
+ // KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender
31963
+ const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
31213
31964
  part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
31214
- part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
31965
+ part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength);
31215
31966
  }
31216
31967
  return part;
31217
31968
  }