littlejsengine 1.24.0 → 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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.24.0';
38
+ const engineVersion = '1.24.1';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -3211,7 +3211,11 @@ function setGLEnable(enable)
3211
3211
  /** Set how many sided polygons to use when drawing circles and ellipses with WebGL
3212
3212
  * @param {number} sides
3213
3213
  * @memberof Settings */
3214
- function setGLCircleSides(sides) { glCircleSides = sides; }
3214
+ function setGLCircleSides(sides)
3215
+ {
3216
+ false&&ASSERT(isNumber(sides) && sides >= 3, 'circles need at least 3 sides');
3217
+ glCircleSides = sides;
3218
+ }
3215
3219
 
3216
3220
  /** Set default size of tiles in pixels
3217
3221
  * @param {Vector2} size
@@ -8733,7 +8737,7 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
8733
8737
  * @param {Object} tileMapData - Level data from exported data
8734
8738
  * @param {TileInfo} [tileInfo] - Default tile info (used for size and texture), tile() by default, none when no image is loaded
8735
8739
  * @param {number} [renderOrder] - Render order of the top layer
8736
- * @param {number} [collisionLayer] - Layer to use for collision if any
8740
+ * @param {number|string} [collisionLayer] - Layer to use for collision if any, by its index or its name
8737
8741
  * @param {boolean} [draw] - Should the layer be drawn automatically
8738
8742
  * @return {Array<TileCollisionLayer>}
8739
8743
  * @memberof TileLayers */
@@ -8765,7 +8769,8 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
8765
8769
  const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size;
8766
8770
  const width = tileInfo.textureInfo?.size.x || 0;
8767
8771
  const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing)));
8768
- tileInfo = new TileInfo(vec2(margin), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
8772
+ const origin = tileInfo.pos.subtract(vec2(tileInfo.padding)); // where the sheet starts, in an atlas too
8773
+ tileInfo = new TileInfo(origin.add(vec2(margin)), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
8769
8774
  }
8770
8775
 
8771
8776
  // flatten group layers in order, a group's color and visibility carry to the layers inside it
@@ -8796,6 +8801,14 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
8796
8801
  };
8797
8802
  addLayers(tileMapData.layers, WHITE, true);
8798
8803
 
8804
+ // a layer named for collision is the first tile layer of that name, which stays the same when layers are added
8805
+ if (typeof collisionLayer === 'string')
8806
+ {
8807
+ const name = collisionLayer;
8808
+ collisionLayer = layers.findIndex((l)=> l.color && l.dataLayer.name === name);
8809
+ false&&ASSERT(collisionLayer >= 0, 'no tile layer named ' + name);
8810
+ }
8811
+
8799
8812
  // create tile layers and fill with data
8800
8813
  const tileLayers = [];
8801
8814
  const levelSize = vec2(tileMapData.width, tileMapData.height);
@@ -8851,31 +8864,38 @@ const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: '
8851
8864
 
8852
8865
  /**
8853
8866
  * Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad
8854
- * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled, so the last layer
8855
- * of the LDtk file is layer 0; where LDtk stacks tiles in a cell, an edge over a fill, each tile over another
8856
- * goes in a layer of its own just above, named with (2), (3) and so on, so a layer of the first tiles has one
8857
- * wherever the LDtk layer has any, which makes it the one for collision
8858
- * - An IntGrid layer with no tiles is a hidden layer of its values, for collision: pass its index as collisionLayer
8867
+ * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled; where LDtk stacks
8868
+ * tiles in a cell, an edge over a fill, each tile over another goes in a layer of its own just above, named with
8869
+ * (2), (3) and so on, and a tile LDtk draws see-through goes in one of its own with that opacity, times the
8870
+ * layer's, named with it, like Ground .25
8871
+ * - An IntGrid layer is a hidden layer of its values under its own name, for collision, whatever tiles its rules
8872
+ * make, which are layers over it named with tiles, like Collisions tiles; pass the name as collisionLayer, since
8873
+ * the stacked and see-through layers change the indices from level to level:
8874
+ * tileLayersLoad(map, tile(0, 16), 0, 'Collisions')
8859
8875
  * - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at
8860
8876
  * its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string)
8861
8877
  * - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image
8862
8878
  * - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of
8863
- * another grid size or another tileset is left out, with a warning in debug builds
8879
+ * another grid size or another tileset is left out, with a warning in debug builds, and a project with no tileset
8880
+ * keeps its tiles for the tile info the game gives; a project of several worlds has its levels counted in order
8881
+ * - Layer offsets are not read
8864
8882
  * - The level editor edits the map this returns, and saves it as a Tiled map
8865
8883
  * @param {Object} ldtk - The LDtk project, its JSON
8866
8884
  * @param {number|string} [level] - Which level, by its index or its identifier
8867
8885
  * @return {Object} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers
8868
8886
  * @example
8869
8887
  * const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0');
8870
- * const layers = tileLayersLoad(map, tile(0, 16), 0, 1); // layer 1 is solid
8888
+ * const layers = tileLayersLoad(map, tile(0, 16), 0, 'Collisions'); // its IntGrid layer is solid
8871
8889
  * objectLayersLoad(map);
8872
8890
  * @memberof TileLayers */
8873
8891
  function tileLayersFromLDtk(ldtk, level=0)
8874
8892
  {
8875
- const levels = ldtk?.levels || [];
8893
+ const levels = ldtk?.levels?.length ? ldtk.levels : (ldtk?.worlds || []).flatMap((w)=> w.levels || []);
8876
8894
  const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level];
8877
- false&&ASSERT(data, 'LDtk level not found', level);
8878
- false&&ASSERT(isArray(data.layerInstances), 'LDtk level has no layers: levels saved as separate files are not read');
8895
+ if (!data)
8896
+ throw new Error('tileLayersFromLDtk: no level ' + level);
8897
+ if (!isArray(data.layerInstances))
8898
+ throw new Error('tileLayersFromLDtk: level ' + data.identifier + ' has no layers, levels saved as separate files are not read');
8879
8899
  const instances = data.layerInstances;
8880
8900
  const tiled = (l)=> l.__type !== 'Entities';
8881
8901
  const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
@@ -8915,33 +8935,53 @@ function tileLayersFromLDtk(ldtk, level=0)
8915
8935
  continue;
8916
8936
  }
8917
8937
  const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
8918
- const usable = instance.__gridSize === grid && instance.__cWid === width && instance.__cHei === height &&
8919
- (!tiles.length || instance.__tilesetDefUid === tileset?.uid);
8920
- if (!usable)
8938
+ const ownTiles = !tiles.length || !tileset ||
8939
+ instance.__tilesetDefUid === tileset.uid && tileset.tileGridSize === grid;
8940
+ if (instance.__gridSize !== grid || instance.__cWid !== width || instance.__cHei !== height || !ownTiles)
8921
8941
  {
8922
8942
  debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
8923
8943
  continue;
8924
8944
  }
8925
- const empty = ()=> new Array(width * height).fill(0), stacks = [empty()];
8926
- if (tiles.length)
8945
+ // an IntGrid layer's values, hidden, under its own name, for collision whatever its tiles do
8946
+ const empty = ()=> new Array(width * height).fill(0), opacity = instance.__opacity ?? 1;
8947
+ const values = instance.__type === 'IntGrid';
8948
+ if (values)
8949
+ {
8950
+ const data = empty();
8951
+ (instance.intGridCsv || []).forEach((value, k)=> k < data.length && (data[k] = value));
8952
+ map.layers.push({id, name, type: 'tilelayer', width, height, data, opacity, visible: false, x: 0, y: 0});
8953
+ if (!tiles.length) continue;
8954
+ }
8955
+
8956
+ // a layer for each depth in a cell and each opacity, the first tiles drawn solid first
8957
+ const tilesName = values ? name + ' tiles' : name;
8958
+ const stacks = [{depth: 0, alpha: 1, data: empty()}], stack = (depth, alpha)=>
8927
8959
  {
8928
- // a tile's place in pixels, its tile in the sheet, and its flips: bit 0 across, bit 1 down, which are
8929
- // Tiled's two top bits; LDtk lists them in the order it draws them, so the first in a cell is the
8930
- // bottom one and each after it goes a layer higher
8960
+ let s = stacks.find((s)=> s.depth === depth && s.alpha === alpha);
8961
+ s || stacks.push(s = {depth, alpha, data: empty()});
8962
+ return s.data;
8963
+ };
8964
+ {
8965
+ // a tile's place in pixels, its tile in the sheet, its flips, bit 0 across and bit 1 down, which are
8966
+ // Tiled's two top bits, and its opacity; LDtk lists them in the order it draws them, so the first in a
8967
+ // cell is the bottom one and each after it goes a layer higher
8931
8968
  const depth = new Uint16Array(width * height);
8932
8969
  for (const t of tiles)
8933
8970
  {
8934
8971
  const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width;
8935
8972
  if (x >= 0 && x < width && y >= 0 && y < height)
8936
- (stacks[depth[cell]++] ||= empty())[cell] =
8973
+ stack(depth[cell]++, round(clamp(t.a ?? 1) * 1e3) / 1e3)[cell] =
8937
8974
  (t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0;
8938
8975
  }
8939
8976
  }
8940
- else
8941
- (instance.intGridCsv || []).forEach((value, k)=> k < stacks[0].length && (stacks[0][k] = value));
8942
- const visible = instance.__type === 'IntGrid' && !tiles.length ? false : instance.visible !== false;
8943
- stacks.forEach((data, k)=> map.layers.push({id: k ? map.nextlayerid++ : id, name: k ? `${name} (${k + 1})` : name,
8944
- type: 'tilelayer', width, height, data, opacity: instance.__opacity ?? 1, visible, x: 0, y: 0}));
8977
+ const visible = instance.visible !== false;
8978
+ stacks.sort((a, b)=> a.depth - b.depth || b.alpha - a.alpha);
8979
+ for (const {depth, alpha, data} of stacks)
8980
+ {
8981
+ const layerName = tilesName + (depth ? ` (${depth + 1})` : '') + (alpha < 1 ? ' ' + String(alpha).replace(/^0/, '') : '');
8982
+ map.layers.push({id: values || depth || alpha < 1 ? map.nextlayerid++ : id, name: layerName, type: 'tilelayer',
8983
+ width, height, data, opacity: opacity * alpha, visible, x: 0, y: 0});
8984
+ }
8945
8985
  }
8946
8986
  return map;
8947
8987
  }
@@ -16502,7 +16542,7 @@ class Box2dObject extends EngineObject
16502
16542
 
16503
16543
  const points = [];
16504
16544
  const radius = diameter/2;
16505
- for (let i=sides; i--;)
16545
+ for (let i=sides; i-- > 0;) // a count that is not a whole number still ends
16506
16546
  points.push(vec2(radius,0).rotate((i+.5)/sides*PI*2));
16507
16547
  return this.addPoly(points, density, friction, restitution, isSensor);
16508
16548
  }
@@ -26970,10 +27010,11 @@ class Light3D extends EngineObject3D
26970
27010
  * @param {number} [count] - How many ghosts there are, besides the glow at the light
26971
27011
  * @param {number} [intensity] - How bright it is
26972
27012
  * @param {number} [saturation] - How colorful the ghosts are
27013
+ * @param {Color} [color] - Tints the flare, with the light's own color
26973
27014
  * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
26974
- addFlare(size, count, intensity, saturation)
27015
+ addFlare(size, count, intensity, saturation, color)
26975
27016
  {
26976
- const flare = new LensFlare3D(size, count, intensity, saturation);
27017
+ const flare = new LensFlare3D(size, count, intensity, saturation, color);
26977
27018
  this.flare = flare;
26978
27019
  return flare;
26979
27020
  }
@@ -28621,7 +28662,7 @@ function render3DFlareTile(shape)
28621
28662
  * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
28622
28663
  * give it elements of your own, which may be tiles of the game's
28623
28664
  * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
28624
- * sun is off the screen, where nothing is tested
28665
+ * sun is too far off the screen for the flare to show, a third of the screen past its edge, where nothing is tested
28625
28666
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
28626
28667
  * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
28627
28668
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
@@ -28668,7 +28709,8 @@ class LensFlare3D extends EngineObject3D
28668
28709
  this.elements = undefined;
28669
28710
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
28670
28711
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
28671
- * in front of the light, and a spotlight's shows from inside its beam only; a DirectionalLight3D's is far
28712
+ * in front of the light but its lamp, a mesh around it no wider than half its radius, and a spotlight's
28713
+ * shows from inside its beam only; a DirectionalLight3D's is far
28672
28714
  * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
28673
28715
  * sets this
28674
28716
  * @type {Light3D|undefined} */
@@ -28808,13 +28850,15 @@ class LensFlare3D extends EngineObject3D
28808
28850
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
28809
28851
  return true;
28810
28852
  // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
28811
- // is in
28853
+ // is in, and no wider than half the light's reach, since a room or a whole level has a box around its lights
28854
+ // too, and its walls do hide them from outside
28812
28855
  const lamp = (o)=>
28813
28856
  {
28814
28857
  if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
28815
- const matrix = render3DObjectMatrix(o);
28816
- if (!matrix.determinant()) return false;
28817
- const p = matrix.copy().invert().transformPoint(source.pos), b = o.mesh.bounds || o.mesh.getBounds();
28858
+ const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds();
28859
+ const side = max(b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z) * render3DMaxStretch(matrix.m);
28860
+ if (!matrix.determinant() || side > this.light.radius / 2) return false;
28861
+ const p = matrix.copy().invert().transformPoint(source.pos);
28818
28862
  return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
28819
28863
  p.z >= b.min.z && p.z <= b.max.z;
28820
28864
  };
@@ -29451,7 +29495,7 @@ class VoxelMap extends EngineObject3D
29451
29495
  /** @type {Array<Vector3>} */
29452
29496
  this.chunkCenters = []; // a chunk mesh's points are around its center, which it is drawn at
29453
29497
  this.chunksChanged = new Set;
29454
- /** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean}|undefined>} */
29498
+ /** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}|undefined>} */
29455
29499
  this.blockTypes = [];
29456
29500
  this.tiles = new Map; // tile index to the uvs of its corners
29457
29501
 
@@ -29523,27 +29567,29 @@ class VoxelMap extends EngineObject3D
29523
29567
  * @param {number} type - 1 to 255
29524
29568
  * @param {number|Array<number>|{top?: number, side: number, bottom?: number}} faces - A tile index for every face,
29525
29569
  * six in the order +x, -x, +y, -y, +z, -z, or the side's with the top and bottom's, which default to the side's
29526
- * @param {{seeThrough?: boolean, transparent?: boolean}} [options] - seeThrough for holes in its texture, like
29527
- * leaves, so the blocks beside it keep their faces; transparent to blend, like glass or water, drawn in the
29528
- * transparent stage, and see-through too */
29529
- setBlockType(type, faces, {seeThrough=false, transparent=false}={})
29570
+ * @param {{seeThrough?: boolean, transparent?: boolean, doubleSided?: boolean}} [options] - seeThrough for holes
29571
+ * in its texture, like leaves, so the blocks beside it keep their faces; transparent to blend, like glass or
29572
+ * water, drawn in the transparent stage, and see-through too; doubleSided for faces seen from inside the
29573
+ * block as well, like water, whose surface then shows from under it */
29574
+ setBlockType(type, faces, {seeThrough=false, transparent=false, doubleSided=false}={})
29530
29575
  {
29531
29576
  false&&ASSERT(type >= 1 && type <= 255 && type % 1 === 0, 'a block type is a whole number from 1 to 255', type);
29532
29577
  const f = /** @type {any} */ (faces);
29533
29578
  const list = isNumber(f) ? [f, f, f, f, f, f] : isArray(f) ? f :
29534
29579
  [f.side, f.side, f.top ?? f.side, f.bottom ?? f.side, f.side, f.side];
29535
29580
  false&&ASSERT(list.length === 6 && list.every(isNumber), 'faces is a tile index, six of them, or {top, side, bottom}');
29536
- this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent};
29581
+ this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent, doubleSided};
29537
29582
  this.rebuild();
29538
29583
  }
29539
29584
 
29540
29585
  /** A block type's faces and how it is seen through
29541
29586
  * @param {number} type
29542
- * @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean}}
29587
+ * @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}}
29543
29588
  * @ignore */
29544
29589
  blockType(type)
29545
29590
  {
29546
- return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false};
29591
+ return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false,
29592
+ doubleSided: false};
29547
29593
  }
29548
29594
 
29549
29595
  /** Build every chunk again, after changing data directly or ambientOcclusion */
@@ -29613,6 +29659,11 @@ class VoxelMap extends EngineObject3D
29613
29659
  colors[k] = ao ? RENDER3D_VOXEL_SHADES[shade[i]] : WHITE;
29614
29660
  }
29615
29661
  mesh.addStrip(points, normal, uvs, colors);
29662
+
29663
+ // a double sided block shows the face from inside too, its corners the other way round
29664
+ if (block.doubleSided)
29665
+ mesh.addStrip([points[1], points[0], points[3], points[2]], normal.scale(-1),
29666
+ [uvs[1], uvs[0], uvs[3], uvs[2]], [colors[1], colors[0], colors[3], colors[2]]);
29616
29667
  }
29617
29668
  }
29618
29669
  this.chunkMeshes[index]?.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.24.0",
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",
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
  }
@@ -4776,10 +4776,11 @@ class Light3D extends EngineObject3D
4776
4776
  * @param {number} [count] - How many ghosts there are, besides the glow at the light
4777
4777
  * @param {number} [intensity] - How bright it is
4778
4778
  * @param {number} [saturation] - How colorful the ghosts are
4779
+ * @param {Color} [color] - Tints the flare, with the light's own color
4779
4780
  * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
4780
- addFlare(size, count, intensity, saturation)
4781
+ addFlare(size, count, intensity, saturation, color)
4781
4782
  {
4782
- const flare = new LensFlare3D(size, count, intensity, saturation);
4783
+ const flare = new LensFlare3D(size, count, intensity, saturation, color);
4783
4784
  this.flare = flare;
4784
4785
  return flare;
4785
4786
  }
@@ -549,9 +549,11 @@ function editor3DLevelLoaded(level)
549
549
  level.objects = editor3DCopy(saved.objects);
550
550
  for (const name of editor3DLevelPartNames)
551
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');
552
554
  }
553
- else if (saved)
554
- 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
555
557
  }
556
558
 
557
559
  // called by level3DLoad for each object, with what its type made
@@ -1575,7 +1575,7 @@ function render3DFlareTile(shape)
1575
1575
  * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
1576
1576
  * give it elements of your own, which may be tiles of the game's
1577
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
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
1579
1579
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
1580
1580
  * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
1581
1581
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
@@ -1622,7 +1622,8 @@ class LensFlare3D extends EngineObject3D
1622
1622
  this.elements = undefined;
1623
1623
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
1624
1624
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
1625
- * in front of the light, and a spotlight's shows from inside its beam only; a DirectionalLight3D's is far
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
1626
1627
  * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
1627
1628
  * sets this
1628
1629
  * @type {Light3D|undefined} */
@@ -1762,13 +1763,15 @@ class LensFlare3D extends EngineObject3D
1762
1763
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
1763
1764
  return true;
1764
1765
  // 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
+ // 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
1766
1768
  const lamp = (o)=>
1767
1769
  {
1768
1770
  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();
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);
1772
1775
  return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
1773
1776
  p.z >= b.min.z && p.z <= b.max.z;
1774
1777
  };
@@ -89,7 +89,7 @@ class VoxelMap extends EngineObject3D
89
89
  /** @type {Array<Vector3>} */
90
90
  this.chunkCenters = []; // a chunk mesh's points are around its center, which it is drawn at
91
91
  this.chunksChanged = new Set;
92
- /** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean}|undefined>} */
92
+ /** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}|undefined>} */
93
93
  this.blockTypes = [];
94
94
  this.tiles = new Map; // tile index to the uvs of its corners
95
95
 
@@ -161,27 +161,29 @@ class VoxelMap extends EngineObject3D
161
161
  * @param {number} type - 1 to 255
162
162
  * @param {number|Array<number>|{top?: number, side: number, bottom?: number}} faces - A tile index for every face,
163
163
  * six in the order +x, -x, +y, -y, +z, -z, or the side's with the top and bottom's, which default to the side's
164
- * @param {{seeThrough?: boolean, transparent?: boolean}} [options] - seeThrough for holes in its texture, like
165
- * leaves, so the blocks beside it keep their faces; transparent to blend, like glass or water, drawn in the
166
- * transparent stage, and see-through too */
167
- setBlockType(type, faces, {seeThrough=false, transparent=false}={})
164
+ * @param {{seeThrough?: boolean, transparent?: boolean, doubleSided?: boolean}} [options] - seeThrough for holes
165
+ * in its texture, like leaves, so the blocks beside it keep their faces; transparent to blend, like glass or
166
+ * water, drawn in the transparent stage, and see-through too; doubleSided for faces seen from inside the
167
+ * block as well, like water, whose surface then shows from under it */
168
+ setBlockType(type, faces, {seeThrough=false, transparent=false, doubleSided=false}={})
168
169
  {
169
170
  ASSERT(type >= 1 && type <= 255 && type % 1 === 0, 'a block type is a whole number from 1 to 255', type);
170
171
  const f = /** @type {any} */ (faces);
171
172
  const list = isNumber(f) ? [f, f, f, f, f, f] : isArray(f) ? f :
172
173
  [f.side, f.side, f.top ?? f.side, f.bottom ?? f.side, f.side, f.side];
173
174
  ASSERT(list.length === 6 && list.every(isNumber), 'faces is a tile index, six of them, or {top, side, bottom}');
174
- this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent};
175
+ this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent, doubleSided};
175
176
  this.rebuild();
176
177
  }
177
178
 
178
179
  /** A block type's faces and how it is seen through
179
180
  * @param {number} type
180
- * @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean}}
181
+ * @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}}
181
182
  * @ignore */
182
183
  blockType(type)
183
184
  {
184
- return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false};
185
+ return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false,
186
+ doubleSided: false};
185
187
  }
186
188
 
187
189
  /** Build every chunk again, after changing data directly or ambientOcclusion */
@@ -251,6 +253,11 @@ class VoxelMap extends EngineObject3D
251
253
  colors[k] = ao ? RENDER3D_VOXEL_SHADES[shade[i]] : WHITE;
252
254
  }
253
255
  mesh.addStrip(points, normal, uvs, colors);
256
+
257
+ // a double sided block shows the face from inside too, its corners the other way round
258
+ if (block.doubleSided)
259
+ mesh.addStrip([points[1], points[0], points[3], points[2]], normal.scale(-1),
260
+ [uvs[1], uvs[0], uvs[3], uvs[2]], [colors[1], colors[0], colors[3], colors[2]]);
254
261
  }
255
262
  }
256
263
  this.chunkMeshes[index]?.dispose();
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.24.0';
35
+ const engineVersion = '1.24.1';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -570,8 +570,8 @@ function editorMapRestore(map)
570
570
  if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height &&
571
571
  editorSameData(saved.layers, data) && editorSameData(saved.objects ?? [], objects))
572
572
  editorDiscardPending(record);
573
- else if (!sameFile || !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) ||
574
- !editorCopyData(data, saved.layers))
573
+ else if (!sameFile || !editorSavedFits(saved, data.length, saved.width ?? map.width, saved.height ?? map.height) ||
574
+ !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) || !editorCopyData(data, saved.layers))
575
575
  {
576
576
  record.pending = saved;
577
577
  console.warn(`LittleJS editor: ${record.fileName} changed since its autosaved edits, ` +
@@ -870,6 +870,11 @@ function editorCopyData(data, saved)
870
870
  return true;
871
871
  }
872
872
 
873
+ // are an autosave's tiles a layer for each of the map's, each of its own size, so the map is resized only for tiles
874
+ // that fit it
875
+ const editorSavedFits = (saved, count, width, height)=> isArray(saved.layers) && saved.layers.length === count &&
876
+ saved.layers.every((layer)=> isArray(layer) && layer.length === width * height);
877
+
873
878
  // remember a map's tile data and objects, or forget them when they are back to the file
874
879
  function editorAutosave(record)
875
880
  {
@@ -898,7 +903,7 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
898
903
  {
899
904
  if (!editorHas('onRestart')) return;
900
905
  const before = editorMapSnapshot(record);
901
- editorSetMapSnapshot(record, {width, height, layers: data, objects});
906
+ editorSetMapSnapshot(record, {width, height, layers: data, objects}, keepUnknown);
902
907
  editorMapChanged(record, before);
903
908
  return;
904
909
  }
@@ -1015,13 +1020,15 @@ function editorMapSnapshot(record)
1015
1020
  return editorObjectsCopy({width: map.width, height: map.height, layers: editorTileLayerData(map.layers), objects});
1016
1021
  }
1017
1022
 
1018
- // give a map a size, tiles and objects, the game's Restart hook makes its layers again
1019
- function editorSetMapSnapshot(record, snapshot)
1023
+ // give a map a size, tiles and objects, the game's Restart hook makes its layers again; keepUnknown leaves an object
1024
+ // layer past the snapshot's as it is, as applying an autosave from before the file had it does
1025
+ function editorSetMapSnapshot(record, snapshot, keepUnknown=false)
1020
1026
  {
1021
1027
  const map = record.map;
1022
1028
  editorResizeMap(map, snapshot.width, snapshot.height);
1023
1029
  editorCopyData(editorTileLayerData(map.layers), snapshot.layers);
1024
1030
  editorRestoreObjects(map, snapshot);
1031
+ if (!keepUnknown)
1025
1032
  for (const group of editorObjectGroups(map.layers).slice(snapshot.objects.length))
1026
1033
  group.objects = []; // an Objects layer the editor made since
1027
1034
  editorClearSelections();
@@ -2181,9 +2188,13 @@ function editorPaletteTiles(layer)
2181
2188
  if (!image) return [];
2182
2189
  if (layer.palette?.image === image) return layer.palette.tiles;
2183
2190
 
2184
- const tiles = [];
2191
+ // a sheet read by its columns, a tileset with a margin or a spacing, is read with frame(), which asserts the
2192
+ // frame is inside the image, so a row past the image's end is found before its frame is made
2193
+ const tiles = [], base = live.tileInfo, columns = base.columns;
2185
2194
  for (let i = 0; i < 4096; ++i)
2186
2195
  {
2196
+ if (columns && base.pos.y + (i / columns | 0) * (base.size.y + base.padding*2) + base.size.y > image.height)
2197
+ break;
2187
2198
  const t = editorTileInfo(live, i);
2188
2199
  if (t.pos.x + t.size.x > image.width || t.pos.y + t.size.y > image.height) break;
2189
2200
  tiles.push({tile: i, tileInfo: t});
@@ -625,7 +625,11 @@ function setGLEnable(enable)
625
625
  /** Set how many sided polygons to use when drawing circles and ellipses with WebGL
626
626
  * @param {number} sides
627
627
  * @memberof Settings */
628
- function setGLCircleSides(sides) { glCircleSides = sides; }
628
+ function setGLCircleSides(sides)
629
+ {
630
+ ASSERT(isNumber(sides) && sides >= 3, 'circles need at least 3 sides');
631
+ glCircleSides = sides;
632
+ }
629
633
 
630
634
  /** Set default size of tiles in pixels
631
635
  * @param {Vector2} size