littlejsengine 1.21.0 → 1.22.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.
@@ -908,7 +908,7 @@ const render3DParticlesCalling = new Set;
908
908
  * @return {Particle3D} */
909
909
  function render3DParticleView(emitter)
910
910
  {
911
- return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, destroyed: false,
911
+ return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, scale: 1, destroyed: false,
912
912
  destroy() { this.destroyed = true; }};
913
913
  }
914
914
 
@@ -921,6 +921,8 @@ function render3DParticleView(emitter)
921
921
  * @property {Vector3} velocity - How far it moves each frame
922
922
  * @property {number} age - Seconds it has lived
923
923
  * @property {number} lifeTime - Seconds it lives
924
+ * @property {number} scale - How much the emitter grows its effect, from its scale3D and its parents', as the 2D
925
+ * particle's scale
924
926
  * @property {boolean} destroyed - Set by destroy
925
927
  * @property {function(): void} destroy - End it this update, the destroy callback gets it
926
928
  * @memberof Render3D
@@ -950,8 +952,9 @@ function render3DParticleView(emitter)
950
952
  * - emitConeAngle spreads them, PI sprays in every direction
951
953
  * - Speeds are per frame and sizes are world units, the same as the 2D emitter
952
954
  * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
953
- * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
954
- * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
955
+ * - gravity here is its own number added to velocity y each frame, so an effect keeps its own fall wherever it is
956
+ * used, the same as the 2D emitter's gravity; gravityScale adds a share of render3D.gravity on top, as the 2D
957
+ * emitter's gravityScale adds the engine's gravity
955
958
  * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
956
959
  * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
957
960
  * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
@@ -981,8 +984,8 @@ class ParticleEmitter3D extends EngineObject3D
981
984
  * @param {number} [sizeEnd] - Particle size at end of life
982
985
  * @param {number} [speed] - Spawn speed in world units per frame
983
986
  * @param {number} [damping] - Per frame velocity multiplier, 1 is none
984
- * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
985
- * not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
987
+ * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number, and
988
+ * gravityScale adds a share of render3D.gravity on top
986
989
  * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
987
990
  * @param {number} [randomness] - Extra randomness applied to speed, size and life
988
991
  * @param {boolean} [additive] - Additive blending */
@@ -997,6 +1000,9 @@ class ParticleEmitter3D extends EngineObject3D
997
1000
 
998
1001
  /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
999
1002
  this.emitSize = emitSize;
1003
+ /** @property {boolean} - Flatten the spawn area across the way it emits, its own up: a sphere becomes a disc
1004
+ * and a box a flat rectangle, for rain from a sheet of sky or flames from a patch of ground */
1005
+ this.emitFlat = false;
1000
1006
  /** @property {number} - How long to keep emitting, 0 is forever */
1001
1007
  this.emitTime = emitTime;
1002
1008
  /** @property {number} - Particles per second, 0 does not emit */
@@ -1023,6 +1029,9 @@ class ParticleEmitter3D extends EngineObject3D
1023
1029
  this.damping = damping;
1024
1030
  /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
1025
1031
  this.gravity = gravity;
1032
+ /** @property {number} - Share of render3D.gravity added each frame on top of its own gravity, 0 by default so
1033
+ * an effect keeps its own fall wherever it is used; 1 falls with the world, like debris */
1034
+ this.gravityScale = 0;
1026
1035
  /** @property {number} - Fraction of life spent fading, half in and half out */
1027
1036
  this.fadeRate = fadeRate;
1028
1037
  /** @property {number} - Extra randomness applied to speed, size and life */
@@ -1078,6 +1087,7 @@ class ParticleEmitter3D extends EngineObject3D
1078
1087
  const matrix = render3DObjectMatrix(this); // the object's own, read only
1079
1088
  this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
1080
1089
  const scale = render3DMaxScale(matrix.m);
1090
+ this.particleView.scale = scale; // the callbacks read it from the view instead of working it out each time
1081
1091
 
1082
1092
  // emit until the emit time is up, then wait for the last particle and go away
1083
1093
  if (!this.emitTime || this.getAliveTime() <= this.emitTime)
@@ -1110,7 +1120,11 @@ class ParticleEmitter3D extends EngineObject3D
1110
1120
  const F = RENDER3D_PARTICLE_FLOATS;
1111
1121
  let data = this.particleData, trail = this.trailData;
1112
1122
  const reread = ()=> { data = this.particleData, trail = this.trailData; };
1113
- const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
1123
+ // its own fall and a share of the world's, grown by the scale: a bigger effect has to fall faster to keep the
1124
+ // same arc
1125
+ const damping = this.damping, angleDamping = this.angleDamping, g = render3D.gravity, share = this.gravityScale;
1126
+ const gravityX = g.x * share * scale, gravityY = (this.gravity + g.y * share) * scale;
1127
+ const gravityZ = g.z * share * scale;
1114
1128
  const collideLevel = this.collideLevel && render3DLevel.length;
1115
1129
  const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
1116
1130
  for (let i = this.particleCount; i--;)
@@ -1118,7 +1132,8 @@ class ParticleEmitter3D extends EngineObject3D
1118
1132
  // damped first and gravity added after, the order the 2D particle uses, so the same
1119
1133
  // damping and gravity give the same arc in both
1120
1134
  const k = i * F;
1121
- const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
1135
+ const vx = data[k+3] = data[k+3] * damping + gravityX, vy = data[k+4] = data[k+4] * damping + gravityY;
1136
+ const vz = data[k+5] = data[k+5] * damping + gravityZ;
1122
1137
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
1123
1138
  data[k+18] += data[k+19] *= angleDamping;
1124
1139
  collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
@@ -1166,10 +1181,18 @@ class ParticleEmitter3D extends EngineObject3D
1166
1181
  // the whole effect grows with the emitter, not just the area the particles start in
1167
1182
  const scale = render3DMaxScale(matrix.m);
1168
1183
 
1169
- // spawn offset: inside a box or a sphere
1170
- const size = this.emitSize, box = /** @type {Vector3} */ (size);
1171
- const offset = isVector3(size) ? vec3(rand(-.5, .5) * box.x, rand(-.5, .5) * box.y, rand(-.5, .5) * box.z)
1172
- : randInSphere(/** @type {number} */ (size) / 2);
1184
+ // spawn offset: inside a box or a sphere, or flat across the emitter's up, a flat rectangle or a disc
1185
+ const size = this.emitSize, box = /** @type {Vector3} */ (size), flat = this.emitFlat;
1186
+ let offset;
1187
+ if (isVector3(size))
1188
+ offset = vec3(rand(-.5, .5) * box.x, flat ? 0 : rand(-.5, .5) * box.y, rand(-.5, .5) * box.z);
1189
+ else if (flat)
1190
+ {
1191
+ const disc = randInCircle(/** @type {number} */ (size) / 2);
1192
+ offset = vec3(disc.x, 0, disc.y);
1193
+ }
1194
+ else
1195
+ offset = randInSphere(/** @type {number} */ (size) / 2);
1173
1196
 
1174
1197
  // direction inside the cone around local +Y
1175
1198
  const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
@@ -1439,6 +1462,195 @@ class Trail3D extends EngineObject3D
1439
1462
  }
1440
1463
  }
1441
1464
 
1465
+ ///////////////////////////////////////////////////////////////////////////////
1466
+ // lens flare
1467
+
1468
+ // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
1469
+ // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
1470
+ let render3DFlareTiles;
1471
+ function render3DFlareTile(shape)
1472
+ {
1473
+ if (!render3DFlareTiles)
1474
+ {
1475
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
1476
+ const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
1477
+ // each shape is how see-through it is from its middle out
1478
+ const alpha =
1479
+ {
1480
+ glow: (t)=> engineGlowAlpha(t, 1),
1481
+ disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
1482
+ ring: (t)=> max(0, 1 - abs(t - .86) / .12),
1483
+ };
1484
+ shapes.forEach((shape, i)=>
1485
+ {
1486
+ const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
1487
+ const gradient = context.createRadialGradient(x, y, 0, x, y, r);
1488
+ for (let k = 0; k <= steps; ++k)
1489
+ gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
1490
+ context.fillStyle = gradient;
1491
+ context.beginPath();
1492
+ context.arc(x, y, r, 0, 2 * PI);
1493
+ context.fill();
1494
+ });
1495
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
1496
+ render3DFlareTiles = new Map(shapes.map((shape, i)=>
1497
+ [shape, new TileInfo(vec2(i * cell, 0), vec2(cell), texture)]));
1498
+ }
1499
+ return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
1500
+ }
1501
+
1502
+ /**
1503
+ * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
1504
+ * sizes along the line from the sun through the middle of the screen
1505
+ * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
1506
+ * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
1507
+ * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
1508
+ * with the sun's own color; or give it elements of your own
1509
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
1510
+ * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
1511
+ * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
1512
+ * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
1513
+ * @extends EngineObject3D
1514
+ * @memberof Render3D
1515
+ * @example
1516
+ * new LensFlare3D; // the sun flares
1517
+ * new LensFlare3D(1.5, 10, .7, 0); // bigger, 10 ghosts, dimmer, all one color
1518
+ */
1519
+ class LensFlare3D extends EngineObject3D
1520
+ {
1521
+ /** Create the sun's lens flare
1522
+ * @param {number} [size] - Scales every part of it, 1 by default
1523
+ * @param {number} [count] - How many ghosts there are along the line, besides the glow at the sun
1524
+ * @param {number} [intensity] - How bright it is
1525
+ * @param {number} [saturation] - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow
1526
+ * @param {Color} [color] - Tints the whole flare, with the sun's color */
1527
+ constructor(size=1, count=7, intensity=1, saturation=1, color=WHITE)
1528
+ {
1529
+ super(vec3(), undefined, undefined, color);
1530
+ this.size3D = vec3(); // not a thing to pick or collect
1531
+ /** @property {number} - Scales every part of the flare */
1532
+ this.flareSize = size;
1533
+ /** @property {number} - How many ghosts there are along the line, besides the glow at the sun */
1534
+ this.count = count;
1535
+ /** @property {number} - How bright it is */
1536
+ this.intensity = intensity;
1537
+ /** @property {number} - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow */
1538
+ this.saturation = saturation;
1539
+ /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
1540
+ this.seed = 1;
1541
+ /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
1542
+ * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
1543
+ * 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
1544
+ * height; shape is glow, disc or ring
1545
+ * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
1546
+ this.elements = undefined;
1547
+ /** @property {boolean} - Fade out when something is between the camera and the sun */
1548
+ this.occlusion = true;
1549
+ /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
1550
+ this.fadeTime = .15;
1551
+ /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
1552
+ this.visible = 1;
1553
+ this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
1554
+ this.madeKey = '';
1555
+ /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
1556
+ this.made = [];
1557
+ }
1558
+
1559
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
1560
+ * and a core at the sun and the ghosts, made again when one of those changes
1561
+ * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
1562
+ getElements()
1563
+ {
1564
+ if (this.elements) return this.elements;
1565
+ const key = [this.count, this.seed, this.saturation].join();
1566
+ if (key !== this.madeKey)
1567
+ {
1568
+ const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
1569
+ const made = this.made = [
1570
+ {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
1571
+ {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
1572
+ for (let i = 0; i < this.count; ++i)
1573
+ {
1574
+ // spread along the line, each a place of its own, the far ones bigger
1575
+ const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
1576
+ const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
1577
+ made.push({at, size: random.float(.04, .1) * (1 + at), shape,
1578
+ color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
1579
+ }
1580
+ this.madeKey = key;
1581
+ }
1582
+ return this.made;
1583
+ }
1584
+
1585
+ /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
1586
+ * @return {Vector2|undefined} */
1587
+ getSunScreenPos()
1588
+ {
1589
+ const direction = render3D.sunDirection;
1590
+ if (!direction.lengthSquared()) return;
1591
+ return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
1592
+ }
1593
+
1594
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
1595
+ * color, dimmed by how much of the sun shows; empty when there is nothing to draw
1596
+ * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
1597
+ getScreenElements()
1598
+ {
1599
+ const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
1600
+ if (!sun || !center.x || !center.y) return [];
1601
+ // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
1602
+ const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
1603
+ const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
1604
+ if (!(strength > 0)) return [];
1605
+ const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
1606
+ return this.getElements().map((e)=>
1607
+ {
1608
+ const c = e.color.multiply(tint);
1609
+ // along the line and past the middle, a lerp would stop there
1610
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
1611
+ color: rgb(c.r, c.g, c.b, c.a * strength)};
1612
+ });
1613
+ }
1614
+
1615
+ /** Is something between the camera and the sun: the level, or an object that is not see through
1616
+ * @return {boolean} */
1617
+ isSunHidden()
1618
+ {
1619
+ const direction = render3D.sunDirection;
1620
+ if (!direction.lengthSquared()) return true;
1621
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
1622
+ !o.additive);
1623
+ return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
1624
+ }
1625
+
1626
+ /** Ease visible toward whether the sun shows, called automatically each frame */
1627
+ update()
1628
+ {
1629
+ super.update();
1630
+ const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
1631
+ const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
1632
+ this.visible = clamp(this.visible + (shows ? step : -step));
1633
+ }
1634
+
1635
+ /** Draw the flare over the 3D scene, added onto it, called automatically in the 2D pass */
1636
+ render()
1637
+ {
1638
+ if (headlessMode || !glEnable) return;
1639
+ const elements = this.getScreenElements();
1640
+ if (!elements.length) return;
1641
+ setAdditiveBlendMode(true);
1642
+ for (const e of elements)
1643
+ {
1644
+ const tileInfo = render3DFlareTile(e.shape);
1645
+ tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
1646
+ }
1647
+ setAdditiveBlendMode(false);
1648
+ }
1649
+
1650
+ /** A flare has nothing to draw in the 3D pass */
1651
+ render3D() {}
1652
+ }
1653
+
1442
1654
  ///////////////////////////////////////////////////////////////////////////////
1443
1655
  // OBJ meshes
1444
1656
 
@@ -73,6 +73,16 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
73
73
  * - What a file written by hand gets wrong uses the default: a value that is not of its default's type
74
74
  * - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
75
75
  * of the level is made
76
+ * - A level may hold a map of blocks, in a voxels block: pos, its corner, size, its cells along x, y and z, and
77
+ * blocks, runs of a count and a type along x, then y, then z; it is made a VoxelMap, the first of what is
78
+ * returned, see level3DVoxelSetup for its sheet
79
+ * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
80
+ * height, how tall a full height is, color, and heights, rows of 0 to 1 from -z to +z, each from -x to +x; it
81
+ * is made a HeightMap, returned with what else was made
82
+ * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
83
+ * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
84
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
85
+ * leaves out stays as the game set it, and a level with no block changes nothing
76
86
  * @param {Object} level - The level, the level editor edits this same object
77
87
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
78
88
  * @memberof Level3D */
@@ -81,7 +91,11 @@ function level3DLoad(level)
81
91
  ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
82
92
  ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
83
93
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
84
- const made = [];
94
+ level3DSceneApply(level.scene);
95
+ const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
96
+ map && made.push(map);
97
+ const terrain = level3DTerrainMap = level3DTerrainMake(level.terrain);
98
+ terrain && made.push(terrain);
85
99
  for (const object of isArray(level.objects) ? level.objects : [])
86
100
  {
87
101
  if (!object || typeof object !== 'object') continue;
@@ -92,6 +106,147 @@ function level3DLoad(level)
92
106
  return made;
93
107
  }
94
108
 
109
+ // the sheet a level's block map is made with, and what sets each one up, see level3DVoxelSetup; and the map the
110
+ // level loaded last made, the one the 3D editor paints
111
+ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
112
+
113
+ /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
114
+ * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
115
+ * a sheet is given here; a block's type shows that tile of the sheet on every face
116
+ * - setup is called with each map a level makes, to give block types their own faces or make them see-through
117
+ * - Call it before level3DLoad; with no arguments the defaults are back
118
+ * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
119
+ * @param {function(VoxelMap): void} [setup]
120
+ * @example
121
+ * level3DVoxelSetup(tile(0, 16, 1), (map)=> map.setBlockType(1, {top: 0, side: 1, bottom: 2})); // grass
122
+ * @memberof Level3D */
123
+ function level3DVoxelSetup(tileInfo, setup)
124
+ {
125
+ level3DVoxelTiles = tileInfo;
126
+ level3DVoxelSetupMap = setup;
127
+ }
128
+
129
+ // the most cells a level's map may have, 256 each way
130
+ const LEVEL3D_VOXEL_CELLS = 2 ** 24;
131
+
132
+ // a map's blocks as the file keeps them, runs of a count and a type, cell by cell in the map's own order
133
+ function level3DVoxelsEncode(data)
134
+ {
135
+ const blocks = [];
136
+ for (let i = 0; i < data.length;)
137
+ {
138
+ let end = i + 1;
139
+ while (end < data.length && data[end] === data[i]) ++end;
140
+ blocks.push(end - i, data[i]);
141
+ i = end;
142
+ }
143
+ return blocks;
144
+ }
145
+
146
+ // runs into a map's cells: what they do not reach is empty, a type that is not 0 to 255 is empty, and a run that
147
+ // is not two numbers ends them
148
+ function level3DVoxelsDecode(blocks, data)
149
+ {
150
+ data.fill(0);
151
+ if (!isArray(blocks)) return;
152
+ for (let i = 0, cell = 0; i + 1 < blocks.length && cell < data.length; i += 2)
153
+ {
154
+ const count = blocks[i], type = blocks[i + 1];
155
+ if (!isNumber(count) || !isNumber(type) || count < 0) return;
156
+ const end = min(cell + floor(count), data.length);
157
+ data.fill(type >= 0 && type <= 255 && type % 1 === 0 ? type : 0, cell, end);
158
+ cell = end;
159
+ }
160
+ }
161
+
162
+ // the corner and the size in cells of a level's voxels block, undefined when it is not one a map can be made of
163
+ function level3DVoxelsShape(voxels)
164
+ {
165
+ if (!voxels || typeof voxels !== 'object') return;
166
+ const size = level3DVector(voxels.size);
167
+ if (!size || [size.x, size.y, size.z].some((n)=> n < 1 || n % 1) ||
168
+ size.x * size.y * size.z > LEVEL3D_VOXEL_CELLS) return;
169
+ return {pos: level3DVector(voxels.pos, vec3()), size};
170
+ }
171
+
172
+ // the VoxelMap of a level's voxels block, undefined when it has none or it is wrong
173
+ function level3DVoxelsMake(voxels)
174
+ {
175
+ const shape = level3DVoxelsShape(voxels);
176
+ if (!shape || typeof VoxelMap == 'undefined') return;
177
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
178
+ level3DVoxelsDecode(voxels.blocks, map.data);
179
+ level3DVoxelSetupMap?.(map);
180
+ map.rebuild();
181
+ return map;
182
+ }
183
+
184
+ // the terrain the level loaded last made, the one the 3D editor sculpts, and the most samples it may have a side
185
+ let level3DTerrainMap;
186
+ const LEVEL3D_TERRAIN_SAMPLES = 129;
187
+
188
+ // what a level's terrain block is made with: its center, its size in the world along x and z, how tall a full
189
+ // height is, and its heights as rows of 0 to 1, a copy, each row as long as the first; undefined when it is not
190
+ // one a HeightMap can be made of
191
+ function level3DTerrainShape(terrain)
192
+ {
193
+ if (!terrain || typeof terrain !== 'object') return;
194
+ const size = terrain.size, rows = terrain.heights, n = LEVEL3D_TERRAIN_SAMPLES;
195
+ if (!isArray(size) || size.length !== 2 || !size.every((v)=> isNumber(v) && v > 0)) return;
196
+ if (!isArray(rows) || rows.length < 2 || rows.length > n || !isArray(rows[0])) return;
197
+ const columns = rows[0].length;
198
+ if (columns < 2 || columns > n || !rows.every((row)=> isArray(row) && row.length === columns)) return;
199
+ const heights = rows.map((row)=> row.map((v)=> isNumber(v) ? clamp(v) : 0));
200
+ return {pos: level3DVector(terrain.pos, vec3()), size: vec2(size[0], size[1]),
201
+ height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
202
+ }
203
+
204
+ // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
205
+ function level3DTerrainMake(terrain)
206
+ {
207
+ const shape = level3DTerrainShape(terrain);
208
+ if (!shape || typeof HeightMap == 'undefined') return;
209
+ const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
210
+ if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color))
211
+ map.color = new Color().setHex(terrain.color);
212
+ return map;
213
+ }
214
+
215
+ // the sky domes levels made, a level's sky takes the place of the one before and disposes it; a dome the game made
216
+ // itself is left whole, for the game to put back
217
+ const level3DSkies = new WeakSet;
218
+
219
+ // set the scene a level's scene block gives: sky as its top, horizon and bottom colors, with ambient how much of
220
+ // them lights the scene, sunDirection and sunColor, fog as its start and end with fogColor, the horizon without one,
221
+ // and shadows; a setting the block does not have, or has wrong, stays as it is
222
+ function level3DSceneApply(scene)
223
+ {
224
+ const r = render3D;
225
+ if (!r || !scene || typeof scene !== 'object') return;
226
+ const color = (value)=> /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ? new Color().setHex(value) : undefined;
227
+ const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky.map(color) : [];
228
+ if (sky.length && sky.every((c)=> c))
229
+ {
230
+ // as setSky does, without disposing a dome that is the game's own
231
+ const [top, horizon, bottom] = sky, ambient = isNumber(scene.ambient) ? scene.ambient : .5;
232
+ level3DSkies.has(r.sky) && r.sky.dispose();
233
+ level3DSkies.add(r.sky = buildSky(top, horizon, bottom));
234
+ r.fogColor = horizon.copy();
235
+ r.ambientColor = top.scale(ambient, 1);
236
+ r.ambientGroundColor = bottom.scale(ambient, 1);
237
+ }
238
+ const sun = level3DVector(scene.sunDirection);
239
+ if (sun && sun.lengthSquared())
240
+ r.sunDirection = sun;
241
+ r.sunColor = color(scene.sunColor) || r.sunColor;
242
+ const fog = scene.fog;
243
+ if (isArray(fog) && fog.length === 2 && fog.every((v)=> isNumber(v)))
244
+ r.fogStart = fog[0], r.fogEnd = fog[1];
245
+ r.fogColor = color(scene.fogColor) || r.fogColor;
246
+ if (typeof scene.shadows === 'boolean')
247
+ r.shadows = scene.shadows;
248
+ }
249
+
95
250
  // a vec3 of an array of three numbers, as the file has them, or the fallback
96
251
  function level3DVector(value, fallback)
97
252
  {
@@ -184,6 +339,14 @@ level3DAddType('Sphere', function(pos, properties)
184
339
  level3DAddType('Cylinder', function(pos, properties)
185
340
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
186
341
  {color: WHITE, tile: -1, solid: true});
342
+ // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
343
+ // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
187
344
  level3DAddType('Light', function(pos, properties)
188
- { return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
189
- {color: WHITE, radius: 5, intensity: 1});
345
+ {
346
+ const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
347
+ light.coneAngle = max(properties.cone, 0) * PI / 180;
348
+ light.coneSoftness = properties.softness;
349
+ if (properties.shadows && render3D)
350
+ render3D.shadowLight = light;
351
+ return light;
352
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
@@ -38,9 +38,10 @@ function setScene(scene)
38
38
 
39
39
  if (sceneCurrent)
40
40
  {
41
+ // a leave that throws still lets the next switch through
41
42
  sceneLeaving = true;
42
- sceneCurrent.leave?.();
43
- sceneLeaving = false;
43
+ try { sceneCurrent.leave?.(); }
44
+ finally { sceneLeaving = false; }
44
45
  }
45
46
  engineObjectsDestroy();
46
47
  sceneCurrent = scene;
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.21.0';
35
+ const engineVersion = '1.22.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -220,9 +220,12 @@ class Sound
220
220
  * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
221
221
  * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
222
222
  */
223
- constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
223
+ constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
224
224
  {
225
225
  if (!soundEnable || headlessMode) return;
226
+ const rangeIsDefault = range === undefined;
227
+ if (rangeIsDefault)
228
+ range = soundDefaultRange;
226
229
 
227
230
  ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
228
231
  ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
@@ -232,6 +235,9 @@ class Sound
232
235
 
233
236
  /** @property {number} - World space max range of sound */
234
237
  this.range = range;
238
+ /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
239
+ * to render3D.soundDefaultRange instead, which is further */
240
+ this.rangeIsDefault = rangeIsDefault;
235
241
  /** @property {number} - At what percentage of range should it start tapering */
236
242
  this.taper = taper;
237
243
  /** @property {number} - How much to randomize frequency each time sound plays
@@ -867,7 +873,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
867
873
  }
868
874
 
869
875
  ///////////////////////////////////////////////////////////////////////////////
870
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
876
+ // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
871
877
 
872
878
  /** Generate and play a ZzFX sound
873
879
  *
@@ -899,7 +905,7 @@ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
899
905
  * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
900
906
  * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
901
907
  * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
902
- * @return {Array} - Array of audio samples
908
+ * @return {Float32Array} - The audio samples
903
909
  * @memberof Audio */
904
910
  function zzfxG
905
911
  (
@@ -937,7 +943,7 @@ function zzfxG
937
943
  crush = 0, // bit crush offset
938
944
  jump = 1, // pitch jump timer
939
945
  length, // sample length
940
- b = [], // sample buffer
946
+ b, // sample buffer
941
947
  t = 0, // sample time
942
948
  i = 0, // sample index
943
949
  s = 0, // sample value
@@ -964,9 +970,12 @@ function zzfxG
964
970
  pitchJumpTime *= sampleRate;
965
971
  repeatTime = repeatTime * sampleRate | 0;
966
972
 
973
+ // allocate the full sample buffer up front, much faster than growing an array
974
+ length = attack + decay + sustain + release + delay | 0;
975
+ b = new Float32Array(length > 0 ? length : 0);
976
+
967
977
  // generate waveform
968
- for (length = attack + decay + sustain + release + delay | 0;
969
- i < length; b[i++] = s * volume) // sample
978
+ for (; i < length; b[i++] = s * volume) // sample
970
979
  {
971
980
  if (!(++crush%(bitCrush*100|0))) // bit crush
972
981
  {
@@ -1002,7 +1011,7 @@ function zzfxG
1002
1011
 
1003
1012
  f = (frequency += slide += deltaSlide) *// frequency
1004
1013
  cos(modulation*modOffset++); // modulation
1005
- t += f + f*noise*sin(i**5); // noise
1014
+ t += f + f*noise*(i*i*PI2%2-1); // noise
1006
1015
 
1007
1016
  if (jump && ++jump > pitchJumpTime) // pitch jump
1008
1017
  {
@@ -56,6 +56,7 @@ const enginePluginFiles =
56
56
  `${PLUGIN_FOLDER}/render3dExtras.js`,
57
57
  `${PLUGIN_FOLDER}/render3dVoxels.js`,
58
58
  `${PLUGIN_FOLDER}/render3dLevel.js`,
59
+ `${PLUGIN_FOLDER}/particleEffects.js`,
59
60
  `${PLUGIN_FOLDER}/gltf.js`,
60
61
  `${PLUGIN_FOLDER}/threejs.js`,
61
62
  ];