littlejsengine 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 off the screen, 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,61 @@ 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, and a spotlight's shows from inside its beam only; a DirectionalLight3D's is far
1626
+ * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
1627
+ * sets this
1560
1628
  * @type {Light3D|undefined} */
1561
1629
  this.light = undefined;
1562
1630
  /** @property {boolean} - Fade out when something is between the camera and the sun */
1563
1631
  this.occlusion = true;
1564
1632
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
1565
1633
  this.fadeTime = .15;
1566
- /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
1634
+ /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
1635
+ * plain view, eased */
1567
1636
  this.visible = 1;
1568
1637
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
1569
1638
  this.madeKey = '';
1570
- /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
1639
+ /** @type {Array<LensFlareElement>} */
1571
1640
  this.made = [];
1572
1641
  }
1573
1642
 
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}>} */
1643
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes,
1644
+ * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes
1645
+ * @return {Array<LensFlareElement>} */
1577
1646
  getElements()
1578
1647
  {
1579
1648
  if (this.elements) return this.elements;
1580
- const key = [this.count, this.seed, this.saturation].join();
1649
+ const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize;
1650
+ const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join();
1581
1651
  if (key !== this.madeKey)
1582
1652
  {
1583
1653
  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'}];
1654
+ /** @type {Array<LensFlareElement>} */
1655
+ const made = this.made = glow > 0 ? [
1656
+ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'},
1657
+ {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : [];
1587
1658
  for (let i = 0; i < this.count; ++i)
1588
1659
  {
1589
1660
  // spread along the line, each a place of its own, the far ones bigger
1590
1661
  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,
1662
+ const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] :
1663
+ pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
1664
+ made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape,
1593
1665
  color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
1594
1666
  }
1595
1667
  this.madeKey = key;
@@ -1597,69 +1669,85 @@ class LensFlare3D extends EngineObject3D
1597
1669
  return this.made;
1598
1670
  }
1599
1671
 
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
1672
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
1673
+ // directional light, which shines from its place toward the origin, and a point to find it on the screen by;
1674
+ // undefined with no direction, or a light that is gone or at the camera
1602
1675
  flareSource()
1603
1676
  {
1604
1677
  const camera = render3D.camera.pos, light = this.light;
1605
- if (!light)
1678
+ if (light?.destroyed) return;
1679
+ if (!light || light.directional)
1606
1680
  {
1607
- const sun = render3D.sunDirection;
1608
- if (!sun.lengthSquared()) return;
1609
- const direction = sun.normalize();
1681
+ const from = light ? light.getWorldPos3D() : render3D.sunDirection;
1682
+ if (!from.lengthSquared()) return;
1683
+ const direction = from.normalize();
1610
1684
  return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
1611
1685
  }
1612
- if (light.destroyed) return;
1613
1686
  const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
1614
1687
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
1615
1688
  }
1616
1689
 
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()
1690
+ // how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
1691
+ // fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
1692
+ // show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
1693
+ flareLook()
1621
1694
  {
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 [];
1695
+ const source = this.flareSource(), center = mainCanvasSize.scale(.5);
1696
+ const sun = source && render3D.worldToScreen(source.pos);
1697
+ if (!sun || !center.x || !center.y) return;
1633
1698
  // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
1634
1699
  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 [];
1700
+ const strength = clamp((1.3 - off) / .5) * this.intensity;
1701
+ if (!(strength > 0)) return;
1637
1702
  let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
1638
- const light = this.light, source = light && this.flareSource();
1703
+ const light = this.light;
1639
1704
  if (light)
1640
1705
  {
1641
- if (!source) return [];
1642
1706
  // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
1643
1707
  const cone = render3DLightCone(light), d = source.direction;
1644
1708
  const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
1645
1709
  const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
1646
- if (!(bright > 0)) return [];
1710
+ if (!(bright > 0)) return;
1647
1711
  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
1712
+ if (!light.directional)
1713
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
1649
1714
  }
1715
+ return height > 0 ? {sun, center, strength, tint, height} : undefined;
1716
+ }
1717
+
1718
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
1719
+ * behind the camera
1720
+ * @return {Vector2|undefined} */
1721
+ getScreenPos()
1722
+ {
1723
+ const source = this.flareSource();
1724
+ return source && render3D.worldToScreen(source.pos);
1725
+ }
1726
+
1727
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector
1728
+ * when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw
1729
+ * @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined,
1730
+ * tileInfo: TileInfo|undefined, angle: number}>} */
1731
+ getScreenElements()
1732
+ {
1733
+ const look = this.visible > 0 && this.flareLook();
1734
+ if (!look) return [];
1735
+ const {sun, center, tint, height} = look, strength = look.strength * this.visible;
1650
1736
  return this.getElements().map((e)=>
1651
1737
  {
1652
1738
  const c = e.color.multiply(tint);
1653
1739
  // 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)};
1740
+ const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height);
1741
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo,
1742
+ angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)};
1656
1743
  });
1657
1744
  }
1658
1745
 
1659
1746
  /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
1660
- * through
1747
+ * through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless
1748
+ * it is doubleSided
1661
1749
  * @return {boolean} */
1662
- isSunHidden()
1750
+ isHidden()
1663
1751
  {
1664
1752
  const source = this.flareSource();
1665
1753
  if (!source) return true;
@@ -1673,22 +1761,29 @@ class LensFlare3D extends EngineObject3D
1673
1761
  for (const map of maps)
1674
1762
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
1675
1763
  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)));
1764
+ // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
1765
+ // is in
1766
+ const lamp = (o)=>
1767
+ {
1768
+ if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
1769
+ const matrix = render3DObjectMatrix(o);
1770
+ if (!matrix.determinant()) return false;
1771
+ const p = matrix.copy().invert().transformPoint(source.pos), b = o.mesh.bounds || o.mesh.getBounds();
1772
+ return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
1773
+ p.z >= b.min.z && p.z <= b.max.z;
1774
+ };
1775
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
1684
1776
  return !!hit && hit.distance < reach;
1685
1777
  }
1686
1778
 
1687
- /** Ease visible toward whether the sun shows, called automatically each frame */
1779
+ /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
1780
+ * wherever the sun is, off the screen or its light off, looks for nothing in the way */
1688
1781
  update()
1689
1782
  {
1690
1783
  super.update();
1691
- const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
1784
+ if (this.light?.destroyed)
1785
+ return this.destroy(); // the flare of a light that is gone
1786
+ const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
1692
1787
  const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
1693
1788
  this.visible = clamp(this.visible + (shows ? step : -step));
1694
1789
  }
@@ -1702,8 +1797,9 @@ class LensFlare3D extends EngineObject3D
1702
1797
  setAdditiveBlendMode(true);
1703
1798
  for (const e of elements)
1704
1799
  {
1705
- const tileInfo = render3DFlareTile(e.shape);
1706
- tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
1800
+ const tileInfo = e.tileInfo || render3DFlareTile(e.shape);
1801
+ const size = typeof e.size == 'number' ? vec2(e.size) : e.size;
1802
+ tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true);
1707
1803
  }
1708
1804
  setAdditiveBlendMode(false);
1709
1805
  }
@@ -84,8 +84,9 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
84
84
  * are added before its objects are made, one the game added itself keeps its place
85
85
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
86
86
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
87
- * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
88
- * leaves out stays as the game set it, and a level with no block changes nothing
87
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
88
+ * sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
89
+ * nothing
89
90
  * @param {Object} level - The level, the level editor edits this same object
90
91
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
91
92
  * @memberof Level3D */
@@ -116,7 +117,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
116
117
 
117
118
  /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
118
119
  * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
119
- * a sheet is given here; a block's type shows that tile of the sheet on every face
120
+ * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
121
+ * image gets plain blocks, a color for each type
120
122
  * - setup is called with each map a level makes, to give block types their own faces or make them see-through
121
123
  * - Call it before level3DLoad; with no arguments the defaults are back
122
124
  * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
@@ -178,7 +180,7 @@ function level3DVoxelsMake(voxels)
178
180
  {
179
181
  const shape = level3DVoxelsShape(voxels);
180
182
  if (!shape || typeof VoxelMap == 'undefined') return;
181
- const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
183
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
182
184
  level3DVoxelsDecode(voxels.blocks, map.data);
183
185
  level3DVoxelSetupMap?.(map);
184
186
  map.rebuild();
@@ -240,6 +242,31 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
240
242
  map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
241
243
  }
242
244
 
245
+ // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
246
+ // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
247
+ // block map in a game with no art can still be seen and painted
248
+ let level3DVoxelPlain;
249
+ function level3DVoxelPlainTiles()
250
+ {
251
+ if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
252
+ return tile();
253
+ if (!level3DVoxelPlain)
254
+ {
255
+ // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
256
+ const context = createCanvasContext(256);
257
+ for (let i = 0; i < 256; ++i)
258
+ {
259
+ const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
260
+ context.fillStyle = hsl(hue, .45, .4).toString();
261
+ context.fillRect(x, y, 16, 16);
262
+ context.fillStyle = hsl(hue, .5, .55).toString();
263
+ context.fillRect(x + 1, y + 1, 14, 14);
264
+ }
265
+ level3DVoxelPlain = new TextureInfo(context.canvas);
266
+ }
267
+ return tile(0, 16, level3DVoxelPlain, 0);
268
+ }
269
+
243
270
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
244
271
  function level3DTerrainMake(terrain)
245
272
  {
@@ -284,7 +311,24 @@ function level3DSceneApply(scene)
284
311
  r.fogColor = color(scene.fogColor) || r.fogColor;
285
312
  if (typeof scene.shadows === 'boolean')
286
313
  r.shadows = scene.shadows;
314
+ if (typeof scene.lensFlare === 'boolean')
315
+ level3DSceneFlare(scene.lensFlare);
316
+ }
317
+
318
+ // 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
319
+ // earlier level's, and off takes away the one a scene made, never the game's
320
+ let level3DSunFlare;
321
+ function level3DSceneFlare(on)
322
+ {
323
+ if (!on)
324
+ {
325
+ level3DSunFlare && !level3DSunFlare.destroyed && level3DSunFlare.destroy();
326
+ level3DSunFlare = undefined;
327
+ }
328
+ else if (!level3DSunHasFlare())
329
+ level3DSunFlare = new LensFlare3D;
287
330
  }
331
+ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light);
288
332
 
289
333
  // a vec3 of an array of three numbers, as the file has them, or the fallback
290
334
  function level3DVector(value, fallback)
@@ -634,7 +678,8 @@ level3DAddType('Cylinder', function(pos, properties)
634
678
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
635
679
  {color: WHITE, tile: -1, solid: true});
636
680
  // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
637
- // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
681
+ // object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and
682
+ // lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare
638
683
  level3DAddType('Light', function(pos, properties)
639
684
  {
640
685
  const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
@@ -642,5 +687,6 @@ level3DAddType('Light', function(pos, properties)
642
687
  light.coneSoftness = properties.softness;
643
688
  if (properties.shadows && render3D)
644
689
  render3D.shadowLight = light;
690
+ properties.lensFlare && light.addFlare();
645
691
  return light;
646
- }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
692
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false, lensFlare: false});
@@ -303,6 +303,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
303
303
  for (const group of parseAtlas(data))
304
304
  {
305
305
  // reserve a block of full size cells, one per frame
306
+ if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
306
307
  const sourceSize = group.frames[0].sourceSize;
307
308
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
308
309
  const added = textureSheetAdd(blockSize, sourceSize, padding);
@@ -593,45 +593,50 @@ function tweenUpdate(gameDelta, realDelta)
593
593
  const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
594
594
  for (const t of tweenActive)
595
595
  list.push(t);
596
- for (let i = list.length; i--;)
596
+ // the list is let go however the walk ends: a callback that throws must not leave it held, or every
597
+ // update after would take it for an update still going and make a list of its own
598
+ try
597
599
  {
598
- const t = list[i];
599
- // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
600
- // counts on from this one
601
- if (!t.active || t.activePass >= pass) continue;
602
- let dt;
603
- if (enginePath)
600
+ for (let i = list.length; i--;)
604
601
  {
605
- // a paused tween keeps count too, so it does not jump when resumed
606
- dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
607
- t.lastTime = time;
608
- t.lastTimeReal = timeReal;
609
- }
610
- else
611
- dt = t.useRealTime ? realDelta : gameDelta;
612
- if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
613
- if (t.paused || dt <= 0) continue;
602
+ const t = list[i];
603
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
604
+ // counts on from this one
605
+ if (!t.active || t.activePass >= pass) continue;
606
+ let dt;
607
+ if (enginePath)
608
+ {
609
+ // a paused tween keeps count too, so it does not jump when resumed
610
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
611
+ t.lastTime = time;
612
+ t.lastTimeReal = timeReal;
613
+ }
614
+ else
615
+ dt = t.useRealTime ? realDelta : gameDelta;
616
+ if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
617
+ if (t.paused || dt <= 0) continue;
614
618
 
615
- t.life -= dt;
616
- if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
617
- {
618
- t.callback(t.interp(t.life));
619
- }
620
- else
621
- {
622
- // Completion: fire end value, remove from active, start the next iteration
623
- // of a loop or pingPong, or when there is none it has completed, fire onComplete
624
- t.callback(t.interp(0));
625
- if (!t.active || t.activePass >= pass)
626
- continue; // stopped or restarted by its own callback, the run it was on ends without completing
627
- tweenDeactivate(t);
628
- const next = t.thenCallback;
629
- t.thenCallback = undefined;
630
- if (!(next && next()) && t.onComplete)
631
- t.onComplete();
619
+ t.life -= dt;
620
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
621
+ {
622
+ t.callback(t.interp(t.life));
623
+ }
624
+ else
625
+ {
626
+ // Completion: fire end value, remove from active, start the next iteration
627
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
628
+ t.callback(t.interp(0));
629
+ if (!t.active || t.activePass >= pass)
630
+ continue; // stopped or restarted by its own callback, the run it was on ends without completing
631
+ tweenDeactivate(t);
632
+ const next = t.thenCallback;
633
+ t.thenCallback = undefined;
634
+ if (!(next && next()) && t.onComplete)
635
+ t.onComplete();
636
+ }
632
637
  }
633
638
  }
634
- list.length = 0;
639
+ finally { list.length = 0; }
635
640
  }
636
641
 
637
642
  /** Stop every active tween, ending loops too, without calling their then-callbacks.
@@ -615,8 +615,7 @@ class UISystemPlugin
615
615
  }
616
616
  }
617
617
 
618
- /** Destroy and remove all objects
619
- * @memberof UISystem */
618
+ /** Destroy and remove all objects */
620
619
  destroyObjects()
621
620
  {
622
621
  for (const o of this.uiObjects)
@@ -1137,8 +1136,10 @@ class UIObject
1137
1136
  return void inputClearKey(0, 0, false, true, false);
1138
1137
  }
1139
1138
  }
1139
+ // the object that was the active one going into this update: one pressed and let go inside a
1140
+ // frame is clicked on the next, with its release, not on both
1140
1141
  if (!uiSystem.activateOnPress)
1141
- if (!mouseDown && this.isActiveObject() && this.interactive)
1142
+ if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
1142
1143
  this.click();
1143
1144
  if (this.destroyed) return;
1144
1145
  }
@@ -1881,7 +1882,10 @@ class UIVideo extends UIObject
1881
1882
  if (this.destroyed)
1882
1883
  return;
1883
1884
 
1885
+ // let go of the media too, a paused video keeps what it has loaded
1884
1886
  this.video.pause();
1887
+ this.video.removeAttribute?.('src');
1888
+ this.video.load?.();
1885
1889
  this.video.remove();
1886
1890
  super.destroy();
1887
1891
  }
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.23.0';
35
+ const engineVersion = '1.24.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}