littlejsengine 1.23.1 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.23.1",
3
+ "version": "1.24.1",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -40,6 +40,7 @@
40
40
  "!plugins/box2d.wasm.*",
41
41
  "COPYRIGHT.txt",
42
42
  "FAQ.md",
43
+ "CHANGELOG.md",
43
44
  "REFERENCE.md",
44
45
  "EDITOR.md"
45
46
  ],
package/plugins/box2d.js CHANGED
@@ -414,7 +414,7 @@ class Box2dObject extends EngineObject
414
414
 
415
415
  const points = [];
416
416
  const radius = diameter/2;
417
- for (let i=sides; i--;)
417
+ for (let i=sides; i-- > 0;) // a count that is not a whole number still ends
418
418
  points.push(vec2(radius,0).rotate((i+.5)/sides*PI*2));
419
419
  return this.addPoly(points, density, friction, restitution, isSensor);
420
420
  }
@@ -44,10 +44,6 @@ export
44
44
  LightSystemPlugin,
45
45
  Light,
46
46
 
47
- // ZzFXMusic
48
- ZzFXMusic,
49
- zzfxM,
50
-
51
47
  // Audio Effects
52
48
  AudioEffect,
53
49
  AudioFilter,
@@ -915,8 +915,8 @@ class Render3DPlugin
915
915
  }
916
916
 
917
917
  /** Find the nearest object under a screen position or along a ray, for clicking on things
918
- * - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
919
- * not triangle by triangle
918
+ * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
919
+ * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
920
920
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
921
921
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
922
922
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -4283,8 +4283,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
4283
4283
  return collected;
4284
4284
  }
4285
4285
 
4286
- // 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
4287
- // its own space, or a sprite as the quad it draws, not triangle by triangle
4286
+ // 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,
4287
+ // and a sprite as the quad it draws
4288
4288
  function render3DRaycastObject(ray, o)
4289
4289
  {
4290
4290
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -4301,25 +4301,55 @@ function render3DRaycastObject(ray, o)
4301
4301
  const distance = raycastSphere(ray, center, radius);
4302
4302
  if (distance === undefined) return;
4303
4303
 
4304
- // 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
4305
- // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
4304
+ // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
4305
+ // against the triangles; the direction is not made unit length, so the distance holds in the world;
4306
4306
  // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
4307
4307
  if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
4308
4308
  const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
4309
4309
  const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
4310
4310
  const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
4311
- if (hit !== 0) return hit;
4311
+ if (hit === undefined || !ray.direction.lengthSquared())
4312
+ return hit; // a ray of no length is where it starts, inside the box
4313
+ return render3DRaycastMesh(local, mesh);
4314
+ }
4312
4315
 
4313
- // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
4314
- // inside comes first
4315
- let exit = Infinity;
4316
- for (const k of ['x', 'y', 'z'])
4317
- {
4318
- const d = local.direction[k];
4319
- if (d)
4320
- exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
4321
- }
4322
- return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
4316
+ // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
4317
+ // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
4318
+ // 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
4319
+ // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
4320
+ function render3DRaycastMesh(ray, mesh)
4321
+ {
4322
+ const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
4323
+ const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
4324
+ const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
4325
+ const count = indices ? indices.length / 3 | 0 : points.length - 2;
4326
+ let nearest;
4327
+ for (let i = 0; i < count; ++i)
4328
+ {
4329
+ // the corners clockwise from the front: an indexed list is written the other way round
4330
+ let a, b, c;
4331
+ if (indices)
4332
+ a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
4333
+ else
4334
+ a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
4335
+
4336
+ // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
4337
+ const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
4338
+ const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
4339
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
4340
+ const det = e1x * hx + e1y * hy + e1z * hz;
4341
+ if (both ? !det : !(det < 0)) continue;
4342
+ const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
4343
+ const u = (sx * hx + sy * hy + sz * hz) / det;
4344
+ if (!(u >= 0 && u <= 1)) continue;
4345
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
4346
+ const v = (dx * qx + dy * qy + dz * qz) / det;
4347
+ if (!(v >= 0 && u + v <= 1)) continue;
4348
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
4349
+ if (t >= 0 && !(t >= nearest))
4350
+ nearest = t;
4351
+ }
4352
+ return nearest;
4323
4353
  }
4324
4354
 
4325
4355
  // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
@@ -4697,10 +4727,12 @@ class InstancedMesh3D extends EngineObject3D
4697
4727
  * - radius is a world distance, so scale3D does not change it
4698
4728
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
4699
4729
  * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
4730
+ * - addFlare gives it a lens flare, see LensFlare3D
4700
4731
  * @extends EngineObject3D
4701
4732
  * @memberof Render3D
4702
4733
  * @example
4703
4734
  * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
4735
+ * torch.addFlare(); // light in the lens when the torch is in view
4704
4736
  */
4705
4737
  class Light3D extends EngineObject3D
4706
4738
  {
@@ -4735,6 +4767,50 @@ class Light3D extends EngineObject3D
4735
4767
  * bright core */
4736
4768
  this.glowFalloff = 1;
4737
4769
  this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
4770
+ /** @type {LensFlare3D|undefined} */
4771
+ this.flareObject = undefined;
4772
+ }
4773
+
4774
+ /** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had
4775
+ * @param {number} [size] - Scales every part of it
4776
+ * @param {number} [count] - How many ghosts there are, besides the glow at the light
4777
+ * @param {number} [intensity] - How bright it is
4778
+ * @param {number} [saturation] - How colorful the ghosts are
4779
+ * @param {Color} [color] - Tints the flare, with the light's own color
4780
+ * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
4781
+ addFlare(size, count, intensity, saturation, color)
4782
+ {
4783
+ const flare = new LensFlare3D(size, count, intensity, saturation, color);
4784
+ this.flare = flare;
4785
+ return flare;
4786
+ }
4787
+
4788
+ /** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and
4789
+ * destroying the flare takes it away
4790
+ * - The flare is the light's: it is attached to the light as its child, so it stays through a scene change
4791
+ * when the light does, and is destroyed with the light or when another takes its place
4792
+ * @type {LensFlare3D|undefined} */
4793
+ get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; }
4794
+
4795
+ set flare(flare)
4796
+ {
4797
+ const old = this.flare; // a flare that was destroyed on its own is no flare
4798
+ if (flare === old) return;
4799
+ old && old.destroy();
4800
+ if (flare)
4801
+ {
4802
+ flare.light = this;
4803
+ flare.parent || this.addChild(flare);
4804
+ }
4805
+ this.flareObject = flare || undefined;
4806
+ }
4807
+
4808
+ /** Destroy the light, and its flare with it
4809
+ * @param {boolean} [immediate] */
4810
+ destroy(immediate)
4811
+ {
4812
+ this.flare = undefined;
4813
+ super.destroy(immediate);
4738
4814
  }
4739
4815
 
4740
4816
  /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
@@ -354,7 +354,7 @@ function editor3DSceneState()
354
354
  const r = render3D;
355
355
  return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
356
356
  fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
357
- sunColor: r.sunColor.copy(), shadows: r.shadows};
357
+ sunColor: r.sunColor.copy(), shadows: r.shadows, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed};
358
358
  }
359
359
  function editor3DSceneRestore(state)
360
360
  {
@@ -369,6 +369,7 @@ function editor3DSceneRestore(state)
369
369
  r.sunDirection = state.sunDirection.copy();
370
370
  r.sunColor = state.sunColor.copy();
371
371
  r.shadows = state.shadows;
372
+ level3DSceneFlare(state.sunFlare); // a flare an earlier level's scene gave the sun, never the game's own
372
373
  }
373
374
 
374
375
  // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
@@ -390,6 +391,7 @@ function editor3DSceneFromView()
390
391
  scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
391
392
  scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
392
393
  scene.shadows = !!r.shadows;
394
+ scene.lensFlare = level3DSunHasFlare();
393
395
  return scene;
394
396
  }
395
397
 
@@ -547,9 +549,11 @@ function editor3DLevelLoaded(level)
547
549
  level.objects = editor3DCopy(saved.objects);
548
550
  for (const name of editor3DLevelPartNames)
549
551
  parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name];
552
+ saved.stale && console.warn(`LittleJS editor: the edits brought back to ${record.fileName} are older than ` +
553
+ 'the last ones, which storage had no room for');
550
554
  }
551
- else if (saved)
552
- record.pending = saved;
555
+ else if (saved && isArray(saved.objects))
556
+ record.pending = saved; // one that is broken is passed by, and the next autosave writes over it
553
557
  }
554
558
 
555
559
  // called by level3DLoad for each object, with what its type made
@@ -3378,6 +3382,8 @@ function editor3DSceneUpdate(box)
3378
3382
  color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
3379
3383
  scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
3380
3384
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
3385
+ toggle('Lens flare', 'The sun flares in the lens when it is in view', !!value('lensFlare'), (on)=>
3386
+ change('lensFlare', on));
3381
3387
  }
3382
3388
 
3383
3389
  // the properties box: the position, rotation and scale of the one selected object and an input for each default
@@ -1476,31 +1476,74 @@ class Trail3D extends EngineObject3D
1476
1476
  // lens flare
1477
1477
 
1478
1478
  // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
1479
- // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
1479
+ // glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star
1480
+ // of six rays; undefined headless or without a canvas
1481
+ const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star'];
1480
1482
  let render3DFlareTiles;
1481
1483
  function render3DFlareTile(shape)
1482
1484
  {
1483
1485
  if (!render3DFlareTiles)
1484
1486
  {
1485
1487
  if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
1486
- const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
1488
+ const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
1487
1489
  // each shape is how see-through it is from its middle out
1488
1490
  const alpha =
1489
1491
  {
1490
1492
  glow: (t)=> engineGlowAlpha(t, 1),
1491
1493
  disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
1492
1494
  ring: (t)=> max(0, 1 - abs(t - .86) / .12),
1495
+ hex: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
1496
+ streak: (t)=> (1 - t) ** 1.5,
1493
1497
  };
1494
- shapes.forEach((shape, i)=>
1498
+ // a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon
1499
+ const fill = (shape, r, path)=>
1495
1500
  {
1496
- const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
1497
- const gradient = context.createRadialGradient(x, y, 0, x, y, r);
1501
+ const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r);
1498
1502
  for (let k = 0; k <= steps; ++k)
1499
1503
  gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
1500
1504
  context.fillStyle = gradient;
1501
1505
  context.beginPath();
1502
- context.arc(x, y, r, 0, 2 * PI);
1506
+ path ? path() : context.arc(0, 0, r, 0, 2 * PI);
1503
1507
  context.fill();
1508
+ };
1509
+ const ray = (r, angle)=>
1510
+ {
1511
+ context.save();
1512
+ context.rotate(angle);
1513
+ context.scale(1, .07);
1514
+ fill('streak', r);
1515
+ context.restore();
1516
+ };
1517
+ shapes.forEach((shape, i)=>
1518
+ {
1519
+ const r = cell / 2 - 2;
1520
+ context.save();
1521
+ context.translate(i * cell + cell / 2, cell / 2);
1522
+ if (shape == 'hex')
1523
+ {
1524
+ // a gradient is round, so the hexagon is set pixel by pixel, by how far each is toward a side
1525
+ const image = context.createImageData(cell, cell), data = image.data, apothem = r * cos(PI / 6);
1526
+ for (let k = 0; k < cell * cell; ++k)
1527
+ {
1528
+ const x = abs(k % cell + .5 - cell / 2), y = abs((k / cell | 0) + .5 - cell / 2);
1529
+ const t = max(y, x * cos(PI / 6) + y / 2) / apothem;
1530
+ data[k * 4] = data[k * 4 + 1] = data[k * 4 + 2] = 255;
1531
+ data[k * 4 + 3] = t < 1 ? clamp(alpha.hex(t)) * 255 : 0;
1532
+ }
1533
+ context.putImageData(image, i * cell, 0);
1534
+ }
1535
+ else if (shape == 'streak')
1536
+ ray(r, 0);
1537
+ else if (shape == 'star')
1538
+ {
1539
+ for (let k = 3; k--;)
1540
+ ray(r, k * PI / 3 + PI / 2);
1541
+ context.scale(.3, .3);
1542
+ fill('glow', r);
1543
+ }
1544
+ else
1545
+ fill(shape, r);
1546
+ context.restore();
1504
1547
  });
1505
1548
  const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
1506
1549
  render3DFlareTiles = new Map(shapes.map((shape, i)=>
@@ -1509,16 +1552,32 @@ function render3DFlareTile(shape)
1509
1552
  return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
1510
1553
  }
1511
1554
 
1555
+ /**
1556
+ * A part of a lens flare
1557
+ * @typedef {Object} LensFlareElement
1558
+ * @property {number} at - Where along the line: 0 the sun, 1 the middle of the screen, 2 as far past it
1559
+ * @property {number|Vector2} size - How big across, as a part of the screen's height, a vector for a part wider
1560
+ * than it is tall
1561
+ * @property {Color} color - Its color, the alpha how bright
1562
+ * @property {string} [shape] - glow, disc, ring, hex, streak or star, a glow when left out
1563
+ * @property {TileInfo} [tileInfo] - A tile of the game's own to draw in place of a shape, best white on clear
1564
+ * @property {number} [angle] - How far it is turned, in radians
1565
+ * @memberof Render3D
1566
+ */
1567
+
1512
1568
  /**
1513
1569
  * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
1514
1570
  * sizes along the line from the sun through the middle of the screen
1515
1571
  * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
1516
1572
  * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
1573
+ * - A Light3D gets one of its own with light.addFlare()
1517
1574
  * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
1518
- * with the sun's own color; or give it elements of your own
1519
- * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
1575
+ * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
1576
+ * give it elements of your own, which may be tiles of the game's
1577
+ * - 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
1578
+ * sun is too far off the screen for the flare to show, a third of the screen past its edge, where nothing is tested
1520
1579
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
1521
- * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
1580
+ * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
1522
1581
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
1523
1582
  * @extends EngineObject3D
1524
1583
  * @memberof Render3D
@@ -1548,48 +1607,62 @@ class LensFlare3D extends EngineObject3D
1548
1607
  this.saturation = saturation;
1549
1608
  /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
1550
1609
  this.seed = 1;
1551
- /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
1552
- * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
1553
- * 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
1554
- * height; shape is glow, disc or ring
1555
- * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
1610
+ /** @property {Array<string>|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex,
1611
+ * streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often;
1612
+ * discs, rings and glows when not set
1613
+ * @type {Array<string>|undefined} */
1614
+ this.shapes = undefined;
1615
+ /** @property {number} - Scales the glow at the sun, 0 for none */
1616
+ this.glowSize = 1;
1617
+ /** @property {number} - Scales the ghosts */
1618
+ this.ghostSize = 1;
1619
+ /** @property {Array<LensFlareElement>|undefined} - The parts of the flare, to set your own in place of the
1620
+ * ones made from count, seed, saturation, shapes, glowSize and ghostSize
1621
+ * @type {Array<LensFlareElement>|undefined} */
1556
1622
  this.elements = undefined;
1557
1623
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
1558
1624
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
1559
- * in front of the light, and a spotlight's shows from inside its beam only
1625
+ * in front of the light but its lamp, a mesh around it no wider than half its radius, and a spotlight's
1626
+ * shows from inside its beam only; a DirectionalLight3D's is far
1627
+ * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
1628
+ * sets this
1560
1629
  * @type {Light3D|undefined} */
1561
1630
  this.light = undefined;
1562
1631
  /** @property {boolean} - Fade out when something is between the camera and the sun */
1563
1632
  this.occlusion = true;
1564
1633
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
1565
1634
  this.fadeTime = .15;
1566
- /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
1635
+ /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
1636
+ * plain view, eased */
1567
1637
  this.visible = 1;
1568
1638
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
1569
1639
  this.madeKey = '';
1570
- /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
1640
+ /** @type {Array<LensFlareElement>} */
1571
1641
  this.made = [];
1572
1642
  }
1573
1643
 
1574
- /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
1575
- * and a core at the sun and the ghosts, made again when one of those changes
1576
- * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
1644
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
1645
+ * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
1646
+ * @return {Array<LensFlareElement>} */
1577
1647
  getElements()
1578
1648
  {
1579
1649
  if (this.elements) return this.elements;
1580
- const key = [this.count, this.seed, this.saturation].join();
1650
+ const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
1651
+ const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
1581
1652
  if (key !== this.madeKey)
1582
1653
  {
1583
1654
  const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
1584
- const made = this.made = [
1585
- {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
1586
- {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
1655
+ /** @type {Array<LensFlareElement>} */
1656
+ const made = this.made = glow > 0 ? [
1657
+ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
1658
+ {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
1587
1659
  for (let i = 0; i < this.count; ++i)
1588
1660
  {
1589
1661
  // spread along the line, each a place of its own, the far ones bigger
1590
1662
  const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
1591
- const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
1592
- made.push({at, size: random.float(.04, .1) * (1 + at), shape,
1663
+ const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
1664
+ pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
1665
+ made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
1593
1666
  color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
1594
1667
  }
1595
1668
  this.madeKey = key;
@@ -1597,69 +1670,85 @@ class LensFlare3D extends EngineObject3D
1597
1670
  return this.made;
1598
1671
  }
1599
1672
 
1600
- // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
1601
- // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
1673
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
1674
+ // directional light, which shines from its place toward the origin, and a point to find it on the screen by;
1675
+ // undefined with no direction, or a light that is gone or at the camera
1602
1676
  flareSource()
1603
1677
  {
1604
1678
  const camera = render3D.camera.pos, light = this.light;
1605
- if (!light)
1679
+ if (light?.destroyed) return;
1680
+ if (!light || light.directional)
1606
1681
  {
1607
- const sun = render3D.sunDirection;
1608
- if (!sun.lengthSquared()) return;
1609
- const direction = sun.normalize();
1682
+ const from = light ? light.getWorldPos3D() : render3D.sunDirection;
1683
+ if (!from.lengthSquared()) return;
1684
+ const direction = from.normalize();
1610
1685
  return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
1611
1686
  }
1612
- if (light.destroyed) return;
1613
1687
  const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
1614
1688
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
1615
1689
  }
1616
1690
 
1617
- /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
1618
- * behind the camera
1619
- * @return {Vector2|undefined} */
1620
- getSunScreenPos()
1691
+ // how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
1692
+ // fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
1693
+ // show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
1694
+ flareLook()
1621
1695
  {
1622
- const source = this.flareSource();
1623
- return source && render3D.worldToScreen(source.pos);
1624
- }
1625
-
1626
- /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
1627
- * color, dimmed by how much of the sun shows; empty when there is nothing to draw
1628
- * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
1629
- getScreenElements()
1630
- {
1631
- const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
1632
- if (!sun || !center.x || !center.y) return [];
1696
+ const source = this.flareSource(), center = mainCanvasSize.scale(.5);
1697
+ const sun = source && render3D.worldToScreen(source.pos);
1698
+ if (!sun || !center.x || !center.y) return;
1633
1699
  // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
1634
1700
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
1635
- const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
1636
- if (!(strength > 0)) return [];
1701
+ const strength = clamp((1.3 - off) / .5) * this.intensity;
1702
+ if (!(strength > 0)) return;
1637
1703
  let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
1638
- const light = this.light, source = light && this.flareSource();
1704
+ const light = this.light;
1639
1705
  if (light)
1640
1706
  {
1641
- if (!source) return [];
1642
1707
  // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
1643
1708
  const cone = render3DLightCone(light), d = source.direction;
1644
1709
  const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
1645
1710
  const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
1646
- if (!(bright > 0)) return [];
1711
+ if (!(bright > 0)) return;
1647
1712
  tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
1648
- height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
1713
+ if (!light.directional)
1714
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
1649
1715
  }
1716
+ return height > 0 ? {sun, center, strength, tint, height} : undefined;
1717
+ }
1718
+
1719
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
1720
+ * behind the camera
1721
+ * @return {Vector2|undefined} */
1722
+ getScreenPos()
1723
+ {
1724
+ const source = this.flareSource();
1725
+ return source && render3D.worldToScreen(source.pos);
1726
+ }
1727
+
1728
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector
1729
+ * when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw
1730
+ * @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined,
1731
+ * tileInfo: TileInfo|undefined, angle: number}>} */
1732
+ getScreenElements()
1733
+ {
1734
+ const look = this.visible > 0 && this.flareLook();
1735
+ if (!look) return [];
1736
+ const {sun, center, tint, height} = look, strength = look.strength * this.visible;
1650
1737
  return this.getElements().map((e)=>
1651
1738
  {
1652
1739
  const c = e.color.multiply(tint);
1653
1740
  // along the line and past the middle, a lerp would stop there
1654
- return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
1655
- color: rgb(c.r, c.g, c.b, c.a * strength)};
1741
+ const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
1742
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
1743
+ angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
1656
1744
  });
1657
1745
  }
1658
1746
 
1659
1747
  /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
1660
- * through
1748
+ * through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless
1749
+ * it is doubleSided
1661
1750
  * @return {boolean} */
1662
- isSunHidden()
1751
+ isHidden()
1663
1752
  {
1664
1753
  const source = this.flareSource();
1665
1754
  if (!source) return true;
@@ -1673,22 +1762,31 @@ class LensFlare3D extends EngineObject3D
1673
1762
  for (const map of maps)
1674
1763
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
1675
1764
  return true;
1676
- // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
1677
- // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
1678
- // one the light is inside, its lamp
1679
- const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
1680
- const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
1681
- const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
1682
- const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
1683
- const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
1765
+ // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
1766
+ // is in, and no wider than half the light's reach, since a room or a whole level has a box around its lights
1767
+ // too, and its walls do hide them from outside
1768
+ const lamp = (o)=>
1769
+ {
1770
+ if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
1771
+ const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds();
1772
+ const side = max(b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z) * render3DMaxStretch(matrix.m);
1773
+ if (!matrix.determinant() || side > this.light.radius / 2) return false;
1774
+ const p = matrix.copy().invert().transformPoint(source.pos);
1775
+ return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
1776
+ p.z >= b.min.z && p.z <= b.max.z;
1777
+ };
1778
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
1684
1779
  return !!hit && hit.distance < reach;
1685
1780
  }
1686
1781
 
1687
- /** Ease visible toward whether the sun shows, called automatically each frame */
1782
+ /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
1783
+ * wherever the sun is, off the screen or its light off, looks for nothing in the way */
1688
1784
  update()
1689
1785
  {
1690
1786
  super.update();
1691
- const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
1787
+ if (this.light?.destroyed)
1788
+ return this.destroy(); // the flare of a light that is gone
1789
+ const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
1692
1790
  const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
1693
1791
  this.visible = clamp(this.visible + (shows ? step : -step));
1694
1792
  }
@@ -1702,8 +1800,9 @@ class LensFlare3D extends EngineObject3D
1702
1800
  setAdditiveBlendMode(true);
1703
1801
  for (const e of elements)
1704
1802
  {
1705
- const tileInfo = render3DFlareTile(e.shape);
1706
- tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
1803
+ const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
1804
+ const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
1805
+ tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
1707
1806
  }
1708
1807
  setAdditiveBlendMode(false);
1709
1808
  }