littlejsengine 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +234 -0
- package/COPYRIGHT.txt +38 -38
- package/EDITOR.md +359 -0
- package/FAQ.md +4 -9
- package/README.md +14 -8
- package/REFERENCE.md +2327 -0
- package/dist/littlejs.d.ts +149 -104
- package/dist/littlejs.esm.js +1652 -1332
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +1651 -1328
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +1553 -1294
- package/package.json +5 -3
- package/plugins/gltf.js +29 -13
- package/plugins/pluginExport.js +0 -4
- package/plugins/postProcess.js +5 -2
- package/plugins/render3d.js +136 -31
- package/plugins/render3dEditor.js +51 -17
- package/plugins/render3dExtras.js +166 -70
- package/plugins/render3dLevel.js +52 -6
- package/plugins/textureSheet.js +1 -0
- package/plugins/tweenSystem.js +39 -34
- package/plugins/uiSystem.js +7 -3
- package/src/engine.js +1 -1
- package/src/engineAudio.js +9 -3
- package/src/engineBuild.mjs +17 -3
- package/src/engineDraw.js +11 -7
- package/src/engineEditor.js +52 -18
- package/src/engineExport.js +1 -0
- package/src/engineObject.js +675 -675
- package/src/engineRelease.js +5 -1
- package/src/engineTileLayer.js +119 -1
- package/src/engineUtilities.js +291 -291
- package/src/engineWebGL.js +12 -3
- package/plugins/zzfxm.js +0 -160
package/dist/littlejs.release.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.
|
|
38
|
+
const engineVersion = '1.24.0';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -984,7 +984,11 @@ function tweakDivider(){}
|
|
|
984
984
|
function tweakEngineDefaults(){}
|
|
985
985
|
class LevelEditor
|
|
986
986
|
{
|
|
987
|
-
constructor()
|
|
987
|
+
constructor()
|
|
988
|
+
{
|
|
989
|
+
this.paletteTiles = this.use3D = this.tool = undefined;
|
|
990
|
+
this.keys = {}, this.buttons = [], this.tools = {};
|
|
991
|
+
}
|
|
988
992
|
get isOpen() { return false; }
|
|
989
993
|
get is3D() { return false; }
|
|
990
994
|
get edit2D() { return undefined; }
|
|
@@ -2292,295 +2296,295 @@ const PURPLE = debugProtectConstant(rgb(.5,0,1));
|
|
|
2292
2296
|
* @type {Color}
|
|
2293
2297
|
* @memberof Math */
|
|
2294
2298
|
const MAGENTA = debugProtectConstant(rgb(1,0,1));
|
|
2295
|
-
/**
|
|
2296
|
-
* LittleJS Utility Classes and Functions
|
|
2297
|
-
* - Timer - tracks time automatically with support for pause and real-time modes
|
|
2298
|
-
* - Time formatting helper
|
|
2299
|
-
* - JSON file fetching
|
|
2300
|
-
* - File saving (text, canvas, data URLs)
|
|
2301
|
-
* - Native share dialog support
|
|
2302
|
-
* - Local storage save data management
|
|
2303
|
-
* - Value noise (1D and 2D)
|
|
2304
|
-
* @namespace Utilities
|
|
2305
|
-
*/
|
|
2306
|
-
|
|
2307
|
-
/**
|
|
2308
|
-
* Timer object tracks how long has passed since it was set
|
|
2309
|
-
* @memberof Engine
|
|
2310
|
-
* @example
|
|
2311
|
-
* let a = new Timer; // creates a timer that is not set
|
|
2312
|
-
* a.set(3); // sets the timer to 3 seconds
|
|
2313
|
-
*
|
|
2314
|
-
* let b = new Timer(1); // creates a timer with 1 second left
|
|
2315
|
-
* b.unset(); // unset the timer
|
|
2316
|
-
*/
|
|
2317
|
-
class Timer
|
|
2318
|
-
{
|
|
2319
|
-
/** Create a timer object set time passed in
|
|
2320
|
-
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
|
|
2321
|
-
* @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
|
|
2322
|
-
constructor(timeLeft, useRealTime=false)
|
|
2323
|
-
{
|
|
2324
|
-
false&&ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
|
|
2325
|
-
this.useRealTime = useRealTime;
|
|
2326
|
-
const globalTime = this.getGlobalTime();
|
|
2327
|
-
/** @type {number|undefined} */
|
|
2328
|
-
this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
|
|
2329
|
-
/** @type {number|undefined} */
|
|
2330
|
-
this.setTime = timeLeft;
|
|
2331
|
-
}
|
|
2332
|
-
|
|
2333
|
-
/** Set the timer with seconds passed in
|
|
2334
|
-
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
|
|
2335
|
-
set(timeLeft=0)
|
|
2336
|
-
{
|
|
2337
|
-
false&&ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
|
|
2338
|
-
const globalTime = this.getGlobalTime();
|
|
2339
|
-
this.time = globalTime + timeLeft;
|
|
2340
|
-
this.setTime = timeLeft;
|
|
2341
|
-
}
|
|
2342
|
-
|
|
2343
|
-
/** Set if the timer should keep running even when the game is paused
|
|
2344
|
-
* @param {boolean} [useRealTime] */
|
|
2345
|
-
setUseRealTime(useRealTime=true)
|
|
2346
|
-
{
|
|
2347
|
-
false&&ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
|
|
2348
|
-
this.useRealTime = useRealTime;
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
/** Unset the timer */
|
|
2352
|
-
unset() { this.time = undefined; }
|
|
2353
|
-
|
|
2354
|
-
/** Returns true if set
|
|
2355
|
-
* @return {boolean} */
|
|
2356
|
-
isSet() { return this.time !== undefined; }
|
|
2357
|
-
|
|
2358
|
-
/** Returns true if set and has not elapsed
|
|
2359
|
-
* @return {boolean} */
|
|
2360
|
-
active() { return this.getGlobalTime() < this.time; }
|
|
2361
|
-
|
|
2362
|
-
/** Returns true if set and elapsed
|
|
2363
|
-
* @return {boolean} */
|
|
2364
|
-
elapsed() { return this.getGlobalTime() >= this.time; }
|
|
2365
|
-
|
|
2366
|
-
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
2367
|
-
* @return {number} */
|
|
2368
|
-
get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
|
|
2369
|
-
|
|
2370
|
-
/** Get percentage elapsed based on time it was set to, returns 0 if not set.
|
|
2371
|
-
* Zero-duration timers report 1 (already elapsed).
|
|
2372
|
-
* @return {number} */
|
|
2373
|
-
getPercent()
|
|
2374
|
-
{
|
|
2375
|
-
if (!this.isSet()) return 0;
|
|
2376
|
-
if (!this.setTime) return 1;
|
|
2377
|
-
return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
/** Get the time this timer was set to, returns 0 if not set
|
|
2381
|
-
* @return {number} */
|
|
2382
|
-
getSetTime() { return this.isSet() ? this.setTime : 0; }
|
|
2383
|
-
|
|
2384
|
-
/** Get the current global time this timer is based on
|
|
2385
|
-
* @return {number} */
|
|
2386
|
-
getGlobalTime() { return this.useRealTime ? timeReal : time; }
|
|
2387
|
-
|
|
2388
|
-
/** Returns this timer expressed as a string
|
|
2389
|
-
* @return {string} */
|
|
2390
|
-
toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
|
|
2391
|
-
|
|
2392
|
-
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
2393
|
-
* @return {number} */
|
|
2394
|
-
valueOf() { return this.get(); }
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2398
|
-
|
|
2399
|
-
/** Formats seconds to mm:ss style for display purposes
|
|
2400
|
-
* @param {number} t - time in seconds
|
|
2401
|
-
* @return {string}
|
|
2402
|
-
* @memberof Utilities */
|
|
2403
|
-
function formatTime(t)
|
|
2404
|
-
{
|
|
2405
|
-
const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
|
|
2406
|
-
t = abs(t)|0;
|
|
2407
|
-
return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
|
|
2408
|
-
}
|
|
2409
|
-
|
|
2410
|
-
/** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
|
|
2411
|
-
* @param {string} url - URL of JSON file
|
|
2412
|
-
* @return {Promise<object>}
|
|
2413
|
-
* @memberof Utilities */
|
|
2414
|
-
async function fetchJSON(url)
|
|
2415
|
-
{
|
|
2416
|
-
const response = await fetch(url);
|
|
2417
|
-
if (!response.ok)
|
|
2418
|
-
throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
|
|
2419
|
-
const json = await response.json();
|
|
2420
|
-
editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
|
|
2421
|
-
return json;
|
|
2422
|
-
}
|
|
2423
|
-
|
|
2424
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2425
|
-
|
|
2426
|
-
/** Save a text file to disk
|
|
2427
|
-
* @param {string} text
|
|
2428
|
-
* @param {string} [filename]
|
|
2429
|
-
* @param {string} [type]
|
|
2430
|
-
* @memberof Utilities */
|
|
2431
|
-
function saveText(text, filename='text', type='text/plain')
|
|
2432
|
-
{ saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
|
|
2433
|
-
|
|
2434
|
-
/** Create an offscreen canvas to draw into, and return its 2D context
|
|
2435
|
-
* - The canvas is context.canvas, which is what TextureInfo and the like take
|
|
2436
|
-
* @param {number} width - In pixels
|
|
2437
|
-
* @param {number} [height] - In pixels, defaults to the width for a square
|
|
2438
|
-
* @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
|
|
2439
|
-
* @return {OffscreenCanvasRenderingContext2D}
|
|
2440
|
-
* @memberof Utilities */
|
|
2441
|
-
function createCanvasContext(width, height=width, willReadFrequently=false)
|
|
2442
|
-
{
|
|
2443
|
-
false&&ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
|
|
2444
|
-
return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
|
|
2445
|
-
}
|
|
2446
|
-
|
|
2447
|
-
/** Save a canvas to disk
|
|
2448
|
-
* @param {HTMLCanvasElement|OffscreenCanvas} canvas
|
|
2449
|
-
* @param {string} [filename]
|
|
2450
|
-
* @param {string} [type]
|
|
2451
|
-
* @memberof Utilities */
|
|
2452
|
-
function saveCanvas(canvas, filename='screenshot', type='image/png')
|
|
2453
|
-
{
|
|
2454
|
-
if (canvas instanceof OffscreenCanvas)
|
|
2455
|
-
{
|
|
2456
|
-
// copy to temporary canvas and save
|
|
2457
|
-
const saveCanvas = document.createElement('canvas');
|
|
2458
|
-
saveCanvas.width = canvas.width;
|
|
2459
|
-
saveCanvas.height = canvas.height;
|
|
2460
|
-
saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
|
|
2461
|
-
saveDataURL(saveCanvas.toDataURL(type), filename);
|
|
2462
|
-
}
|
|
2463
|
-
else
|
|
2464
|
-
saveDataURL(canvas.toDataURL(type), filename);
|
|
2465
|
-
}
|
|
2466
|
-
|
|
2467
|
-
/** Save a data url to disk
|
|
2468
|
-
* @param {string} url
|
|
2469
|
-
* @param {string} [filename]
|
|
2470
|
-
* @param {number} [revokeTime] - how long before revoking the url
|
|
2471
|
-
* @memberof Utilities */
|
|
2472
|
-
function saveDataURL(url, filename='download', revokeTime)
|
|
2473
|
-
{
|
|
2474
|
-
false&&ASSERT(isStringLike(url), 'saveDataURL requires url string');
|
|
2475
|
-
false&&ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
|
|
2476
|
-
|
|
2477
|
-
// create link for saving screenshots
|
|
2478
|
-
const link = document.createElement('a');
|
|
2479
|
-
link.download = filename;
|
|
2480
|
-
link.href = url;
|
|
2481
|
-
link.click();
|
|
2482
|
-
if (revokeTime !== undefined)
|
|
2483
|
-
setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
/** Share content using the native share dialog if available
|
|
2487
|
-
* @param {string} title - title of the share
|
|
2488
|
-
* @param {string} url - url to share
|
|
2489
|
-
* @param {Function} [callback] - Called when share is complete
|
|
2490
|
-
* @memberof Utilities */
|
|
2491
|
-
function shareURL(title, url, callback)
|
|
2492
|
-
{
|
|
2493
|
-
false&&ASSERT(isStringLike(title), 'shareURL requires title string');
|
|
2494
|
-
false&&ASSERT(isStringLike(url), 'shareURL requires url string');
|
|
2495
|
-
navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
|
|
2496
|
-
}
|
|
2497
|
-
|
|
2498
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2499
|
-
|
|
2500
|
-
/** Read save data from local storage
|
|
2501
|
-
* - The result has the type of defaultSaveData, or any when there is none
|
|
2502
|
-
* @template {Object<string, any>} [T=any]
|
|
2503
|
-
* @param {string} saveName - unique name for the game/save
|
|
2504
|
-
* @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
|
|
2505
|
-
* @return {T}
|
|
2506
|
-
* @memberof Utilities */
|
|
2507
|
-
function readSaveData(saveName, defaultSaveData)
|
|
2508
|
-
{
|
|
2509
|
-
false&&ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
|
|
2510
|
-
false&&ASSERT(defaultSaveData === undefined ||
|
|
2511
|
-
(typeof defaultSaveData === 'object' && defaultSaveData !== null),
|
|
2512
|
-
'readSaveData: default must be an object - the result is ' +
|
|
2513
|
-
'{...default, ...loaded}, so a scalar default yields {}. ' +
|
|
2514
|
-
'Use readSaveData(key, {best:0}).best');
|
|
2515
|
-
|
|
2516
|
-
// tolerate localStorage being unavailable (iOS private mode, sandboxed
|
|
2517
|
-
// iframes) and corrupt JSON in stored data
|
|
2518
|
-
let loadedData = {};
|
|
2519
|
-
try
|
|
2520
|
-
{
|
|
2521
|
-
const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
|
|
2522
|
-
if (data)
|
|
2523
|
-
{
|
|
2524
|
-
try { loadedData = JSON.parse(data); }
|
|
2525
|
-
catch { false&&LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
2526
|
-
}
|
|
2527
|
-
}
|
|
2528
|
-
catch { false&&LOG('readSaveData: localStorage unavailable — using defaults'); }
|
|
2529
|
-
return { .../** @type {object} */ (defaultSaveData), ...loadedData };
|
|
2530
|
-
}
|
|
2531
|
-
|
|
2532
|
-
/** Write save data to local storage
|
|
2533
|
-
* @param {string} saveName - unique name for the game/save
|
|
2534
|
-
* @param {object} saveData - object containing data to be saved
|
|
2535
|
-
* @memberof Utilities */
|
|
2536
|
-
function writeSaveData(saveName, saveData)
|
|
2537
|
-
{
|
|
2538
|
-
false&&ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
|
|
2539
|
-
// tolerate localStorage being unavailable or quota exceeded
|
|
2540
|
-
try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
|
|
2541
|
-
catch { false&&LOG('writeSaveData: failed to write', saveName); }
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2545
|
-
|
|
2546
|
-
// Deterministic well-distributed hash of an integer lattice index to [0, 1).
|
|
2547
|
-
// Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
|
|
2548
|
-
function noiseHash(i)
|
|
2549
|
-
{
|
|
2550
|
-
let h = (i | 0) ^ 0x9e3779b9;
|
|
2551
|
-
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
|
|
2552
|
-
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
2553
|
-
h ^= h >>> 16;
|
|
2554
|
-
return (h >>> 0) / 2**32;
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
|
-
/** 1D value noise — returns a smooth value in [0, 1] for any real x.
|
|
2558
|
-
* Integer inputs land on deterministic lattice values; non-integer inputs
|
|
2559
|
-
* are interpolated with smoothStep for C1 continuity.
|
|
2560
|
-
* @param {number} x
|
|
2561
|
-
* @return {number}
|
|
2562
|
-
* @memberof Utilities */
|
|
2563
|
-
function noise1D(x)
|
|
2564
|
-
{
|
|
2565
|
-
const i = floor(x);
|
|
2566
|
-
return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
|
|
2567
|
-
}
|
|
2568
|
-
|
|
2569
|
-
/** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
|
|
2570
|
-
* @param {number} x
|
|
2571
|
-
* @param {number} y
|
|
2572
|
-
* @return {number}
|
|
2573
|
-
* @memberof Utilities */
|
|
2574
|
-
function noise2D(x, y)
|
|
2575
|
-
{
|
|
2576
|
-
const ix = floor(x), iy = floor(y);
|
|
2577
|
-
const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
|
|
2578
|
-
// large prime decorrelates neighboring rows
|
|
2579
|
-
const h = (a, b) => noiseHash(a + b * 374761393);
|
|
2580
|
-
return lerp(
|
|
2581
|
-
lerp(h(ix, iy ), h(ix + 1, iy ), fx),
|
|
2582
|
-
lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
|
|
2583
|
-
fy);
|
|
2299
|
+
/**
|
|
2300
|
+
* LittleJS Utility Classes and Functions
|
|
2301
|
+
* - Timer - tracks time automatically with support for pause and real-time modes
|
|
2302
|
+
* - Time formatting helper
|
|
2303
|
+
* - JSON file fetching
|
|
2304
|
+
* - File saving (text, canvas, data URLs)
|
|
2305
|
+
* - Native share dialog support
|
|
2306
|
+
* - Local storage save data management
|
|
2307
|
+
* - Value noise (1D and 2D)
|
|
2308
|
+
* @namespace Utilities
|
|
2309
|
+
*/
|
|
2310
|
+
|
|
2311
|
+
/**
|
|
2312
|
+
* Timer object tracks how long has passed since it was set
|
|
2313
|
+
* @memberof Engine
|
|
2314
|
+
* @example
|
|
2315
|
+
* let a = new Timer; // creates a timer that is not set
|
|
2316
|
+
* a.set(3); // sets the timer to 3 seconds
|
|
2317
|
+
*
|
|
2318
|
+
* let b = new Timer(1); // creates a timer with 1 second left
|
|
2319
|
+
* b.unset(); // unset the timer
|
|
2320
|
+
*/
|
|
2321
|
+
class Timer
|
|
2322
|
+
{
|
|
2323
|
+
/** Create a timer object set time passed in
|
|
2324
|
+
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
|
|
2325
|
+
* @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
|
|
2326
|
+
constructor(timeLeft, useRealTime=false)
|
|
2327
|
+
{
|
|
2328
|
+
false&&ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
|
|
2329
|
+
this.useRealTime = useRealTime;
|
|
2330
|
+
const globalTime = this.getGlobalTime();
|
|
2331
|
+
/** @type {number|undefined} */
|
|
2332
|
+
this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
|
|
2333
|
+
/** @type {number|undefined} */
|
|
2334
|
+
this.setTime = timeLeft;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/** Set the timer with seconds passed in
|
|
2338
|
+
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
|
|
2339
|
+
set(timeLeft=0)
|
|
2340
|
+
{
|
|
2341
|
+
false&&ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
|
|
2342
|
+
const globalTime = this.getGlobalTime();
|
|
2343
|
+
this.time = globalTime + timeLeft;
|
|
2344
|
+
this.setTime = timeLeft;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/** Set if the timer should keep running even when the game is paused
|
|
2348
|
+
* @param {boolean} [useRealTime] */
|
|
2349
|
+
setUseRealTime(useRealTime=true)
|
|
2350
|
+
{
|
|
2351
|
+
false&&ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
|
|
2352
|
+
this.useRealTime = useRealTime;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
/** Unset the timer */
|
|
2356
|
+
unset() { this.time = undefined; }
|
|
2357
|
+
|
|
2358
|
+
/** Returns true if set
|
|
2359
|
+
* @return {boolean} */
|
|
2360
|
+
isSet() { return this.time !== undefined; }
|
|
2361
|
+
|
|
2362
|
+
/** Returns true if set and has not elapsed
|
|
2363
|
+
* @return {boolean} */
|
|
2364
|
+
active() { return this.getGlobalTime() < this.time; }
|
|
2365
|
+
|
|
2366
|
+
/** Returns true if set and elapsed
|
|
2367
|
+
* @return {boolean} */
|
|
2368
|
+
elapsed() { return this.getGlobalTime() >= this.time; }
|
|
2369
|
+
|
|
2370
|
+
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
2371
|
+
* @return {number} */
|
|
2372
|
+
get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
|
|
2373
|
+
|
|
2374
|
+
/** Get percentage elapsed based on time it was set to, returns 0 if not set.
|
|
2375
|
+
* Zero-duration timers report 1 (already elapsed).
|
|
2376
|
+
* @return {number} */
|
|
2377
|
+
getPercent()
|
|
2378
|
+
{
|
|
2379
|
+
if (!this.isSet()) return 0;
|
|
2380
|
+
if (!(this.setTime > 0)) return 1;
|
|
2381
|
+
return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/** Get the time this timer was set to, returns 0 if not set
|
|
2385
|
+
* @return {number} */
|
|
2386
|
+
getSetTime() { return this.isSet() ? this.setTime : 0; }
|
|
2387
|
+
|
|
2388
|
+
/** Get the current global time this timer is based on
|
|
2389
|
+
* @return {number} */
|
|
2390
|
+
getGlobalTime() { return this.useRealTime ? timeReal : time; }
|
|
2391
|
+
|
|
2392
|
+
/** Returns this timer expressed as a string
|
|
2393
|
+
* @return {string} */
|
|
2394
|
+
toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
|
|
2395
|
+
|
|
2396
|
+
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
|
|
2397
|
+
* @return {number} */
|
|
2398
|
+
valueOf() { return this.get(); }
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2402
|
+
|
|
2403
|
+
/** Formats seconds to mm:ss style for display purposes
|
|
2404
|
+
* @param {number} t - time in seconds
|
|
2405
|
+
* @return {string}
|
|
2406
|
+
* @memberof Utilities */
|
|
2407
|
+
function formatTime(t)
|
|
2408
|
+
{
|
|
2409
|
+
const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
|
|
2410
|
+
t = abs(t)|0;
|
|
2411
|
+
return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
|
|
2415
|
+
* @param {string} url - URL of JSON file
|
|
2416
|
+
* @return {Promise<object>}
|
|
2417
|
+
* @memberof Utilities */
|
|
2418
|
+
async function fetchJSON(url)
|
|
2419
|
+
{
|
|
2420
|
+
const response = await fetch(url);
|
|
2421
|
+
if (!response.ok)
|
|
2422
|
+
throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
|
|
2423
|
+
const json = await response.json();
|
|
2424
|
+
editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
|
|
2425
|
+
return json;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2429
|
+
|
|
2430
|
+
/** Save a text file to disk
|
|
2431
|
+
* @param {string} text
|
|
2432
|
+
* @param {string} [filename]
|
|
2433
|
+
* @param {string} [type]
|
|
2434
|
+
* @memberof Utilities */
|
|
2435
|
+
function saveText(text, filename='text', type='text/plain')
|
|
2436
|
+
{ saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
|
|
2437
|
+
|
|
2438
|
+
/** Create an offscreen canvas to draw into, and return its 2D context
|
|
2439
|
+
* - The canvas is context.canvas, which is what TextureInfo and the like take
|
|
2440
|
+
* @param {number} width - In pixels
|
|
2441
|
+
* @param {number} [height] - In pixels, defaults to the width for a square
|
|
2442
|
+
* @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
|
|
2443
|
+
* @return {OffscreenCanvasRenderingContext2D}
|
|
2444
|
+
* @memberof Utilities */
|
|
2445
|
+
function createCanvasContext(width, height=width, willReadFrequently=false)
|
|
2446
|
+
{
|
|
2447
|
+
false&&ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
|
|
2448
|
+
return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/** Save a canvas to disk
|
|
2452
|
+
* @param {HTMLCanvasElement|OffscreenCanvas} canvas
|
|
2453
|
+
* @param {string} [filename]
|
|
2454
|
+
* @param {string} [type]
|
|
2455
|
+
* @memberof Utilities */
|
|
2456
|
+
function saveCanvas(canvas, filename='screenshot', type='image/png')
|
|
2457
|
+
{
|
|
2458
|
+
if (canvas instanceof OffscreenCanvas)
|
|
2459
|
+
{
|
|
2460
|
+
// copy to temporary canvas and save
|
|
2461
|
+
const saveCanvas = document.createElement('canvas');
|
|
2462
|
+
saveCanvas.width = canvas.width;
|
|
2463
|
+
saveCanvas.height = canvas.height;
|
|
2464
|
+
saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
|
|
2465
|
+
saveDataURL(saveCanvas.toDataURL(type), filename);
|
|
2466
|
+
}
|
|
2467
|
+
else
|
|
2468
|
+
saveDataURL(canvas.toDataURL(type), filename);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** Save a data url to disk
|
|
2472
|
+
* @param {string} url
|
|
2473
|
+
* @param {string} [filename]
|
|
2474
|
+
* @param {number} [revokeTime] - how long before revoking the url
|
|
2475
|
+
* @memberof Utilities */
|
|
2476
|
+
function saveDataURL(url, filename='download', revokeTime)
|
|
2477
|
+
{
|
|
2478
|
+
false&&ASSERT(isStringLike(url), 'saveDataURL requires url string');
|
|
2479
|
+
false&&ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
|
|
2480
|
+
|
|
2481
|
+
// create link for saving screenshots
|
|
2482
|
+
const link = document.createElement('a');
|
|
2483
|
+
link.download = filename;
|
|
2484
|
+
link.href = url;
|
|
2485
|
+
link.click();
|
|
2486
|
+
if (revokeTime !== undefined)
|
|
2487
|
+
setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/** Share content using the native share dialog if available
|
|
2491
|
+
* @param {string} title - title of the share
|
|
2492
|
+
* @param {string} url - url to share
|
|
2493
|
+
* @param {Function} [callback] - Called when share is complete
|
|
2494
|
+
* @memberof Utilities */
|
|
2495
|
+
function shareURL(title, url, callback)
|
|
2496
|
+
{
|
|
2497
|
+
false&&ASSERT(isStringLike(title), 'shareURL requires title string');
|
|
2498
|
+
false&&ASSERT(isStringLike(url), 'shareURL requires url string');
|
|
2499
|
+
navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2503
|
+
|
|
2504
|
+
/** Read save data from local storage
|
|
2505
|
+
* - The result has the type of defaultSaveData, or any when there is none
|
|
2506
|
+
* @template {Object<string, any>} [T=any]
|
|
2507
|
+
* @param {string} saveName - unique name for the game/save
|
|
2508
|
+
* @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
|
|
2509
|
+
* @return {T}
|
|
2510
|
+
* @memberof Utilities */
|
|
2511
|
+
function readSaveData(saveName, defaultSaveData)
|
|
2512
|
+
{
|
|
2513
|
+
false&&ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
|
|
2514
|
+
false&&ASSERT(defaultSaveData === undefined ||
|
|
2515
|
+
(typeof defaultSaveData === 'object' && defaultSaveData !== null),
|
|
2516
|
+
'readSaveData: default must be an object - the result is ' +
|
|
2517
|
+
'{...default, ...loaded}, so a scalar default yields {}. ' +
|
|
2518
|
+
'Use readSaveData(key, {best:0}).best');
|
|
2519
|
+
|
|
2520
|
+
// tolerate localStorage being unavailable (iOS private mode, sandboxed
|
|
2521
|
+
// iframes) and corrupt JSON in stored data
|
|
2522
|
+
let loadedData = {};
|
|
2523
|
+
try
|
|
2524
|
+
{
|
|
2525
|
+
const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
|
|
2526
|
+
if (data)
|
|
2527
|
+
{
|
|
2528
|
+
try { loadedData = JSON.parse(data); }
|
|
2529
|
+
catch { false&&LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
catch { false&&LOG('readSaveData: localStorage unavailable — using defaults'); }
|
|
2533
|
+
return { .../** @type {object} */ (defaultSaveData), ...loadedData };
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/** Write save data to local storage
|
|
2537
|
+
* @param {string} saveName - unique name for the game/save
|
|
2538
|
+
* @param {object} saveData - object containing data to be saved
|
|
2539
|
+
* @memberof Utilities */
|
|
2540
|
+
function writeSaveData(saveName, saveData)
|
|
2541
|
+
{
|
|
2542
|
+
false&&ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
|
|
2543
|
+
// tolerate localStorage being unavailable or quota exceeded
|
|
2544
|
+
try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
|
|
2545
|
+
catch { false&&LOG('writeSaveData: failed to write', saveName); }
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2549
|
+
|
|
2550
|
+
// Deterministic well-distributed hash of an integer lattice index to [0, 1).
|
|
2551
|
+
// Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
|
|
2552
|
+
function noiseHash(i)
|
|
2553
|
+
{
|
|
2554
|
+
let h = (i | 0) ^ 0x9e3779b9;
|
|
2555
|
+
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
|
|
2556
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
2557
|
+
h ^= h >>> 16;
|
|
2558
|
+
return (h >>> 0) / 2**32;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
/** 1D value noise — returns a smooth value in [0, 1] for any real x.
|
|
2562
|
+
* Integer inputs land on deterministic lattice values; non-integer inputs
|
|
2563
|
+
* are interpolated with smoothStep for C1 continuity.
|
|
2564
|
+
* @param {number} x
|
|
2565
|
+
* @return {number}
|
|
2566
|
+
* @memberof Utilities */
|
|
2567
|
+
function noise1D(x)
|
|
2568
|
+
{
|
|
2569
|
+
const i = floor(x);
|
|
2570
|
+
return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
/** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
|
|
2574
|
+
* @param {number} x
|
|
2575
|
+
* @param {number} y
|
|
2576
|
+
* @return {number}
|
|
2577
|
+
* @memberof Utilities */
|
|
2578
|
+
function noise2D(x, y)
|
|
2579
|
+
{
|
|
2580
|
+
const ix = floor(x), iy = floor(y);
|
|
2581
|
+
const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
|
|
2582
|
+
// large prime decorrelates neighboring rows
|
|
2583
|
+
const h = (a, b) => noiseHash(a + b * 374761393);
|
|
2584
|
+
return lerp(
|
|
2585
|
+
lerp(h(ix, iy ), h(ix + 1, iy ), fx),
|
|
2586
|
+
lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
|
|
2587
|
+
fy);
|
|
2584
2588
|
}
|
|
2585
2589
|
/**
|
|
2586
2590
|
* LittleJS Engine Settings
|
|
@@ -3440,679 +3444,679 @@ function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; }
|
|
|
3440
3444
|
* @param {boolean} [show]
|
|
3441
3445
|
* @memberof Debug */
|
|
3442
3446
|
function setDebugTweakables(show=true) { debugTweakables = show; }
|
|
3443
|
-
/**
|
|
3444
|
-
* LittleJS Object System
|
|
3445
|
-
* - EngineObject is the base class for all game objects
|
|
3446
|
-
* - Handles automatic updating, rendering, physics, and collision
|
|
3447
|
-
* - Supports parent-child hierarchies with transform inheritance
|
|
3448
|
-
* - 2D physics with velocity, acceleration, damping, and gravity
|
|
3449
|
-
* - Collision system with tiles and other objects
|
|
3450
|
-
* - Renders sprites from tile sheets with color and rotation
|
|
3451
|
-
* - Objects sorted by renderOrder for layered rendering
|
|
3452
|
-
*/
|
|
3453
|
-
|
|
3454
|
-
/**
|
|
3455
|
-
* LittleJS Object Base Object Class
|
|
3456
|
-
* - Top level object class used by the engine
|
|
3457
|
-
* - Automatically adds self to object list
|
|
3458
|
-
* - Will be updated and rendered each frame
|
|
3459
|
-
* - Renders as a sprite from a tilesheet by default
|
|
3460
|
-
* - Can have color and additive color applied
|
|
3461
|
-
* - 2D Physics and collision system
|
|
3462
|
-
* - Sorted by renderOrder
|
|
3463
|
-
* - Objects can have children attached
|
|
3464
|
-
* - Parents are updated before children, and set child transform
|
|
3465
|
-
* - Call destroy() to get rid of objects
|
|
3466
|
-
*
|
|
3467
|
-
* The physics system used by objects is simple and fast with some caveats...
|
|
3468
|
-
* - Collision uses the axis aligned size, the object's rotation angle is only for rendering
|
|
3469
|
-
* - Objects are guaranteed to not intersect tile collision from physics
|
|
3470
|
-
* - If an object starts or is moved inside tile collision, it will not collide with that tile
|
|
3471
|
-
* - Collision for objects can be set to be solid to block other objects
|
|
3472
|
-
* - Objects may get pushed into overlapping other solid objects, if so they will push away
|
|
3473
|
-
* - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
|
|
3474
|
-
* - Solid objects are more performance intensive and should be used sparingly
|
|
3475
|
-
* @memberof Engine
|
|
3476
|
-
* @example
|
|
3477
|
-
* // create an engine object, normally you would first extend the class with your own
|
|
3478
|
-
* const pos = vec2(2,3);
|
|
3479
|
-
* const object = new EngineObject(pos);
|
|
3480
|
-
*/
|
|
3481
|
-
class EngineObject
|
|
3482
|
-
{
|
|
3483
|
-
/** Create an engine object and adds it to the list of objects
|
|
3484
|
-
* @param {Vector2} [pos=vec2()] - World space position of the object
|
|
3485
|
-
* @param {Vector2} [size=vec2(1)] - World space size of the object
|
|
3486
|
-
* @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
|
|
3487
|
-
* @param {number} [angle] - Angle the object is rotated by
|
|
3488
|
-
* @param {Color} [color=WHITE] - Color to apply to tile when rendered
|
|
3489
|
-
* @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
|
|
3490
|
-
*/
|
|
3491
|
-
constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
|
|
3492
|
-
{
|
|
3493
|
-
// check passed in params
|
|
3494
|
-
false&&ASSERT(isVector2(pos), 'object pos must be a vec2');
|
|
3495
|
-
false&&ASSERT(isVector2(size), 'object size must be a vec2');
|
|
3496
|
-
false&&ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
|
|
3497
|
-
false&&ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
|
|
3498
|
-
false&&ASSERT(isColor(color), 'object color should be a valid rgba color');
|
|
3499
|
-
false&&ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
|
|
3500
|
-
|
|
3501
|
-
/** @property {Vector2} - World space position of the object */
|
|
3502
|
-
this.pos = pos.copy();
|
|
3503
|
-
/** @property {Vector2} - World space width and height of the object */
|
|
3504
|
-
this.size = size.copy();
|
|
3505
|
-
/** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
|
|
3506
|
-
* @type {Vector2|undefined} */
|
|
3507
|
-
this.drawSize = undefined;
|
|
3508
|
-
/** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
|
|
3509
|
-
* @type {TileInfo|undefined} */
|
|
3510
|
-
this.tileInfo = tileInfo;
|
|
3511
|
-
/** @property {number} - Angle to rotate the object */
|
|
3512
|
-
this.angle = angle;
|
|
3513
|
-
/** @property {Color} - Color to apply when rendered */
|
|
3514
|
-
this.color = color.copy();
|
|
3515
|
-
/** @property {Color|undefined} - Additive color to apply when rendered
|
|
3516
|
-
* @type {Color|undefined} */
|
|
3517
|
-
this.additiveColor = undefined;
|
|
3518
|
-
/** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
|
|
3519
|
-
* @type {Shader|undefined} */
|
|
3520
|
-
this.shader = undefined;
|
|
3521
|
-
/** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
|
|
3522
|
-
this.castShadow = true;
|
|
3523
|
-
/** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
|
|
3524
|
-
* brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
|
|
3525
|
-
* renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
|
|
3526
|
-
* too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
|
|
3527
|
-
this.emissive = 0;
|
|
3528
|
-
/** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
|
|
3529
|
-
this.mirror = false;
|
|
3530
|
-
/** @property {boolean} - Has object been destroyed? */
|
|
3531
|
-
this.destroyed = false;
|
|
3532
|
-
this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
|
|
3533
|
-
|
|
3534
|
-
// physical properties
|
|
3535
|
-
/** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
|
|
3536
|
-
* collide on its own, the moving ones collide with it */
|
|
3537
|
-
this.mass = objectDefaultMass;
|
|
3538
|
-
/** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
3539
|
-
this.damping = objectDefaultDamping;
|
|
3540
|
-
/** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
3541
|
-
this.angleDamping = objectDefaultAngleDamping;
|
|
3542
|
-
/** @property {number} - How bouncy the object is when colliding (0-1) */
|
|
3543
|
-
this.restitution = objectDefaultRestitution;
|
|
3544
|
-
/** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
|
|
3545
|
-
* once, the more slippery of the object and its ground is used */
|
|
3546
|
-
this.friction = objectDefaultFriction;
|
|
3547
|
-
/** @property {number} - How much to scale gravity by for this object */
|
|
3548
|
-
this.gravityScale = 1;
|
|
3549
|
-
/** @property {number} - Objects are sorted by render order */
|
|
3550
|
-
this.renderOrder = renderOrder;
|
|
3551
|
-
/** @property {Vector2} - Velocity of the object, in world units per frame */
|
|
3552
|
-
this.velocity = vec2();
|
|
3553
|
-
/** @property {number} - Angular velocity of the object, in radians per frame */
|
|
3554
|
-
this.angleVelocity = 0;
|
|
3555
|
-
/** @property {number} - Track when object was created */
|
|
3556
|
-
this.spawnTime = time;
|
|
3557
|
-
/** @property {Array<EngineObject>} - List of children of this object
|
|
3558
|
-
* @type {Array<EngineObject>} */
|
|
3559
|
-
this.children = [];
|
|
3560
|
-
/** @property {boolean} - Limit object speed along x and y axis */
|
|
3561
|
-
this.clampSpeed = true;
|
|
3562
|
-
/** @property {EngineObject|undefined} - Object we are standing on, if any
|
|
3563
|
-
* @type {EngineObject|undefined} */
|
|
3564
|
-
this.groundObject = undefined;
|
|
3565
|
-
|
|
3566
|
-
// parent child system
|
|
3567
|
-
/** @property {EngineObject|undefined} - Parent of object if in local space
|
|
3568
|
-
* @type {EngineObject|undefined} */
|
|
3569
|
-
this.parent = undefined;
|
|
3570
|
-
/** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
|
|
3571
|
-
* @type {Vector2|undefined} */
|
|
3572
|
-
this.localPos = undefined;
|
|
3573
|
-
/** @property {number} - Local angle if child */
|
|
3574
|
-
this.localAngle = 0;
|
|
3575
|
-
|
|
3576
|
-
// collision flags
|
|
3577
|
-
/** @property {boolean} - Object collides with the level, its tile collision layers
|
|
3578
|
-
* @type {boolean} */
|
|
3579
|
-
this.collideLevel = false;
|
|
3580
|
-
/** @property {boolean} - Object collides with solid objects */
|
|
3581
|
-
this.collideSolidObjects = false;
|
|
3582
|
-
/** @property {boolean} - Object collides with and blocks other objects */
|
|
3583
|
-
this.isSolid = false;
|
|
3584
|
-
/** @property {boolean} - Object collides with raycasts */
|
|
3585
|
-
this.collideRaycast = false;
|
|
3586
|
-
|
|
3587
|
-
/** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
|
|
3588
|
-
* - Calling destroy on it still destroys it, and its children go with it either way */
|
|
3589
|
-
this.persistent = false;
|
|
3590
|
-
|
|
3591
|
-
// add to list of objects
|
|
3592
|
-
engineObjects.push(this);
|
|
3593
|
-
}
|
|
3594
|
-
|
|
3595
|
-
/** Update the object transform, called automatically by engine even when paused
|
|
3596
|
-
* @param {boolean} [updateChildren] - Also update the children's transforms */
|
|
3597
|
-
updateTransforms(updateChildren=true)
|
|
3598
|
-
{
|
|
3599
|
-
const parent = this.parent;
|
|
3600
|
-
if (parent)
|
|
3601
|
-
{
|
|
3602
|
-
// compose with parent transform inline to avoid intermediate vector allocs
|
|
3603
|
-
const mirror = parent.getMirrorSign();
|
|
3604
|
-
const lp = this.localPos, pp = parent.pos;
|
|
3605
|
-
const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
|
|
3606
|
-
if (pa)
|
|
3607
|
-
{
|
|
3608
|
-
const c = cos(-pa), s = sin(-pa);
|
|
3609
|
-
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
3610
|
-
}
|
|
3611
|
-
else
|
|
3612
|
-
this.pos.set(lx + pp.x, ly + pp.y);
|
|
3613
|
-
this.angle = mirror*this.localAngle + pa;
|
|
3614
|
-
}
|
|
3615
|
-
|
|
3616
|
-
// update children
|
|
3617
|
-
if (updateChildren)
|
|
3618
|
-
for (const child of this.children)
|
|
3619
|
-
child.updateTransforms();
|
|
3620
|
-
}
|
|
3621
|
-
|
|
3622
|
-
/** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
|
|
3623
|
-
updatePhysics()
|
|
3624
|
-
{
|
|
3625
|
-
// child objects do not have physics
|
|
3626
|
-
false&&ASSERT(!this.parent);
|
|
3627
|
-
|
|
3628
|
-
// bail if a collision callback destroyed us mid-frame
|
|
3629
|
-
if (this.destroyed) return;
|
|
3630
|
-
|
|
3631
|
-
// limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
|
|
3632
|
-
// has a mass, which tile collision needs; anything else moves as fast as it is told
|
|
3633
|
-
if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
|
|
3634
|
-
{
|
|
3635
|
-
this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
|
|
3636
|
-
this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
|
|
3637
|
-
}
|
|
3638
|
-
|
|
3639
|
-
// physics sanity checks
|
|
3640
|
-
false&&ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
|
|
3641
|
-
false&&ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
|
|
3642
|
-
|
|
3643
|
-
// apply physics; only the solver needs where the object was, so only then is it copied
|
|
3644
|
-
const solve = enablePhysicsSolver && this.mass;
|
|
3645
|
-
const oldPos = solve ? this.pos.copy() : undefined;
|
|
3646
|
-
this.velocity.x *= this.damping;
|
|
3647
|
-
this.velocity.y *= this.damping;
|
|
3648
|
-
if (this.mass)
|
|
3649
|
-
{
|
|
3650
|
-
// apply gravity only if it has mass
|
|
3651
|
-
this.velocity.x += gravity.x * this.gravityScale;
|
|
3652
|
-
this.velocity.y += gravity.y * this.gravityScale;
|
|
3653
|
-
}
|
|
3654
|
-
this.pos.x += this.velocity.x;
|
|
3655
|
-
this.pos.y += this.velocity.y;
|
|
3656
|
-
this.angle += this.angleVelocity *= this.angleDamping;
|
|
3657
|
-
|
|
3658
|
-
// don't do collision for static objects or if solver disabled
|
|
3659
|
-
if (!solve) return;
|
|
3660
|
-
|
|
3661
|
-
// which way is down for this object, a negative gravityScale falls up and lands on ceilings
|
|
3662
|
-
const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
|
|
3663
|
-
const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
|
|
3664
|
-
if (this.groundObject)
|
|
3665
|
-
{
|
|
3666
|
-
// apply friction in local space of ground object
|
|
3667
|
-
const friction = max(this.friction, this.groundObject.friction);
|
|
3668
|
-
const groundSpeed = this.groundObject.velocity.x;
|
|
3669
|
-
this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
|
|
3670
|
-
this.groundObject = undefined;
|
|
3671
|
-
}
|
|
3672
|
-
|
|
3673
|
-
// an object with no width or height has no box to push out of, or to be pushed out of
|
|
3674
|
-
if (this.collideSolidObjects && this.size.x && this.size.y)
|
|
3675
|
-
{
|
|
3676
|
-
// check collisions against solid objects
|
|
3677
|
-
const epsilon = .001; // necessary to push slightly outside of the collision
|
|
3678
|
-
for (const o of engineObjectsCollideStaticLast)
|
|
3679
|
-
{
|
|
3680
|
-
// skip destroyed, child objects, self collision, or objects with no box
|
|
3681
|
-
if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
|
|
3682
|
-
|
|
3683
|
-
// non solid objects don't collide with each other
|
|
3684
|
-
if (!this.isSolid && !o.isSolid) continue;
|
|
3685
|
-
|
|
3686
|
-
// check collision
|
|
3687
|
-
if (!this.isOverlappingObject(o)) continue;
|
|
3688
|
-
|
|
3689
|
-
// each moving object checks its own contacts, so a pair the other one already asked about this frame
|
|
3690
|
-
// is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
|
|
3691
|
-
// to resolve, pushed back together since, is resolved again
|
|
3692
|
-
const answer = engineObjectsCollidePairAnswer(o, this);
|
|
3693
|
-
if (answer === false) continue;
|
|
3694
|
-
if (!answer)
|
|
3695
|
-
{
|
|
3696
|
-
// notify objects of collision and check if should be resolved
|
|
3697
|
-
const collide1 = this.collideWithObject(o);
|
|
3698
|
-
const collide2 = o.collideWithObject(this);
|
|
3699
|
-
if (!collide1 || !collide2)
|
|
3700
|
-
{
|
|
3701
|
-
engineObjectsCollidePairAdd(this, o);
|
|
3702
|
-
continue;
|
|
3703
|
-
}
|
|
3704
|
-
}
|
|
3705
|
-
|
|
3706
|
-
const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
|
|
3707
|
-
if (wasOverlapping && (!o.mass || o.groundObject))
|
|
3708
|
-
{
|
|
3709
|
-
// a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
|
|
3710
|
-
// at once and carries it along, it would only drift out slowly and the solid would pass through;
|
|
3711
|
-
// an object standing on something counts as fixed too, so a stack on an elevator rides together;
|
|
3712
|
-
// it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
|
|
3713
|
-
const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
|
|
3714
|
-
if (push)
|
|
3715
|
-
{
|
|
3716
|
-
this.pos.x += push.x + sign(push.x) * epsilon;
|
|
3717
|
-
this.pos.y += push.y + sign(push.y) * epsilon;
|
|
3718
|
-
const restitution = max(this.restitution, o.restitution);
|
|
3719
|
-
if (push.x)
|
|
3720
|
-
{
|
|
3721
|
-
const v = this.velocity.x - o.velocity.x;
|
|
3722
|
-
if (v * push.x < 0) // moving into it
|
|
3723
|
-
this.velocity.x = o.velocity.x - v * restitution;
|
|
3724
|
-
}
|
|
3725
|
-
else
|
|
3726
|
-
{
|
|
3727
|
-
const v = this.velocity.y - o.velocity.y;
|
|
3728
|
-
if (v * push.y < 0) // moving into it
|
|
3729
|
-
this.velocity.y = o.velocity.y - v * restitution;
|
|
3730
|
-
if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
|
|
3731
|
-
this.groundObject = o;
|
|
3732
|
-
}
|
|
3733
|
-
}
|
|
3734
|
-
engineObjectsCollidePairAdd(this, o);
|
|
3735
|
-
|
|
3736
|
-
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
3737
|
-
continue;
|
|
3738
|
-
}
|
|
3739
|
-
if (wasOverlapping)
|
|
3740
|
-
{
|
|
3741
|
-
// if already was touching, try to push away
|
|
3742
|
-
const deltaPos = oldPos.subtract(o.pos);
|
|
3743
|
-
const length = deltaPos.length();
|
|
3744
|
-
const pushAwayAccel = .001;
|
|
3745
|
-
const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
|
|
3746
|
-
this.velocity = this.velocity.add(velocity);
|
|
3747
|
-
if (o.mass) // push away other object if not fixed
|
|
3748
|
-
o.velocity = o.velocity.subtract(velocity);
|
|
3749
|
-
engineObjectsCollidePairAdd(this, o);
|
|
3750
|
-
|
|
3751
|
-
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
3752
|
-
continue;
|
|
3753
|
-
}
|
|
3754
|
-
|
|
3755
|
-
// check for collision
|
|
3756
|
-
const sizeBoth = this.size.add(o.size);
|
|
3757
|
-
// prefer to push up if small delta, up away from this object's own gravity
|
|
3758
|
-
const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
|
|
3759
|
-
const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
|
|
3760
|
-
const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
|
|
3761
|
-
const restitution = max(this.restitution, o.restitution);
|
|
3762
|
-
|
|
3763
|
-
if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
|
|
3764
|
-
{
|
|
3765
|
-
// push outside object collision
|
|
3766
|
-
this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
|
|
3767
|
-
if ((o.groundObject && wasFalling) || !o.mass)
|
|
3768
|
-
{
|
|
3769
|
-
// set ground object if landed on something
|
|
3770
|
-
if (wasFalling)
|
|
3771
|
-
this.groundObject = o;
|
|
3772
|
-
|
|
3773
|
-
// bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
|
|
3774
|
-
// platform moving down instead of landing on it again every few frames
|
|
3775
|
-
this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
|
|
3776
|
-
}
|
|
3777
|
-
else // o has mass here, the other branch already handled the massless case
|
|
3778
|
-
{
|
|
3779
|
-
// inelastic collision
|
|
3780
|
-
const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
|
|
3781
|
-
|
|
3782
|
-
// elastic collision
|
|
3783
|
-
const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
|
|
3784
|
-
+ o.velocity.y * 2 * o.mass / (this.mass + o.mass);
|
|
3785
|
-
const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
|
|
3786
|
-
+ this.velocity.y * 2 * this.mass / (this.mass + o.mass);
|
|
3787
|
-
|
|
3788
|
-
// lerp between elastic or inelastic based on restitution
|
|
3789
|
-
this.velocity.y = lerp(inelastic, elastic0, restitution);
|
|
3790
|
-
o.velocity.y = lerp(inelastic, elastic1, restitution);
|
|
3791
|
-
}
|
|
3792
|
-
}
|
|
3793
|
-
if (!smallStepUp && isBlockedX) // resolve x collision
|
|
3794
|
-
{
|
|
3795
|
-
// push outside collision
|
|
3796
|
-
this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
|
|
3797
|
-
if (o.mass)
|
|
3798
|
-
{
|
|
3799
|
-
// inelastic collision
|
|
3800
|
-
const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
|
|
3801
|
-
|
|
3802
|
-
// elastic collision
|
|
3803
|
-
const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
|
|
3804
|
-
+ o.velocity.x * 2 * o.mass / (this.mass + o.mass);
|
|
3805
|
-
const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
|
|
3806
|
-
+ this.velocity.x * 2 * this.mass / (this.mass + o.mass);
|
|
3807
|
-
|
|
3808
|
-
// lerp between elastic or inelastic based on restitution
|
|
3809
|
-
this.velocity.x = lerp(inelastic, elastic0, restitution);
|
|
3810
|
-
o.velocity.x = lerp(inelastic, elastic1, restitution);
|
|
3811
|
-
}
|
|
3812
|
-
else // bounce if other object is fixed, relative to it as a landing is
|
|
3813
|
-
this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
|
|
3814
|
-
}
|
|
3815
|
-
engineObjectsCollidePairAdd(this, o, true);
|
|
3816
|
-
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
|
|
3817
|
-
}
|
|
3818
|
-
}
|
|
3819
|
-
if (this.collideLevel)
|
|
3820
|
-
{
|
|
3821
|
-
// check collision against tiles
|
|
3822
|
-
const hitLayer = tileCollisionTest(this.pos, this.size, this);
|
|
3823
|
-
if (hitLayer)
|
|
3824
|
-
{
|
|
3825
|
-
// if already was stuck in collision, don't do anything
|
|
3826
|
-
// this should not happen unless something starts in collision
|
|
3827
|
-
if (!tileCollisionTest(oldPos, this.size, this))
|
|
3828
|
-
{
|
|
3829
|
-
// test which side we bounced off (or both if a corner)
|
|
3830
|
-
const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
|
|
3831
|
-
const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
|
|
3832
|
-
const restitution = max(this.restitution, hitLayer.restitution);
|
|
3833
|
-
if (isBlockedX)
|
|
3834
|
-
{
|
|
3835
|
-
// a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
|
|
3836
|
-
// down off a ceiling ledge when gravity points up; zero gravity counts as down
|
|
3837
|
-
const epsilon = 1e-3;
|
|
3838
|
-
const maxMove = .1;
|
|
3839
|
-
const y = gravityY > 0 ?
|
|
3840
|
-
ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
|
|
3841
|
-
floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
|
|
3842
|
-
if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
|
|
3843
|
-
{
|
|
3844
|
-
this.pos.y = y;
|
|
3845
|
-
debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
|
|
3846
|
-
return;
|
|
3847
|
-
}
|
|
3848
|
-
|
|
3849
|
-
// move to previous X position and bounce
|
|
3850
|
-
this.pos.x = oldPos.x;
|
|
3851
|
-
this.velocity.x *= -restitution;
|
|
3852
|
-
}
|
|
3853
|
-
if (isBlockedY || !isBlockedX)
|
|
3854
|
-
{
|
|
3855
|
-
if (wasFalling)
|
|
3856
|
-
{
|
|
3857
|
-
// adjust position to slightly away from nearest tile
|
|
3858
|
-
// this prevents gap between object and ground
|
|
3859
|
-
const epsilon = .0001;
|
|
3860
|
-
const offset = this.size.y/2 + epsilon;
|
|
3861
|
-
// rounded in the layer's space as its collision test is, or a bottom a hair below a
|
|
3862
|
-
// grid line would round to the row under it, inside the floor, and fall through
|
|
3863
|
-
const layerY = hitLayer.pos.y;
|
|
3864
|
-
this.pos.y = layerY + (gravityY < 0 ?
|
|
3865
|
-
floor(oldPos.y - layerY - this.size.y/2) + offset :
|
|
3866
|
-
ceil( oldPos.y - layerY + this.size.y/2) - offset);
|
|
3867
|
-
|
|
3868
|
-
// set ground object for tile collision
|
|
3869
|
-
this.groundObject = hitLayer;
|
|
3870
|
-
}
|
|
3871
|
-
else
|
|
3872
|
-
{
|
|
3873
|
-
// move to previous Y position
|
|
3874
|
-
this.pos.y = oldPos.y;
|
|
3875
|
-
this.groundObject = undefined;
|
|
3876
|
-
}
|
|
3877
|
-
// bounce velocity
|
|
3878
|
-
this.velocity.y *= -restitution;
|
|
3879
|
-
}
|
|
3880
|
-
debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
|
|
3881
|
-
}
|
|
3882
|
-
}
|
|
3883
|
-
}
|
|
3884
|
-
}
|
|
3885
|
-
|
|
3886
|
-
/** Update the object, called automatically by engine once each frame. Does nothing by default. */
|
|
3887
|
-
update() {}
|
|
3888
|
-
|
|
3889
|
-
/** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
|
|
3890
|
-
render()
|
|
3891
|
-
{
|
|
3892
|
-
// default object render
|
|
3893
|
-
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
|
|
3894
|
-
}
|
|
3895
|
-
|
|
3896
|
-
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
3897
|
-
renderLight() {}
|
|
3898
|
-
|
|
3899
|
-
/** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
|
|
3900
|
-
* Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
|
|
3901
|
-
* screen space WebGL draws in render() are skipped during the pass */
|
|
3902
|
-
renderShadow() { this.render(); }
|
|
3903
|
-
|
|
3904
|
-
/** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
|
|
3905
|
-
* 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
|
|
3906
|
-
* glows; override to glow a part, like a robot's eyes */
|
|
3907
|
-
renderEmissive() { this.render(); }
|
|
3908
|
-
|
|
3909
|
-
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
3910
|
-
* @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
|
|
3911
|
-
destroy(immediate=false)
|
|
3912
|
-
{
|
|
3913
|
-
if (this.destroyed) return;
|
|
3914
|
-
|
|
3915
|
-
// disconnect from parent and destroy children
|
|
3916
|
-
this.destroyed = true;
|
|
3917
|
-
this.parent?.removeChild(this);
|
|
3918
|
-
for (const child of this.children)
|
|
3919
|
-
{
|
|
3920
|
-
child.parent = undefined;
|
|
3921
|
-
child.destroy(immediate);
|
|
3922
|
-
}
|
|
3923
|
-
}
|
|
3924
|
-
|
|
3925
|
-
/** Convert from local space to world space
|
|
3926
|
-
* @param {Vector2} pos - local space point */
|
|
3927
|
-
localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
|
|
3928
|
-
|
|
3929
|
-
/** Convert from world space to local space
|
|
3930
|
-
* @param {Vector2} pos - world space point */
|
|
3931
|
-
worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
|
|
3932
|
-
|
|
3933
|
-
/** Convert from local space to world space for a vector (rotation only)
|
|
3934
|
-
* @param {Vector2} vec - local space vector */
|
|
3935
|
-
localToWorldVector(vec) { return vec.rotate(this.angle); }
|
|
3936
|
-
|
|
3937
|
-
/** Convert from world space to local space for a vector (rotation only)
|
|
3938
|
-
* @param {Vector2} vec - world space vector */
|
|
3939
|
-
worldToLocalVector(vec) { return vec.rotate(-this.angle); }
|
|
3940
|
-
|
|
3941
|
-
/** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
3942
|
-
* - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
|
|
3943
|
-
* positions it only tries, so keep it free of side effects or guard them to once a frame
|
|
3944
|
-
* - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
|
|
3945
|
-
* saved in update, as the platformer example does
|
|
3946
|
-
* @param {number} tileData - the value of the tile at the position
|
|
3947
|
-
* @param {Vector2} pos - the tile's bottom left corner in world space
|
|
3948
|
-
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
|
|
3949
|
-
collideWithTile(tileData, pos) { return tileData > 0; }
|
|
3950
|
-
|
|
3951
|
-
/** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
3952
|
-
* - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
|
|
3953
|
-
* destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
|
|
3954
|
-
* bullet that should hit one thing checks its own destroyed flag first
|
|
3955
|
-
* @param {EngineObject} object - the object to test against
|
|
3956
|
-
* @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
|
|
3957
|
-
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
|
|
3958
|
-
*/
|
|
3959
|
-
collideWithObject(object, push) { return true; }
|
|
3960
|
-
|
|
3961
|
-
/** Get this object's up vector
|
|
3962
|
-
* @param {number} [scale] - length of the vector
|
|
3963
|
-
* @return {Vector2} */
|
|
3964
|
-
getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
|
|
3965
|
-
|
|
3966
|
-
/** Get this object's right vector
|
|
3967
|
-
* @param {number} [scale] - length of the vector
|
|
3968
|
-
* @return {Vector2} */
|
|
3969
|
-
getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
|
|
3970
|
-
|
|
3971
|
-
/** How long since the object was created
|
|
3972
|
-
* @return {number} */
|
|
3973
|
-
getAliveTime() { return time - this.spawnTime; }
|
|
3974
|
-
|
|
3975
|
-
/** Get the speed of this object
|
|
3976
|
-
* @return {number} */
|
|
3977
|
-
getSpeed() { return this.velocity.length(); }
|
|
3978
|
-
|
|
3979
|
-
/** Apply acceleration to this object (adjust velocity, not affected by mass)
|
|
3980
|
-
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
3981
|
-
* @param {Vector2} acceleration */
|
|
3982
|
-
applyAcceleration(acceleration)
|
|
3983
|
-
{ if (this.mass) this.velocity = this.velocity.add(acceleration); }
|
|
3984
|
-
|
|
3985
|
-
/** Apply angular acceleration to this object
|
|
3986
|
-
* - Does nothing on a static object (mass 0), set its angleVelocity instead
|
|
3987
|
-
* @param {number} acceleration */
|
|
3988
|
-
applyAngularAcceleration(acceleration)
|
|
3989
|
-
{ if (this.mass) this.angleVelocity += acceleration; }
|
|
3990
|
-
|
|
3991
|
-
/** Apply force to this object (adjust velocity, affected by mass)
|
|
3992
|
-
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
3993
|
-
* @param {Vector2} force */
|
|
3994
|
-
applyForce(force)
|
|
3995
|
-
{ if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
|
|
3996
|
-
|
|
3997
|
-
/** Get the direction of the mirror
|
|
3998
|
-
* @return {number} -1 if this.mirror is true, or 1 if not mirrored */
|
|
3999
|
-
getMirrorSign() { return this.mirror ? -1 : 1; }
|
|
4000
|
-
|
|
4001
|
-
/** Attaches a child to this with a local transform, returns child for chaining
|
|
4002
|
-
* @param {EngineObject} child
|
|
4003
|
-
* @param {Vector2} [localPos=vec2()]
|
|
4004
|
-
* @param {number} [localAngle]
|
|
4005
|
-
* @return {EngineObject} The child object added */
|
|
4006
|
-
addChild(child, localPos=vec2(), localAngle=0)
|
|
4007
|
-
{
|
|
4008
|
-
false&&ASSERT(!this.destroyed, 'cannot add child to destroyed object');
|
|
4009
|
-
if (this.destroyed) return child;
|
|
4010
|
-
false&&ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
|
|
4011
|
-
false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4012
|
-
false&&ASSERT(!child.destroyed, 'cannot add a destroyed child');
|
|
4013
|
-
for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
|
|
4014
|
-
false&&ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
|
|
4015
|
-
this.children.push(child);
|
|
4016
|
-
child.parent = this;
|
|
4017
|
-
child.localPos = localPos.copy();
|
|
4018
|
-
child.localAngle = localAngle;
|
|
4019
|
-
child.updateTransforms();
|
|
4020
|
-
return child;
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4023
|
-
/** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
|
|
4024
|
-
* where addChild takes one; a child of something else is moved over, returns child for chaining
|
|
4025
|
-
* @param {EngineObject} child
|
|
4026
|
-
* @return {EngineObject} The child object attached */
|
|
4027
|
-
attach(child)
|
|
4028
|
-
{
|
|
4029
|
-
false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4030
|
-
false&&ASSERT(child !== this, 'cannot attach self');
|
|
4031
|
-
child.parent?.removeChild(child);
|
|
4032
|
-
// the local values that updateTransforms turns back into the child's current pos and angle
|
|
4033
|
-
const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
|
|
4034
|
-
local.x *= mirror;
|
|
4035
|
-
return this.addChild(child, local, mirror * (child.angle - this.angle));
|
|
4036
|
-
}
|
|
4037
|
-
|
|
4038
|
-
/** Removes a child from this one, it stays where it is in the world
|
|
4039
|
-
* @param {EngineObject} child */
|
|
4040
|
-
removeChild(child)
|
|
4041
|
-
{
|
|
4042
|
-
false&&ASSERT(child.parent === this && this.children.includes(child));
|
|
4043
|
-
const i = this.children.indexOf(child);
|
|
4044
|
-
if (i < 0) return; // not a child of this one, release has no assert
|
|
4045
|
-
this.children.splice(i, 1);
|
|
4046
|
-
child.parent = child.localPos = undefined;
|
|
4047
|
-
}
|
|
4048
|
-
|
|
4049
|
-
/** Check if this object's box overlaps another object's box
|
|
4050
|
-
* @param {EngineObject} object
|
|
4051
|
-
* @return {boolean} */
|
|
4052
|
-
isOverlappingObject(object)
|
|
4053
|
-
{ return this.isOverlapping(object.pos, object.size); }
|
|
4054
|
-
|
|
4055
|
-
/** Check if overlapping a point or aligned bounding box
|
|
4056
|
-
* @param {Vector2} pos - Center of box
|
|
4057
|
-
* @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
|
|
4058
|
-
* @return {boolean} */
|
|
4059
|
-
isOverlapping(pos, size=vec2())
|
|
4060
|
-
{ return isOverlapping(this.pos, this.size, pos, size); }
|
|
4061
|
-
|
|
4062
|
-
/** Set how this object collides
|
|
4063
|
-
* @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
|
|
4064
|
-
* @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
|
|
4065
|
-
* @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
|
|
4066
|
-
* @param {boolean} [collideRaycast] - Does it collide with raycasts? */
|
|
4067
|
-
setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
|
|
4068
|
-
{
|
|
4069
|
-
false&&ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
|
|
4070
|
-
|
|
4071
|
-
this.collideSolidObjects = collideSolidObjects;
|
|
4072
|
-
this.isSolid = isSolid;
|
|
4073
|
-
this.collideLevel = collideLevel;
|
|
4074
|
-
this.collideRaycast = collideRaycast;
|
|
4075
|
-
}
|
|
4076
|
-
|
|
4077
|
-
/** @deprecated since 1.20, use collideLevel
|
|
4078
|
-
* @type {boolean} */
|
|
4079
|
-
get collideTiles() { return this.collideLevel; }
|
|
4080
|
-
set collideTiles(collide) { this.collideLevel = collide; }
|
|
4081
|
-
|
|
4082
|
-
/** Returns string containing info about this object for debugging
|
|
4083
|
-
* @return {string} */
|
|
4084
|
-
toString()
|
|
4085
|
-
{
|
|
4086
|
-
let text = 'type = ' + this.constructor.name;
|
|
4087
|
-
if (this.pos.x || this.pos.y)
|
|
4088
|
-
text += '\npos = ' + this.pos;
|
|
4089
|
-
if (this.velocity.x || this.velocity.y)
|
|
4090
|
-
text += '\nvelocity = ' + this.velocity;
|
|
4091
|
-
if (this.size.x || this.size.y)
|
|
4092
|
-
text += '\nsize = ' + this.size;
|
|
4093
|
-
if (this.angle)
|
|
4094
|
-
text += '\nangle = ' + this.angle.toFixed(3);
|
|
4095
|
-
text += '\ncolor = ' + this.color;
|
|
4096
|
-
return text;
|
|
4097
|
-
}
|
|
4098
|
-
|
|
4099
|
-
/** Render debug info for this object */
|
|
4100
|
-
renderDebugInfo()
|
|
4101
|
-
{
|
|
4102
|
-
if (!debug) return;
|
|
4103
|
-
|
|
4104
|
-
// check if there is anything to show
|
|
4105
|
-
const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
|
|
4106
|
-
if (!hasPhysics && !this.parent) return;
|
|
4107
|
-
|
|
4108
|
-
// show object info for debugging
|
|
4109
|
-
const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
|
|
4110
|
-
const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
|
|
4111
|
-
debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
|
|
4112
|
-
if (this.parent)
|
|
4113
|
-
debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
|
|
4114
|
-
this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
|
|
4115
|
-
}
|
|
3447
|
+
/**
|
|
3448
|
+
* LittleJS Object System
|
|
3449
|
+
* - EngineObject is the base class for all game objects
|
|
3450
|
+
* - Handles automatic updating, rendering, physics, and collision
|
|
3451
|
+
* - Supports parent-child hierarchies with transform inheritance
|
|
3452
|
+
* - 2D physics with velocity, acceleration, damping, and gravity
|
|
3453
|
+
* - Collision system with tiles and other objects
|
|
3454
|
+
* - Renders sprites from tile sheets with color and rotation
|
|
3455
|
+
* - Objects sorted by renderOrder for layered rendering
|
|
3456
|
+
*/
|
|
3457
|
+
|
|
3458
|
+
/**
|
|
3459
|
+
* LittleJS Object Base Object Class
|
|
3460
|
+
* - Top level object class used by the engine
|
|
3461
|
+
* - Automatically adds self to object list
|
|
3462
|
+
* - Will be updated and rendered each frame
|
|
3463
|
+
* - Renders as a sprite from a tilesheet by default
|
|
3464
|
+
* - Can have color and additive color applied
|
|
3465
|
+
* - 2D Physics and collision system
|
|
3466
|
+
* - Sorted by renderOrder
|
|
3467
|
+
* - Objects can have children attached
|
|
3468
|
+
* - Parents are updated before children, and set child transform
|
|
3469
|
+
* - Call destroy() to get rid of objects
|
|
3470
|
+
*
|
|
3471
|
+
* The physics system used by objects is simple and fast with some caveats...
|
|
3472
|
+
* - Collision uses the axis aligned size, the object's rotation angle is only for rendering
|
|
3473
|
+
* - Objects are guaranteed to not intersect tile collision from physics
|
|
3474
|
+
* - If an object starts or is moved inside tile collision, it will not collide with that tile
|
|
3475
|
+
* - Collision for objects can be set to be solid to block other objects
|
|
3476
|
+
* - Objects may get pushed into overlapping other solid objects, if so they will push away
|
|
3477
|
+
* - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
|
|
3478
|
+
* - Solid objects are more performance intensive and should be used sparingly
|
|
3479
|
+
* @memberof Engine
|
|
3480
|
+
* @example
|
|
3481
|
+
* // create an engine object, normally you would first extend the class with your own
|
|
3482
|
+
* const pos = vec2(2,3);
|
|
3483
|
+
* const object = new EngineObject(pos);
|
|
3484
|
+
*/
|
|
3485
|
+
class EngineObject
|
|
3486
|
+
{
|
|
3487
|
+
/** Create an engine object and adds it to the list of objects
|
|
3488
|
+
* @param {Vector2} [pos=vec2()] - World space position of the object
|
|
3489
|
+
* @param {Vector2} [size=vec2(1)] - World space size of the object
|
|
3490
|
+
* @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
|
|
3491
|
+
* @param {number} [angle] - Angle the object is rotated by
|
|
3492
|
+
* @param {Color} [color=WHITE] - Color to apply to tile when rendered
|
|
3493
|
+
* @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
|
|
3494
|
+
*/
|
|
3495
|
+
constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
|
|
3496
|
+
{
|
|
3497
|
+
// check passed in params
|
|
3498
|
+
false&&ASSERT(isVector2(pos), 'object pos must be a vec2');
|
|
3499
|
+
false&&ASSERT(isVector2(size), 'object size must be a vec2');
|
|
3500
|
+
false&&ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
|
|
3501
|
+
false&&ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
|
|
3502
|
+
false&&ASSERT(isColor(color), 'object color should be a valid rgba color');
|
|
3503
|
+
false&&ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
|
|
3504
|
+
|
|
3505
|
+
/** @property {Vector2} - World space position of the object */
|
|
3506
|
+
this.pos = pos.copy();
|
|
3507
|
+
/** @property {Vector2} - World space width and height of the object */
|
|
3508
|
+
this.size = size.copy();
|
|
3509
|
+
/** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
|
|
3510
|
+
* @type {Vector2|undefined} */
|
|
3511
|
+
this.drawSize = undefined;
|
|
3512
|
+
/** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
|
|
3513
|
+
* @type {TileInfo|undefined} */
|
|
3514
|
+
this.tileInfo = tileInfo;
|
|
3515
|
+
/** @property {number} - Angle to rotate the object */
|
|
3516
|
+
this.angle = angle;
|
|
3517
|
+
/** @property {Color} - Color to apply when rendered */
|
|
3518
|
+
this.color = color.copy();
|
|
3519
|
+
/** @property {Color|undefined} - Additive color to apply when rendered
|
|
3520
|
+
* @type {Color|undefined} */
|
|
3521
|
+
this.additiveColor = undefined;
|
|
3522
|
+
/** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
|
|
3523
|
+
* @type {Shader|undefined} */
|
|
3524
|
+
this.shader = undefined;
|
|
3525
|
+
/** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
|
|
3526
|
+
this.castShadow = true;
|
|
3527
|
+
/** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
|
|
3528
|
+
* brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
|
|
3529
|
+
* renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
|
|
3530
|
+
* too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
|
|
3531
|
+
this.emissive = 0;
|
|
3532
|
+
/** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
|
|
3533
|
+
this.mirror = false;
|
|
3534
|
+
/** @property {boolean} - Has object been destroyed? */
|
|
3535
|
+
this.destroyed = false;
|
|
3536
|
+
this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
|
|
3537
|
+
|
|
3538
|
+
// physical properties
|
|
3539
|
+
/** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
|
|
3540
|
+
* collide on its own, the moving ones collide with it */
|
|
3541
|
+
this.mass = objectDefaultMass;
|
|
3542
|
+
/** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
3543
|
+
this.damping = objectDefaultDamping;
|
|
3544
|
+
/** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
|
|
3545
|
+
this.angleDamping = objectDefaultAngleDamping;
|
|
3546
|
+
/** @property {number} - How bouncy the object is when colliding (0-1) */
|
|
3547
|
+
this.restitution = objectDefaultRestitution;
|
|
3548
|
+
/** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
|
|
3549
|
+
* once, the more slippery of the object and its ground is used */
|
|
3550
|
+
this.friction = objectDefaultFriction;
|
|
3551
|
+
/** @property {number} - How much to scale gravity by for this object */
|
|
3552
|
+
this.gravityScale = 1;
|
|
3553
|
+
/** @property {number} - Objects are sorted by render order */
|
|
3554
|
+
this.renderOrder = renderOrder;
|
|
3555
|
+
/** @property {Vector2} - Velocity of the object, in world units per frame */
|
|
3556
|
+
this.velocity = vec2();
|
|
3557
|
+
/** @property {number} - Angular velocity of the object, in radians per frame */
|
|
3558
|
+
this.angleVelocity = 0;
|
|
3559
|
+
/** @property {number} - Track when object was created */
|
|
3560
|
+
this.spawnTime = time;
|
|
3561
|
+
/** @property {Array<EngineObject>} - List of children of this object
|
|
3562
|
+
* @type {Array<EngineObject>} */
|
|
3563
|
+
this.children = [];
|
|
3564
|
+
/** @property {boolean} - Limit object speed along x and y axis */
|
|
3565
|
+
this.clampSpeed = true;
|
|
3566
|
+
/** @property {EngineObject|undefined} - Object we are standing on, if any
|
|
3567
|
+
* @type {EngineObject|undefined} */
|
|
3568
|
+
this.groundObject = undefined;
|
|
3569
|
+
|
|
3570
|
+
// parent child system
|
|
3571
|
+
/** @property {EngineObject|undefined} - Parent of object if in local space
|
|
3572
|
+
* @type {EngineObject|undefined} */
|
|
3573
|
+
this.parent = undefined;
|
|
3574
|
+
/** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
|
|
3575
|
+
* @type {Vector2|undefined} */
|
|
3576
|
+
this.localPos = undefined;
|
|
3577
|
+
/** @property {number} - Local angle if child */
|
|
3578
|
+
this.localAngle = 0;
|
|
3579
|
+
|
|
3580
|
+
// collision flags
|
|
3581
|
+
/** @property {boolean} - Object collides with the level, its tile collision layers
|
|
3582
|
+
* @type {boolean} */
|
|
3583
|
+
this.collideLevel = false;
|
|
3584
|
+
/** @property {boolean} - Object collides with solid objects */
|
|
3585
|
+
this.collideSolidObjects = false;
|
|
3586
|
+
/** @property {boolean} - Object collides with and blocks other objects */
|
|
3587
|
+
this.isSolid = false;
|
|
3588
|
+
/** @property {boolean} - Object collides with raycasts */
|
|
3589
|
+
this.collideRaycast = false;
|
|
3590
|
+
|
|
3591
|
+
/** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
|
|
3592
|
+
* - Calling destroy on it still destroys it, and its children go with it either way */
|
|
3593
|
+
this.persistent = false;
|
|
3594
|
+
|
|
3595
|
+
// add to list of objects
|
|
3596
|
+
engineObjects.push(this);
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/** Update the object transform, called automatically by engine even when paused
|
|
3600
|
+
* @param {boolean} [updateChildren] - Also update the children's transforms */
|
|
3601
|
+
updateTransforms(updateChildren=true)
|
|
3602
|
+
{
|
|
3603
|
+
const parent = this.parent;
|
|
3604
|
+
if (parent)
|
|
3605
|
+
{
|
|
3606
|
+
// compose with parent transform inline to avoid intermediate vector allocs
|
|
3607
|
+
const mirror = parent.getMirrorSign();
|
|
3608
|
+
const lp = this.localPos, pp = parent.pos;
|
|
3609
|
+
const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
|
|
3610
|
+
if (pa)
|
|
3611
|
+
{
|
|
3612
|
+
const c = cos(-pa), s = sin(-pa);
|
|
3613
|
+
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
3614
|
+
}
|
|
3615
|
+
else
|
|
3616
|
+
this.pos.set(lx + pp.x, ly + pp.y);
|
|
3617
|
+
this.angle = mirror*this.localAngle + pa;
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
// update children
|
|
3621
|
+
if (updateChildren)
|
|
3622
|
+
for (const child of this.children)
|
|
3623
|
+
child.updateTransforms();
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
/** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
|
|
3627
|
+
updatePhysics()
|
|
3628
|
+
{
|
|
3629
|
+
// child objects do not have physics
|
|
3630
|
+
false&&ASSERT(!this.parent);
|
|
3631
|
+
|
|
3632
|
+
// bail if a collision callback destroyed us mid-frame
|
|
3633
|
+
if (this.destroyed) return;
|
|
3634
|
+
|
|
3635
|
+
// limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
|
|
3636
|
+
// has a mass, which tile collision needs; anything else moves as fast as it is told
|
|
3637
|
+
if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
|
|
3638
|
+
{
|
|
3639
|
+
this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
|
|
3640
|
+
this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
// physics sanity checks
|
|
3644
|
+
false&&ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
|
|
3645
|
+
false&&ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
|
|
3646
|
+
|
|
3647
|
+
// apply physics; only the solver needs where the object was, so only then is it copied
|
|
3648
|
+
const solve = enablePhysicsSolver && this.mass;
|
|
3649
|
+
const oldPos = solve ? this.pos.copy() : undefined;
|
|
3650
|
+
this.velocity.x *= this.damping;
|
|
3651
|
+
this.velocity.y *= this.damping;
|
|
3652
|
+
if (this.mass)
|
|
3653
|
+
{
|
|
3654
|
+
// apply gravity only if it has mass
|
|
3655
|
+
this.velocity.x += gravity.x * this.gravityScale;
|
|
3656
|
+
this.velocity.y += gravity.y * this.gravityScale;
|
|
3657
|
+
}
|
|
3658
|
+
this.pos.x += this.velocity.x;
|
|
3659
|
+
this.pos.y += this.velocity.y;
|
|
3660
|
+
this.angle += this.angleVelocity *= this.angleDamping;
|
|
3661
|
+
|
|
3662
|
+
// don't do collision for static objects or if solver disabled
|
|
3663
|
+
if (!solve) return;
|
|
3664
|
+
|
|
3665
|
+
// which way is down for this object, a negative gravityScale falls up and lands on ceilings
|
|
3666
|
+
const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
|
|
3667
|
+
const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
|
|
3668
|
+
if (this.groundObject)
|
|
3669
|
+
{
|
|
3670
|
+
// apply friction in local space of ground object
|
|
3671
|
+
const friction = max(this.friction, this.groundObject.friction);
|
|
3672
|
+
const groundSpeed = this.groundObject.velocity.x;
|
|
3673
|
+
this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
|
|
3674
|
+
this.groundObject = undefined;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
// an object with no width or height has no box to push out of, or to be pushed out of
|
|
3678
|
+
if (this.collideSolidObjects && this.size.x && this.size.y)
|
|
3679
|
+
{
|
|
3680
|
+
// check collisions against solid objects
|
|
3681
|
+
const epsilon = .001; // necessary to push slightly outside of the collision
|
|
3682
|
+
for (const o of engineObjectsCollideStaticLast)
|
|
3683
|
+
{
|
|
3684
|
+
// skip destroyed, child objects, self collision, or objects with no box
|
|
3685
|
+
if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
|
|
3686
|
+
|
|
3687
|
+
// non solid objects don't collide with each other
|
|
3688
|
+
if (!this.isSolid && !o.isSolid) continue;
|
|
3689
|
+
|
|
3690
|
+
// check collision
|
|
3691
|
+
if (!this.isOverlappingObject(o)) continue;
|
|
3692
|
+
|
|
3693
|
+
// each moving object checks its own contacts, so a pair the other one already asked about this frame
|
|
3694
|
+
// is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
|
|
3695
|
+
// to resolve, pushed back together since, is resolved again
|
|
3696
|
+
const answer = engineObjectsCollidePairAnswer(o, this);
|
|
3697
|
+
if (answer === false) continue;
|
|
3698
|
+
if (!answer)
|
|
3699
|
+
{
|
|
3700
|
+
// notify objects of collision and check if should be resolved
|
|
3701
|
+
const collide1 = this.collideWithObject(o);
|
|
3702
|
+
const collide2 = o.collideWithObject(this);
|
|
3703
|
+
if (!collide1 || !collide2)
|
|
3704
|
+
{
|
|
3705
|
+
engineObjectsCollidePairAdd(this, o);
|
|
3706
|
+
continue;
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
|
|
3711
|
+
if (wasOverlapping && (!o.mass || o.groundObject))
|
|
3712
|
+
{
|
|
3713
|
+
// a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
|
|
3714
|
+
// at once and carries it along, it would only drift out slowly and the solid would pass through;
|
|
3715
|
+
// an object standing on something counts as fixed too, so a stack on an elevator rides together;
|
|
3716
|
+
// it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
|
|
3717
|
+
const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
|
|
3718
|
+
if (push)
|
|
3719
|
+
{
|
|
3720
|
+
this.pos.x += push.x + sign(push.x) * epsilon;
|
|
3721
|
+
this.pos.y += push.y + sign(push.y) * epsilon;
|
|
3722
|
+
const restitution = max(this.restitution, o.restitution);
|
|
3723
|
+
if (push.x)
|
|
3724
|
+
{
|
|
3725
|
+
const v = this.velocity.x - o.velocity.x;
|
|
3726
|
+
if (v * push.x < 0) // moving into it
|
|
3727
|
+
this.velocity.x = o.velocity.x - v * restitution;
|
|
3728
|
+
}
|
|
3729
|
+
else
|
|
3730
|
+
{
|
|
3731
|
+
const v = this.velocity.y - o.velocity.y;
|
|
3732
|
+
if (v * push.y < 0) // moving into it
|
|
3733
|
+
this.velocity.y = o.velocity.y - v * restitution;
|
|
3734
|
+
if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
|
|
3735
|
+
this.groundObject = o;
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
engineObjectsCollidePairAdd(this, o);
|
|
3739
|
+
|
|
3740
|
+
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
3741
|
+
continue;
|
|
3742
|
+
}
|
|
3743
|
+
if (wasOverlapping)
|
|
3744
|
+
{
|
|
3745
|
+
// if already was touching, try to push away
|
|
3746
|
+
const deltaPos = oldPos.subtract(o.pos);
|
|
3747
|
+
const length = deltaPos.length();
|
|
3748
|
+
const pushAwayAccel = .001;
|
|
3749
|
+
const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
|
|
3750
|
+
this.velocity = this.velocity.add(velocity);
|
|
3751
|
+
if (o.mass) // push away other object if not fixed
|
|
3752
|
+
o.velocity = o.velocity.subtract(velocity);
|
|
3753
|
+
engineObjectsCollidePairAdd(this, o);
|
|
3754
|
+
|
|
3755
|
+
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
|
|
3756
|
+
continue;
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
// check for collision
|
|
3760
|
+
const sizeBoth = this.size.add(o.size);
|
|
3761
|
+
// prefer to push up if small delta, up away from this object's own gravity
|
|
3762
|
+
const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
|
|
3763
|
+
const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
|
|
3764
|
+
const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
|
|
3765
|
+
const restitution = max(this.restitution, o.restitution);
|
|
3766
|
+
|
|
3767
|
+
if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
|
|
3768
|
+
{
|
|
3769
|
+
// push outside object collision
|
|
3770
|
+
this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
|
|
3771
|
+
if ((o.groundObject && wasFalling) || !o.mass)
|
|
3772
|
+
{
|
|
3773
|
+
// set ground object if landed on something
|
|
3774
|
+
if (wasFalling)
|
|
3775
|
+
this.groundObject = o;
|
|
3776
|
+
|
|
3777
|
+
// bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
|
|
3778
|
+
// platform moving down instead of landing on it again every few frames
|
|
3779
|
+
this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
|
|
3780
|
+
}
|
|
3781
|
+
else // o has mass here, the other branch already handled the massless case
|
|
3782
|
+
{
|
|
3783
|
+
// inelastic collision
|
|
3784
|
+
const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
|
|
3785
|
+
|
|
3786
|
+
// elastic collision
|
|
3787
|
+
const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
|
|
3788
|
+
+ o.velocity.y * 2 * o.mass / (this.mass + o.mass);
|
|
3789
|
+
const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
|
|
3790
|
+
+ this.velocity.y * 2 * this.mass / (this.mass + o.mass);
|
|
3791
|
+
|
|
3792
|
+
// lerp between elastic or inelastic based on restitution
|
|
3793
|
+
this.velocity.y = lerp(inelastic, elastic0, restitution);
|
|
3794
|
+
o.velocity.y = lerp(inelastic, elastic1, restitution);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
if (!smallStepUp && isBlockedX) // resolve x collision
|
|
3798
|
+
{
|
|
3799
|
+
// push outside collision
|
|
3800
|
+
this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
|
|
3801
|
+
if (o.mass)
|
|
3802
|
+
{
|
|
3803
|
+
// inelastic collision
|
|
3804
|
+
const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
|
|
3805
|
+
|
|
3806
|
+
// elastic collision
|
|
3807
|
+
const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
|
|
3808
|
+
+ o.velocity.x * 2 * o.mass / (this.mass + o.mass);
|
|
3809
|
+
const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
|
|
3810
|
+
+ this.velocity.x * 2 * this.mass / (this.mass + o.mass);
|
|
3811
|
+
|
|
3812
|
+
// lerp between elastic or inelastic based on restitution
|
|
3813
|
+
this.velocity.x = lerp(inelastic, elastic0, restitution);
|
|
3814
|
+
o.velocity.x = lerp(inelastic, elastic1, restitution);
|
|
3815
|
+
}
|
|
3816
|
+
else // bounce if other object is fixed, relative to it as a landing is
|
|
3817
|
+
this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
|
|
3818
|
+
}
|
|
3819
|
+
engineObjectsCollidePairAdd(this, o, true);
|
|
3820
|
+
debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
if (this.collideLevel)
|
|
3824
|
+
{
|
|
3825
|
+
// check collision against tiles
|
|
3826
|
+
const hitLayer = tileCollisionTest(this.pos, this.size, this);
|
|
3827
|
+
if (hitLayer)
|
|
3828
|
+
{
|
|
3829
|
+
// if already was stuck in collision, don't do anything
|
|
3830
|
+
// this should not happen unless something starts in collision
|
|
3831
|
+
if (!tileCollisionTest(oldPos, this.size, this))
|
|
3832
|
+
{
|
|
3833
|
+
// test which side we bounced off (or both if a corner)
|
|
3834
|
+
const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
|
|
3835
|
+
const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
|
|
3836
|
+
const restitution = max(this.restitution, hitLayer.restitution);
|
|
3837
|
+
if (isBlockedX)
|
|
3838
|
+
{
|
|
3839
|
+
// a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
|
|
3840
|
+
// down off a ceiling ledge when gravity points up; zero gravity counts as down
|
|
3841
|
+
const epsilon = 1e-3;
|
|
3842
|
+
const maxMove = .1;
|
|
3843
|
+
const y = gravityY > 0 ?
|
|
3844
|
+
ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
|
|
3845
|
+
floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
|
|
3846
|
+
if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
|
|
3847
|
+
{
|
|
3848
|
+
this.pos.y = y;
|
|
3849
|
+
debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
|
|
3850
|
+
return;
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
// move to previous X position and bounce
|
|
3854
|
+
this.pos.x = oldPos.x;
|
|
3855
|
+
this.velocity.x *= -restitution;
|
|
3856
|
+
}
|
|
3857
|
+
if (isBlockedY || !isBlockedX)
|
|
3858
|
+
{
|
|
3859
|
+
if (wasFalling)
|
|
3860
|
+
{
|
|
3861
|
+
// adjust position to slightly away from nearest tile
|
|
3862
|
+
// this prevents gap between object and ground
|
|
3863
|
+
const epsilon = .0001;
|
|
3864
|
+
const offset = this.size.y/2 + epsilon;
|
|
3865
|
+
// rounded in the layer's space as its collision test is, or a bottom a hair below a
|
|
3866
|
+
// grid line would round to the row under it, inside the floor, and fall through
|
|
3867
|
+
const layerY = hitLayer.pos.y;
|
|
3868
|
+
this.pos.y = layerY + (gravityY < 0 ?
|
|
3869
|
+
floor(oldPos.y - layerY - this.size.y/2) + offset :
|
|
3870
|
+
ceil( oldPos.y - layerY + this.size.y/2) - offset);
|
|
3871
|
+
|
|
3872
|
+
// set ground object for tile collision
|
|
3873
|
+
this.groundObject = hitLayer;
|
|
3874
|
+
}
|
|
3875
|
+
else
|
|
3876
|
+
{
|
|
3877
|
+
// move to previous Y position
|
|
3878
|
+
this.pos.y = oldPos.y;
|
|
3879
|
+
this.groundObject = undefined;
|
|
3880
|
+
}
|
|
3881
|
+
// bounce velocity
|
|
3882
|
+
this.velocity.y *= -restitution;
|
|
3883
|
+
}
|
|
3884
|
+
debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
/** Update the object, called automatically by engine once each frame. Does nothing by default. */
|
|
3891
|
+
update() {}
|
|
3892
|
+
|
|
3893
|
+
/** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
|
|
3894
|
+
render()
|
|
3895
|
+
{
|
|
3896
|
+
// default object render
|
|
3897
|
+
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
3901
|
+
renderLight() {}
|
|
3902
|
+
|
|
3903
|
+
/** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
|
|
3904
|
+
* Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
|
|
3905
|
+
* screen space WebGL draws in render() are skipped during the pass */
|
|
3906
|
+
renderShadow() { this.render(); }
|
|
3907
|
+
|
|
3908
|
+
/** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
|
|
3909
|
+
* 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
|
|
3910
|
+
* glows; override to glow a part, like a robot's eyes */
|
|
3911
|
+
renderEmissive() { this.render(); }
|
|
3912
|
+
|
|
3913
|
+
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
3914
|
+
* @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
|
|
3915
|
+
destroy(immediate=false)
|
|
3916
|
+
{
|
|
3917
|
+
if (this.destroyed) return;
|
|
3918
|
+
|
|
3919
|
+
// disconnect from parent and destroy children
|
|
3920
|
+
this.destroyed = true;
|
|
3921
|
+
this.parent?.removeChild(this);
|
|
3922
|
+
for (const child of this.children)
|
|
3923
|
+
{
|
|
3924
|
+
child.parent = undefined;
|
|
3925
|
+
child.destroy(immediate);
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
/** Convert from local space to world space
|
|
3930
|
+
* @param {Vector2} pos - local space point */
|
|
3931
|
+
localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
|
|
3932
|
+
|
|
3933
|
+
/** Convert from world space to local space
|
|
3934
|
+
* @param {Vector2} pos - world space point */
|
|
3935
|
+
worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
|
|
3936
|
+
|
|
3937
|
+
/** Convert from local space to world space for a vector (rotation only)
|
|
3938
|
+
* @param {Vector2} vec - local space vector */
|
|
3939
|
+
localToWorldVector(vec) { return vec.rotate(this.angle); }
|
|
3940
|
+
|
|
3941
|
+
/** Convert from world space to local space for a vector (rotation only)
|
|
3942
|
+
* @param {Vector2} vec - world space vector */
|
|
3943
|
+
worldToLocalVector(vec) { return vec.rotate(-this.angle); }
|
|
3944
|
+
|
|
3945
|
+
/** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
3946
|
+
* - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
|
|
3947
|
+
* positions it only tries, so keep it free of side effects or guard them to once a frame
|
|
3948
|
+
* - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
|
|
3949
|
+
* saved in update, as the platformer example does
|
|
3950
|
+
* @param {number} tileData - the value of the tile at the position
|
|
3951
|
+
* @param {Vector2} pos - the tile's bottom left corner in world space
|
|
3952
|
+
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
|
|
3953
|
+
collideWithTile(tileData, pos) { return tileData > 0; }
|
|
3954
|
+
|
|
3955
|
+
/** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
|
|
3956
|
+
* - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
|
|
3957
|
+
* destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
|
|
3958
|
+
* bullet that should hit one thing checks its own destroyed flag first
|
|
3959
|
+
* @param {EngineObject} object - the object to test against
|
|
3960
|
+
* @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
|
|
3961
|
+
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
|
|
3962
|
+
*/
|
|
3963
|
+
collideWithObject(object, push) { return true; }
|
|
3964
|
+
|
|
3965
|
+
/** Get this object's up vector
|
|
3966
|
+
* @param {number} [scale] - length of the vector
|
|
3967
|
+
* @return {Vector2} */
|
|
3968
|
+
getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
|
|
3969
|
+
|
|
3970
|
+
/** Get this object's right vector
|
|
3971
|
+
* @param {number} [scale] - length of the vector
|
|
3972
|
+
* @return {Vector2} */
|
|
3973
|
+
getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
|
|
3974
|
+
|
|
3975
|
+
/** How long since the object was created
|
|
3976
|
+
* @return {number} */
|
|
3977
|
+
getAliveTime() { return time - this.spawnTime; }
|
|
3978
|
+
|
|
3979
|
+
/** Get the speed of this object
|
|
3980
|
+
* @return {number} */
|
|
3981
|
+
getSpeed() { return this.velocity.length(); }
|
|
3982
|
+
|
|
3983
|
+
/** Apply acceleration to this object (adjust velocity, not affected by mass)
|
|
3984
|
+
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
3985
|
+
* @param {Vector2} acceleration */
|
|
3986
|
+
applyAcceleration(acceleration)
|
|
3987
|
+
{ if (this.mass) this.velocity = this.velocity.add(acceleration); }
|
|
3988
|
+
|
|
3989
|
+
/** Apply angular acceleration to this object
|
|
3990
|
+
* - Does nothing on a static object (mass 0), set its angleVelocity instead
|
|
3991
|
+
* @param {number} acceleration */
|
|
3992
|
+
applyAngularAcceleration(acceleration)
|
|
3993
|
+
{ if (this.mass) this.angleVelocity += acceleration; }
|
|
3994
|
+
|
|
3995
|
+
/** Apply force to this object (adjust velocity, affected by mass)
|
|
3996
|
+
* - Does nothing on a static object (mass 0), set its velocity instead
|
|
3997
|
+
* @param {Vector2} force */
|
|
3998
|
+
applyForce(force)
|
|
3999
|
+
{ if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
|
|
4000
|
+
|
|
4001
|
+
/** Get the direction of the mirror
|
|
4002
|
+
* @return {number} -1 if this.mirror is true, or 1 if not mirrored */
|
|
4003
|
+
getMirrorSign() { return this.mirror ? -1 : 1; }
|
|
4004
|
+
|
|
4005
|
+
/** Attaches a child to this with a local transform, returns child for chaining
|
|
4006
|
+
* @param {EngineObject} child
|
|
4007
|
+
* @param {Vector2} [localPos=vec2()]
|
|
4008
|
+
* @param {number} [localAngle]
|
|
4009
|
+
* @return {EngineObject} The child object added */
|
|
4010
|
+
addChild(child, localPos=vec2(), localAngle=0)
|
|
4011
|
+
{
|
|
4012
|
+
false&&ASSERT(!this.destroyed, 'cannot add child to destroyed object');
|
|
4013
|
+
if (this.destroyed) return child;
|
|
4014
|
+
false&&ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
|
|
4015
|
+
false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4016
|
+
false&&ASSERT(!child.destroyed, 'cannot add a destroyed child');
|
|
4017
|
+
for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
|
|
4018
|
+
false&&ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
|
|
4019
|
+
this.children.push(child);
|
|
4020
|
+
child.parent = this;
|
|
4021
|
+
child.localPos = localPos.copy();
|
|
4022
|
+
child.localAngle = localAngle;
|
|
4023
|
+
child.updateTransforms();
|
|
4024
|
+
return child;
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
/** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
|
|
4028
|
+
* where addChild takes one; a child of something else is moved over, returns child for chaining
|
|
4029
|
+
* @param {EngineObject} child
|
|
4030
|
+
* @return {EngineObject} The child object attached */
|
|
4031
|
+
attach(child)
|
|
4032
|
+
{
|
|
4033
|
+
false&&ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
4034
|
+
false&&ASSERT(child !== this, 'cannot attach self');
|
|
4035
|
+
child.parent?.removeChild(child);
|
|
4036
|
+
// the local values that updateTransforms turns back into the child's current pos and angle
|
|
4037
|
+
const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
|
|
4038
|
+
local.x *= mirror;
|
|
4039
|
+
return this.addChild(child, local, mirror * (child.angle - this.angle));
|
|
4040
|
+
}
|
|
4041
|
+
|
|
4042
|
+
/** Removes a child from this one, it stays where it is in the world
|
|
4043
|
+
* @param {EngineObject} child */
|
|
4044
|
+
removeChild(child)
|
|
4045
|
+
{
|
|
4046
|
+
false&&ASSERT(child.parent === this && this.children.includes(child));
|
|
4047
|
+
const i = this.children.indexOf(child);
|
|
4048
|
+
if (i < 0) return; // not a child of this one, release has no assert
|
|
4049
|
+
this.children.splice(i, 1);
|
|
4050
|
+
child.parent = child.localPos = undefined;
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
/** Check if this object's box overlaps another object's box
|
|
4054
|
+
* @param {EngineObject} object
|
|
4055
|
+
* @return {boolean} */
|
|
4056
|
+
isOverlappingObject(object)
|
|
4057
|
+
{ return this.isOverlapping(object.pos, object.size); }
|
|
4058
|
+
|
|
4059
|
+
/** Check if overlapping a point or aligned bounding box
|
|
4060
|
+
* @param {Vector2} pos - Center of box
|
|
4061
|
+
* @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
|
|
4062
|
+
* @return {boolean} */
|
|
4063
|
+
isOverlapping(pos, size=vec2())
|
|
4064
|
+
{ return isOverlapping(this.pos, this.size, pos, size); }
|
|
4065
|
+
|
|
4066
|
+
/** Set how this object collides
|
|
4067
|
+
* @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
|
|
4068
|
+
* @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
|
|
4069
|
+
* @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
|
|
4070
|
+
* @param {boolean} [collideRaycast] - Does it collide with raycasts? */
|
|
4071
|
+
setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
|
|
4072
|
+
{
|
|
4073
|
+
false&&ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
|
|
4074
|
+
|
|
4075
|
+
this.collideSolidObjects = collideSolidObjects;
|
|
4076
|
+
this.isSolid = isSolid;
|
|
4077
|
+
this.collideLevel = collideLevel;
|
|
4078
|
+
this.collideRaycast = collideRaycast;
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
/** @deprecated since 1.20, use collideLevel
|
|
4082
|
+
* @type {boolean} */
|
|
4083
|
+
get collideTiles() { return this.collideLevel; }
|
|
4084
|
+
set collideTiles(collide) { this.collideLevel = collide; }
|
|
4085
|
+
|
|
4086
|
+
/** Returns string containing info about this object for debugging
|
|
4087
|
+
* @return {string} */
|
|
4088
|
+
toString()
|
|
4089
|
+
{
|
|
4090
|
+
let text = 'type = ' + this.constructor.name;
|
|
4091
|
+
if (this.pos.x || this.pos.y)
|
|
4092
|
+
text += '\npos = ' + this.pos;
|
|
4093
|
+
if (this.velocity.x || this.velocity.y)
|
|
4094
|
+
text += '\nvelocity = ' + this.velocity;
|
|
4095
|
+
if (this.size.x || this.size.y)
|
|
4096
|
+
text += '\nsize = ' + this.size;
|
|
4097
|
+
if (this.angle)
|
|
4098
|
+
text += '\nangle = ' + this.angle.toFixed(3);
|
|
4099
|
+
text += '\ncolor = ' + this.color;
|
|
4100
|
+
return text;
|
|
4101
|
+
}
|
|
4102
|
+
|
|
4103
|
+
/** Render debug info for this object */
|
|
4104
|
+
renderDebugInfo()
|
|
4105
|
+
{
|
|
4106
|
+
if (!debug) return;
|
|
4107
|
+
|
|
4108
|
+
// check if there is anything to show
|
|
4109
|
+
const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
|
|
4110
|
+
if (!hasPhysics && !this.parent) return;
|
|
4111
|
+
|
|
4112
|
+
// show object info for debugging
|
|
4113
|
+
const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
|
|
4114
|
+
const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
|
|
4115
|
+
debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
|
|
4116
|
+
if (this.parent)
|
|
4117
|
+
debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
|
|
4118
|
+
this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
|
|
4119
|
+
}
|
|
4116
4120
|
}
|
|
4117
4121
|
/**
|
|
4118
4122
|
* LittleJS Drawing System
|
|
@@ -5057,7 +5061,7 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
|
|
|
5057
5061
|
// build regular polygon points
|
|
5058
5062
|
const points = [];
|
|
5059
5063
|
const sizeX = size.x/2, sizeY = size.y/2;
|
|
5060
|
-
for (let i=sides; i
|
|
5064
|
+
for (let i=sides; i-- > 0;) // a count that is not whole, or below zero, still ends
|
|
5061
5065
|
{
|
|
5062
5066
|
const a = (i/sides)*PI*2;
|
|
5063
5067
|
points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
|
|
@@ -5168,7 +5172,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
|
|
|
5168
5172
|
if (!ring)
|
|
5169
5173
|
{
|
|
5170
5174
|
const points = [];
|
|
5171
|
-
for (let i=sides; i
|
|
5175
|
+
for (let i=sides; i-- > 0;)
|
|
5172
5176
|
{
|
|
5173
5177
|
const a = (i/sides)*PI*2;
|
|
5174
5178
|
points.push(vec2(sin(a), cos(a)));
|
|
@@ -5268,7 +5272,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
|
|
|
5268
5272
|
const startA = (offset%sides)/sides*PI*2;
|
|
5269
5273
|
const points = [rim(startA)];
|
|
5270
5274
|
const colors = [outerInt];
|
|
5271
|
-
for (let i=sides; i
|
|
5275
|
+
for (let i=sides; i-- > 0;)
|
|
5272
5276
|
{
|
|
5273
5277
|
const a = ((i+offset)%sides)/sides*PI*2;
|
|
5274
5278
|
points.push(pos);
|
|
@@ -5691,7 +5695,8 @@ function isOnScreen(pos, size=0)
|
|
|
5691
5695
|
function setAdditiveBlendMode(additive=true)
|
|
5692
5696
|
{
|
|
5693
5697
|
glAdditive = additive;
|
|
5694
|
-
drawContext
|
|
5698
|
+
if (drawContext) // none headless
|
|
5699
|
+
drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
|
|
5695
5700
|
}
|
|
5696
5701
|
|
|
5697
5702
|
/** Set the Shader that 2D draws use from now on, none for the engine's own
|
|
@@ -5814,9 +5819,12 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
|
|
|
5814
5819
|
}
|
|
5815
5820
|
else
|
|
5816
5821
|
{
|
|
5817
|
-
// copy to offscreen canvas
|
|
5818
|
-
|
|
5819
|
-
workReadCanvas.height
|
|
5822
|
+
// copy to offscreen canvas, sized again only when the size changes: setting a size remakes the canvas even
|
|
5823
|
+
// to the same one, and a game's tinted tiles are mostly of one size
|
|
5824
|
+
if (workReadCanvas.width !== sWidth || workReadCanvas.height !== sHeight)
|
|
5825
|
+
workReadCanvas.width = sWidth, workReadCanvas.height = sHeight;
|
|
5826
|
+
else
|
|
5827
|
+
workReadContext.clearRect(0, 0, sWidth, sHeight);
|
|
5820
5828
|
workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
|
|
5821
5829
|
|
|
5822
5830
|
// tint image using offscreen work context
|
|
@@ -7549,7 +7557,7 @@ function touchGamepadPointerUp(e)
|
|
|
7549
7557
|
* - 2D spatial audio based on camera position with distance-based falloff
|
|
7550
7558
|
* - Sound instance management (pause, resume, stop)
|
|
7551
7559
|
* - Speech synthesis for text-to-speech
|
|
7552
|
-
* - Music playback
|
|
7560
|
+
* - Music playback from audio files, looping by default
|
|
7553
7561
|
* - Web Audio API integration with master gain control
|
|
7554
7562
|
* - Sounds and the master bus can route through effects, see the audio effects plugin
|
|
7555
7563
|
* @namespace Audio
|
|
@@ -7762,7 +7770,13 @@ class Sound
|
|
|
7762
7770
|
*/
|
|
7763
7771
|
constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
|
|
7764
7772
|
{
|
|
7765
|
-
if (!soundEnable || headlessMode)
|
|
7773
|
+
if (!soundEnable || headlessMode)
|
|
7774
|
+
{
|
|
7775
|
+
// no sound is made: it counts as loaded, so a game that waits for its sounds goes on
|
|
7776
|
+
this.loadedPercent = 1;
|
|
7777
|
+
onloadCallback?.(this);
|
|
7778
|
+
return;
|
|
7779
|
+
}
|
|
7766
7780
|
const rangeIsDefault = range === undefined;
|
|
7767
7781
|
if (rangeIsDefault)
|
|
7768
7782
|
range = soundDefaultRange;
|
|
@@ -8421,7 +8435,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
|
|
|
8421
8435
|
}
|
|
8422
8436
|
|
|
8423
8437
|
///////////////////////////////////////////////////////////////////////////////
|
|
8424
|
-
//
|
|
8438
|
+
// ZzFX - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
|
|
8425
8439
|
|
|
8426
8440
|
/** Generate and play a ZzFX sound
|
|
8427
8441
|
*
|
|
@@ -8706,6 +8720,10 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
|
|
|
8706
8720
|
* Load tile layers from exported data
|
|
8707
8721
|
* - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
|
|
8708
8722
|
* tiles continue its numbering), finite maps in the CSV or array layer format; layer offsets and parallax are not read
|
|
8723
|
+
* - A tileset kept in the map with a margin or a spacing, a sheet with gaps between its tiles, is read where its
|
|
8724
|
+
* tiles are, whatever padding the tile info has; one in a file of its own (a tsx) is not read, pass a tile info
|
|
8725
|
+
* with its padding
|
|
8726
|
+
* - An LDtk level loads through tileLayersFromLDtk, which makes it a map like these
|
|
8709
8727
|
* - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices
|
|
8710
8728
|
* (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and
|
|
8711
8729
|
* visibility carry to the layers inside it
|
|
@@ -8738,6 +8756,18 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
|
|
|
8738
8756
|
false&&ASSERT(tileMapData.width && tileMapData.height);
|
|
8739
8757
|
false&&ASSERT(tileMapData.layers && tileMapData.layers.length);
|
|
8740
8758
|
|
|
8759
|
+
// a sheet with a margin around its tiles or a spacing between them: the first tile is at the margin and each
|
|
8760
|
+
// cell is a tile and a spacing, which is a padding of half the spacing counted from there, by the tileset's
|
|
8761
|
+
// own count of columns, since the image's width does not say with a spacing that is not all around
|
|
8762
|
+
const tileset = tileMapData.tilesets?.[0];
|
|
8763
|
+
if (tileInfo && tileset && (tileset.margin > 0 || tileset.spacing > 0))
|
|
8764
|
+
{
|
|
8765
|
+
const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size;
|
|
8766
|
+
const width = tileInfo.textureInfo?.size.x || 0;
|
|
8767
|
+
const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing)));
|
|
8768
|
+
tileInfo = new TileInfo(vec2(margin), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
|
|
8769
|
+
}
|
|
8770
|
+
|
|
8741
8771
|
// flatten group layers in order, a group's color and visibility carry to the layers inside it
|
|
8742
8772
|
/** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */
|
|
8743
8773
|
const layers = [];
|
|
@@ -8815,6 +8845,107 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
|
|
|
8815
8845
|
function tileLayersColor(hex)
|
|
8816
8846
|
{ return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); }
|
|
8817
8847
|
|
|
8848
|
+
// the Tiled property type of an LDtk field type, for the ones Tiled has a property for
|
|
8849
|
+
const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: 'string', Multilines: 'string',
|
|
8850
|
+
Color: 'color', FilePath: 'file'};
|
|
8851
|
+
|
|
8852
|
+
/**
|
|
8853
|
+
* Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad
|
|
8854
|
+
* - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled, so the last layer
|
|
8855
|
+
* of the LDtk file is layer 0; where LDtk stacks tiles in a cell, an edge over a fill, each tile over another
|
|
8856
|
+
* goes in a layer of its own just above, named with (2), (3) and so on, so a layer of the first tiles has one
|
|
8857
|
+
* wherever the LDtk layer has any, which makes it the one for collision
|
|
8858
|
+
* - An IntGrid layer with no tiles is a hidden layer of its values, for collision: pass its index as collisionLayer
|
|
8859
|
+
* - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at
|
|
8860
|
+
* its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string)
|
|
8861
|
+
* - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image
|
|
8862
|
+
* - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of
|
|
8863
|
+
* another grid size or another tileset is left out, with a warning in debug builds
|
|
8864
|
+
* - The level editor edits the map this returns, and saves it as a Tiled map
|
|
8865
|
+
* @param {Object} ldtk - The LDtk project, its JSON
|
|
8866
|
+
* @param {number|string} [level] - Which level, by its index or its identifier
|
|
8867
|
+
* @return {Object} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers
|
|
8868
|
+
* @example
|
|
8869
|
+
* const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0');
|
|
8870
|
+
* const layers = tileLayersLoad(map, tile(0, 16), 0, 1); // layer 1 is solid
|
|
8871
|
+
* objectLayersLoad(map);
|
|
8872
|
+
* @memberof TileLayers */
|
|
8873
|
+
function tileLayersFromLDtk(ldtk, level=0)
|
|
8874
|
+
{
|
|
8875
|
+
const levels = ldtk?.levels || [];
|
|
8876
|
+
const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level];
|
|
8877
|
+
false&&ASSERT(data, 'LDtk level not found', level);
|
|
8878
|
+
false&&ASSERT(isArray(data.layerInstances), 'LDtk level has no layers: levels saved as separate files are not read');
|
|
8879
|
+
const instances = data.layerInstances;
|
|
8880
|
+
const tiled = (l)=> l.__type !== 'Entities';
|
|
8881
|
+
const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
|
|
8882
|
+
const grid = first?.__gridSize || 16;
|
|
8883
|
+
const width = first?.__cWid || ceil(data.pxWid / grid), height = first?.__cHei || ceil(data.pxHei / grid);
|
|
8884
|
+
const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down',
|
|
8885
|
+
infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1};
|
|
8886
|
+
|
|
8887
|
+
// the tileset of the first layer that has one, where its tiles are in its image
|
|
8888
|
+
const tileset = (ldtk?.defs?.tilesets || []).find((t)=> t.uid === first?.__tilesetDefUid);
|
|
8889
|
+
if (tileset)
|
|
8890
|
+
map.tilesets = [{firstgid: 1, name: String(tileset.relPath || '').replace(/^.*[\\/]/, '').replace(/\.\w+$/, ''),
|
|
8891
|
+
image: tileset.relPath, imagewidth: tileset.pxWid, imageheight: tileset.pxHei,
|
|
8892
|
+
tilewidth: tileset.tileGridSize, tileheight: tileset.tileGridSize, margin: tileset.padding || 0,
|
|
8893
|
+
spacing: tileset.spacing || 0, columns: tileset.__cWid, tilecount: tileset.__cWid * tileset.__cHei}];
|
|
8894
|
+
|
|
8895
|
+
// LDtk lists the top layer first, Tiled the bottom one
|
|
8896
|
+
for (let i = instances.length; i--;)
|
|
8897
|
+
{
|
|
8898
|
+
const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++;
|
|
8899
|
+
if (!tiled(instance))
|
|
8900
|
+
{
|
|
8901
|
+
const objects = (instance.entityInstances || []).map((entity)=>
|
|
8902
|
+
{
|
|
8903
|
+
// an entity is placed by its pivot, an object here by its middle
|
|
8904
|
+
const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0;
|
|
8905
|
+
const properties = [];
|
|
8906
|
+
for (const field of entity.fieldInstances || [])
|
|
8907
|
+
{
|
|
8908
|
+
const type = tileLayersLDtkTypes[field.__type] || (/^(Local|Extern)Enum\./.test(field.__type) && 'string');
|
|
8909
|
+
type && field.__value != undefined && properties.push({name: field.__identifier, type, value: field.__value});
|
|
8910
|
+
}
|
|
8911
|
+
return {id: map.nextobjectid++, name: '', type: entity.__identifier, x: entity.px[0] + (.5 - pivotX) * w,
|
|
8912
|
+
y: entity.px[1] + (.5 - pivotY) * h, width: w, height: h, rotation: 0, visible: true, properties};
|
|
8913
|
+
});
|
|
8914
|
+
map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0});
|
|
8915
|
+
continue;
|
|
8916
|
+
}
|
|
8917
|
+
const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
|
|
8918
|
+
const usable = instance.__gridSize === grid && instance.__cWid === width && instance.__cHei === height &&
|
|
8919
|
+
(!tiles.length || instance.__tilesetDefUid === tileset?.uid);
|
|
8920
|
+
if (!usable)
|
|
8921
|
+
{
|
|
8922
|
+
debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
|
|
8923
|
+
continue;
|
|
8924
|
+
}
|
|
8925
|
+
const empty = ()=> new Array(width * height).fill(0), stacks = [empty()];
|
|
8926
|
+
if (tiles.length)
|
|
8927
|
+
{
|
|
8928
|
+
// a tile's place in pixels, its tile in the sheet, and its flips: bit 0 across, bit 1 down, which are
|
|
8929
|
+
// Tiled's two top bits; LDtk lists them in the order it draws them, so the first in a cell is the
|
|
8930
|
+
// bottom one and each after it goes a layer higher
|
|
8931
|
+
const depth = new Uint16Array(width * height);
|
|
8932
|
+
for (const t of tiles)
|
|
8933
|
+
{
|
|
8934
|
+
const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width;
|
|
8935
|
+
if (x >= 0 && x < width && y >= 0 && y < height)
|
|
8936
|
+
(stacks[depth[cell]++] ||= empty())[cell] =
|
|
8937
|
+
(t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0;
|
|
8938
|
+
}
|
|
8939
|
+
}
|
|
8940
|
+
else
|
|
8941
|
+
(instance.intGridCsv || []).forEach((value, k)=> k < stacks[0].length && (stacks[0][k] = value));
|
|
8942
|
+
const visible = instance.__type === 'IntGrid' && !tiles.length ? false : instance.visible !== false;
|
|
8943
|
+
stacks.forEach((data, k)=> map.layers.push({id: k ? map.nextlayerid++ : id, name: k ? `${name} (${k + 1})` : name,
|
|
8944
|
+
type: 'tilelayer', width, height, data, opacity: instance.__opacity ?? 1, visible, x: 0, y: 0}));
|
|
8945
|
+
}
|
|
8946
|
+
return map;
|
|
8947
|
+
}
|
|
8948
|
+
|
|
8818
8949
|
///////////////////////////////////////////////////////////////////////////////
|
|
8819
8950
|
// Object layers
|
|
8820
8951
|
|
|
@@ -8909,7 +9040,8 @@ function objectLayersMake(tileMapData, object)
|
|
|
8909
9040
|
const name = object.type || object.class, type = objectLayersTypes.get(name);
|
|
8910
9041
|
if (!type)
|
|
8911
9042
|
{
|
|
8912
|
-
|
|
9043
|
+
// an object with no type, a shape or a text someone drew in Tiled, is not one a game makes
|
|
9044
|
+
debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
|
|
8913
9045
|
return;
|
|
8914
9046
|
}
|
|
8915
9047
|
const {height=0, tilewidth=1, tileheight=1} = tileMapData;
|
|
@@ -10993,6 +11125,9 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
|
10993
11125
|
glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
|
|
10994
11126
|
}
|
|
10995
11127
|
|
|
11128
|
+
// the list and the vectors glDrawPointsTransform writes its points into
|
|
11129
|
+
const glPointsOut = [], glPointsPool = [];
|
|
11130
|
+
|
|
10996
11131
|
/** Transform and add a polygon to the gl draw list
|
|
10997
11132
|
* @param {Array<Vector2>} points - Array of Vector2 points
|
|
10998
11133
|
* @param {number} rgba - Color of the polygon as a 32-bit integer
|
|
@@ -11005,15 +11140,21 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
|
11005
11140
|
* @memberof WebGL */
|
|
11006
11141
|
function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
|
|
11007
11142
|
{
|
|
11008
|
-
|
|
11143
|
+
// written into vectors kept for this, a circle would make one for each of its sides at every draw; they are
|
|
11144
|
+
// read into the batch before this returns
|
|
11145
|
+
const pointsOut = glPointsOut, pool = glPointsPool, count = points.length;
|
|
11146
|
+
pointsOut.length = count;
|
|
11009
11147
|
const sa = sin(-angle);
|
|
11010
11148
|
const ca = cos(-angle);
|
|
11011
|
-
for (
|
|
11149
|
+
for (let i = 0; i < count; ++i)
|
|
11012
11150
|
{
|
|
11013
11151
|
// transform the point
|
|
11152
|
+
const p = points[i], out = pool[i] ||= vec2();
|
|
11014
11153
|
const px = p.x*sx;
|
|
11015
11154
|
const py = p.y*sy;
|
|
11016
|
-
|
|
11155
|
+
out.x = x + ca*px - sa*py;
|
|
11156
|
+
out.y = y + sa*px + ca*py;
|
|
11157
|
+
pointsOut[i] = out;
|
|
11017
11158
|
}
|
|
11018
11159
|
const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
|
|
11019
11160
|
glDrawPoints(drawPoints, rgba);
|
|
@@ -12448,8 +12589,11 @@ class PostProcessPlugin
|
|
|
12448
12589
|
{
|
|
12449
12590
|
// copy main canvas to work canvas at the backing store size,
|
|
12450
12591
|
// mainCanvasSize is css pixels so it would lose resolution
|
|
12451
|
-
|
|
12452
|
-
workCanvas.height
|
|
12592
|
+
// sized again only when the canvas changed, setting a size remakes the canvas even to the same one
|
|
12593
|
+
if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height)
|
|
12594
|
+
workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height;
|
|
12595
|
+
else
|
|
12596
|
+
workContext.clearRect(0, 0, workCanvas.width, workCanvas.height);
|
|
12453
12597
|
glCopyToContext(workContext);
|
|
12454
12598
|
workContext.drawImage(mainCanvas, 0, 0);
|
|
12455
12599
|
// clear the main canvas with clearRect, resizing it would also
|
|
@@ -13543,164 +13687,6 @@ class Light extends EngineObject
|
|
|
13543
13687
|
}
|
|
13544
13688
|
}
|
|
13545
13689
|
|
|
13546
|
-
/**
|
|
13547
|
-
* LittleJS ZzFXM Plugin
|
|
13548
|
-
* @namespace ZzFXM
|
|
13549
|
-
*/
|
|
13550
|
-
|
|
13551
|
-
/**
|
|
13552
|
-
* Music Object - Stores a zzfx music track for later use
|
|
13553
|
-
*
|
|
13554
|
-
* <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
|
|
13555
|
-
* @extends Sound
|
|
13556
|
-
* @memberof ZzFXM
|
|
13557
|
-
* @example
|
|
13558
|
-
* // create some music
|
|
13559
|
-
* const music_example = new ZzFXMusic(
|
|
13560
|
-
* [
|
|
13561
|
-
* [ // instruments
|
|
13562
|
-
* [,0,400] // simple note
|
|
13563
|
-
* ],
|
|
13564
|
-
* [ // patterns
|
|
13565
|
-
* [ // pattern 1
|
|
13566
|
-
* [ // channel 0
|
|
13567
|
-
* 0, -1, // instrument 0, left speaker
|
|
13568
|
-
* 1, 0, 9, 1 // channel notes
|
|
13569
|
-
* ],
|
|
13570
|
-
* [ // channel 1
|
|
13571
|
-
* 0, 1, // instrument 0, right speaker
|
|
13572
|
-
* 0, 12, 17, -1 // channel notes
|
|
13573
|
-
* ]
|
|
13574
|
-
* ],
|
|
13575
|
-
* ],
|
|
13576
|
-
* [0, 0, 0, 0], // sequence, play pattern 0 four times
|
|
13577
|
-
* 90 // BPM
|
|
13578
|
-
* ]);
|
|
13579
|
-
*
|
|
13580
|
-
* // play the music on a loop
|
|
13581
|
-
* music_example.playMusic();
|
|
13582
|
-
*/
|
|
13583
|
-
class ZzFXMusic extends Sound
|
|
13584
|
-
{
|
|
13585
|
-
/** Create a music object and cache the zzfx music samples for later use
|
|
13586
|
-
* @param {[Array, Array, Array, number?, ...any[]]} zzfxMusic - Array of zzfx music parameters: instruments, patterns, sequence, and an optional BPM; anything after it, like the tracker's metadata, is ignored
|
|
13587
|
-
*/
|
|
13588
|
-
constructor(zzfxMusic)
|
|
13589
|
-
{
|
|
13590
|
-
super(undefined);
|
|
13591
|
-
|
|
13592
|
-
if (!soundEnable || headlessMode) return;
|
|
13593
|
-
const [instruments, patterns, sequence, BPM] = zzfxMusic;
|
|
13594
|
-
super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
|
|
13595
|
-
this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
|
|
13596
|
-
this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
|
|
13597
|
-
}
|
|
13598
|
-
}
|
|
13599
|
-
|
|
13600
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
13601
|
-
// ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
|
|
13602
|
-
|
|
13603
|
-
/** Generate samples for a ZzFM song with given parameters
|
|
13604
|
-
* @param {Array} instruments - Array of ZzFX sound parameters
|
|
13605
|
-
* @param {Array} patterns - Array of pattern data
|
|
13606
|
-
* @param {Array} sequence - Array of pattern indexes
|
|
13607
|
-
* @param {number} [BPM] - Playback speed of the song in BPM
|
|
13608
|
-
* @return {Array} - Left and right channel sample data
|
|
13609
|
-
* @memberof ZzFXM */
|
|
13610
|
-
function zzfxM(instruments, patterns, sequence, BPM = 125)
|
|
13611
|
-
{
|
|
13612
|
-
let i, j, k;
|
|
13613
|
-
let instrumentParameters;
|
|
13614
|
-
let note;
|
|
13615
|
-
let sample;
|
|
13616
|
-
let patternChannel;
|
|
13617
|
-
let notFirstBeat;
|
|
13618
|
-
let stop;
|
|
13619
|
-
let instrument;
|
|
13620
|
-
let attenuation;
|
|
13621
|
-
let outSampleOffset;
|
|
13622
|
-
let isSequenceEnd;
|
|
13623
|
-
let sampleOffset = 0;
|
|
13624
|
-
let nextSampleOffset;
|
|
13625
|
-
let sampleBuffer = [];
|
|
13626
|
-
let leftChannelBuffer = [];
|
|
13627
|
-
let rightChannelBuffer = [];
|
|
13628
|
-
let channelIndex = 0;
|
|
13629
|
-
let panning = 0;
|
|
13630
|
-
let hasMore = 1;
|
|
13631
|
-
let sampleCache = {};
|
|
13632
|
-
let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
|
|
13633
|
-
|
|
13634
|
-
// for each channel in order until there are no more
|
|
13635
|
-
for (; hasMore; channelIndex++) {
|
|
13636
|
-
|
|
13637
|
-
// reset current values
|
|
13638
|
-
sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
|
|
13639
|
-
|
|
13640
|
-
// for each pattern in sequence
|
|
13641
|
-
sequence.forEach((patternIndex, sequenceIndex)=> {
|
|
13642
|
-
// get pattern for current channel, use empty 1 note pattern if none found
|
|
13643
|
-
patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
|
|
13644
|
-
|
|
13645
|
-
// check if there are more channels
|
|
13646
|
-
hasMore |= patterns[patternIndex][channelIndex]&&1;
|
|
13647
|
-
|
|
13648
|
-
// get next offset, use the length of first channel
|
|
13649
|
-
nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
|
|
13650
|
-
// for each beat in pattern, plus one extra if end of sequence
|
|
13651
|
-
isSequenceEnd = sequenceIndex === sequence.length - 1;
|
|
13652
|
-
for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
|
|
13653
|
-
|
|
13654
|
-
// <channel-note>
|
|
13655
|
-
note = patternChannel[i];
|
|
13656
|
-
|
|
13657
|
-
// stop if end, different instrument or new note
|
|
13658
|
-
stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
|
|
13659
|
-
instrument !== (patternChannel[0] || 0) || note | 0;
|
|
13660
|
-
|
|
13661
|
-
// fill buffer with samples for previous beat, most cpu intensive part
|
|
13662
|
-
for (j = 0; j < beatLength && notFirstBeat;
|
|
13663
|
-
|
|
13664
|
-
// fade off attenuation at end of beat if stopping note, prevents clicking
|
|
13665
|
-
j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
|
|
13666
|
-
) {
|
|
13667
|
-
// copy sample to stereo buffers with panning
|
|
13668
|
-
sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
|
|
13669
|
-
leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
|
|
13670
|
-
rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
|
|
13671
|
-
}
|
|
13672
|
-
|
|
13673
|
-
// set up for next note
|
|
13674
|
-
if (note) {
|
|
13675
|
-
// set attenuation
|
|
13676
|
-
attenuation = note % 1;
|
|
13677
|
-
panning = patternChannel[1] || 0;
|
|
13678
|
-
if (note |= 0) {
|
|
13679
|
-
// get cached sample
|
|
13680
|
-
sampleBuffer = sampleCache[
|
|
13681
|
-
[
|
|
13682
|
-
instrument = patternChannel[sampleOffset = 0] || 0,
|
|
13683
|
-
note
|
|
13684
|
-
]
|
|
13685
|
-
] = sampleCache[[instrument, note]] || (
|
|
13686
|
-
// add sample to cache
|
|
13687
|
-
instrumentParameters = [...instruments[instrument]],
|
|
13688
|
-
instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
|
|
13689
|
-
|
|
13690
|
-
// allow negative values to stop notes
|
|
13691
|
-
note > 0 ? zzfxG(...instrumentParameters) : []
|
|
13692
|
-
);
|
|
13693
|
-
}
|
|
13694
|
-
}
|
|
13695
|
-
}
|
|
13696
|
-
|
|
13697
|
-
// update the sample offset
|
|
13698
|
-
outSampleOffset = nextSampleOffset;
|
|
13699
|
-
});
|
|
13700
|
-
}
|
|
13701
|
-
|
|
13702
|
-
return [leftChannelBuffer, rightChannelBuffer];
|
|
13703
|
-
}
|
|
13704
13690
|
/**
|
|
13705
13691
|
* LittleJS Audio Effects Plugin
|
|
13706
13692
|
* - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
|
|
@@ -14690,8 +14676,7 @@ class UISystemPlugin
|
|
|
14690
14676
|
}
|
|
14691
14677
|
}
|
|
14692
14678
|
|
|
14693
|
-
/** Destroy and remove all objects
|
|
14694
|
-
* @memberof UISystem */
|
|
14679
|
+
/** Destroy and remove all objects */
|
|
14695
14680
|
destroyObjects()
|
|
14696
14681
|
{
|
|
14697
14682
|
for (const o of this.uiObjects)
|
|
@@ -15212,8 +15197,10 @@ class UIObject
|
|
|
15212
15197
|
return void inputClearKey(0, 0, false, true, false);
|
|
15213
15198
|
}
|
|
15214
15199
|
}
|
|
15200
|
+
// the object that was the active one going into this update: one pressed and let go inside a
|
|
15201
|
+
// frame is clicked on the next, with its release, not on both
|
|
15215
15202
|
if (!uiSystem.activateOnPress)
|
|
15216
|
-
if (!mouseDown && this.isActiveObject() && this.interactive)
|
|
15203
|
+
if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
|
|
15217
15204
|
this.click();
|
|
15218
15205
|
if (this.destroyed) return;
|
|
15219
15206
|
}
|
|
@@ -15956,7 +15943,10 @@ class UIVideo extends UIObject
|
|
|
15956
15943
|
if (this.destroyed)
|
|
15957
15944
|
return;
|
|
15958
15945
|
|
|
15946
|
+
// let go of the media too, a paused video keeps what it has loaded
|
|
15959
15947
|
this.video.pause();
|
|
15948
|
+
this.video.removeAttribute?.('src');
|
|
15949
|
+
this.video.load?.();
|
|
15960
15950
|
this.video.remove();
|
|
15961
15951
|
super.destroy();
|
|
15962
15952
|
}
|
|
@@ -19295,6 +19285,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
|
|
|
19295
19285
|
for (const group of parseAtlas(data))
|
|
19296
19286
|
{
|
|
19297
19287
|
// reserve a block of full size cells, one per frame
|
|
19288
|
+
if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
|
|
19298
19289
|
const sourceSize = group.frames[0].sourceSize;
|
|
19299
19290
|
const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
|
|
19300
19291
|
const added = textureSheetAdd(blockSize, sourceSize, padding);
|
|
@@ -20080,45 +20071,50 @@ function tweenUpdate(gameDelta, realDelta)
|
|
|
20080
20071
|
const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
|
|
20081
20072
|
for (const t of tweenActive)
|
|
20082
20073
|
list.push(t);
|
|
20083
|
-
|
|
20074
|
+
// the list is let go however the walk ends: a callback that throws must not leave it held, or every
|
|
20075
|
+
// update after would take it for an update still going and make a list of its own
|
|
20076
|
+
try
|
|
20084
20077
|
{
|
|
20085
|
-
|
|
20086
|
-
// stopped, or started again by a callback this update, or during an update a callback ran inside it, which
|
|
20087
|
-
// counts on from this one
|
|
20088
|
-
if (!t.active || t.activePass >= pass) continue;
|
|
20089
|
-
let dt;
|
|
20090
|
-
if (enginePath)
|
|
20078
|
+
for (let i = list.length; i--;)
|
|
20091
20079
|
{
|
|
20092
|
-
|
|
20093
|
-
|
|
20094
|
-
|
|
20095
|
-
t.
|
|
20096
|
-
|
|
20097
|
-
|
|
20098
|
-
|
|
20099
|
-
|
|
20100
|
-
|
|
20080
|
+
const t = list[i];
|
|
20081
|
+
// stopped, or started again by a callback this update, or during an update a callback ran inside it, which
|
|
20082
|
+
// counts on from this one
|
|
20083
|
+
if (!t.active || t.activePass >= pass) continue;
|
|
20084
|
+
let dt;
|
|
20085
|
+
if (enginePath)
|
|
20086
|
+
{
|
|
20087
|
+
// a paused tween keeps count too, so it does not jump when resumed
|
|
20088
|
+
dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
|
|
20089
|
+
t.lastTime = time;
|
|
20090
|
+
t.lastTimeReal = timeReal;
|
|
20091
|
+
}
|
|
20092
|
+
else
|
|
20093
|
+
dt = t.useRealTime ? realDelta : gameDelta;
|
|
20094
|
+
if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
|
|
20095
|
+
if (t.paused || dt <= 0) continue;
|
|
20101
20096
|
|
|
20102
|
-
|
|
20103
|
-
|
|
20104
|
-
|
|
20105
|
-
|
|
20106
|
-
|
|
20107
|
-
|
|
20108
|
-
|
|
20109
|
-
|
|
20110
|
-
|
|
20111
|
-
|
|
20112
|
-
|
|
20113
|
-
|
|
20114
|
-
|
|
20115
|
-
|
|
20116
|
-
|
|
20117
|
-
|
|
20118
|
-
|
|
20097
|
+
t.life -= dt;
|
|
20098
|
+
if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
|
|
20099
|
+
{
|
|
20100
|
+
t.callback(t.interp(t.life));
|
|
20101
|
+
}
|
|
20102
|
+
else
|
|
20103
|
+
{
|
|
20104
|
+
// Completion: fire end value, remove from active, start the next iteration
|
|
20105
|
+
// of a loop or pingPong, or when there is none it has completed, fire onComplete
|
|
20106
|
+
t.callback(t.interp(0));
|
|
20107
|
+
if (!t.active || t.activePass >= pass)
|
|
20108
|
+
continue; // stopped or restarted by its own callback, the run it was on ends without completing
|
|
20109
|
+
tweenDeactivate(t);
|
|
20110
|
+
const next = t.thenCallback;
|
|
20111
|
+
t.thenCallback = undefined;
|
|
20112
|
+
if (!(next && next()) && t.onComplete)
|
|
20113
|
+
t.onComplete();
|
|
20114
|
+
}
|
|
20119
20115
|
}
|
|
20120
20116
|
}
|
|
20121
|
-
list.length = 0;
|
|
20117
|
+
finally { list.length = 0; }
|
|
20122
20118
|
}
|
|
20123
20119
|
|
|
20124
20120
|
/** Stop every active tween, ending loops too, without calling their then-callbacks.
|
|
@@ -22500,17 +22496,19 @@ function render3DInstance(mesh, matrix, tileInfo, color)
|
|
|
22500
22496
|
// make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
|
|
22501
22497
|
// differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
|
|
22502
22498
|
// drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
|
|
22503
|
-
// so it never sets the draw state for them, which would move the state's version at every draw
|
|
22504
|
-
|
|
22499
|
+
// so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard
|
|
22500
|
+
// passes that its batch is unlit, for the same reason
|
|
22501
|
+
function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false)
|
|
22505
22502
|
{
|
|
22506
22503
|
const r = render3D;
|
|
22507
22504
|
if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
|
|
22508
|
-
|| render3DStateChanged(mesh.instanceState)))
|
|
22505
|
+
|| mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState)))
|
|
22509
22506
|
render3DFlushInstances(mesh);
|
|
22510
22507
|
if (!mesh.instanceCount)
|
|
22511
22508
|
{
|
|
22512
22509
|
mesh.instanceTextureInfo = textureInfo;
|
|
22513
22510
|
mesh.instanceMirrored = mirrored;
|
|
22511
|
+
mesh.instanceUnlit = unlit;
|
|
22514
22512
|
mesh.instanceState = render3DCaptureBatchState();
|
|
22515
22513
|
r.instanceMeshes.push(mesh);
|
|
22516
22514
|
}
|
|
@@ -22544,6 +22542,8 @@ function render3DFlushInstances(only)
|
|
|
22544
22542
|
const state = mesh.instanceState;
|
|
22545
22543
|
if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
|
|
22546
22544
|
state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
|
|
22545
|
+
if (mesh.instanceUnlit) // a batch of sprites
|
|
22546
|
+
state.lighting = false;
|
|
22547
22547
|
render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
|
|
22548
22548
|
}
|
|
22549
22549
|
if (!only)
|
|
@@ -22900,6 +22900,7 @@ class Render3DPlugin
|
|
|
22900
22900
|
/** @type {TextureInfo|undefined} */
|
|
22901
22901
|
this.streamTileInfo = undefined;
|
|
22902
22902
|
this.streamState = undefined; // captured state the pending batch was drawn under
|
|
22903
|
+
this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are
|
|
22903
22904
|
/** @type {Mesh|undefined} */
|
|
22904
22905
|
this.capture = undefined; // the mesh a bake is filling
|
|
22905
22906
|
/** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
|
|
@@ -23108,8 +23109,8 @@ class Render3DPlugin
|
|
|
23108
23109
|
}
|
|
23109
23110
|
|
|
23110
23111
|
/** Find the nearest object under a screen position or along a ray, for clicking on things
|
|
23111
|
-
* -
|
|
23112
|
-
*
|
|
23112
|
+
* - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
|
|
23113
|
+
* mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
|
|
23113
23114
|
* - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
|
|
23114
23115
|
* @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
|
|
23115
23116
|
* @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
|
|
@@ -23290,6 +23291,8 @@ class Render3DPlugin
|
|
|
23290
23291
|
{
|
|
23291
23292
|
if (!this.streamCount || !render3DCanDraw()) return;
|
|
23292
23293
|
const gl = glContext;
|
|
23294
|
+
if (this.streamUnlit)
|
|
23295
|
+
this.streamState.lighting = false; // its own copy of the state
|
|
23293
23296
|
render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
|
|
23294
23297
|
render3DBindVertexBuffer(this.streamBuffer);
|
|
23295
23298
|
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
|
|
@@ -23524,13 +23527,34 @@ class Render3DPlugin
|
|
|
23524
23527
|
return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
|
|
23525
23528
|
}
|
|
23526
23529
|
|
|
23527
|
-
//
|
|
23528
|
-
//
|
|
23529
|
-
const
|
|
23530
|
-
this.
|
|
23531
|
-
|
|
23532
|
-
|
|
23533
|
-
|
|
23530
|
+
// unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not
|
|
23531
|
+
// set, which would move its version at every sprite
|
|
23532
|
+
const unlit = !this.shadowPass;
|
|
23533
|
+
if (!this.blend && this.depthTest && this.instancing)
|
|
23534
|
+
{
|
|
23535
|
+
// an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a
|
|
23536
|
+
// sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader
|
|
23537
|
+
// drops its see through texels, so it needs no sorting
|
|
23538
|
+
if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow
|
|
23539
|
+
if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2))
|
|
23540
|
+
return;
|
|
23541
|
+
const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo);
|
|
23542
|
+
render3DMeshUpload(quad);
|
|
23543
|
+
// the quad's half axes, doubled for its unit square
|
|
23544
|
+
const a = render3DBillboardAxes(size, angle, upright);
|
|
23545
|
+
const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData;
|
|
23546
|
+
data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
|
|
23547
|
+
data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
|
|
23548
|
+
data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
|
|
23549
|
+
data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1;
|
|
23550
|
+
data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
|
|
23551
|
+
data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
|
|
23552
|
+
return;
|
|
23553
|
+
}
|
|
23554
|
+
|
|
23555
|
+
// one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no
|
|
23556
|
+
// vectors made, for sprites that blend, and for particles when instancing is off
|
|
23557
|
+
const uvRect = render3DBeginStrip(6, tileInfo, unlit);
|
|
23534
23558
|
if (!uvRect) return;
|
|
23535
23559
|
const a = render3DBillboardAxes(size, angle, upright);
|
|
23536
23560
|
const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
|
|
@@ -24642,8 +24666,9 @@ function render3DRenderDepth()
|
|
|
24642
24666
|
// which way they face, so keeping the count even keeps every strip facing out.
|
|
24643
24667
|
|
|
24644
24668
|
// make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
|
|
24645
|
-
// differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
|
|
24646
|
-
|
|
24669
|
+
// differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws
|
|
24670
|
+
// the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw
|
|
24671
|
+
function render3DBeginStrip(count, tileInfo, unlit=false)
|
|
24647
24672
|
{
|
|
24648
24673
|
const r = render3D;
|
|
24649
24674
|
if (!render3DCanDraw()) return;
|
|
@@ -24651,12 +24676,13 @@ function render3DBeginStrip(count, tileInfo)
|
|
|
24651
24676
|
false&&ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
|
|
24652
24677
|
if (count > RENDER3D_MAX_STREAM_VERTS) return;
|
|
24653
24678
|
const textureInfo = render3DTextureOf(tileInfo);
|
|
24654
|
-
if (r.streamCount && (textureInfo !== r.streamTileInfo ||
|
|
24655
|
-
|| r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
|
|
24679
|
+
if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit
|
|
24680
|
+
|| render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
|
|
24656
24681
|
r.flush();
|
|
24657
24682
|
render3DFlushBeforeOverlay();
|
|
24658
24683
|
if (!r.streamCount)
|
|
24659
24684
|
r.streamState = render3DCaptureBatchState();
|
|
24685
|
+
r.streamUnlit = unlit;
|
|
24660
24686
|
r.streamTileInfo = textureInfo;
|
|
24661
24687
|
return render3DGetTileUVs(tileInfo);
|
|
24662
24688
|
}
|
|
@@ -26451,8 +26477,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
|
|
|
26451
26477
|
return collected;
|
|
26452
26478
|
}
|
|
26453
26479
|
|
|
26454
|
-
// how far along a ray an object is hit, or undefined for a miss;
|
|
26455
|
-
//
|
|
26480
|
+
// how far along a ray an object is hit, or undefined for a miss; a mesh is hit on its triangles, in its own space,
|
|
26481
|
+
// and a sprite as the quad it draws
|
|
26456
26482
|
function render3DRaycastObject(ray, o)
|
|
26457
26483
|
{
|
|
26458
26484
|
if (o.destroyed || !(o instanceof EngineObject3D)) return;
|
|
@@ -26469,25 +26495,55 @@ function render3DRaycastObject(ray, o)
|
|
|
26469
26495
|
const distance = raycastSphere(ray, center, radius);
|
|
26470
26496
|
if (distance === undefined) return;
|
|
26471
26497
|
|
|
26472
|
-
// the sphere
|
|
26473
|
-
//
|
|
26498
|
+
// the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
|
|
26499
|
+
// against the triangles; the direction is not made unit length, so the distance holds in the world;
|
|
26474
26500
|
// a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
|
|
26475
26501
|
if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
|
|
26476
26502
|
const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
|
|
26477
26503
|
const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
|
|
26478
26504
|
const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
|
|
26479
|
-
if (hit
|
|
26480
|
-
|
|
26481
|
-
|
|
26482
|
-
|
|
26483
|
-
|
|
26484
|
-
|
|
26505
|
+
if (hit === undefined || !ray.direction.lengthSquared())
|
|
26506
|
+
return hit; // a ray of no length is where it starts, inside the box
|
|
26507
|
+
return render3DRaycastMesh(local, mesh);
|
|
26508
|
+
}
|
|
26509
|
+
|
|
26510
|
+
// how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
|
|
26511
|
+
// the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
|
|
26512
|
+
// triangle i is (i-2, i-1, i), the odd ones read the other way, and a join between its pieces has no area and is
|
|
26513
|
+
// never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
|
|
26514
|
+
function render3DRaycastMesh(ray, mesh)
|
|
26515
|
+
{
|
|
26516
|
+
const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
|
|
26517
|
+
const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
|
|
26518
|
+
const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
|
|
26519
|
+
const count = indices ? indices.length / 3 | 0 : points.length - 2;
|
|
26520
|
+
let nearest;
|
|
26521
|
+
for (let i = 0; i < count; ++i)
|
|
26485
26522
|
{
|
|
26486
|
-
|
|
26487
|
-
|
|
26488
|
-
|
|
26523
|
+
// the corners clockwise from the front: an indexed list is written the other way round
|
|
26524
|
+
let a, b, c;
|
|
26525
|
+
if (indices)
|
|
26526
|
+
a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
|
|
26527
|
+
else
|
|
26528
|
+
a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
|
|
26529
|
+
|
|
26530
|
+
// Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
|
|
26531
|
+
const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
|
|
26532
|
+
const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
|
|
26533
|
+
const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
|
|
26534
|
+
const det = e1x * hx + e1y * hy + e1z * hz;
|
|
26535
|
+
if (both ? !det : !(det < 0)) continue;
|
|
26536
|
+
const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
|
|
26537
|
+
const u = (sx * hx + sy * hy + sz * hz) / det;
|
|
26538
|
+
if (!(u >= 0 && u <= 1)) continue;
|
|
26539
|
+
const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
|
|
26540
|
+
const v = (dx * qx + dy * qy + dz * qz) / det;
|
|
26541
|
+
if (!(v >= 0 && u + v <= 1)) continue;
|
|
26542
|
+
const t = (e2x * qx + e2y * qy + e2z * qz) / det;
|
|
26543
|
+
if (t >= 0 && !(t >= nearest))
|
|
26544
|
+
nearest = t;
|
|
26489
26545
|
}
|
|
26490
|
-
return
|
|
26546
|
+
return nearest;
|
|
26491
26547
|
}
|
|
26492
26548
|
|
|
26493
26549
|
// a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
|
|
@@ -26865,10 +26921,12 @@ class InstancedMesh3D extends EngineObject3D
|
|
|
26865
26921
|
* - radius is a world distance, so scale3D does not change it
|
|
26866
26922
|
* - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
|
|
26867
26923
|
* - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
|
|
26924
|
+
* - addFlare gives it a lens flare, see LensFlare3D
|
|
26868
26925
|
* @extends EngineObject3D
|
|
26869
26926
|
* @memberof Render3D
|
|
26870
26927
|
* @example
|
|
26871
26928
|
* const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
|
|
26929
|
+
* torch.addFlare(); // light in the lens when the torch is in view
|
|
26872
26930
|
*/
|
|
26873
26931
|
class Light3D extends EngineObject3D
|
|
26874
26932
|
{
|
|
@@ -26903,6 +26961,49 @@ class Light3D extends EngineObject3D
|
|
|
26903
26961
|
* bright core */
|
|
26904
26962
|
this.glowFalloff = 1;
|
|
26905
26963
|
this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
|
|
26964
|
+
/** @type {LensFlare3D|undefined} */
|
|
26965
|
+
this.flareObject = undefined;
|
|
26966
|
+
}
|
|
26967
|
+
|
|
26968
|
+
/** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had
|
|
26969
|
+
* @param {number} [size] - Scales every part of it
|
|
26970
|
+
* @param {number} [count] - How many ghosts there are, besides the glow at the light
|
|
26971
|
+
* @param {number} [intensity] - How bright it is
|
|
26972
|
+
* @param {number} [saturation] - How colorful the ghosts are
|
|
26973
|
+
* @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
|
|
26974
|
+
addFlare(size, count, intensity, saturation)
|
|
26975
|
+
{
|
|
26976
|
+
const flare = new LensFlare3D(size, count, intensity, saturation);
|
|
26977
|
+
this.flare = flare;
|
|
26978
|
+
return flare;
|
|
26979
|
+
}
|
|
26980
|
+
|
|
26981
|
+
/** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and
|
|
26982
|
+
* destroying the flare takes it away
|
|
26983
|
+
* - The flare is the light's: it is attached to the light as its child, so it stays through a scene change
|
|
26984
|
+
* when the light does, and is destroyed with the light or when another takes its place
|
|
26985
|
+
* @type {LensFlare3D|undefined} */
|
|
26986
|
+
get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; }
|
|
26987
|
+
|
|
26988
|
+
set flare(flare)
|
|
26989
|
+
{
|
|
26990
|
+
const old = this.flare; // a flare that was destroyed on its own is no flare
|
|
26991
|
+
if (flare === old) return;
|
|
26992
|
+
old && old.destroy();
|
|
26993
|
+
if (flare)
|
|
26994
|
+
{
|
|
26995
|
+
flare.light = this;
|
|
26996
|
+
flare.parent || this.addChild(flare);
|
|
26997
|
+
}
|
|
26998
|
+
this.flareObject = flare || undefined;
|
|
26999
|
+
}
|
|
27000
|
+
|
|
27001
|
+
/** Destroy the light, and its flare with it
|
|
27002
|
+
* @param {boolean} [immediate] */
|
|
27003
|
+
destroy(immediate)
|
|
27004
|
+
{
|
|
27005
|
+
this.flare = undefined;
|
|
27006
|
+
super.destroy(immediate);
|
|
26906
27007
|
}
|
|
26907
27008
|
|
|
26908
27009
|
/** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
|
|
@@ -28421,31 +28522,74 @@ class Trail3D extends EngineObject3D
|
|
|
28421
28522
|
// lens flare
|
|
28422
28523
|
|
|
28423
28524
|
// the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
|
|
28424
|
-
// glow, a flat disc with a brighter rim,
|
|
28525
|
+
// glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star
|
|
28526
|
+
// of six rays; undefined headless or without a canvas
|
|
28527
|
+
const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star'];
|
|
28425
28528
|
let render3DFlareTiles;
|
|
28426
28529
|
function render3DFlareTile(shape)
|
|
28427
28530
|
{
|
|
28428
28531
|
if (!render3DFlareTiles)
|
|
28429
28532
|
{
|
|
28430
28533
|
if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
|
|
28431
|
-
const cell = 128, shapes =
|
|
28534
|
+
const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
|
|
28432
28535
|
// each shape is how see-through it is from its middle out
|
|
28433
28536
|
const alpha =
|
|
28434
28537
|
{
|
|
28435
28538
|
glow: (t)=> engineGlowAlpha(t, 1),
|
|
28436
28539
|
disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
|
|
28437
28540
|
ring: (t)=> max(0, 1 - abs(t - .86) / .12),
|
|
28541
|
+
hex: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
|
|
28542
|
+
streak: (t)=> (1 - t) ** 1.5,
|
|
28438
28543
|
};
|
|
28439
|
-
|
|
28544
|
+
// a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon
|
|
28545
|
+
const fill = (shape, r, path)=>
|
|
28440
28546
|
{
|
|
28441
|
-
const
|
|
28442
|
-
const gradient = context.createRadialGradient(x, y, 0, x, y, r);
|
|
28547
|
+
const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r);
|
|
28443
28548
|
for (let k = 0; k <= steps; ++k)
|
|
28444
28549
|
gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
|
|
28445
28550
|
context.fillStyle = gradient;
|
|
28446
28551
|
context.beginPath();
|
|
28447
|
-
context.arc(
|
|
28552
|
+
path ? path() : context.arc(0, 0, r, 0, 2 * PI);
|
|
28448
28553
|
context.fill();
|
|
28554
|
+
};
|
|
28555
|
+
const ray = (r, angle)=>
|
|
28556
|
+
{
|
|
28557
|
+
context.save();
|
|
28558
|
+
context.rotate(angle);
|
|
28559
|
+
context.scale(1, .07);
|
|
28560
|
+
fill('streak', r);
|
|
28561
|
+
context.restore();
|
|
28562
|
+
};
|
|
28563
|
+
shapes.forEach((shape, i)=>
|
|
28564
|
+
{
|
|
28565
|
+
const r = cell / 2 - 2;
|
|
28566
|
+
context.save();
|
|
28567
|
+
context.translate(i * cell + cell / 2, cell / 2);
|
|
28568
|
+
if (shape == 'hex')
|
|
28569
|
+
{
|
|
28570
|
+
// a gradient is round, so the hexagon is set pixel by pixel, by how far each is toward a side
|
|
28571
|
+
const image = context.createImageData(cell, cell), data = image.data, apothem = r * cos(PI / 6);
|
|
28572
|
+
for (let k = 0; k < cell * cell; ++k)
|
|
28573
|
+
{
|
|
28574
|
+
const x = abs(k % cell + .5 - cell / 2), y = abs((k / cell | 0) + .5 - cell / 2);
|
|
28575
|
+
const t = max(y, x * cos(PI / 6) + y / 2) / apothem;
|
|
28576
|
+
data[k * 4] = data[k * 4 + 1] = data[k * 4 + 2] = 255;
|
|
28577
|
+
data[k * 4 + 3] = t < 1 ? clamp(alpha.hex(t)) * 255 : 0;
|
|
28578
|
+
}
|
|
28579
|
+
context.putImageData(image, i * cell, 0);
|
|
28580
|
+
}
|
|
28581
|
+
else if (shape == 'streak')
|
|
28582
|
+
ray(r, 0);
|
|
28583
|
+
else if (shape == 'star')
|
|
28584
|
+
{
|
|
28585
|
+
for (let k = 3; k--;)
|
|
28586
|
+
ray(r, k * PI / 3 + PI / 2);
|
|
28587
|
+
context.scale(.3, .3);
|
|
28588
|
+
fill('glow', r);
|
|
28589
|
+
}
|
|
28590
|
+
else
|
|
28591
|
+
fill(shape, r);
|
|
28592
|
+
context.restore();
|
|
28449
28593
|
});
|
|
28450
28594
|
const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
|
|
28451
28595
|
render3DFlareTiles = new Map(shapes.map((shape, i)=>
|
|
@@ -28454,16 +28598,32 @@ function render3DFlareTile(shape)
|
|
|
28454
28598
|
return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
|
|
28455
28599
|
}
|
|
28456
28600
|
|
|
28601
|
+
/**
|
|
28602
|
+
* A part of a lens flare
|
|
28603
|
+
* @typedef {Object} LensFlareElement
|
|
28604
|
+
* @property {number} at - Where along the line: 0 the sun, 1 the middle of the screen, 2 as far past it
|
|
28605
|
+
* @property {number|Vector2} size - How big across, as a part of the screen's height, a vector for a part wider
|
|
28606
|
+
* than it is tall
|
|
28607
|
+
* @property {Color} color - Its color, the alpha how bright
|
|
28608
|
+
* @property {string} [shape] - glow, disc, ring, hex, streak or star, a glow when left out
|
|
28609
|
+
* @property {TileInfo} [tileInfo] - A tile of the game's own to draw in place of a shape, best white on clear
|
|
28610
|
+
* @property {number} [angle] - How far it is turned, in radians
|
|
28611
|
+
* @memberof Render3D
|
|
28612
|
+
*/
|
|
28613
|
+
|
|
28457
28614
|
/**
|
|
28458
28615
|
* LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
|
|
28459
28616
|
* sizes along the line from the sun through the middle of the screen
|
|
28460
28617
|
* - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
|
|
28461
28618
|
* - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
|
|
28619
|
+
* - A Light3D gets one of its own with light.addFlare()
|
|
28462
28620
|
* - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
|
|
28463
|
-
* with the sun's own color;
|
|
28464
|
-
*
|
|
28621
|
+
* with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
|
|
28622
|
+
* give it elements of your own, which may be tiles of the game's
|
|
28623
|
+
* - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
|
|
28624
|
+
* sun is off the screen, where nothing is tested
|
|
28465
28625
|
* - What hides the sun is found with a ray from the camera, against the level and every object that is not see
|
|
28466
|
-
* through, each
|
|
28626
|
+
* through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
|
|
28467
28627
|
* - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
|
|
28468
28628
|
* @extends EngineObject3D
|
|
28469
28629
|
* @memberof Render3D
|
|
@@ -28493,48 +28653,61 @@ class LensFlare3D extends EngineObject3D
|
|
|
28493
28653
|
this.saturation = saturation;
|
|
28494
28654
|
/** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
|
|
28495
28655
|
this.seed = 1;
|
|
28496
|
-
/** @property {Array<
|
|
28497
|
-
*
|
|
28498
|
-
*
|
|
28499
|
-
*
|
|
28500
|
-
|
|
28656
|
+
/** @property {Array<string>|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex,
|
|
28657
|
+
* streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often;
|
|
28658
|
+
* discs, rings and glows when not set
|
|
28659
|
+
* @type {Array<string>|undefined} */
|
|
28660
|
+
this.shapes = undefined;
|
|
28661
|
+
/** @property {number} - Scales the glow at the sun, 0 for none */
|
|
28662
|
+
this.glowSize = 1;
|
|
28663
|
+
/** @property {number} - Scales the ghosts */
|
|
28664
|
+
this.ghostSize = 1;
|
|
28665
|
+
/** @property {Array<LensFlareElement>|undefined} - The parts of the flare, to set your own in place of the
|
|
28666
|
+
* ones made from count, seed, saturation, shapes, glowSize and ghostSize
|
|
28667
|
+
* @type {Array<LensFlareElement>|undefined} */
|
|
28501
28668
|
this.elements = undefined;
|
|
28502
28669
|
/** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
|
|
28503
28670
|
* flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
|
|
28504
|
-
* in front of the light, and a spotlight's shows from inside its beam only
|
|
28671
|
+
* in front of the light, and a spotlight's shows from inside its beam only; a DirectionalLight3D's is far
|
|
28672
|
+
* away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
|
|
28673
|
+
* sets this
|
|
28505
28674
|
* @type {Light3D|undefined} */
|
|
28506
28675
|
this.light = undefined;
|
|
28507
28676
|
/** @property {boolean} - Fade out when something is between the camera and the sun */
|
|
28508
28677
|
this.occlusion = true;
|
|
28509
28678
|
/** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
|
|
28510
28679
|
this.fadeTime = .15;
|
|
28511
|
-
/** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in
|
|
28680
|
+
/** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
|
|
28681
|
+
* plain view, eased */
|
|
28512
28682
|
this.visible = 1;
|
|
28513
28683
|
this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
|
|
28514
28684
|
this.madeKey = '';
|
|
28515
|
-
/** @type {Array<
|
|
28685
|
+
/** @type {Array<LensFlareElement>} */
|
|
28516
28686
|
this.made = [];
|
|
28517
28687
|
}
|
|
28518
28688
|
|
|
28519
|
-
/** The parts of the flare: the elements set by hand, or the ones made from count, seed
|
|
28520
|
-
* and a core at the sun and the ghosts, made again when one of those changes
|
|
28521
|
-
* @return {Array<
|
|
28689
|
+
/** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
|
|
28690
|
+
* glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
|
|
28691
|
+
* @return {Array<LensFlareElement>} */
|
|
28522
28692
|
getElements()
|
|
28523
28693
|
{
|
|
28524
28694
|
if (this.elements) return this.elements;
|
|
28525
|
-
const
|
|
28695
|
+
const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
|
|
28696
|
+
const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
|
|
28526
28697
|
if (key !== this.madeKey)
|
|
28527
28698
|
{
|
|
28528
28699
|
const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
|
|
28529
|
-
|
|
28530
|
-
|
|
28531
|
-
{at: 0, size: .
|
|
28700
|
+
/** @type {Array<LensFlareElement>} */
|
|
28701
|
+
const made = this.made = glow > 0 ? [
|
|
28702
|
+
{at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
|
|
28703
|
+
{at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
|
|
28532
28704
|
for (let i = 0; i < this.count; ++i)
|
|
28533
28705
|
{
|
|
28534
28706
|
// spread along the line, each a place of its own, the far ones bigger
|
|
28535
28707
|
const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
|
|
28536
|
-
const pick = random.float(), shape = pick
|
|
28537
|
-
|
|
28708
|
+
const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
|
|
28709
|
+
pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
|
|
28710
|
+
made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
|
|
28538
28711
|
color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
|
|
28539
28712
|
}
|
|
28540
28713
|
this.madeKey = key;
|
|
@@ -28542,69 +28715,85 @@ class LensFlare3D extends EngineObject3D
|
|
|
28542
28715
|
return this.made;
|
|
28543
28716
|
}
|
|
28544
28717
|
|
|
28545
|
-
// what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun
|
|
28546
|
-
//
|
|
28718
|
+
// what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
|
|
28719
|
+
// directional light, which shines from its place toward the origin, and a point to find it on the screen by;
|
|
28720
|
+
// undefined with no direction, or a light that is gone or at the camera
|
|
28547
28721
|
flareSource()
|
|
28548
28722
|
{
|
|
28549
28723
|
const camera = render3D.camera.pos, light = this.light;
|
|
28550
|
-
if (
|
|
28724
|
+
if (light?.destroyed) return;
|
|
28725
|
+
if (!light || light.directional)
|
|
28551
28726
|
{
|
|
28552
|
-
const
|
|
28553
|
-
if (!
|
|
28554
|
-
const direction =
|
|
28727
|
+
const from = light ? light.getWorldPos3D() : render3D.sunDirection;
|
|
28728
|
+
if (!from.lengthSquared()) return;
|
|
28729
|
+
const direction = from.normalize();
|
|
28555
28730
|
return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
|
|
28556
28731
|
}
|
|
28557
|
-
if (light.destroyed) return;
|
|
28558
28732
|
const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
|
|
28559
28733
|
return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
|
|
28560
28734
|
}
|
|
28561
28735
|
|
|
28562
|
-
|
|
28563
|
-
|
|
28564
|
-
|
|
28565
|
-
|
|
28566
|
-
{
|
|
28567
|
-
const source = this.flareSource();
|
|
28568
|
-
return source && render3D.worldToScreen(source.pos);
|
|
28569
|
-
}
|
|
28570
|
-
|
|
28571
|
-
/** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
|
|
28572
|
-
* color, dimmed by how much of the sun shows; empty when there is nothing to draw
|
|
28573
|
-
* @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
|
|
28574
|
-
getScreenElements()
|
|
28736
|
+
// how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
|
|
28737
|
+
// fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
|
|
28738
|
+
// show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
|
|
28739
|
+
flareLook()
|
|
28575
28740
|
{
|
|
28576
|
-
const
|
|
28577
|
-
|
|
28741
|
+
const source = this.flareSource(), center = mainCanvasSize.scale(.5);
|
|
28742
|
+
const sun = source && render3D.worldToScreen(source.pos);
|
|
28743
|
+
if (!sun || !center.x || !center.y) return;
|
|
28578
28744
|
// it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
|
|
28579
28745
|
const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
|
|
28580
|
-
const strength =
|
|
28581
|
-
if (!(strength > 0)) return
|
|
28746
|
+
const strength = clamp((1.3 - off) / .5) * this.intensity;
|
|
28747
|
+
if (!(strength > 0)) return;
|
|
28582
28748
|
let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
|
|
28583
|
-
const light = this.light
|
|
28749
|
+
const light = this.light;
|
|
28584
28750
|
if (light)
|
|
28585
28751
|
{
|
|
28586
|
-
if (!source) return [];
|
|
28587
28752
|
// a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
|
|
28588
28753
|
const cone = render3DLightCone(light), d = source.direction;
|
|
28589
28754
|
const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
|
|
28590
28755
|
const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
|
|
28591
|
-
if (!(bright > 0)) return
|
|
28756
|
+
if (!(bright > 0)) return;
|
|
28592
28757
|
tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
|
|
28593
|
-
|
|
28758
|
+
if (!light.directional)
|
|
28759
|
+
height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
|
|
28594
28760
|
}
|
|
28761
|
+
return height > 0 ? {sun, center, strength, tint, height} : undefined;
|
|
28762
|
+
}
|
|
28763
|
+
|
|
28764
|
+
/** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
|
|
28765
|
+
* behind the camera
|
|
28766
|
+
* @return {Vector2|undefined} */
|
|
28767
|
+
getScreenPos()
|
|
28768
|
+
{
|
|
28769
|
+
const source = this.flareSource();
|
|
28770
|
+
return source && render3D.worldToScreen(source.pos);
|
|
28771
|
+
}
|
|
28772
|
+
|
|
28773
|
+
/** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector
|
|
28774
|
+
* when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw
|
|
28775
|
+
* @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined,
|
|
28776
|
+
* tileInfo: TileInfo|undefined, angle: number}>} */
|
|
28777
|
+
getScreenElements()
|
|
28778
|
+
{
|
|
28779
|
+
const look = this.visible > 0 && this.flareLook();
|
|
28780
|
+
if (!look) return [];
|
|
28781
|
+
const {sun, center, tint, height} = look, strength = look.strength * this.visible;
|
|
28595
28782
|
return this.getElements().map((e)=>
|
|
28596
28783
|
{
|
|
28597
28784
|
const c = e.color.multiply(tint);
|
|
28598
28785
|
// along the line and past the middle, a lerp would stop there
|
|
28599
|
-
|
|
28600
|
-
|
|
28786
|
+
const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
|
|
28787
|
+
return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
|
|
28788
|
+
angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
|
|
28601
28789
|
});
|
|
28602
28790
|
}
|
|
28603
28791
|
|
|
28604
28792
|
/** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
|
|
28605
|
-
* through
|
|
28793
|
+
* through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless
|
|
28794
|
+
* it is doubleSided
|
|
28606
28795
|
* @return {boolean} */
|
|
28607
|
-
|
|
28796
|
+
isHidden()
|
|
28608
28797
|
{
|
|
28609
28798
|
const source = this.flareSource();
|
|
28610
28799
|
if (!source) return true;
|
|
@@ -28618,22 +28807,29 @@ class LensFlare3D extends EngineObject3D
|
|
|
28618
28807
|
for (const map of maps)
|
|
28619
28808
|
if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
|
|
28620
28809
|
return true;
|
|
28621
|
-
//
|
|
28622
|
-
//
|
|
28623
|
-
|
|
28624
|
-
|
|
28625
|
-
|
|
28626
|
-
|
|
28627
|
-
|
|
28628
|
-
|
|
28810
|
+
// a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
|
|
28811
|
+
// is in
|
|
28812
|
+
const lamp = (o)=>
|
|
28813
|
+
{
|
|
28814
|
+
if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
|
|
28815
|
+
const matrix = render3DObjectMatrix(o);
|
|
28816
|
+
if (!matrix.determinant()) return false;
|
|
28817
|
+
const p = matrix.copy().invert().transformPoint(source.pos), b = o.mesh.bounds || o.mesh.getBounds();
|
|
28818
|
+
return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
|
|
28819
|
+
p.z >= b.min.z && p.z <= b.max.z;
|
|
28820
|
+
};
|
|
28821
|
+
const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
|
|
28629
28822
|
return !!hit && hit.distance < reach;
|
|
28630
28823
|
}
|
|
28631
28824
|
|
|
28632
|
-
/** Ease visible toward whether the sun shows, called automatically each frame
|
|
28825
|
+
/** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
|
|
28826
|
+
* wherever the sun is, off the screen or its light off, looks for nothing in the way */
|
|
28633
28827
|
update()
|
|
28634
28828
|
{
|
|
28635
28829
|
super.update();
|
|
28636
|
-
|
|
28830
|
+
if (this.light?.destroyed)
|
|
28831
|
+
return this.destroy(); // the flare of a light that is gone
|
|
28832
|
+
const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
|
|
28637
28833
|
const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
|
|
28638
28834
|
this.visible = clamp(this.visible + (shows ? step : -step));
|
|
28639
28835
|
}
|
|
@@ -28647,8 +28843,9 @@ class LensFlare3D extends EngineObject3D
|
|
|
28647
28843
|
setAdditiveBlendMode(true);
|
|
28648
28844
|
for (const e of elements)
|
|
28649
28845
|
{
|
|
28650
|
-
const tileInfo = render3DFlareTile(e.shape);
|
|
28651
|
-
|
|
28846
|
+
const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
|
|
28847
|
+
const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
|
|
28848
|
+
tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
|
|
28652
28849
|
}
|
|
28653
28850
|
setAdditiveBlendMode(false);
|
|
28654
28851
|
}
|
|
@@ -29762,8 +29959,9 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
|
|
|
29762
29959
|
* are added before its objects are made, one the game added itself keeps its place
|
|
29763
29960
|
* - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
|
|
29764
29961
|
* horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
|
|
29765
|
-
* sunColor, fog, its start and end, fogColor, the horizon color when not given, and
|
|
29766
|
-
* leaves out stays as the game set it, and a level with no block changes
|
|
29962
|
+
* sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
|
|
29963
|
+
* sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
|
|
29964
|
+
* nothing
|
|
29767
29965
|
* @param {Object} level - The level, the level editor edits this same object
|
|
29768
29966
|
* @return {Array<any>} - What each object's type made, a function that made nothing is left out
|
|
29769
29967
|
* @memberof Level3D */
|
|
@@ -29794,7 +29992,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
|
|
|
29794
29992
|
|
|
29795
29993
|
/** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
|
|
29796
29994
|
* - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
|
|
29797
|
-
* a sheet is given here; a block's type shows that tile of the sheet on every face
|
|
29995
|
+
* a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
|
|
29996
|
+
* image gets plain blocks, a color for each type
|
|
29798
29997
|
* - setup is called with each map a level makes, to give block types their own faces or make them see-through
|
|
29799
29998
|
* - Call it before level3DLoad; with no arguments the defaults are back
|
|
29800
29999
|
* @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
|
|
@@ -29856,7 +30055,7 @@ function level3DVoxelsMake(voxels)
|
|
|
29856
30055
|
{
|
|
29857
30056
|
const shape = level3DVoxelsShape(voxels);
|
|
29858
30057
|
if (!shape || typeof VoxelMap == 'undefined') return;
|
|
29859
|
-
const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles ||
|
|
30058
|
+
const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
|
|
29860
30059
|
level3DVoxelsDecode(voxels.blocks, map.data);
|
|
29861
30060
|
level3DVoxelSetupMap?.(map);
|
|
29862
30061
|
map.rebuild();
|
|
@@ -29918,6 +30117,31 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
|
|
|
29918
30117
|
map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
|
|
29919
30118
|
}
|
|
29920
30119
|
|
|
30120
|
+
// the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
|
|
30121
|
+
// VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
|
|
30122
|
+
// block map in a game with no art can still be seen and painted
|
|
30123
|
+
let level3DVoxelPlain;
|
|
30124
|
+
function level3DVoxelPlainTiles()
|
|
30125
|
+
{
|
|
30126
|
+
if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
|
|
30127
|
+
return tile();
|
|
30128
|
+
if (!level3DVoxelPlain)
|
|
30129
|
+
{
|
|
30130
|
+
// 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
|
|
30131
|
+
const context = createCanvasContext(256);
|
|
30132
|
+
for (let i = 0; i < 256; ++i)
|
|
30133
|
+
{
|
|
30134
|
+
const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
|
|
30135
|
+
context.fillStyle = hsl(hue, .45, .4).toString();
|
|
30136
|
+
context.fillRect(x, y, 16, 16);
|
|
30137
|
+
context.fillStyle = hsl(hue, .5, .55).toString();
|
|
30138
|
+
context.fillRect(x + 1, y + 1, 14, 14);
|
|
30139
|
+
}
|
|
30140
|
+
level3DVoxelPlain = new TextureInfo(context.canvas);
|
|
30141
|
+
}
|
|
30142
|
+
return tile(0, 16, level3DVoxelPlain, 0);
|
|
30143
|
+
}
|
|
30144
|
+
|
|
29921
30145
|
// the HeightMap of a level's terrain block, undefined when it has none or it is wrong
|
|
29922
30146
|
function level3DTerrainMake(terrain)
|
|
29923
30147
|
{
|
|
@@ -29962,8 +30186,25 @@ function level3DSceneApply(scene)
|
|
|
29962
30186
|
r.fogColor = color(scene.fogColor) || r.fogColor;
|
|
29963
30187
|
if (typeof scene.shadows === 'boolean')
|
|
29964
30188
|
r.shadows = scene.shadows;
|
|
30189
|
+
if (typeof scene.lensFlare === 'boolean')
|
|
30190
|
+
level3DSceneFlare(scene.lensFlare);
|
|
29965
30191
|
}
|
|
29966
30192
|
|
|
30193
|
+
// the sun's lens flare as a scene sets it: on makes one unless the sun has a flare already, the game's own or an
|
|
30194
|
+
// earlier level's, and off takes away the one a scene made, never the game's
|
|
30195
|
+
let level3DSunFlare;
|
|
30196
|
+
function level3DSceneFlare(on)
|
|
30197
|
+
{
|
|
30198
|
+
if (!on)
|
|
30199
|
+
{
|
|
30200
|
+
level3DSunFlare && !level3DSunFlare.destroyed && level3DSunFlare.destroy();
|
|
30201
|
+
level3DSunFlare = undefined;
|
|
30202
|
+
}
|
|
30203
|
+
else if (!level3DSunHasFlare())
|
|
30204
|
+
level3DSunFlare = new LensFlare3D;
|
|
30205
|
+
}
|
|
30206
|
+
const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light);
|
|
30207
|
+
|
|
29967
30208
|
// a vec3 of an array of three numbers, as the file has them, or the fallback
|
|
29968
30209
|
function level3DVector(value, fallback)
|
|
29969
30210
|
{
|
|
@@ -30312,7 +30553,8 @@ level3DAddType('Cylinder', function(pos, properties)
|
|
|
30312
30553
|
{ return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
|
|
30313
30554
|
{color: WHITE, tile: -1, solid: true});
|
|
30314
30555
|
// a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
|
|
30315
|
-
// object's rotation aims it, softness is how much of the cone fades,
|
|
30556
|
+
// object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and
|
|
30557
|
+
// lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare
|
|
30316
30558
|
level3DAddType('Light', function(pos, properties)
|
|
30317
30559
|
{
|
|
30318
30560
|
const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
|
|
@@ -30320,8 +30562,9 @@ level3DAddType('Light', function(pos, properties)
|
|
|
30320
30562
|
light.coneSoftness = properties.softness;
|
|
30321
30563
|
if (properties.shadows && render3D)
|
|
30322
30564
|
render3D.shadowLight = light;
|
|
30565
|
+
properties.lensFlare && light.addFlare();
|
|
30323
30566
|
return light;
|
|
30324
|
-
}, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
|
|
30567
|
+
}, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false, lensFlare: false});
|
|
30325
30568
|
|
|
30326
30569
|
/**
|
|
30327
30570
|
* LittleJS Particle Effects Plugin
|
|
@@ -31487,6 +31730,11 @@ async function parseGLTF(data, baseUrl='')
|
|
|
31487
31730
|
for (const name of json.extensionsRequired || [])
|
|
31488
31731
|
if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
|
|
31489
31732
|
throw new Error(`glTF with ${name} is not read, export it uncompressed`);
|
|
31733
|
+
else if (name === 'KHR_texture_basisu')
|
|
31734
|
+
throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg');
|
|
31735
|
+
// one that only may use them has other images to fall back on, or none: said, since it loads with them left out
|
|
31736
|
+
if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu'))
|
|
31737
|
+
console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg');
|
|
31490
31738
|
|
|
31491
31739
|
// the buffers: the GLB's own, a data uri, or a file beside the model
|
|
31492
31740
|
const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
|
|
@@ -31529,7 +31777,8 @@ async function parseGLTF(data, baseUrl='')
|
|
|
31529
31777
|
const bitmap = normalTextures.has(index) ?
|
|
31530
31778
|
await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
|
|
31531
31779
|
opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
|
|
31532
|
-
|
|
31780
|
+
// REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are
|
|
31781
|
+
return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728);
|
|
31533
31782
|
}
|
|
31534
31783
|
catch (e) { false&&LOG('glTF image not loaded', e); }
|
|
31535
31784
|
}));
|
|
@@ -31639,17 +31888,25 @@ function gltfOpaqueImage(image)
|
|
|
31639
31888
|
{
|
|
31640
31889
|
const gl = glContext, {width, height} = image;
|
|
31641
31890
|
if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
|
|
31642
|
-
|
|
31643
|
-
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
31644
|
-
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
31645
|
-
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
31646
|
-
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
31891
|
+
// the room to read into first: for a huge image it can fail, and must not with the framebuffer bound
|
|
31647
31892
|
const data = new Uint8ClampedArray(width * height * 4);
|
|
31648
|
-
gl.
|
|
31649
|
-
|
|
31650
|
-
|
|
31651
|
-
|
|
31652
|
-
|
|
31893
|
+
const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
|
|
31894
|
+
try
|
|
31895
|
+
{
|
|
31896
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
31897
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
31898
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
31899
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
31900
|
+
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
|
|
31901
|
+
}
|
|
31902
|
+
finally
|
|
31903
|
+
{
|
|
31904
|
+
// the 2D renderer's own bindings back, whatever happened
|
|
31905
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
|
|
31906
|
+
gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
|
|
31907
|
+
gl.deleteFramebuffer(framebuffer);
|
|
31908
|
+
gl.deleteTexture(texture);
|
|
31909
|
+
}
|
|
31653
31910
|
for (let i = 3; i < data.length; i += 4)
|
|
31654
31911
|
data[i] = 255;
|
|
31655
31912
|
image.close();
|
|
@@ -31786,7 +32043,7 @@ function gltfAccessor(json, buffers, index)
|
|
|
31786
32043
|
}
|
|
31787
32044
|
if (scale !== 1)
|
|
31788
32045
|
for (let i = 0; i < out.length; ++i)
|
|
31789
|
-
out[i]
|
|
32046
|
+
out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says
|
|
31790
32047
|
return {data: out, components, count: a.count};
|
|
31791
32048
|
}
|
|
31792
32049
|
|
|
@@ -31850,8 +32107,10 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
|
|
|
31850
32107
|
const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
|
|
31851
32108
|
if (er || eg || eb)
|
|
31852
32109
|
{
|
|
32110
|
+
// KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender
|
|
32111
|
+
const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
|
|
31853
32112
|
part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
|
|
31854
|
-
part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
|
|
32113
|
+
part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength);
|
|
31855
32114
|
}
|
|
31856
32115
|
return part;
|
|
31857
32116
|
}
|