littlejsengine 1.23.1 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.23.1';
38
+ const engineVersion = '1.24.1';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -4084,7 +4084,11 @@ function setGLEnable(enable)
4084
4084
  /** Set how many sided polygons to use when drawing circles and ellipses with WebGL
4085
4085
  * @param {number} sides
4086
4086
  * @memberof Settings */
4087
- function setGLCircleSides(sides) { glCircleSides = sides; }
4087
+ function setGLCircleSides(sides)
4088
+ {
4089
+ ASSERT(isNumber(sides) && sides >= 3, 'circles need at least 3 sides');
4090
+ glCircleSides = sides;
4091
+ }
4088
4092
 
4089
4093
  /** Set default size of tiles in pixels
4090
4094
  * @param {Vector2} size
@@ -8430,7 +8434,7 @@ function touchGamepadPointerUp(e)
8430
8434
  * - 2D spatial audio based on camera position with distance-based falloff
8431
8435
  * - Sound instance management (pause, resume, stop)
8432
8436
  * - Speech synthesis for text-to-speech
8433
- * - Music playback with ZzFXM support
8437
+ * - Music playback from audio files, looping by default
8434
8438
  * - Web Audio API integration with master gain control
8435
8439
  * - Sounds and the master bus can route through effects, see the audio effects plugin
8436
8440
  * @namespace Audio
@@ -9308,7 +9312,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
9308
9312
  }
9309
9313
 
9310
9314
  ///////////////////////////////////////////////////////////////////////////////
9311
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
9315
+ // ZzFX - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
9312
9316
 
9313
9317
  /** Generate and play a ZzFX sound
9314
9318
  *
@@ -9593,6 +9597,10 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
9593
9597
  * Load tile layers from exported data
9594
9598
  * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
9595
9599
  * tiles continue its numbering), finite maps in the CSV or array layer format; layer offsets and parallax are not read
9600
+ * - A tileset kept in the map with a margin or a spacing, a sheet with gaps between its tiles, is read where its
9601
+ * tiles are, whatever padding the tile info has; one in a file of its own (a tsx) is not read, pass a tile info
9602
+ * with its padding
9603
+ * - An LDtk level loads through tileLayersFromLDtk, which makes it a map like these
9596
9604
  * - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices
9597
9605
  * (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and
9598
9606
  * visibility carry to the layers inside it
@@ -9602,7 +9610,7 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
9602
9610
  * @param {Object} tileMapData - Level data from exported data
9603
9611
  * @param {TileInfo} [tileInfo] - Default tile info (used for size and texture), tile() by default, none when no image is loaded
9604
9612
  * @param {number} [renderOrder] - Render order of the top layer
9605
- * @param {number} [collisionLayer] - Layer to use for collision if any
9613
+ * @param {number|string} [collisionLayer] - Layer to use for collision if any, by its index or its name
9606
9614
  * @param {boolean} [draw] - Should the layer be drawn automatically
9607
9615
  * @return {Array<TileCollisionLayer>}
9608
9616
  * @memberof TileLayers */
@@ -9625,6 +9633,19 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
9625
9633
  ASSERT(tileMapData.width && tileMapData.height);
9626
9634
  ASSERT(tileMapData.layers && tileMapData.layers.length);
9627
9635
 
9636
+ // a sheet with a margin around its tiles or a spacing between them: the first tile is at the margin and each
9637
+ // cell is a tile and a spacing, which is a padding of half the spacing counted from there, by the tileset's
9638
+ // own count of columns, since the image's width does not say with a spacing that is not all around
9639
+ const tileset = tileMapData.tilesets?.[0];
9640
+ if (tileInfo && tileset && (tileset.margin > 0 || tileset.spacing > 0))
9641
+ {
9642
+ const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size;
9643
+ const width = tileInfo.textureInfo?.size.x || 0;
9644
+ const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing)));
9645
+ const origin = tileInfo.pos.subtract(vec2(tileInfo.padding)); // where the sheet starts, in an atlas too
9646
+ tileInfo = new TileInfo(origin.add(vec2(margin)), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
9647
+ }
9648
+
9628
9649
  // flatten group layers in order, a group's color and visibility carry to the layers inside it
9629
9650
  /** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */
9630
9651
  const layers = [];
@@ -9653,6 +9674,14 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
9653
9674
  };
9654
9675
  addLayers(tileMapData.layers, WHITE, true);
9655
9676
 
9677
+ // a layer named for collision is the first tile layer of that name, which stays the same when layers are added
9678
+ if (typeof collisionLayer === 'string')
9679
+ {
9680
+ const name = collisionLayer;
9681
+ collisionLayer = layers.findIndex((l)=> l.color && l.dataLayer.name === name);
9682
+ ASSERT(collisionLayer >= 0, 'no tile layer named ' + name);
9683
+ }
9684
+
9656
9685
  // create tile layers and fill with data
9657
9686
  const tileLayers = [];
9658
9687
  const levelSize = vec2(tileMapData.width, tileMapData.height);
@@ -9702,6 +9731,134 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
9702
9731
  function tileLayersColor(hex)
9703
9732
  { return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); }
9704
9733
 
9734
+ // the Tiled property type of an LDtk field type, for the ones Tiled has a property for
9735
+ const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: 'string', Multilines: 'string',
9736
+ Color: 'color', FilePath: 'file'};
9737
+
9738
+ /**
9739
+ * Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad
9740
+ * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled; where LDtk stacks
9741
+ * tiles in a cell, an edge over a fill, each tile over another goes in a layer of its own just above, named with
9742
+ * (2), (3) and so on, and a tile LDtk draws see-through goes in one of its own with that opacity, times the
9743
+ * layer's, named with it, like Ground .25
9744
+ * - An IntGrid layer is a hidden layer of its values under its own name, for collision, whatever tiles its rules
9745
+ * make, which are layers over it named with tiles, like Collisions tiles; pass the name as collisionLayer, since
9746
+ * the stacked and see-through layers change the indices from level to level:
9747
+ * tileLayersLoad(map, tile(0, 16), 0, 'Collisions')
9748
+ * - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at
9749
+ * its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string)
9750
+ * - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image
9751
+ * - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of
9752
+ * another grid size or another tileset is left out, with a warning in debug builds, and a project with no tileset
9753
+ * keeps its tiles for the tile info the game gives; a project of several worlds has its levels counted in order
9754
+ * - Layer offsets are not read
9755
+ * - The level editor edits the map this returns, and saves it as a Tiled map
9756
+ * @param {Object} ldtk - The LDtk project, its JSON
9757
+ * @param {number|string} [level] - Which level, by its index or its identifier
9758
+ * @return {Object} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers
9759
+ * @example
9760
+ * const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0');
9761
+ * const layers = tileLayersLoad(map, tile(0, 16), 0, 'Collisions'); // its IntGrid layer is solid
9762
+ * objectLayersLoad(map);
9763
+ * @memberof TileLayers */
9764
+ function tileLayersFromLDtk(ldtk, level=0)
9765
+ {
9766
+ const levels = ldtk?.levels?.length ? ldtk.levels : (ldtk?.worlds || []).flatMap((w)=> w.levels || []);
9767
+ const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level];
9768
+ if (!data)
9769
+ throw new Error('tileLayersFromLDtk: no level ' + level);
9770
+ if (!isArray(data.layerInstances))
9771
+ throw new Error('tileLayersFromLDtk: level ' + data.identifier + ' has no layers, levels saved as separate files are not read');
9772
+ const instances = data.layerInstances;
9773
+ const tiled = (l)=> l.__type !== 'Entities';
9774
+ const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
9775
+ const grid = first?.__gridSize || 16;
9776
+ const width = first?.__cWid || ceil(data.pxWid / grid), height = first?.__cHei || ceil(data.pxHei / grid);
9777
+ const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down',
9778
+ infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1};
9779
+
9780
+ // the tileset of the first layer that has one, where its tiles are in its image
9781
+ const tileset = (ldtk?.defs?.tilesets || []).find((t)=> t.uid === first?.__tilesetDefUid);
9782
+ if (tileset)
9783
+ map.tilesets = [{firstgid: 1, name: String(tileset.relPath || '').replace(/^.*[\\/]/, '').replace(/\.\w+$/, ''),
9784
+ image: tileset.relPath, imagewidth: tileset.pxWid, imageheight: tileset.pxHei,
9785
+ tilewidth: tileset.tileGridSize, tileheight: tileset.tileGridSize, margin: tileset.padding || 0,
9786
+ spacing: tileset.spacing || 0, columns: tileset.__cWid, tilecount: tileset.__cWid * tileset.__cHei}];
9787
+
9788
+ // LDtk lists the top layer first, Tiled the bottom one
9789
+ for (let i = instances.length; i--;)
9790
+ {
9791
+ const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++;
9792
+ if (!tiled(instance))
9793
+ {
9794
+ const objects = (instance.entityInstances || []).map((entity)=>
9795
+ {
9796
+ // an entity is placed by its pivot, an object here by its middle
9797
+ const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0;
9798
+ const properties = [];
9799
+ for (const field of entity.fieldInstances || [])
9800
+ {
9801
+ const type = tileLayersLDtkTypes[field.__type] || (/^(Local|Extern)Enum\./.test(field.__type) && 'string');
9802
+ type && field.__value != undefined && properties.push({name: field.__identifier, type, value: field.__value});
9803
+ }
9804
+ return {id: map.nextobjectid++, name: '', type: entity.__identifier, x: entity.px[0] + (.5 - pivotX) * w,
9805
+ y: entity.px[1] + (.5 - pivotY) * h, width: w, height: h, rotation: 0, visible: true, properties};
9806
+ });
9807
+ map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0});
9808
+ continue;
9809
+ }
9810
+ const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
9811
+ const ownTiles = !tiles.length || !tileset ||
9812
+ instance.__tilesetDefUid === tileset.uid && tileset.tileGridSize === grid;
9813
+ if (instance.__gridSize !== grid || instance.__cWid !== width || instance.__cHei !== height || !ownTiles)
9814
+ {
9815
+ debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
9816
+ continue;
9817
+ }
9818
+ // an IntGrid layer's values, hidden, under its own name, for collision whatever its tiles do
9819
+ const empty = ()=> new Array(width * height).fill(0), opacity = instance.__opacity ?? 1;
9820
+ const values = instance.__type === 'IntGrid';
9821
+ if (values)
9822
+ {
9823
+ const data = empty();
9824
+ (instance.intGridCsv || []).forEach((value, k)=> k < data.length && (data[k] = value));
9825
+ map.layers.push({id, name, type: 'tilelayer', width, height, data, opacity, visible: false, x: 0, y: 0});
9826
+ if (!tiles.length) continue;
9827
+ }
9828
+
9829
+ // a layer for each depth in a cell and each opacity, the first tiles drawn solid first
9830
+ const tilesName = values ? name + ' tiles' : name;
9831
+ const stacks = [{depth: 0, alpha: 1, data: empty()}], stack = (depth, alpha)=>
9832
+ {
9833
+ let s = stacks.find((s)=> s.depth === depth && s.alpha === alpha);
9834
+ s || stacks.push(s = {depth, alpha, data: empty()});
9835
+ return s.data;
9836
+ };
9837
+ {
9838
+ // a tile's place in pixels, its tile in the sheet, its flips, bit 0 across and bit 1 down, which are
9839
+ // Tiled's two top bits, and its opacity; LDtk lists them in the order it draws them, so the first in a
9840
+ // cell is the bottom one and each after it goes a layer higher
9841
+ const depth = new Uint16Array(width * height);
9842
+ for (const t of tiles)
9843
+ {
9844
+ const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width;
9845
+ if (x >= 0 && x < width && y >= 0 && y < height)
9846
+ stack(depth[cell]++, round(clamp(t.a ?? 1) * 1e3) / 1e3)[cell] =
9847
+ (t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0;
9848
+ }
9849
+ }
9850
+ const visible = instance.visible !== false;
9851
+ stacks.sort((a, b)=> a.depth - b.depth || b.alpha - a.alpha);
9852
+ for (const {depth, alpha, data} of stacks)
9853
+ {
9854
+ const layerName = tilesName + (depth ? ` (${depth + 1})` : '') + (alpha < 1 ? ' ' + String(alpha).replace(/^0/, '') : '');
9855
+ map.layers.push({id: values || depth || alpha < 1 ? map.nextlayerid++ : id, name: layerName, type: 'tilelayer',
9856
+ width, height, data, opacity: opacity * alpha, visible, x: 0, y: 0});
9857
+ }
9858
+ }
9859
+ return map;
9860
+ }
9861
+
9705
9862
  ///////////////////////////////////////////////////////////////////////////////
9706
9863
  // Object layers
9707
9864
 
@@ -14443,166 +14600,6 @@ class Light extends EngineObject
14443
14600
  }
14444
14601
  }
14445
14602
 
14446
- /**
14447
- * LittleJS ZzFXM Plugin
14448
- * - A port of ZzFXM, the Zuper Zmall Zound Zynth music player, by Keith Clark and Frank Force, MIT licensed,
14449
- * https://github.com/keithclark/ZzFXM; its notice is in COPYRIGHT.txt
14450
- * @namespace ZzFXM
14451
- */
14452
-
14453
- /**
14454
- * Music Object - Stores a zzfx music track for later use
14455
- *
14456
- * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
14457
- * @extends Sound
14458
- * @memberof ZzFXM
14459
- * @example
14460
- * // create some music
14461
- * const music_example = new ZzFXMusic(
14462
- * [
14463
- * [ // instruments
14464
- * [,0,400] // simple note
14465
- * ],
14466
- * [ // patterns
14467
- * [ // pattern 1
14468
- * [ // channel 0
14469
- * 0, -1, // instrument 0, left speaker
14470
- * 1, 0, 9, 1 // channel notes
14471
- * ],
14472
- * [ // channel 1
14473
- * 0, 1, // instrument 0, right speaker
14474
- * 0, 12, 17, -1 // channel notes
14475
- * ]
14476
- * ],
14477
- * ],
14478
- * [0, 0, 0, 0], // sequence, play pattern 0 four times
14479
- * 90 // BPM
14480
- * ]);
14481
- *
14482
- * // play the music on a loop
14483
- * music_example.playMusic();
14484
- */
14485
- class ZzFXMusic extends Sound
14486
- {
14487
- /** Create a music object and cache the zzfx music samples for later use
14488
- * @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
14489
- */
14490
- constructor(zzfxMusic)
14491
- {
14492
- super(undefined);
14493
-
14494
- if (!soundEnable || headlessMode) return;
14495
- const [instruments, patterns, sequence, BPM] = zzfxMusic;
14496
- super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
14497
- this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
14498
- this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
14499
- }
14500
- }
14501
-
14502
- ///////////////////////////////////////////////////////////////////////////////
14503
- // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
14504
-
14505
- /** Generate samples for a ZzFM song with given parameters
14506
- * @param {Array} instruments - Array of ZzFX sound parameters
14507
- * @param {Array} patterns - Array of pattern data
14508
- * @param {Array} sequence - Array of pattern indexes
14509
- * @param {number} [BPM] - Playback speed of the song in BPM
14510
- * @return {Array} - Left and right channel sample data
14511
- * @memberof ZzFXM */
14512
- function zzfxM(instruments, patterns, sequence, BPM = 125)
14513
- {
14514
- let i, j, k;
14515
- let instrumentParameters;
14516
- let note;
14517
- let sample;
14518
- let patternChannel;
14519
- let notFirstBeat;
14520
- let stop;
14521
- let instrument;
14522
- let attenuation;
14523
- let outSampleOffset;
14524
- let isSequenceEnd;
14525
- let sampleOffset = 0;
14526
- let nextSampleOffset;
14527
- let sampleBuffer = [];
14528
- let leftChannelBuffer = [];
14529
- let rightChannelBuffer = [];
14530
- let channelIndex = 0;
14531
- let panning = 0;
14532
- let hasMore = 1;
14533
- let sampleCache = {};
14534
- let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
14535
-
14536
- // for each channel in order until there are no more
14537
- for (; hasMore; channelIndex++) {
14538
-
14539
- // reset current values
14540
- sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
14541
-
14542
- // for each pattern in sequence
14543
- sequence.forEach((patternIndex, sequenceIndex)=> {
14544
- // get pattern for current channel, use empty 1 note pattern if none found
14545
- patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
14546
-
14547
- // check if there are more channels
14548
- hasMore |= patterns[patternIndex][channelIndex]&&1;
14549
-
14550
- // get next offset, use the length of first channel
14551
- nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
14552
- // for each beat in pattern, plus one extra if end of sequence
14553
- isSequenceEnd = sequenceIndex === sequence.length - 1;
14554
- for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
14555
-
14556
- // <channel-note>
14557
- note = patternChannel[i];
14558
-
14559
- // stop if end, different instrument or new note
14560
- stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
14561
- instrument !== (patternChannel[0] || 0) || note | 0;
14562
-
14563
- // fill buffer with samples for previous beat, most cpu intensive part
14564
- for (j = 0; j < beatLength && notFirstBeat;
14565
-
14566
- // fade off attenuation at end of beat if stopping note, prevents clicking
14567
- j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
14568
- ) {
14569
- // copy sample to stereo buffers with panning
14570
- sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
14571
- leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
14572
- rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
14573
- }
14574
-
14575
- // set up for next note
14576
- if (note) {
14577
- // set attenuation
14578
- attenuation = note % 1;
14579
- panning = patternChannel[1] || 0;
14580
- if (note |= 0) {
14581
- // get cached sample
14582
- sampleBuffer = sampleCache[
14583
- [
14584
- instrument = patternChannel[sampleOffset = 0] || 0,
14585
- note
14586
- ]
14587
- ] = sampleCache[[instrument, note]] || (
14588
- // add sample to cache
14589
- instrumentParameters = [...instruments[instrument]],
14590
- instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
14591
-
14592
- // allow negative values to stop notes
14593
- note > 0 ? zzfxG(...instrumentParameters) : []
14594
- );
14595
- }
14596
- }
14597
- }
14598
-
14599
- // update the sample offset
14600
- outSampleOffset = nextSampleOffset;
14601
- });
14602
- }
14603
-
14604
- return [leftChannelBuffer, rightChannelBuffer];
14605
- }
14606
14603
  /**
14607
14604
  * LittleJS Audio Effects Plugin
14608
14605
  * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
@@ -17418,7 +17415,7 @@ class Box2dObject extends EngineObject
17418
17415
 
17419
17416
  const points = [];
17420
17417
  const radius = diameter/2;
17421
- for (let i=sides; i--;)
17418
+ for (let i=sides; i-- > 0;) // a count that is not a whole number still ends
17422
17419
  points.push(vec2(radius,0).rotate((i+.5)/sides*PI*2));
17423
17420
  return this.addPoly(points, density, friction, restitution, isSensor);
17424
17421
  }
@@ -24025,8 +24022,8 @@ class Render3DPlugin
24025
24022
  }
24026
24023
 
24027
24024
  /** Find the nearest object under a screen position or along a ray, for clicking on things
24028
- * - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
24029
- * not triangle by triangle
24025
+ * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
24026
+ * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
24030
24027
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
24031
24028
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
24032
24029
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -27393,8 +27390,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
27393
27390
  return collected;
27394
27391
  }
27395
27392
 
27396
- // how far along a ray an object is hit, or undefined for a miss; each one is tested as the box around its mesh in
27397
- // its own space, or a sprite as the quad it draws, not triangle by triangle
27393
+ // 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,
27394
+ // and a sprite as the quad it draws
27398
27395
  function render3DRaycastObject(ray, o)
27399
27396
  {
27400
27397
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -27411,25 +27408,55 @@ function render3DRaycastObject(ray, o)
27411
27408
  const distance = raycastSphere(ray, center, radius);
27412
27409
  if (distance === undefined) return;
27413
27410
 
27414
- // the sphere is a quick reject, a mesh is hit where the ray meets its box in its own space, since a wide floor's
27415
- // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
27411
+ // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
27412
+ // against the triangles; the direction is not made unit length, so the distance holds in the world;
27416
27413
  // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
27417
27414
  if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
27418
27415
  const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
27419
27416
  const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
27420
27417
  const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
27421
- if (hit !== 0) return hit;
27422
-
27423
- // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
27424
- // inside comes first
27425
- let exit = Infinity;
27426
- for (const k of ['x', 'y', 'z'])
27418
+ if (hit === undefined || !ray.direction.lengthSquared())
27419
+ return hit; // a ray of no length is where it starts, inside the box
27420
+ return render3DRaycastMesh(local, mesh);
27421
+ }
27422
+
27423
+ // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
27424
+ // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
27425
+ // 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
27426
+ // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
27427
+ function render3DRaycastMesh(ray, mesh)
27428
+ {
27429
+ const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
27430
+ const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
27431
+ const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
27432
+ const count = indices ? indices.length / 3 | 0 : points.length - 2;
27433
+ let nearest;
27434
+ for (let i = 0; i < count; ++i)
27427
27435
  {
27428
- const d = local.direction[k];
27429
- if (d)
27430
- exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
27436
+ // the corners clockwise from the front: an indexed list is written the other way round
27437
+ let a, b, c;
27438
+ if (indices)
27439
+ a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
27440
+ else
27441
+ a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
27442
+
27443
+ // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
27444
+ const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
27445
+ const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
27446
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
27447
+ const det = e1x * hx + e1y * hy + e1z * hz;
27448
+ if (both ? !det : !(det < 0)) continue;
27449
+ const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
27450
+ const u = (sx * hx + sy * hy + sz * hz) / det;
27451
+ if (!(u >= 0 && u <= 1)) continue;
27452
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
27453
+ const v = (dx * qx + dy * qy + dz * qz) / det;
27454
+ if (!(v >= 0 && u + v <= 1)) continue;
27455
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
27456
+ if (t >= 0 && !(t >= nearest))
27457
+ nearest = t;
27431
27458
  }
27432
- return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
27459
+ return nearest;
27433
27460
  }
27434
27461
 
27435
27462
  // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
@@ -27807,10 +27834,12 @@ class InstancedMesh3D extends EngineObject3D
27807
27834
  * - radius is a world distance, so scale3D does not change it
27808
27835
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
27809
27836
  * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
27837
+ * - addFlare gives it a lens flare, see LensFlare3D
27810
27838
  * @extends EngineObject3D
27811
27839
  * @memberof Render3D
27812
27840
  * @example
27813
27841
  * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
27842
+ * torch.addFlare(); // light in the lens when the torch is in view
27814
27843
  */
27815
27844
  class Light3D extends EngineObject3D
27816
27845
  {
@@ -27845,6 +27874,50 @@ class Light3D extends EngineObject3D
27845
27874
  * bright core */
27846
27875
  this.glowFalloff = 1;
27847
27876
  this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
27877
+ /** @type {LensFlare3D|undefined} */
27878
+ this.flareObject = undefined;
27879
+ }
27880
+
27881
+ /** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had
27882
+ * @param {number} [size] - Scales every part of it
27883
+ * @param {number} [count] - How many ghosts there are, besides the glow at the light
27884
+ * @param {number} [intensity] - How bright it is
27885
+ * @param {number} [saturation] - How colorful the ghosts are
27886
+ * @param {Color} [color] - Tints the flare, with the light's own color
27887
+ * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
27888
+ addFlare(size, count, intensity, saturation, color)
27889
+ {
27890
+ const flare = new LensFlare3D(size, count, intensity, saturation, color);
27891
+ this.flare = flare;
27892
+ return flare;
27893
+ }
27894
+
27895
+ /** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and
27896
+ * destroying the flare takes it away
27897
+ * - The flare is the light's: it is attached to the light as its child, so it stays through a scene change
27898
+ * when the light does, and is destroyed with the light or when another takes its place
27899
+ * @type {LensFlare3D|undefined} */
27900
+ get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; }
27901
+
27902
+ set flare(flare)
27903
+ {
27904
+ const old = this.flare; // a flare that was destroyed on its own is no flare
27905
+ if (flare === old) return;
27906
+ old && old.destroy();
27907
+ if (flare)
27908
+ {
27909
+ flare.light = this;
27910
+ flare.parent || this.addChild(flare);
27911
+ }
27912
+ this.flareObject = flare || undefined;
27913
+ }
27914
+
27915
+ /** Destroy the light, and its flare with it
27916
+ * @param {boolean} [immediate] */
27917
+ destroy(immediate)
27918
+ {
27919
+ this.flare = undefined;
27920
+ super.destroy(immediate);
27848
27921
  }
27849
27922
 
27850
27923
  /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
@@ -29363,31 +29436,74 @@ class Trail3D extends EngineObject3D
29363
29436
  // lens flare
29364
29437
 
29365
29438
  // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
29366
- // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
29439
+ // glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star
29440
+ // of six rays; undefined headless or without a canvas
29441
+ const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star'];
29367
29442
  let render3DFlareTiles;
29368
29443
  function render3DFlareTile(shape)
29369
29444
  {
29370
29445
  if (!render3DFlareTiles)
29371
29446
  {
29372
29447
  if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
29373
- const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
29448
+ const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
29374
29449
  // each shape is how see-through it is from its middle out
29375
29450
  const alpha =
29376
29451
  {
29377
29452
  glow: (t)=> engineGlowAlpha(t, 1),
29378
29453
  disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
29379
29454
  ring: (t)=> max(0, 1 - abs(t - .86) / .12),
29455
+ hex: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
29456
+ streak: (t)=> (1 - t) ** 1.5,
29380
29457
  };
29381
- shapes.forEach((shape, i)=>
29458
+ // a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon
29459
+ const fill = (shape, r, path)=>
29382
29460
  {
29383
- const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
29384
- const gradient = context.createRadialGradient(x, y, 0, x, y, r);
29461
+ const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r);
29385
29462
  for (let k = 0; k <= steps; ++k)
29386
29463
  gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
29387
29464
  context.fillStyle = gradient;
29388
29465
  context.beginPath();
29389
- context.arc(x, y, r, 0, 2 * PI);
29466
+ path ? path() : context.arc(0, 0, r, 0, 2 * PI);
29390
29467
  context.fill();
29468
+ };
29469
+ const ray = (r, angle)=>
29470
+ {
29471
+ context.save();
29472
+ context.rotate(angle);
29473
+ context.scale(1, .07);
29474
+ fill('streak', r);
29475
+ context.restore();
29476
+ };
29477
+ shapes.forEach((shape, i)=>
29478
+ {
29479
+ const r = cell / 2 - 2;
29480
+ context.save();
29481
+ context.translate(i * cell + cell / 2, cell / 2);
29482
+ if (shape == 'hex')
29483
+ {
29484
+ // a gradient is round, so the hexagon is set pixel by pixel, by how far each is toward a side
29485
+ const image = context.createImageData(cell, cell), data = image.data, apothem = r * cos(PI / 6);
29486
+ for (let k = 0; k < cell * cell; ++k)
29487
+ {
29488
+ const x = abs(k % cell + .5 - cell / 2), y = abs((k / cell | 0) + .5 - cell / 2);
29489
+ const t = max(y, x * cos(PI / 6) + y / 2) / apothem;
29490
+ data[k * 4] = data[k * 4 + 1] = data[k * 4 + 2] = 255;
29491
+ data[k * 4 + 3] = t < 1 ? clamp(alpha.hex(t)) * 255 : 0;
29492
+ }
29493
+ context.putImageData(image, i * cell, 0);
29494
+ }
29495
+ else if (shape == 'streak')
29496
+ ray(r, 0);
29497
+ else if (shape == 'star')
29498
+ {
29499
+ for (let k = 3; k--;)
29500
+ ray(r, k * PI / 3 + PI / 2);
29501
+ context.scale(.3, .3);
29502
+ fill('glow', r);
29503
+ }
29504
+ else
29505
+ fill(shape, r);
29506
+ context.restore();
29391
29507
  });
29392
29508
  const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
29393
29509
  render3DFlareTiles = new Map(shapes.map((shape, i)=>
@@ -29396,16 +29512,32 @@ function render3DFlareTile(shape)
29396
29512
  return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
29397
29513
  }
29398
29514
 
29515
+ /**
29516
+ * A part of a lens flare
29517
+ * @typedef {Object} LensFlareElement
29518
+ * @property {number} at - Where along the line: 0 the sun, 1 the middle of the screen, 2 as far past it
29519
+ * @property {number|Vector2} size - How big across, as a part of the screen's height, a vector for a part wider
29520
+ * than it is tall
29521
+ * @property {Color} color - Its color, the alpha how bright
29522
+ * @property {string} [shape] - glow, disc, ring, hex, streak or star, a glow when left out
29523
+ * @property {TileInfo} [tileInfo] - A tile of the game's own to draw in place of a shape, best white on clear
29524
+ * @property {number} [angle] - How far it is turned, in radians
29525
+ * @memberof Render3D
29526
+ */
29527
+
29399
29528
  /**
29400
29529
  * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
29401
29530
  * sizes along the line from the sun through the middle of the screen
29402
29531
  * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
29403
29532
  * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
29533
+ * - A Light3D gets one of its own with light.addFlare()
29404
29534
  * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
29405
- * with the sun's own color; or give it elements of your own
29406
- * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
29535
+ * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
29536
+ * give it elements of your own, which may be tiles of the game's
29537
+ * - 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
29538
+ * sun is too far off the screen for the flare to show, a third of the screen past its edge, where nothing is tested
29407
29539
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
29408
- * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
29540
+ * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
29409
29541
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
29410
29542
  * @extends EngineObject3D
29411
29543
  * @memberof Render3D
@@ -29435,48 +29567,62 @@ class LensFlare3D extends EngineObject3D
29435
29567
  this.saturation = saturation;
29436
29568
  /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
29437
29569
  this.seed = 1;
29438
- /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
29439
- * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
29440
- * line, 0 the sun, 1 the middle of the screen, 2 as far past it; size is across, as a part of the screen's
29441
- * height; shape is glow, disc or ring
29442
- * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
29570
+ /** @property {Array<string>|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex,
29571
+ * streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often;
29572
+ * discs, rings and glows when not set
29573
+ * @type {Array<string>|undefined} */
29574
+ this.shapes = undefined;
29575
+ /** @property {number} - Scales the glow at the sun, 0 for none */
29576
+ this.glowSize = 1;
29577
+ /** @property {number} - Scales the ghosts */
29578
+ this.ghostSize = 1;
29579
+ /** @property {Array<LensFlareElement>|undefined} - The parts of the flare, to set your own in place of the
29580
+ * ones made from count, seed, saturation, shapes, glowSize and ghostSize
29581
+ * @type {Array<LensFlareElement>|undefined} */
29443
29582
  this.elements = undefined;
29444
29583
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
29445
29584
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
29446
- * in front of the light, and a spotlight's shows from inside its beam only
29585
+ * in front of the light but its lamp, a mesh around it no wider than half its radius, and a spotlight's
29586
+ * shows from inside its beam only; a DirectionalLight3D's is far
29587
+ * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
29588
+ * sets this
29447
29589
  * @type {Light3D|undefined} */
29448
29590
  this.light = undefined;
29449
29591
  /** @property {boolean} - Fade out when something is between the camera and the sun */
29450
29592
  this.occlusion = true;
29451
29593
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
29452
29594
  this.fadeTime = .15;
29453
- /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
29595
+ /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
29596
+ * plain view, eased */
29454
29597
  this.visible = 1;
29455
29598
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
29456
29599
  this.madeKey = '';
29457
- /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
29600
+ /** @type {Array<LensFlareElement>} */
29458
29601
  this.made = [];
29459
29602
  }
29460
29603
 
29461
- /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
29462
- * and a core at the sun and the ghosts, made again when one of those changes
29463
- * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
29604
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
29605
+ * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
29606
+ * @return {Array<LensFlareElement>} */
29464
29607
  getElements()
29465
29608
  {
29466
29609
  if (this.elements) return this.elements;
29467
- const key = [this.count, this.seed, this.saturation].join();
29610
+ const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
29611
+ const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
29468
29612
  if (key !== this.madeKey)
29469
29613
  {
29470
29614
  const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
29471
- const made = this.made = [
29472
- {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
29473
- {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
29615
+ /** @type {Array<LensFlareElement>} */
29616
+ const made = this.made = glow > 0 ? [
29617
+ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
29618
+ {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
29474
29619
  for (let i = 0; i < this.count; ++i)
29475
29620
  {
29476
29621
  // spread along the line, each a place of its own, the far ones bigger
29477
29622
  const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
29478
- const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
29479
- made.push({at, size: random.float(.04, .1) * (1 + at), shape,
29623
+ const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
29624
+ pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
29625
+ made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
29480
29626
  color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
29481
29627
  }
29482
29628
  this.madeKey = key;
@@ -29484,69 +29630,85 @@ class LensFlare3D extends EngineObject3D
29484
29630
  return this.made;
29485
29631
  }
29486
29632
 
29487
- // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
29488
- // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
29633
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
29634
+ // directional light, which shines from its place toward the origin, and a point to find it on the screen by;
29635
+ // undefined with no direction, or a light that is gone or at the camera
29489
29636
  flareSource()
29490
29637
  {
29491
29638
  const camera = render3D.camera.pos, light = this.light;
29492
- if (!light)
29639
+ if (light?.destroyed) return;
29640
+ if (!light || light.directional)
29493
29641
  {
29494
- const sun = render3D.sunDirection;
29495
- if (!sun.lengthSquared()) return;
29496
- const direction = sun.normalize();
29642
+ const from = light ? light.getWorldPos3D() : render3D.sunDirection;
29643
+ if (!from.lengthSquared()) return;
29644
+ const direction = from.normalize();
29497
29645
  return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
29498
29646
  }
29499
- if (light.destroyed) return;
29500
29647
  const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
29501
29648
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
29502
29649
  }
29503
29650
 
29504
- /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
29505
- * behind the camera
29506
- * @return {Vector2|undefined} */
29507
- getSunScreenPos()
29651
+ // how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
29652
+ // fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
29653
+ // show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
29654
+ flareLook()
29508
29655
  {
29509
- const source = this.flareSource();
29510
- return source && render3D.worldToScreen(source.pos);
29511
- }
29512
-
29513
- /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
29514
- * color, dimmed by how much of the sun shows; empty when there is nothing to draw
29515
- * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
29516
- getScreenElements()
29517
- {
29518
- const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
29519
- if (!sun || !center.x || !center.y) return [];
29656
+ const source = this.flareSource(), center = mainCanvasSize.scale(.5);
29657
+ const sun = source && render3D.worldToScreen(source.pos);
29658
+ if (!sun || !center.x || !center.y) return;
29520
29659
  // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
29521
29660
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
29522
- const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
29523
- if (!(strength > 0)) return [];
29661
+ const strength = clamp((1.3 - off) / .5) * this.intensity;
29662
+ if (!(strength > 0)) return;
29524
29663
  let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
29525
- const light = this.light, source = light && this.flareSource();
29664
+ const light = this.light;
29526
29665
  if (light)
29527
29666
  {
29528
- if (!source) return [];
29529
29667
  // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
29530
29668
  const cone = render3DLightCone(light), d = source.direction;
29531
29669
  const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
29532
29670
  const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
29533
- if (!(bright > 0)) return [];
29671
+ if (!(bright > 0)) return;
29534
29672
  tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
29535
- height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
29673
+ if (!light.directional)
29674
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
29536
29675
  }
29676
+ return height > 0 ? {sun, center, strength, tint, height} : undefined;
29677
+ }
29678
+
29679
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
29680
+ * behind the camera
29681
+ * @return {Vector2|undefined} */
29682
+ getScreenPos()
29683
+ {
29684
+ const source = this.flareSource();
29685
+ return source && render3D.worldToScreen(source.pos);
29686
+ }
29687
+
29688
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector
29689
+ * when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw
29690
+ * @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined,
29691
+ * tileInfo: TileInfo|undefined, angle: number}>} */
29692
+ getScreenElements()
29693
+ {
29694
+ const look = this.visible > 0 && this.flareLook();
29695
+ if (!look) return [];
29696
+ const {sun, center, tint, height} = look, strength = look.strength * this.visible;
29537
29697
  return this.getElements().map((e)=>
29538
29698
  {
29539
29699
  const c = e.color.multiply(tint);
29540
29700
  // along the line and past the middle, a lerp would stop there
29541
- return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
29542
- color: rgb(c.r, c.g, c.b, c.a * strength)};
29701
+ const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
29702
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
29703
+ angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
29543
29704
  });
29544
29705
  }
29545
29706
 
29546
29707
  /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
29547
- * through
29708
+ * through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless
29709
+ * it is doubleSided
29548
29710
  * @return {boolean} */
29549
- isSunHidden()
29711
+ isHidden()
29550
29712
  {
29551
29713
  const source = this.flareSource();
29552
29714
  if (!source) return true;
@@ -29560,22 +29722,31 @@ class LensFlare3D extends EngineObject3D
29560
29722
  for (const map of maps)
29561
29723
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
29562
29724
  return true;
29563
- // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
29564
- // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
29565
- // one the light is inside, its lamp
29566
- const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
29567
- const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
29568
- const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
29569
- const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
29570
- const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
29725
+ // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
29726
+ // is in, and no wider than half the light's reach, since a room or a whole level has a box around its lights
29727
+ // too, and its walls do hide them from outside
29728
+ const lamp = (o)=>
29729
+ {
29730
+ if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
29731
+ const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds();
29732
+ const side = max(b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z) * render3DMaxStretch(matrix.m);
29733
+ if (!matrix.determinant() || side > this.light.radius / 2) return false;
29734
+ const p = matrix.copy().invert().transformPoint(source.pos);
29735
+ return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
29736
+ p.z >= b.min.z && p.z <= b.max.z;
29737
+ };
29738
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
29571
29739
  return !!hit && hit.distance < reach;
29572
29740
  }
29573
29741
 
29574
- /** Ease visible toward whether the sun shows, called automatically each frame */
29742
+ /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
29743
+ * wherever the sun is, off the screen or its light off, looks for nothing in the way */
29575
29744
  update()
29576
29745
  {
29577
29746
  super.update();
29578
- const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
29747
+ if (this.light?.destroyed)
29748
+ return this.destroy(); // the flare of a light that is gone
29749
+ const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
29579
29750
  const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
29580
29751
  this.visible = clamp(this.visible + (shows ? step : -step));
29581
29752
  }
@@ -29589,8 +29760,9 @@ class LensFlare3D extends EngineObject3D
29589
29760
  setAdditiveBlendMode(true);
29590
29761
  for (const e of elements)
29591
29762
  {
29592
- const tileInfo = render3DFlareTile(e.shape);
29593
- tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
29763
+ const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
29764
+ const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
29765
+ tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
29594
29766
  }
29595
29767
  setAdditiveBlendMode(false);
29596
29768
  }
@@ -30196,7 +30368,7 @@ class VoxelMap extends EngineObject3D
30196
30368
  /** @type {Array<Vector3>} */
30197
30369
  this.chunkCenters = []; // a chunk mesh's points are around its center, which it is drawn at
30198
30370
  this.chunksChanged = new Set;
30199
- /** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean}|undefined>} */
30371
+ /** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}|undefined>} */
30200
30372
  this.blockTypes = [];
30201
30373
  this.tiles = new Map; // tile index to the uvs of its corners
30202
30374
 
@@ -30268,27 +30440,29 @@ class VoxelMap extends EngineObject3D
30268
30440
  * @param {number} type - 1 to 255
30269
30441
  * @param {number|Array<number>|{top?: number, side: number, bottom?: number}} faces - A tile index for every face,
30270
30442
  * six in the order +x, -x, +y, -y, +z, -z, or the side's with the top and bottom's, which default to the side's
30271
- * @param {{seeThrough?: boolean, transparent?: boolean}} [options] - seeThrough for holes in its texture, like
30272
- * leaves, so the blocks beside it keep their faces; transparent to blend, like glass or water, drawn in the
30273
- * transparent stage, and see-through too */
30274
- setBlockType(type, faces, {seeThrough=false, transparent=false}={})
30443
+ * @param {{seeThrough?: boolean, transparent?: boolean, doubleSided?: boolean}} [options] - seeThrough for holes
30444
+ * in its texture, like leaves, so the blocks beside it keep their faces; transparent to blend, like glass or
30445
+ * water, drawn in the transparent stage, and see-through too; doubleSided for faces seen from inside the
30446
+ * block as well, like water, whose surface then shows from under it */
30447
+ setBlockType(type, faces, {seeThrough=false, transparent=false, doubleSided=false}={})
30275
30448
  {
30276
30449
  ASSERT(type >= 1 && type <= 255 && type % 1 === 0, 'a block type is a whole number from 1 to 255', type);
30277
30450
  const f = /** @type {any} */ (faces);
30278
30451
  const list = isNumber(f) ? [f, f, f, f, f, f] : isArray(f) ? f :
30279
30452
  [f.side, f.side, f.top ?? f.side, f.bottom ?? f.side, f.side, f.side];
30280
30453
  ASSERT(list.length === 6 && list.every(isNumber), 'faces is a tile index, six of them, or {top, side, bottom}');
30281
- this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent};
30454
+ this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent, doubleSided};
30282
30455
  this.rebuild();
30283
30456
  }
30284
30457
 
30285
30458
  /** A block type's faces and how it is seen through
30286
30459
  * @param {number} type
30287
- * @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean}}
30460
+ * @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}}
30288
30461
  * @ignore */
30289
30462
  blockType(type)
30290
30463
  {
30291
- return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false};
30464
+ return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false,
30465
+ doubleSided: false};
30292
30466
  }
30293
30467
 
30294
30468
  /** Build every chunk again, after changing data directly or ambientOcclusion */
@@ -30358,6 +30532,11 @@ class VoxelMap extends EngineObject3D
30358
30532
  colors[k] = ao ? RENDER3D_VOXEL_SHADES[shade[i]] : WHITE;
30359
30533
  }
30360
30534
  mesh.addStrip(points, normal, uvs, colors);
30535
+
30536
+ // a double sided block shows the face from inside too, its corners the other way round
30537
+ if (block.doubleSided)
30538
+ mesh.addStrip([points[1], points[0], points[3], points[2]], normal.scale(-1),
30539
+ [uvs[1], uvs[0], uvs[3], uvs[2]], [colors[1], colors[0], colors[3], colors[2]]);
30361
30540
  }
30362
30541
  }
30363
30542
  this.chunkMeshes[index]?.dispose();
@@ -30704,8 +30883,9 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
30704
30883
  * are added before its objects are made, one the game added itself keeps its place
30705
30884
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
30706
30885
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
30707
- * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
30708
- * leaves out stays as the game set it, and a level with no block changes nothing
30886
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
30887
+ * sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
30888
+ * nothing
30709
30889
  * @param {Object} level - The level, the level editor edits this same object
30710
30890
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
30711
30891
  * @memberof Level3D */
@@ -30930,7 +31110,24 @@ function level3DSceneApply(scene)
30930
31110
  r.fogColor = color(scene.fogColor) || r.fogColor;
30931
31111
  if (typeof scene.shadows === 'boolean')
30932
31112
  r.shadows = scene.shadows;
31113
+ if (typeof scene.lensFlare === 'boolean')
31114
+ level3DSceneFlare(scene.lensFlare);
31115
+ }
31116
+
31117
+ // 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
31118
+ // earlier level's, and off takes away the one a scene made, never the game's
31119
+ let level3DSunFlare;
31120
+ function level3DSceneFlare(on)
31121
+ {
31122
+ if (!on)
31123
+ {
31124
+ level3DSunFlare && !level3DSunFlare.destroyed && level3DSunFlare.destroy();
31125
+ level3DSunFlare = undefined;
31126
+ }
31127
+ else if (!level3DSunHasFlare())
31128
+ level3DSunFlare = new LensFlare3D;
30933
31129
  }
31130
+ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light);
30934
31131
 
30935
31132
  // a vec3 of an array of three numbers, as the file has them, or the fallback
30936
31133
  function level3DVector(value, fallback)
@@ -31280,7 +31477,8 @@ level3DAddType('Cylinder', function(pos, properties)
31280
31477
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
31281
31478
  {color: WHITE, tile: -1, solid: true});
31282
31479
  // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
31283
- // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
31480
+ // object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and
31481
+ // lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare
31284
31482
  level3DAddType('Light', function(pos, properties)
31285
31483
  {
31286
31484
  const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
@@ -31288,8 +31486,9 @@ level3DAddType('Light', function(pos, properties)
31288
31486
  light.coneSoftness = properties.softness;
31289
31487
  if (properties.shadows && render3D)
31290
31488
  render3D.shadowLight = light;
31489
+ properties.lensFlare && light.addFlare();
31291
31490
  return light;
31292
- }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
31491
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false, lensFlare: false});
31293
31492
 
31294
31493
  /**
31295
31494
  * LittleJS Particle Effects Plugin
@@ -34128,8 +34327,8 @@ function editorMapRestore(map)
34128
34327
  if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height &&
34129
34328
  editorSameData(saved.layers, data) && editorSameData(saved.objects ?? [], objects))
34130
34329
  editorDiscardPending(record);
34131
- else if (!sameFile || !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) ||
34132
- !editorCopyData(data, saved.layers))
34330
+ else if (!sameFile || !editorSavedFits(saved, data.length, saved.width ?? map.width, saved.height ?? map.height) ||
34331
+ !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) || !editorCopyData(data, saved.layers))
34133
34332
  {
34134
34333
  record.pending = saved;
34135
34334
  console.warn(`LittleJS editor: ${record.fileName} changed since its autosaved edits, ` +
@@ -34428,6 +34627,11 @@ function editorCopyData(data, saved)
34428
34627
  return true;
34429
34628
  }
34430
34629
 
34630
+ // are an autosave's tiles a layer for each of the map's, each of its own size, so the map is resized only for tiles
34631
+ // that fit it
34632
+ const editorSavedFits = (saved, count, width, height)=> isArray(saved.layers) && saved.layers.length === count &&
34633
+ saved.layers.every((layer)=> isArray(layer) && layer.length === width * height);
34634
+
34431
34635
  // remember a map's tile data and objects, or forget them when they are back to the file
34432
34636
  function editorAutosave(record)
34433
34637
  {
@@ -34456,7 +34660,7 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
34456
34660
  {
34457
34661
  if (!editorHas('onRestart')) return;
34458
34662
  const before = editorMapSnapshot(record);
34459
- editorSetMapSnapshot(record, {width, height, layers: data, objects});
34663
+ editorSetMapSnapshot(record, {width, height, layers: data, objects}, keepUnknown);
34460
34664
  editorMapChanged(record, before);
34461
34665
  return;
34462
34666
  }
@@ -34573,13 +34777,15 @@ function editorMapSnapshot(record)
34573
34777
  return editorObjectsCopy({width: map.width, height: map.height, layers: editorTileLayerData(map.layers), objects});
34574
34778
  }
34575
34779
 
34576
- // give a map a size, tiles and objects, the game's Restart hook makes its layers again
34577
- function editorSetMapSnapshot(record, snapshot)
34780
+ // give a map a size, tiles and objects, the game's Restart hook makes its layers again; keepUnknown leaves an object
34781
+ // layer past the snapshot's as it is, as applying an autosave from before the file had it does
34782
+ function editorSetMapSnapshot(record, snapshot, keepUnknown=false)
34578
34783
  {
34579
34784
  const map = record.map;
34580
34785
  editorResizeMap(map, snapshot.width, snapshot.height);
34581
34786
  editorCopyData(editorTileLayerData(map.layers), snapshot.layers);
34582
34787
  editorRestoreObjects(map, snapshot);
34788
+ if (!keepUnknown)
34583
34789
  for (const group of editorObjectGroups(map.layers).slice(snapshot.objects.length))
34584
34790
  group.objects = []; // an Objects layer the editor made since
34585
34791
  editorClearSelections();
@@ -35739,9 +35945,13 @@ function editorPaletteTiles(layer)
35739
35945
  if (!image) return [];
35740
35946
  if (layer.palette?.image === image) return layer.palette.tiles;
35741
35947
 
35742
- const tiles = [];
35948
+ // a sheet read by its columns, a tileset with a margin or a spacing, is read with frame(), which asserts the
35949
+ // frame is inside the image, so a row past the image's end is found before its frame is made
35950
+ const tiles = [], base = live.tileInfo, columns = base.columns;
35743
35951
  for (let i = 0; i < 4096; ++i)
35744
35952
  {
35953
+ if (columns && base.pos.y + (i / columns | 0) * (base.size.y + base.padding*2) + base.size.y > image.height)
35954
+ break;
35745
35955
  const t = editorTileInfo(live, i);
35746
35956
  if (t.pos.x + t.size.x > image.width || t.pos.y + t.size.y > image.height) break;
35747
35957
  tiles.push({tile: i, tileInfo: t});
@@ -36835,7 +37045,7 @@ function editor3DSceneState()
36835
37045
  const r = render3D;
36836
37046
  return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
36837
37047
  fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
36838
- sunColor: r.sunColor.copy(), shadows: r.shadows};
37048
+ sunColor: r.sunColor.copy(), shadows: r.shadows, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed};
36839
37049
  }
36840
37050
  function editor3DSceneRestore(state)
36841
37051
  {
@@ -36850,6 +37060,7 @@ function editor3DSceneRestore(state)
36850
37060
  r.sunDirection = state.sunDirection.copy();
36851
37061
  r.sunColor = state.sunColor.copy();
36852
37062
  r.shadows = state.shadows;
37063
+ level3DSceneFlare(state.sunFlare); // a flare an earlier level's scene gave the sun, never the game's own
36853
37064
  }
36854
37065
 
36855
37066
  // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
@@ -36871,6 +37082,7 @@ function editor3DSceneFromView()
36871
37082
  scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
36872
37083
  scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
36873
37084
  scene.shadows = !!r.shadows;
37085
+ scene.lensFlare = level3DSunHasFlare();
36874
37086
  return scene;
36875
37087
  }
36876
37088
 
@@ -37028,9 +37240,11 @@ function editor3DLevelLoaded(level)
37028
37240
  level.objects = editor3DCopy(saved.objects);
37029
37241
  for (const name of editor3DLevelPartNames)
37030
37242
  parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name];
37243
+ saved.stale && console.warn(`LittleJS editor: the edits brought back to ${record.fileName} are older than ` +
37244
+ 'the last ones, which storage had no room for');
37031
37245
  }
37032
- else if (saved)
37033
- record.pending = saved;
37246
+ else if (saved && isArray(saved.objects))
37247
+ record.pending = saved; // one that is broken is passed by, and the next autosave writes over it
37034
37248
  }
37035
37249
 
37036
37250
  // called by level3DLoad for each object, with what its type made
@@ -39859,6 +40073,8 @@ function editor3DSceneUpdate(box)
39859
40073
  color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
39860
40074
  scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
39861
40075
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
40076
+ toggle('Lens flare', 'The sun flares in the lens when it is in view', !!value('lensFlare'), (on)=>
40077
+ change('lensFlare', on));
39862
40078
  }
39863
40079
 
39864
40080
  // the properties box: the position, rotation and scale of the one selected object and an input for each default
@@ -40418,6 +40634,7 @@ export
40418
40634
  tileCollisionTest,
40419
40635
  tileCollisionRaycast,
40420
40636
  tileLayersLoad,
40637
+ tileLayersFromLDtk,
40421
40638
  objectLayersAddType,
40422
40639
  objectLayersLoad,
40423
40640
  TileLayerData,
@@ -40476,10 +40693,6 @@ export
40476
40693
  LightSystemPlugin,
40477
40694
  Light,
40478
40695
 
40479
- // ZzFXMusic
40480
- ZzFXMusic,
40481
- zzfxM,
40482
-
40483
40696
  // Audio Effects
40484
40697
  AudioEffect,
40485
40698
  AudioFilter,