littlejsengine 1.23.1 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.23.1';
38
+ const engineVersion = '1.24.0';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -7557,7 +7557,7 @@ function touchGamepadPointerUp(e)
7557
7557
  * - 2D spatial audio based on camera position with distance-based falloff
7558
7558
  * - Sound instance management (pause, resume, stop)
7559
7559
  * - Speech synthesis for text-to-speech
7560
- * - Music playback with ZzFXM support
7560
+ * - Music playback from audio files, looping by default
7561
7561
  * - Web Audio API integration with master gain control
7562
7562
  * - Sounds and the master bus can route through effects, see the audio effects plugin
7563
7563
  * @namespace Audio
@@ -8435,7 +8435,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
8435
8435
  }
8436
8436
 
8437
8437
  ///////////////////////////////////////////////////////////////////////////////
8438
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
8438
+ // ZzFX - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
8439
8439
 
8440
8440
  /** Generate and play a ZzFX sound
8441
8441
  *
@@ -8720,6 +8720,10 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
8720
8720
  * Load tile layers from exported data
8721
8721
  * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
8722
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
8723
8727
  * - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices
8724
8728
  * (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and
8725
8729
  * visibility carry to the layers inside it
@@ -8752,6 +8756,18 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
8752
8756
  false&&ASSERT(tileMapData.width && tileMapData.height);
8753
8757
  false&&ASSERT(tileMapData.layers && tileMapData.layers.length);
8754
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
+
8755
8771
  // flatten group layers in order, a group's color and visibility carry to the layers inside it
8756
8772
  /** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */
8757
8773
  const layers = [];
@@ -8829,6 +8845,107 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
8829
8845
  function tileLayersColor(hex)
8830
8846
  { return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); }
8831
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
+
8832
8949
  ///////////////////////////////////////////////////////////////////////////////
8833
8950
  // Object layers
8834
8951
 
@@ -13570,166 +13687,6 @@ class Light extends EngineObject
13570
13687
  }
13571
13688
  }
13572
13689
 
13573
- /**
13574
- * LittleJS ZzFXM Plugin
13575
- * - A port of ZzFXM, the Zuper Zmall Zound Zynth music player, by Keith Clark and Frank Force, MIT licensed,
13576
- * https://github.com/keithclark/ZzFXM; its notice is in COPYRIGHT.txt
13577
- * @namespace ZzFXM
13578
- */
13579
-
13580
- /**
13581
- * Music Object - Stores a zzfx music track for later use
13582
- *
13583
- * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
13584
- * @extends Sound
13585
- * @memberof ZzFXM
13586
- * @example
13587
- * // create some music
13588
- * const music_example = new ZzFXMusic(
13589
- * [
13590
- * [ // instruments
13591
- * [,0,400] // simple note
13592
- * ],
13593
- * [ // patterns
13594
- * [ // pattern 1
13595
- * [ // channel 0
13596
- * 0, -1, // instrument 0, left speaker
13597
- * 1, 0, 9, 1 // channel notes
13598
- * ],
13599
- * [ // channel 1
13600
- * 0, 1, // instrument 0, right speaker
13601
- * 0, 12, 17, -1 // channel notes
13602
- * ]
13603
- * ],
13604
- * ],
13605
- * [0, 0, 0, 0], // sequence, play pattern 0 four times
13606
- * 90 // BPM
13607
- * ]);
13608
- *
13609
- * // play the music on a loop
13610
- * music_example.playMusic();
13611
- */
13612
- class ZzFXMusic extends Sound
13613
- {
13614
- /** Create a music object and cache the zzfx music samples for later use
13615
- * @param {[Array, Array, Array, number?, ...any[]]} zzfxMusic - Array of zzfx music parameters: instruments, patterns, sequence, and an optional BPM; anything after it, like the tracker's metadata, is ignored
13616
- */
13617
- constructor(zzfxMusic)
13618
- {
13619
- super(undefined);
13620
-
13621
- if (!soundEnable || headlessMode) return;
13622
- const [instruments, patterns, sequence, BPM] = zzfxMusic;
13623
- super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
13624
- this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
13625
- this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
13626
- }
13627
- }
13628
-
13629
- ///////////////////////////////////////////////////////////////////////////////
13630
- // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
13631
-
13632
- /** Generate samples for a ZzFM song with given parameters
13633
- * @param {Array} instruments - Array of ZzFX sound parameters
13634
- * @param {Array} patterns - Array of pattern data
13635
- * @param {Array} sequence - Array of pattern indexes
13636
- * @param {number} [BPM] - Playback speed of the song in BPM
13637
- * @return {Array} - Left and right channel sample data
13638
- * @memberof ZzFXM */
13639
- function zzfxM(instruments, patterns, sequence, BPM = 125)
13640
- {
13641
- let i, j, k;
13642
- let instrumentParameters;
13643
- let note;
13644
- let sample;
13645
- let patternChannel;
13646
- let notFirstBeat;
13647
- let stop;
13648
- let instrument;
13649
- let attenuation;
13650
- let outSampleOffset;
13651
- let isSequenceEnd;
13652
- let sampleOffset = 0;
13653
- let nextSampleOffset;
13654
- let sampleBuffer = [];
13655
- let leftChannelBuffer = [];
13656
- let rightChannelBuffer = [];
13657
- let channelIndex = 0;
13658
- let panning = 0;
13659
- let hasMore = 1;
13660
- let sampleCache = {};
13661
- let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
13662
-
13663
- // for each channel in order until there are no more
13664
- for (; hasMore; channelIndex++) {
13665
-
13666
- // reset current values
13667
- sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
13668
-
13669
- // for each pattern in sequence
13670
- sequence.forEach((patternIndex, sequenceIndex)=> {
13671
- // get pattern for current channel, use empty 1 note pattern if none found
13672
- patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
13673
-
13674
- // check if there are more channels
13675
- hasMore |= patterns[patternIndex][channelIndex]&&1;
13676
-
13677
- // get next offset, use the length of first channel
13678
- nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
13679
- // for each beat in pattern, plus one extra if end of sequence
13680
- isSequenceEnd = sequenceIndex === sequence.length - 1;
13681
- for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
13682
-
13683
- // <channel-note>
13684
- note = patternChannel[i];
13685
-
13686
- // stop if end, different instrument or new note
13687
- stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
13688
- instrument !== (patternChannel[0] || 0) || note | 0;
13689
-
13690
- // fill buffer with samples for previous beat, most cpu intensive part
13691
- for (j = 0; j < beatLength && notFirstBeat;
13692
-
13693
- // fade off attenuation at end of beat if stopping note, prevents clicking
13694
- j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
13695
- ) {
13696
- // copy sample to stereo buffers with panning
13697
- sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
13698
- leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
13699
- rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
13700
- }
13701
-
13702
- // set up for next note
13703
- if (note) {
13704
- // set attenuation
13705
- attenuation = note % 1;
13706
- panning = patternChannel[1] || 0;
13707
- if (note |= 0) {
13708
- // get cached sample
13709
- sampleBuffer = sampleCache[
13710
- [
13711
- instrument = patternChannel[sampleOffset = 0] || 0,
13712
- note
13713
- ]
13714
- ] = sampleCache[[instrument, note]] || (
13715
- // add sample to cache
13716
- instrumentParameters = [...instruments[instrument]],
13717
- instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
13718
-
13719
- // allow negative values to stop notes
13720
- note > 0 ? zzfxG(...instrumentParameters) : []
13721
- );
13722
- }
13723
- }
13724
- }
13725
-
13726
- // update the sample offset
13727
- outSampleOffset = nextSampleOffset;
13728
- });
13729
- }
13730
-
13731
- return [leftChannelBuffer, rightChannelBuffer];
13732
- }
13733
13690
  /**
13734
13691
  * LittleJS Audio Effects Plugin
13735
13692
  * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
@@ -23152,8 +23109,8 @@ class Render3DPlugin
23152
23109
  }
23153
23110
 
23154
23111
  /** Find the nearest object under a screen position or along a ray, for clicking on things
23155
- * - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
23156
- * not triangle by triangle
23112
+ * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
23113
+ * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
23157
23114
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
23158
23115
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
23159
23116
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -26520,8 +26477,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
26520
26477
  return collected;
26521
26478
  }
26522
26479
 
26523
- // 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
26524
- // its own space, or a sprite as the quad it draws, not triangle by triangle
26480
+ // how far along a ray an object is hit, or undefined for a miss; a mesh is hit on its triangles, in its own space,
26481
+ // and a sprite as the quad it draws
26525
26482
  function render3DRaycastObject(ray, o)
26526
26483
  {
26527
26484
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -26538,25 +26495,55 @@ function render3DRaycastObject(ray, o)
26538
26495
  const distance = raycastSphere(ray, center, radius);
26539
26496
  if (distance === undefined) return;
26540
26497
 
26541
- // 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
26542
- // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
26498
+ // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
26499
+ // against the triangles; the direction is not made unit length, so the distance holds in the world;
26543
26500
  // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
26544
26501
  if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
26545
26502
  const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
26546
26503
  const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
26547
26504
  const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
26548
- if (hit !== 0) return hit;
26549
-
26550
- // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
26551
- // inside comes first
26552
- let exit = Infinity;
26553
- for (const k of ['x', 'y', 'z'])
26505
+ if (hit === undefined || !ray.direction.lengthSquared())
26506
+ return hit; // a ray of no length is where it starts, inside the box
26507
+ return render3DRaycastMesh(local, mesh);
26508
+ }
26509
+
26510
+ // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
26511
+ // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
26512
+ // triangle i is (i-2, i-1, i), the odd ones read the other way, and a join between its pieces has no area and is
26513
+ // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
26514
+ function render3DRaycastMesh(ray, mesh)
26515
+ {
26516
+ const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
26517
+ const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
26518
+ const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
26519
+ const count = indices ? indices.length / 3 | 0 : points.length - 2;
26520
+ let nearest;
26521
+ for (let i = 0; i < count; ++i)
26554
26522
  {
26555
- const d = local.direction[k];
26556
- if (d)
26557
- exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
26523
+ // the corners clockwise from the front: an indexed list is written the other way round
26524
+ let a, b, c;
26525
+ if (indices)
26526
+ a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
26527
+ else
26528
+ a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
26529
+
26530
+ // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
26531
+ const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
26532
+ const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
26533
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
26534
+ const det = e1x * hx + e1y * hy + e1z * hz;
26535
+ if (both ? !det : !(det < 0)) continue;
26536
+ const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
26537
+ const u = (sx * hx + sy * hy + sz * hz) / det;
26538
+ if (!(u >= 0 && u <= 1)) continue;
26539
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
26540
+ const v = (dx * qx + dy * qy + dz * qz) / det;
26541
+ if (!(v >= 0 && u + v <= 1)) continue;
26542
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
26543
+ if (t >= 0 && !(t >= nearest))
26544
+ nearest = t;
26558
26545
  }
26559
- return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
26546
+ return nearest;
26560
26547
  }
26561
26548
 
26562
26549
  // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
@@ -26934,10 +26921,12 @@ class InstancedMesh3D extends EngineObject3D
26934
26921
  * - radius is a world distance, so scale3D does not change it
26935
26922
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
26936
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
26937
26925
  * @extends EngineObject3D
26938
26926
  * @memberof Render3D
26939
26927
  * @example
26940
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
26941
26930
  */
26942
26931
  class Light3D extends EngineObject3D
26943
26932
  {
@@ -26972,6 +26961,49 @@ class Light3D extends EngineObject3D
26972
26961
  * bright core */
26973
26962
  this.glowFalloff = 1;
26974
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);
26975
27007
  }
26976
27008
 
26977
27009
  /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
@@ -28490,31 +28522,74 @@ class Trail3D extends EngineObject3D
28490
28522
  // lens flare
28491
28523
 
28492
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
28493
- // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
28525
+ // glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star
28526
+ // of six rays; undefined headless or without a canvas
28527
+ const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star'];
28494
28528
  let render3DFlareTiles;
28495
28529
  function render3DFlareTile(shape)
28496
28530
  {
28497
28531
  if (!render3DFlareTiles)
28498
28532
  {
28499
28533
  if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
28500
- const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
28534
+ const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
28501
28535
  // each shape is how see-through it is from its middle out
28502
28536
  const alpha =
28503
28537
  {
28504
28538
  glow: (t)=> engineGlowAlpha(t, 1),
28505
28539
  disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
28506
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,
28507
28543
  };
28508
- shapes.forEach((shape, i)=>
28544
+ // a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon
28545
+ const fill = (shape, r, path)=>
28509
28546
  {
28510
- const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
28511
- const gradient = context.createRadialGradient(x, y, 0, x, y, r);
28547
+ const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r);
28512
28548
  for (let k = 0; k <= steps; ++k)
28513
28549
  gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
28514
28550
  context.fillStyle = gradient;
28515
28551
  context.beginPath();
28516
- context.arc(x, y, r, 0, 2 * PI);
28552
+ path ? path() : context.arc(0, 0, r, 0, 2 * PI);
28517
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();
28518
28593
  });
28519
28594
  const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
28520
28595
  render3DFlareTiles = new Map(shapes.map((shape, i)=>
@@ -28523,16 +28598,32 @@ function render3DFlareTile(shape)
28523
28598
  return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
28524
28599
  }
28525
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
+
28526
28614
  /**
28527
28615
  * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
28528
28616
  * sizes along the line from the sun through the middle of the screen
28529
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
28530
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()
28531
28620
  * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
28532
- * with the sun's own color; or give it elements of your own
28533
- * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
28621
+ * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
28622
+ * give it elements of your own, which may be tiles of the game's
28623
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
28624
+ * sun is off the screen, where nothing is tested
28534
28625
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
28535
- * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
28626
+ * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
28536
28627
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
28537
28628
  * @extends EngineObject3D
28538
28629
  * @memberof Render3D
@@ -28562,48 +28653,61 @@ class LensFlare3D extends EngineObject3D
28562
28653
  this.saturation = saturation;
28563
28654
  /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
28564
28655
  this.seed = 1;
28565
- /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
28566
- * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
28567
- * 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
28568
- * height; shape is glow, disc or ring
28569
- * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
28656
+ /** @property {Array<string>|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex,
28657
+ * streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often;
28658
+ * discs, rings and glows when not set
28659
+ * @type {Array<string>|undefined} */
28660
+ this.shapes = undefined;
28661
+ /** @property {number} - Scales the glow at the sun, 0 for none */
28662
+ this.glowSize = 1;
28663
+ /** @property {number} - Scales the ghosts */
28664
+ this.ghostSize = 1;
28665
+ /** @property {Array<LensFlareElement>|undefined} - The parts of the flare, to set your own in place of the
28666
+ * ones made from count, seed, saturation, shapes, glowSize and ghostSize
28667
+ * @type {Array<LensFlareElement>|undefined} */
28570
28668
  this.elements = undefined;
28571
28669
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
28572
28670
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
28573
- * in front of the light, and a spotlight's shows from inside its beam only
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
28574
28674
  * @type {Light3D|undefined} */
28575
28675
  this.light = undefined;
28576
28676
  /** @property {boolean} - Fade out when something is between the camera and the sun */
28577
28677
  this.occlusion = true;
28578
28678
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
28579
28679
  this.fadeTime = .15;
28580
- /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
28680
+ /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
28681
+ * plain view, eased */
28581
28682
  this.visible = 1;
28582
28683
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
28583
28684
  this.madeKey = '';
28584
- /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
28685
+ /** @type {Array<LensFlareElement>} */
28585
28686
  this.made = [];
28586
28687
  }
28587
28688
 
28588
- /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
28589
- * and a core at the sun and the ghosts, made again when one of those changes
28590
- * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
28689
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
28690
+ * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
28691
+ * @return {Array<LensFlareElement>} */
28591
28692
  getElements()
28592
28693
  {
28593
28694
  if (this.elements) return this.elements;
28594
- const key = [this.count, this.seed, this.saturation].join();
28695
+ const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
28696
+ const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
28595
28697
  if (key !== this.madeKey)
28596
28698
  {
28597
28699
  const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
28598
- const made = this.made = [
28599
- {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
28600
- {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
28700
+ /** @type {Array<LensFlareElement>} */
28701
+ const made = this.made = glow > 0 ? [
28702
+ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
28703
+ {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
28601
28704
  for (let i = 0; i < this.count; ++i)
28602
28705
  {
28603
28706
  // spread along the line, each a place of its own, the far ones bigger
28604
28707
  const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
28605
- const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
28606
- made.push({at, size: random.float(.04, .1) * (1 + at), shape,
28708
+ const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
28709
+ pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
28710
+ made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
28607
28711
  color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
28608
28712
  }
28609
28713
  this.madeKey = key;
@@ -28611,69 +28715,85 @@ class LensFlare3D extends EngineObject3D
28611
28715
  return this.made;
28612
28716
  }
28613
28717
 
28614
- // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
28615
- // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
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
28616
28721
  flareSource()
28617
28722
  {
28618
28723
  const camera = render3D.camera.pos, light = this.light;
28619
- if (!light)
28724
+ if (light?.destroyed) return;
28725
+ if (!light || light.directional)
28620
28726
  {
28621
- const sun = render3D.sunDirection;
28622
- if (!sun.lengthSquared()) return;
28623
- const direction = sun.normalize();
28727
+ const from = light ? light.getWorldPos3D() : render3D.sunDirection;
28728
+ if (!from.lengthSquared()) return;
28729
+ const direction = from.normalize();
28624
28730
  return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
28625
28731
  }
28626
- if (light.destroyed) return;
28627
28732
  const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
28628
28733
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
28629
28734
  }
28630
28735
 
28631
- /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
28632
- * behind the camera
28633
- * @return {Vector2|undefined} */
28634
- getSunScreenPos()
28635
- {
28636
- const source = this.flareSource();
28637
- return source && render3D.worldToScreen(source.pos);
28638
- }
28639
-
28640
- /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
28641
- * color, dimmed by how much of the sun shows; empty when there is nothing to draw
28642
- * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
28643
- 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()
28644
28740
  {
28645
- const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
28646
- if (!sun || !center.x || !center.y) return [];
28741
+ const source = this.flareSource(), center = mainCanvasSize.scale(.5);
28742
+ const sun = source && render3D.worldToScreen(source.pos);
28743
+ if (!sun || !center.x || !center.y) return;
28647
28744
  // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
28648
28745
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
28649
- const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
28650
- if (!(strength > 0)) return [];
28746
+ const strength = clamp((1.3 - off) / .5) * this.intensity;
28747
+ if (!(strength > 0)) return;
28651
28748
  let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28652
- const light = this.light, source = light && this.flareSource();
28749
+ const light = this.light;
28653
28750
  if (light)
28654
28751
  {
28655
- if (!source) return [];
28656
28752
  // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
28657
28753
  const cone = render3DLightCone(light), d = source.direction;
28658
28754
  const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
28659
28755
  const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
28660
- if (!(bright > 0)) return [];
28756
+ if (!(bright > 0)) return;
28661
28757
  tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
28662
- height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
28758
+ if (!light.directional)
28759
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
28663
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;
28664
28782
  return this.getElements().map((e)=>
28665
28783
  {
28666
28784
  const c = e.color.multiply(tint);
28667
28785
  // along the line and past the middle, a lerp would stop there
28668
- return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
28669
- color: rgb(c.r, c.g, c.b, c.a * strength)};
28786
+ const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
28787
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
28788
+ angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
28670
28789
  });
28671
28790
  }
28672
28791
 
28673
28792
  /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
28674
- * 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
28675
28795
  * @return {boolean} */
28676
- isSunHidden()
28796
+ isHidden()
28677
28797
  {
28678
28798
  const source = this.flareSource();
28679
28799
  if (!source) return true;
@@ -28687,22 +28807,29 @@ class LensFlare3D extends EngineObject3D
28687
28807
  for (const map of maps)
28688
28808
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
28689
28809
  return true;
28690
- // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
28691
- // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
28692
- // one the light is inside, its lamp
28693
- const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
28694
- const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
28695
- const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
28696
- const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
28697
- const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
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)));
28698
28822
  return !!hit && hit.distance < reach;
28699
28823
  }
28700
28824
 
28701
- /** 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 */
28702
28827
  update()
28703
28828
  {
28704
28829
  super.update();
28705
- const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
28830
+ if (this.light?.destroyed)
28831
+ return this.destroy(); // the flare of a light that is gone
28832
+ const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
28706
28833
  const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
28707
28834
  this.visible = clamp(this.visible + (shows ? step : -step));
28708
28835
  }
@@ -28716,8 +28843,9 @@ class LensFlare3D extends EngineObject3D
28716
28843
  setAdditiveBlendMode(true);
28717
28844
  for (const e of elements)
28718
28845
  {
28719
- const tileInfo = render3DFlareTile(e.shape);
28720
- tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
28846
+ const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
28847
+ const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
28848
+ tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
28721
28849
  }
28722
28850
  setAdditiveBlendMode(false);
28723
28851
  }
@@ -29831,8 +29959,9 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
29831
29959
  * are added before its objects are made, one the game added itself keeps its place
29832
29960
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
29833
29961
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
29834
- * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
29835
- * leaves out stays as the game set it, and a level with no block changes nothing
29962
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
29963
+ * sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
29964
+ * nothing
29836
29965
  * @param {Object} level - The level, the level editor edits this same object
29837
29966
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
29838
29967
  * @memberof Level3D */
@@ -30057,7 +30186,24 @@ function level3DSceneApply(scene)
30057
30186
  r.fogColor = color(scene.fogColor) || r.fogColor;
30058
30187
  if (typeof scene.shadows === 'boolean')
30059
30188
  r.shadows = scene.shadows;
30189
+ if (typeof scene.lensFlare === 'boolean')
30190
+ level3DSceneFlare(scene.lensFlare);
30191
+ }
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;
30060
30205
  }
30206
+ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light);
30061
30207
 
30062
30208
  // a vec3 of an array of three numbers, as the file has them, or the fallback
30063
30209
  function level3DVector(value, fallback)
@@ -30407,7 +30553,8 @@ level3DAddType('Cylinder', function(pos, properties)
30407
30553
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
30408
30554
  {color: WHITE, tile: -1, solid: true});
30409
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
30410
- // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
30556
+ // object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and
30557
+ // lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare
30411
30558
  level3DAddType('Light', function(pos, properties)
30412
30559
  {
30413
30560
  const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
@@ -30415,8 +30562,9 @@ level3DAddType('Light', function(pos, properties)
30415
30562
  light.coneSoftness = properties.softness;
30416
30563
  if (properties.shadows && render3D)
30417
30564
  render3D.shadowLight = light;
30565
+ properties.lensFlare && light.addFlare();
30418
30566
  return light;
30419
- }, {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});
30420
30568
 
30421
30569
  /**
30422
30570
  * LittleJS Particle Effects Plugin