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}
@@ -8430,7 +8430,7 @@ function touchGamepadPointerUp(e)
8430
8430
  * - 2D spatial audio based on camera position with distance-based falloff
8431
8431
  * - Sound instance management (pause, resume, stop)
8432
8432
  * - Speech synthesis for text-to-speech
8433
- * - Music playback with ZzFXM support
8433
+ * - Music playback from audio files, looping by default
8434
8434
  * - Web Audio API integration with master gain control
8435
8435
  * - Sounds and the master bus can route through effects, see the audio effects plugin
8436
8436
  * @namespace Audio
@@ -9308,7 +9308,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
9308
9308
  }
9309
9309
 
9310
9310
  ///////////////////////////////////////////////////////////////////////////////
9311
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
9311
+ // ZzFX - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
9312
9312
 
9313
9313
  /** Generate and play a ZzFX sound
9314
9314
  *
@@ -9593,6 +9593,10 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
9593
9593
  * Load tile layers from exported data
9594
9594
  * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
9595
9595
  * tiles continue its numbering), finite maps in the CSV or array layer format; layer offsets and parallax are not read
9596
+ * - A tileset kept in the map with a margin or a spacing, a sheet with gaps between its tiles, is read where its
9597
+ * tiles are, whatever padding the tile info has; one in a file of its own (a tsx) is not read, pass a tile info
9598
+ * with its padding
9599
+ * - An LDtk level loads through tileLayersFromLDtk, which makes it a map like these
9596
9600
  * - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices
9597
9601
  * (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and
9598
9602
  * visibility carry to the layers inside it
@@ -9625,6 +9629,18 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
9625
9629
  ASSERT(tileMapData.width && tileMapData.height);
9626
9630
  ASSERT(tileMapData.layers && tileMapData.layers.length);
9627
9631
 
9632
+ // a sheet with a margin around its tiles or a spacing between them: the first tile is at the margin and each
9633
+ // cell is a tile and a spacing, which is a padding of half the spacing counted from there, by the tileset's
9634
+ // own count of columns, since the image's width does not say with a spacing that is not all around
9635
+ const tileset = tileMapData.tilesets?.[0];
9636
+ if (tileInfo && tileset && (tileset.margin > 0 || tileset.spacing > 0))
9637
+ {
9638
+ const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size;
9639
+ const width = tileInfo.textureInfo?.size.x || 0;
9640
+ const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing)));
9641
+ tileInfo = new TileInfo(vec2(margin), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
9642
+ }
9643
+
9628
9644
  // flatten group layers in order, a group's color and visibility carry to the layers inside it
9629
9645
  /** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */
9630
9646
  const layers = [];
@@ -9702,6 +9718,107 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
9702
9718
  function tileLayersColor(hex)
9703
9719
  { return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); }
9704
9720
 
9721
+ // the Tiled property type of an LDtk field type, for the ones Tiled has a property for
9722
+ const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: 'string', Multilines: 'string',
9723
+ Color: 'color', FilePath: 'file'};
9724
+
9725
+ /**
9726
+ * Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad
9727
+ * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled, so the last layer
9728
+ * of the LDtk file is layer 0; where LDtk stacks tiles in a cell, an edge over a fill, each tile over another
9729
+ * 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
9730
+ * wherever the LDtk layer has any, which makes it the one for collision
9731
+ * - An IntGrid layer with no tiles is a hidden layer of its values, for collision: pass its index as collisionLayer
9732
+ * - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at
9733
+ * its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string)
9734
+ * - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image
9735
+ * - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of
9736
+ * another grid size or another tileset is left out, with a warning in debug builds
9737
+ * - The level editor edits the map this returns, and saves it as a Tiled map
9738
+ * @param {Object} ldtk - The LDtk project, its JSON
9739
+ * @param {number|string} [level] - Which level, by its index or its identifier
9740
+ * @return {Object} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers
9741
+ * @example
9742
+ * const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0');
9743
+ * const layers = tileLayersLoad(map, tile(0, 16), 0, 1); // layer 1 is solid
9744
+ * objectLayersLoad(map);
9745
+ * @memberof TileLayers */
9746
+ function tileLayersFromLDtk(ldtk, level=0)
9747
+ {
9748
+ const levels = ldtk?.levels || [];
9749
+ const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level];
9750
+ ASSERT(data, 'LDtk level not found', level);
9751
+ ASSERT(isArray(data.layerInstances), 'LDtk level has no layers: levels saved as separate files are not read');
9752
+ const instances = data.layerInstances;
9753
+ const tiled = (l)=> l.__type !== 'Entities';
9754
+ const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
9755
+ const grid = first?.__gridSize || 16;
9756
+ const width = first?.__cWid || ceil(data.pxWid / grid), height = first?.__cHei || ceil(data.pxHei / grid);
9757
+ const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down',
9758
+ infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1};
9759
+
9760
+ // the tileset of the first layer that has one, where its tiles are in its image
9761
+ const tileset = (ldtk?.defs?.tilesets || []).find((t)=> t.uid === first?.__tilesetDefUid);
9762
+ if (tileset)
9763
+ map.tilesets = [{firstgid: 1, name: String(tileset.relPath || '').replace(/^.*[\\/]/, '').replace(/\.\w+$/, ''),
9764
+ image: tileset.relPath, imagewidth: tileset.pxWid, imageheight: tileset.pxHei,
9765
+ tilewidth: tileset.tileGridSize, tileheight: tileset.tileGridSize, margin: tileset.padding || 0,
9766
+ spacing: tileset.spacing || 0, columns: tileset.__cWid, tilecount: tileset.__cWid * tileset.__cHei}];
9767
+
9768
+ // LDtk lists the top layer first, Tiled the bottom one
9769
+ for (let i = instances.length; i--;)
9770
+ {
9771
+ const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++;
9772
+ if (!tiled(instance))
9773
+ {
9774
+ const objects = (instance.entityInstances || []).map((entity)=>
9775
+ {
9776
+ // an entity is placed by its pivot, an object here by its middle
9777
+ const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0;
9778
+ const properties = [];
9779
+ for (const field of entity.fieldInstances || [])
9780
+ {
9781
+ const type = tileLayersLDtkTypes[field.__type] || (/^(Local|Extern)Enum\./.test(field.__type) && 'string');
9782
+ type && field.__value != undefined && properties.push({name: field.__identifier, type, value: field.__value});
9783
+ }
9784
+ return {id: map.nextobjectid++, name: '', type: entity.__identifier, x: entity.px[0] + (.5 - pivotX) * w,
9785
+ y: entity.px[1] + (.5 - pivotY) * h, width: w, height: h, rotation: 0, visible: true, properties};
9786
+ });
9787
+ map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0});
9788
+ continue;
9789
+ }
9790
+ const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
9791
+ const usable = instance.__gridSize === grid && instance.__cWid === width && instance.__cHei === height &&
9792
+ (!tiles.length || instance.__tilesetDefUid === tileset?.uid);
9793
+ if (!usable)
9794
+ {
9795
+ debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
9796
+ continue;
9797
+ }
9798
+ const empty = ()=> new Array(width * height).fill(0), stacks = [empty()];
9799
+ if (tiles.length)
9800
+ {
9801
+ // a tile's place in pixels, its tile in the sheet, and its flips: bit 0 across, bit 1 down, which are
9802
+ // Tiled's two top bits; LDtk lists them in the order it draws them, so the first in a cell is the
9803
+ // bottom one and each after it goes a layer higher
9804
+ const depth = new Uint16Array(width * height);
9805
+ for (const t of tiles)
9806
+ {
9807
+ const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width;
9808
+ if (x >= 0 && x < width && y >= 0 && y < height)
9809
+ (stacks[depth[cell]++] ||= empty())[cell] =
9810
+ (t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0;
9811
+ }
9812
+ }
9813
+ else
9814
+ (instance.intGridCsv || []).forEach((value, k)=> k < stacks[0].length && (stacks[0][k] = value));
9815
+ const visible = instance.__type === 'IntGrid' && !tiles.length ? false : instance.visible !== false;
9816
+ stacks.forEach((data, k)=> map.layers.push({id: k ? map.nextlayerid++ : id, name: k ? `${name} (${k + 1})` : name,
9817
+ type: 'tilelayer', width, height, data, opacity: instance.__opacity ?? 1, visible, x: 0, y: 0}));
9818
+ }
9819
+ return map;
9820
+ }
9821
+
9705
9822
  ///////////////////////////////////////////////////////////////////////////////
9706
9823
  // Object layers
9707
9824
 
@@ -14443,166 +14560,6 @@ class Light extends EngineObject
14443
14560
  }
14444
14561
  }
14445
14562
 
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
14563
  /**
14607
14564
  * LittleJS Audio Effects Plugin
14608
14565
  * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
@@ -24025,8 +23982,8 @@ class Render3DPlugin
24025
23982
  }
24026
23983
 
24027
23984
  /** 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
23985
+ * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
23986
+ * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
24030
23987
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
24031
23988
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
24032
23989
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -27393,8 +27350,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
27393
27350
  return collected;
27394
27351
  }
27395
27352
 
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
27353
+ // 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,
27354
+ // and a sprite as the quad it draws
27398
27355
  function render3DRaycastObject(ray, o)
27399
27356
  {
27400
27357
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -27411,25 +27368,55 @@ function render3DRaycastObject(ray, o)
27411
27368
  const distance = raycastSphere(ray, center, radius);
27412
27369
  if (distance === undefined) return;
27413
27370
 
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;
27371
+ // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
27372
+ // against the triangles; the direction is not made unit length, so the distance holds in the world;
27416
27373
  // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
27417
27374
  if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
27418
27375
  const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
27419
27376
  const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
27420
27377
  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'])
27378
+ if (hit === undefined || !ray.direction.lengthSquared())
27379
+ return hit; // a ray of no length is where it starts, inside the box
27380
+ return render3DRaycastMesh(local, mesh);
27381
+ }
27382
+
27383
+ // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
27384
+ // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
27385
+ // 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
27386
+ // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
27387
+ function render3DRaycastMesh(ray, mesh)
27388
+ {
27389
+ const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
27390
+ const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
27391
+ const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
27392
+ const count = indices ? indices.length / 3 | 0 : points.length - 2;
27393
+ let nearest;
27394
+ for (let i = 0; i < count; ++i)
27427
27395
  {
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);
27396
+ // the corners clockwise from the front: an indexed list is written the other way round
27397
+ let a, b, c;
27398
+ if (indices)
27399
+ a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
27400
+ else
27401
+ a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
27402
+
27403
+ // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
27404
+ const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
27405
+ const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
27406
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
27407
+ const det = e1x * hx + e1y * hy + e1z * hz;
27408
+ if (both ? !det : !(det < 0)) continue;
27409
+ const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
27410
+ const u = (sx * hx + sy * hy + sz * hz) / det;
27411
+ if (!(u >= 0 && u <= 1)) continue;
27412
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
27413
+ const v = (dx * qx + dy * qy + dz * qz) / det;
27414
+ if (!(v >= 0 && u + v <= 1)) continue;
27415
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
27416
+ if (t >= 0 && !(t >= nearest))
27417
+ nearest = t;
27431
27418
  }
27432
- return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
27419
+ return nearest;
27433
27420
  }
27434
27421
 
27435
27422
  // 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 +27794,12 @@ class InstancedMesh3D extends EngineObject3D
27807
27794
  * - radius is a world distance, so scale3D does not change it
27808
27795
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
27809
27796
  * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
27797
+ * - addFlare gives it a lens flare, see LensFlare3D
27810
27798
  * @extends EngineObject3D
27811
27799
  * @memberof Render3D
27812
27800
  * @example
27813
27801
  * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
27802
+ * torch.addFlare(); // light in the lens when the torch is in view
27814
27803
  */
27815
27804
  class Light3D extends EngineObject3D
27816
27805
  {
@@ -27845,6 +27834,49 @@ class Light3D extends EngineObject3D
27845
27834
  * bright core */
27846
27835
  this.glowFalloff = 1;
27847
27836
  this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
27837
+ /** @type {LensFlare3D|undefined} */
27838
+ this.flareObject = undefined;
27839
+ }
27840
+
27841
+ /** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had
27842
+ * @param {number} [size] - Scales every part of it
27843
+ * @param {number} [count] - How many ghosts there are, besides the glow at the light
27844
+ * @param {number} [intensity] - How bright it is
27845
+ * @param {number} [saturation] - How colorful the ghosts are
27846
+ * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
27847
+ addFlare(size, count, intensity, saturation)
27848
+ {
27849
+ const flare = new LensFlare3D(size, count, intensity, saturation);
27850
+ this.flare = flare;
27851
+ return flare;
27852
+ }
27853
+
27854
+ /** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and
27855
+ * destroying the flare takes it away
27856
+ * - The flare is the light's: it is attached to the light as its child, so it stays through a scene change
27857
+ * when the light does, and is destroyed with the light or when another takes its place
27858
+ * @type {LensFlare3D|undefined} */
27859
+ get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; }
27860
+
27861
+ set flare(flare)
27862
+ {
27863
+ const old = this.flare; // a flare that was destroyed on its own is no flare
27864
+ if (flare === old) return;
27865
+ old && old.destroy();
27866
+ if (flare)
27867
+ {
27868
+ flare.light = this;
27869
+ flare.parent || this.addChild(flare);
27870
+ }
27871
+ this.flareObject = flare || undefined;
27872
+ }
27873
+
27874
+ /** Destroy the light, and its flare with it
27875
+ * @param {boolean} [immediate] */
27876
+ destroy(immediate)
27877
+ {
27878
+ this.flare = undefined;
27879
+ super.destroy(immediate);
27848
27880
  }
27849
27881
 
27850
27882
  /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
@@ -29363,31 +29395,74 @@ class Trail3D extends EngineObject3D
29363
29395
  // lens flare
29364
29396
 
29365
29397
  // 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
29398
+ // glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star
29399
+ // of six rays; undefined headless or without a canvas
29400
+ const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star'];
29367
29401
  let render3DFlareTiles;
29368
29402
  function render3DFlareTile(shape)
29369
29403
  {
29370
29404
  if (!render3DFlareTiles)
29371
29405
  {
29372
29406
  if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
29373
- const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
29407
+ const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
29374
29408
  // each shape is how see-through it is from its middle out
29375
29409
  const alpha =
29376
29410
  {
29377
29411
  glow: (t)=> engineGlowAlpha(t, 1),
29378
29412
  disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
29379
29413
  ring: (t)=> max(0, 1 - abs(t - .86) / .12),
29414
+ hex: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
29415
+ streak: (t)=> (1 - t) ** 1.5,
29380
29416
  };
29381
- shapes.forEach((shape, i)=>
29417
+ // a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon
29418
+ const fill = (shape, r, path)=>
29382
29419
  {
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);
29420
+ const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r);
29385
29421
  for (let k = 0; k <= steps; ++k)
29386
29422
  gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
29387
29423
  context.fillStyle = gradient;
29388
29424
  context.beginPath();
29389
- context.arc(x, y, r, 0, 2 * PI);
29425
+ path ? path() : context.arc(0, 0, r, 0, 2 * PI);
29390
29426
  context.fill();
29427
+ };
29428
+ const ray = (r, angle)=>
29429
+ {
29430
+ context.save();
29431
+ context.rotate(angle);
29432
+ context.scale(1, .07);
29433
+ fill('streak', r);
29434
+ context.restore();
29435
+ };
29436
+ shapes.forEach((shape, i)=>
29437
+ {
29438
+ const r = cell / 2 - 2;
29439
+ context.save();
29440
+ context.translate(i * cell + cell / 2, cell / 2);
29441
+ if (shape == 'hex')
29442
+ {
29443
+ // a gradient is round, so the hexagon is set pixel by pixel, by how far each is toward a side
29444
+ const image = context.createImageData(cell, cell), data = image.data, apothem = r * cos(PI / 6);
29445
+ for (let k = 0; k < cell * cell; ++k)
29446
+ {
29447
+ const x = abs(k % cell + .5 - cell / 2), y = abs((k / cell | 0) + .5 - cell / 2);
29448
+ const t = max(y, x * cos(PI / 6) + y / 2) / apothem;
29449
+ data[k * 4] = data[k * 4 + 1] = data[k * 4 + 2] = 255;
29450
+ data[k * 4 + 3] = t < 1 ? clamp(alpha.hex(t)) * 255 : 0;
29451
+ }
29452
+ context.putImageData(image, i * cell, 0);
29453
+ }
29454
+ else if (shape == 'streak')
29455
+ ray(r, 0);
29456
+ else if (shape == 'star')
29457
+ {
29458
+ for (let k = 3; k--;)
29459
+ ray(r, k * PI / 3 + PI / 2);
29460
+ context.scale(.3, .3);
29461
+ fill('glow', r);
29462
+ }
29463
+ else
29464
+ fill(shape, r);
29465
+ context.restore();
29391
29466
  });
29392
29467
  const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
29393
29468
  render3DFlareTiles = new Map(shapes.map((shape, i)=>
@@ -29396,16 +29471,32 @@ function render3DFlareTile(shape)
29396
29471
  return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
29397
29472
  }
29398
29473
 
29474
+ /**
29475
+ * A part of a lens flare
29476
+ * @typedef {Object} LensFlareElement
29477
+ * @property {number} at - Where along the line: 0 the sun, 1 the middle of the screen, 2 as far past it
29478
+ * @property {number|Vector2} size - How big across, as a part of the screen's height, a vector for a part wider
29479
+ * than it is tall
29480
+ * @property {Color} color - Its color, the alpha how bright
29481
+ * @property {string} [shape] - glow, disc, ring, hex, streak or star, a glow when left out
29482
+ * @property {TileInfo} [tileInfo] - A tile of the game's own to draw in place of a shape, best white on clear
29483
+ * @property {number} [angle] - How far it is turned, in radians
29484
+ * @memberof Render3D
29485
+ */
29486
+
29399
29487
  /**
29400
29488
  * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
29401
29489
  * sizes along the line from the sun through the middle of the screen
29402
29490
  * - 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
29491
  * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
29492
+ * - A Light3D gets one of its own with light.addFlare()
29404
29493
  * - 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
29494
+ * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
29495
+ * give it elements of your own, which may be tiles of the game's
29496
+ * - 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
29497
+ * sun is off the screen, where nothing is tested
29407
29498
  * - 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
29499
+ * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
29409
29500
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
29410
29501
  * @extends EngineObject3D
29411
29502
  * @memberof Render3D
@@ -29435,48 +29526,61 @@ class LensFlare3D extends EngineObject3D
29435
29526
  this.saturation = saturation;
29436
29527
  /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
29437
29528
  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} */
29529
+ /** @property {Array<string>|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex,
29530
+ * streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often;
29531
+ * discs, rings and glows when not set
29532
+ * @type {Array<string>|undefined} */
29533
+ this.shapes = undefined;
29534
+ /** @property {number} - Scales the glow at the sun, 0 for none */
29535
+ this.glowSize = 1;
29536
+ /** @property {number} - Scales the ghosts */
29537
+ this.ghostSize = 1;
29538
+ /** @property {Array<LensFlareElement>|undefined} - The parts of the flare, to set your own in place of the
29539
+ * ones made from count, seed, saturation, shapes, glowSize and ghostSize
29540
+ * @type {Array<LensFlareElement>|undefined} */
29443
29541
  this.elements = undefined;
29444
29542
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
29445
29543
  * 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
29544
+ * in front of the light, and a spotlight's shows from inside its beam only; a DirectionalLight3D's is far
29545
+ * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
29546
+ * sets this
29447
29547
  * @type {Light3D|undefined} */
29448
29548
  this.light = undefined;
29449
29549
  /** @property {boolean} - Fade out when something is between the camera and the sun */
29450
29550
  this.occlusion = true;
29451
29551
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
29452
29552
  this.fadeTime = .15;
29453
- /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
29553
+ /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
29554
+ * plain view, eased */
29454
29555
  this.visible = 1;
29455
29556
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
29456
29557
  this.madeKey = '';
29457
- /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
29558
+ /** @type {Array<LensFlareElement>} */
29458
29559
  this.made = [];
29459
29560
  }
29460
29561
 
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}>} */
29562
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
29563
+ * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
29564
+ * @return {Array<LensFlareElement>} */
29464
29565
  getElements()
29465
29566
  {
29466
29567
  if (this.elements) return this.elements;
29467
- const key = [this.count, this.seed, this.saturation].join();
29568
+ const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
29569
+ const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
29468
29570
  if (key !== this.madeKey)
29469
29571
  {
29470
29572
  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'}];
29573
+ /** @type {Array<LensFlareElement>} */
29574
+ const made = this.made = glow > 0 ? [
29575
+ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
29576
+ {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
29474
29577
  for (let i = 0; i < this.count; ++i)
29475
29578
  {
29476
29579
  // spread along the line, each a place of its own, the far ones bigger
29477
29580
  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,
29581
+ const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
29582
+ pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
29583
+ made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
29480
29584
  color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
29481
29585
  }
29482
29586
  this.madeKey = key;
@@ -29484,69 +29588,85 @@ class LensFlare3D extends EngineObject3D
29484
29588
  return this.made;
29485
29589
  }
29486
29590
 
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
29591
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
29592
+ // directional light, which shines from its place toward the origin, and a point to find it on the screen by;
29593
+ // undefined with no direction, or a light that is gone or at the camera
29489
29594
  flareSource()
29490
29595
  {
29491
29596
  const camera = render3D.camera.pos, light = this.light;
29492
- if (!light)
29597
+ if (light?.destroyed) return;
29598
+ if (!light || light.directional)
29493
29599
  {
29494
- const sun = render3D.sunDirection;
29495
- if (!sun.lengthSquared()) return;
29496
- const direction = sun.normalize();
29600
+ const from = light ? light.getWorldPos3D() : render3D.sunDirection;
29601
+ if (!from.lengthSquared()) return;
29602
+ const direction = from.normalize();
29497
29603
  return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
29498
29604
  }
29499
- if (light.destroyed) return;
29500
29605
  const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
29501
29606
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
29502
29607
  }
29503
29608
 
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()
29609
+ // how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
29610
+ // fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
29611
+ // show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
29612
+ flareLook()
29508
29613
  {
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 [];
29614
+ const source = this.flareSource(), center = mainCanvasSize.scale(.5);
29615
+ const sun = source && render3D.worldToScreen(source.pos);
29616
+ if (!sun || !center.x || !center.y) return;
29520
29617
  // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
29521
29618
  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 [];
29619
+ const strength = clamp((1.3 - off) / .5) * this.intensity;
29620
+ if (!(strength > 0)) return;
29524
29621
  let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
29525
- const light = this.light, source = light && this.flareSource();
29622
+ const light = this.light;
29526
29623
  if (light)
29527
29624
  {
29528
- if (!source) return [];
29529
29625
  // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
29530
29626
  const cone = render3DLightCone(light), d = source.direction;
29531
29627
  const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
29532
29628
  const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
29533
- if (!(bright > 0)) return [];
29629
+ if (!(bright > 0)) return;
29534
29630
  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
29631
+ if (!light.directional)
29632
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
29536
29633
  }
29634
+ return height > 0 ? {sun, center, strength, tint, height} : undefined;
29635
+ }
29636
+
29637
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
29638
+ * behind the camera
29639
+ * @return {Vector2|undefined} */
29640
+ getScreenPos()
29641
+ {
29642
+ const source = this.flareSource();
29643
+ return source && render3D.worldToScreen(source.pos);
29644
+ }
29645
+
29646
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector
29647
+ * when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw
29648
+ * @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined,
29649
+ * tileInfo: TileInfo|undefined, angle: number}>} */
29650
+ getScreenElements()
29651
+ {
29652
+ const look = this.visible > 0 && this.flareLook();
29653
+ if (!look) return [];
29654
+ const {sun, center, tint, height} = look, strength = look.strength * this.visible;
29537
29655
  return this.getElements().map((e)=>
29538
29656
  {
29539
29657
  const c = e.color.multiply(tint);
29540
29658
  // 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)};
29659
+ const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
29660
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
29661
+ angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
29543
29662
  });
29544
29663
  }
29545
29664
 
29546
29665
  /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
29547
- * through
29666
+ * through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless
29667
+ * it is doubleSided
29548
29668
  * @return {boolean} */
29549
- isSunHidden()
29669
+ isHidden()
29550
29670
  {
29551
29671
  const source = this.flareSource();
29552
29672
  if (!source) return true;
@@ -29560,22 +29680,29 @@ class LensFlare3D extends EngineObject3D
29560
29680
  for (const map of maps)
29561
29681
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
29562
29682
  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)));
29683
+ // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
29684
+ // is in
29685
+ const lamp = (o)=>
29686
+ {
29687
+ if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
29688
+ const matrix = render3DObjectMatrix(o);
29689
+ if (!matrix.determinant()) return false;
29690
+ const p = matrix.copy().invert().transformPoint(source.pos), b = o.mesh.bounds || o.mesh.getBounds();
29691
+ return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
29692
+ p.z >= b.min.z && p.z <= b.max.z;
29693
+ };
29694
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
29571
29695
  return !!hit && hit.distance < reach;
29572
29696
  }
29573
29697
 
29574
- /** Ease visible toward whether the sun shows, called automatically each frame */
29698
+ /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
29699
+ * wherever the sun is, off the screen or its light off, looks for nothing in the way */
29575
29700
  update()
29576
29701
  {
29577
29702
  super.update();
29578
- const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
29703
+ if (this.light?.destroyed)
29704
+ return this.destroy(); // the flare of a light that is gone
29705
+ const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
29579
29706
  const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
29580
29707
  this.visible = clamp(this.visible + (shows ? step : -step));
29581
29708
  }
@@ -29589,8 +29716,9 @@ class LensFlare3D extends EngineObject3D
29589
29716
  setAdditiveBlendMode(true);
29590
29717
  for (const e of elements)
29591
29718
  {
29592
- const tileInfo = render3DFlareTile(e.shape);
29593
- tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
29719
+ const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
29720
+ const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
29721
+ tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
29594
29722
  }
29595
29723
  setAdditiveBlendMode(false);
29596
29724
  }
@@ -30704,8 +30832,9 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
30704
30832
  * are added before its objects are made, one the game added itself keeps its place
30705
30833
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
30706
30834
  * 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
30835
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
30836
+ * sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
30837
+ * nothing
30709
30838
  * @param {Object} level - The level, the level editor edits this same object
30710
30839
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
30711
30840
  * @memberof Level3D */
@@ -30930,8 +31059,25 @@ function level3DSceneApply(scene)
30930
31059
  r.fogColor = color(scene.fogColor) || r.fogColor;
30931
31060
  if (typeof scene.shadows === 'boolean')
30932
31061
  r.shadows = scene.shadows;
31062
+ if (typeof scene.lensFlare === 'boolean')
31063
+ level3DSceneFlare(scene.lensFlare);
30933
31064
  }
30934
31065
 
31066
+ // 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
31067
+ // earlier level's, and off takes away the one a scene made, never the game's
31068
+ let level3DSunFlare;
31069
+ function level3DSceneFlare(on)
31070
+ {
31071
+ if (!on)
31072
+ {
31073
+ level3DSunFlare && !level3DSunFlare.destroyed && level3DSunFlare.destroy();
31074
+ level3DSunFlare = undefined;
31075
+ }
31076
+ else if (!level3DSunHasFlare())
31077
+ level3DSunFlare = new LensFlare3D;
31078
+ }
31079
+ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light);
31080
+
30935
31081
  // a vec3 of an array of three numbers, as the file has them, or the fallback
30936
31082
  function level3DVector(value, fallback)
30937
31083
  {
@@ -31280,7 +31426,8 @@ level3DAddType('Cylinder', function(pos, properties)
31280
31426
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
31281
31427
  {color: WHITE, tile: -1, solid: true});
31282
31428
  // 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
31429
+ // object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and
31430
+ // lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare
31284
31431
  level3DAddType('Light', function(pos, properties)
31285
31432
  {
31286
31433
  const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
@@ -31288,8 +31435,9 @@ level3DAddType('Light', function(pos, properties)
31288
31435
  light.coneSoftness = properties.softness;
31289
31436
  if (properties.shadows && render3D)
31290
31437
  render3D.shadowLight = light;
31438
+ properties.lensFlare && light.addFlare();
31291
31439
  return light;
31292
- }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
31440
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false, lensFlare: false});
31293
31441
 
31294
31442
  /**
31295
31443
  * LittleJS Particle Effects Plugin
@@ -36835,7 +36983,7 @@ function editor3DSceneState()
36835
36983
  const r = render3D;
36836
36984
  return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
36837
36985
  fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
36838
- sunColor: r.sunColor.copy(), shadows: r.shadows};
36986
+ sunColor: r.sunColor.copy(), shadows: r.shadows, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed};
36839
36987
  }
36840
36988
  function editor3DSceneRestore(state)
36841
36989
  {
@@ -36850,6 +36998,7 @@ function editor3DSceneRestore(state)
36850
36998
  r.sunDirection = state.sunDirection.copy();
36851
36999
  r.sunColor = state.sunColor.copy();
36852
37000
  r.shadows = state.shadows;
37001
+ level3DSceneFlare(state.sunFlare); // a flare an earlier level's scene gave the sun, never the game's own
36853
37002
  }
36854
37003
 
36855
37004
  // 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 +37020,7 @@ function editor3DSceneFromView()
36871
37020
  scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
36872
37021
  scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
36873
37022
  scene.shadows = !!r.shadows;
37023
+ scene.lensFlare = level3DSunHasFlare();
36874
37024
  return scene;
36875
37025
  }
36876
37026
 
@@ -39859,6 +40009,8 @@ function editor3DSceneUpdate(box)
39859
40009
  color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
39860
40010
  scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
39861
40011
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
40012
+ toggle('Lens flare', 'The sun flares in the lens when it is in view', !!value('lensFlare'), (on)=>
40013
+ change('lensFlare', on));
39862
40014
  }
39863
40015
 
39864
40016
  // the properties box: the position, rotation and scale of the one selected object and an input for each default
@@ -40418,6 +40570,7 @@ export
40418
40570
  tileCollisionTest,
40419
40571
  tileCollisionRaycast,
40420
40572
  tileLayersLoad,
40573
+ tileLayersFromLDtk,
40421
40574
  objectLayersAddType,
40422
40575
  objectLayersLoad,
40423
40576
  TileLayerData,
@@ -40476,10 +40629,6 @@ export
40476
40629
  LightSystemPlugin,
40477
40630
  Light,
40478
40631
 
40479
- // ZzFXMusic
40480
- ZzFXMusic,
40481
- zzfxM,
40482
-
40483
40632
  // Audio Effects
40484
40633
  AudioEffect,
40485
40634
  AudioFilter,