littlejsengine 1.24.1 → 1.24.3

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/EDITOR.md +2 -0
  3. package/FAQ.md +13 -2
  4. package/README.md +1 -1
  5. package/REFERENCE.md +22 -7
  6. package/dist/littlejs.d.ts +204 -118
  7. package/dist/littlejs.esm.js +2479 -1983
  8. package/dist/littlejs.esm.min.js +3 -3
  9. package/dist/littlejs.js +2476 -1983
  10. package/dist/littlejs.min.js +2 -2
  11. package/dist/littlejs.release.js +2434 -1990
  12. package/package.json +1 -1
  13. package/plugins/box2d.js +9 -5
  14. package/plugins/gltf.js +67 -9
  15. package/plugins/lightSystem.js +1 -1
  16. package/plugins/math3d.js +1014 -1013
  17. package/plugins/medalSystem.js +7 -11
  18. package/plugins/particleEffects.js +2 -2
  19. package/plugins/pathFinder.js +2 -2
  20. package/plugins/pluginExport.js +1 -0
  21. package/plugins/render3d.js +131 -55
  22. package/plugins/render3dEditor.js +24 -10
  23. package/plugins/render3dExtras.js +41 -25
  24. package/plugins/render3dLevel.js +21 -12
  25. package/plugins/render3dVoxels.js +2 -2
  26. package/plugins/textureSheet.js +109 -7
  27. package/plugins/tweakables.js +1 -1
  28. package/plugins/tweenSystem.js +2 -2
  29. package/plugins/uiSystem.js +6 -2
  30. package/src/engine.js +60 -23
  31. package/src/engineAudio.js +16 -6
  32. package/src/engineBuild.mjs +49 -2
  33. package/src/engineDebug.js +4 -1
  34. package/src/engineDraw.js +40 -25
  35. package/src/engineEditor.js +82 -50
  36. package/src/engineExport.js +2 -0
  37. package/src/engineInput.js +21 -4
  38. package/src/engineMath.js +6 -2
  39. package/src/engineObject.js +675 -675
  40. package/src/engineParticles.js +1 -1
  41. package/src/engineSettings.js +16 -0
  42. package/src/engineTileLayer.js +85 -22
  43. package/src/engineUtilities.js +20 -8
  44. package/src/engineWebGL.js +15 -11
@@ -28,7 +28,7 @@ function render3DDetach(o)
28
28
  let render3DSoftDotTexture;
29
29
  function render3DSoftDot()
30
30
  {
31
- if (render3DSoftDotTexture || !glContext || typeof OffscreenCanvas == 'undefined') return render3DSoftDotTexture;
31
+ if (render3DSoftDotTexture || !glContext || !canvasAvailable()) return render3DSoftDotTexture;
32
32
  const size = 32, context = createCanvasContext(size);
33
33
  const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
34
34
  for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
@@ -50,7 +50,7 @@ function normalMapFromHeight(size, heightFunction, strength=1)
50
50
  {
51
51
  ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
52
52
  const width = size.x | 0, height = size.y | 0;
53
- if (typeof OffscreenCanvas == 'undefined')
53
+ if (!canvasAvailable())
54
54
  {
55
55
  // headless, nothing to draw into; the right size so what reads it still works
56
56
  const textureInfo = new TextureInfo(undefined, false, true);
@@ -631,9 +631,9 @@ class HeightMap extends EngineObject3D
631
631
  levelSegment3D(from, to)
632
632
  {
633
633
  const m = this.pos3D, size = this.mapSize;
634
- if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return; // off the map
634
+ if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return undefined; // off the map
635
635
  const a = from.y - this.getHeight(from.x, from.z), b = to.y - this.getHeight(to.x, to.z);
636
- if (a < 0 || b >= 0) return; // above all the way, or under from the start
636
+ if (a < 0 || b >= 0) return undefined; // above all the way, or under from the start
637
637
  const distance = a / (a - b);
638
638
  return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)};
639
639
  }
@@ -1484,7 +1484,7 @@ function render3DFlareTile(shape)
1484
1484
  {
1485
1485
  if (!render3DFlareTiles)
1486
1486
  {
1487
- if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
1487
+ if (headlessMode || !glContext || !canvasAvailable()) return;
1488
1488
  const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
1489
1489
  // each shape is how see-through it is from its middle out
1490
1490
  const alpha =
@@ -1575,7 +1575,8 @@ 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 too far off the screen for the flare to show, a third of the screen past its edge, where nothing is tested
1578
+ * sun is too far off the screen for the flare to show, about a seventh of the screen past its edge, where nothing
1579
+ * is tested
1579
1580
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
1580
1581
  * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
1581
1582
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
@@ -1622,7 +1623,7 @@ class LensFlare3D extends EngineObject3D
1622
1623
  this.elements = undefined;
1623
1624
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
1624
1625
  * 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 but its lamp, a mesh around it no wider than half its radius, and a spotlight's
1626
+ * in front of the light but its lamp, a mesh around it whose surface is near it, and a spotlight's
1626
1627
  * shows from inside its beam only; a DirectionalLight3D's is far
1627
1628
  * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
1628
1629
  * sets this
@@ -1670,9 +1671,11 @@ class LensFlare3D extends EngineObject3D
1670
1671
  return this.made;
1671
1672
  }
1672
1673
 
1673
- // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
1674
- // directional light, which shines from its place toward the origin, and a point to find it on the screen by;
1675
- // undefined with no direction, or a light that is gone or at the camera
1674
+ /** What the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
1675
+ * directional light, which shines from its place toward the origin, and a point to find it on the screen by;
1676
+ * undefined with no direction, or a light that is gone or at the camera
1677
+ * @return {{direction: Vector3, distance: number, pos: Vector3}|undefined}
1678
+ * @ignore */
1676
1679
  flareSource()
1677
1680
  {
1678
1681
  const camera = render3D.camera.pos, light = this.light;
@@ -1688,15 +1691,17 @@ class LensFlare3D extends EngineObject3D
1688
1691
  return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
1689
1692
  }
1690
1693
 
1691
- // how the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
1692
- // fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
1693
- // show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
1694
+ /** How the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
1695
+ * fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
1696
+ * show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
1697
+ * @return {{sun: Vector2, center: Vector2, strength: number, tint: Color, height: number}|undefined}
1698
+ * @ignore */
1694
1699
  flareLook()
1695
1700
  {
1696
1701
  const source = this.flareSource(), center = mainCanvasSize.scale(.5);
1697
1702
  const sun = source && render3D.worldToScreen(source.pos);
1698
1703
  if (!sun || !center.x || !center.y) return;
1699
- // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
1704
+ // it fades as the sun leaves the screen, gone .3 of half the screen past the edge
1700
1705
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
1701
1706
  const strength = clamp((1.3 - off) / .5) * this.intensity;
1702
1707
  if (!(strength > 0)) return;
@@ -1762,21 +1767,30 @@ class LensFlare3D extends EngineObject3D
1762
1767
  for (const map of maps)
1763
1768
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
1764
1769
  return true;
1765
- // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
1766
- // is in, and no wider than half the light's reach, since a room or a whole level has a box around its lights
1767
- // too, and its walls do hide them from outside
1768
- const lamp = (o)=>
1770
+ // a light's lamp, a mesh around the light, does not hide it: one whose box, in its own space, the light is in,
1771
+ // hit close to the light, as a shade or a globe is, and small, its thinnest side no more than twice that; a
1772
+ // room or a whole level holds its lights too, and its walls do hide them from outside, even one close behind
1773
+ const inside = (o)=>
1769
1774
  {
1770
- if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
1775
+ if (!o.mesh || o instanceof HeightMap) return false;
1771
1776
  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;
1777
+ const m = matrix.m, scale = (k)=> hypot(m[k], m[k + 1], m[k + 2]); // each axis's own stretch
1778
+ const thinnest = min((b.max.x - b.min.x) * scale(0), (b.max.y - b.min.y) * scale(4), (b.max.z - b.min.z) * scale(8));
1779
+ if (!matrix.determinant() || thinnest > near * 2) return false;
1774
1780
  const p = matrix.copy().invert().transformPoint(source.pos);
1775
1781
  return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
1776
1782
  p.z >= b.min.z && p.z <= b.max.z;
1777
1783
  };
1778
- const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
1779
- return !!hit && hit.distance < reach;
1784
+ const near = this.light && reach < Infinity ? min(2, this.light.radius * .4) : 0;
1785
+ let candidates = blockers.filter((o)=> !maps.some((map)=> map === o));
1786
+ for (let tries = 5; tries--;)
1787
+ {
1788
+ const hit = render3D.pick(ray, candidates);
1789
+ if (!hit || hit.distance >= reach) return false;
1790
+ if (!(reach - hit.distance <= near && inside(hit.object))) return true;
1791
+ candidates = candidates.filter((o)=> o !== hit.object); // its lamp, look past it
1792
+ }
1793
+ return true; // lamps on lamps, take it as hidden
1780
1794
  }
1781
1795
 
1782
1796
  /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
@@ -1837,9 +1851,10 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
1837
1851
  // OBJ indices count from 1, and a negative one counts back from the end of the list so far
1838
1852
  const index = (s, list)=> { const i = parseInt(s); return i < 0 ? list.length + i : i - 1; };
1839
1853
  const lookup = (s, list)=> list[index(s, list)];
1840
- for (const line of text.split('\n'))
1854
+ // any line end, a line carried on by a backslash at its end, and a comment after what a line holds
1855
+ for (const line of text.replace(/\\\r?\n/g, ' ').split(/\r\n?|\n/))
1841
1856
  {
1842
- const parts = line.trim().split(/\s+/);
1857
+ const parts = line.replace(/#.*/, '').trim().split(/\s+/);
1843
1858
  switch (parts[0])
1844
1859
  {
1845
1860
  case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
@@ -1851,6 +1866,7 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
1851
1866
  if (corners.length < 3) break;
1852
1867
  const facePoints = corners.map(c=> lookup(c[0], positions));
1853
1868
  ASSERT(facePoints.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
1869
+ if (!facePoints.every(isVector3)) break; // a release build leaves the face out
1854
1870
  const hasNormals = corners.every(c=> c[2]);
1855
1871
  missingNormals ||= !hasNormals;
1856
1872
  const faceNormal = hasNormals ? undefined : render3DFaceNormal(facePoints[0], facePoints[1], facePoints[2], facePoints[3]);
@@ -198,18 +198,21 @@ function level3DTerrainShape(terrain)
198
198
  {
199
199
  if (!terrain || typeof terrain !== 'object') return;
200
200
  const size = terrain.size, rows = terrain.heights, n = LEVEL3D_TERRAIN_SAMPLES;
201
- if (!isArray(size) || size.length !== 2 || !size.every((v)=> isNumber(v) && v > 0)) return;
201
+ if (!isArray(size) || size.length !== 2 || !size.every((v)=> level3DFinite(v) && v > 0)) return;
202
202
  if (!isArray(rows) || rows.length < 2 || rows.length > n || !isArray(rows[0])) return;
203
203
  const columns = rows[0].length;
204
204
  if (columns < 2 || columns > n || !rows.every((row)=> isArray(row) && row.length === columns)) return;
205
- const heights = rows.map((row)=> row.map((v)=> isNumber(v) ? clamp(v) : 0));
205
+ const heights = rows.map((row)=> row.map((v)=> level3DFinite(v) ? clamp(v) : 0));
206
206
  return {pos: level3DVector(terrain.pos, vec3()), size: vec2(size[0], size[1]),
207
- height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
207
+ height: level3DFinite(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
208
208
  }
209
209
 
210
210
  // a hex color of a level as a Color, undefined when it is not one
211
211
  function level3DHexColor(hex)
212
- { return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
212
+ { return typeof hex === 'string' && /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
213
+
214
+ // a number of a level that is a real one, not NaN or Infinity, which JSON writes as 1e999
215
+ const level3DFinite = (v)=> typeof v === 'number' && isFinite(v);
213
216
 
214
217
  // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
215
218
  // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
@@ -248,7 +251,7 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
248
251
  let level3DVoxelPlain;
249
252
  function level3DVoxelPlainTiles()
250
253
  {
251
- if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
254
+ if (textureInfos[0]?.size.x || headlessMode || !canvasAvailable())
252
255
  return tile();
253
256
  if (!level3DVoxelPlain)
254
257
  {
@@ -289,12 +292,12 @@ function level3DSceneApply(scene)
289
292
  {
290
293
  const r = render3D;
291
294
  if (!r || !scene || typeof scene !== 'object') return;
292
- const color = (value)=> /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ? new Color().setHex(value) : undefined;
295
+ const color = level3DHexColor;
293
296
  const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky.map(color) : [];
294
297
  if (sky.length && sky.every((c)=> c))
295
298
  {
296
299
  // as setSky does, without disposing a dome that is the game's own
297
- const [top, horizon, bottom] = sky, ambient = isNumber(scene.ambient) ? scene.ambient : .5;
300
+ const [top, horizon, bottom] = sky, ambient = level3DFinite(scene.ambient) ? scene.ambient : .5;
298
301
  level3DSkies.has(r.sky) && r.sky.dispose();
299
302
  level3DSkies.add(r.sky = buildSky(top, horizon, bottom));
300
303
  r.fogColor = horizon.copy();
@@ -306,7 +309,7 @@ function level3DSceneApply(scene)
306
309
  r.sunDirection = sun;
307
310
  r.sunColor = color(scene.sunColor) || r.sunColor;
308
311
  const fog = scene.fog;
309
- if (isArray(fog) && fog.length === 2 && fog.every((v)=> isNumber(v)))
312
+ if (isArray(fog) && fog.length === 2 && fog.every(level3DFinite))
310
313
  r.fogStart = fog[0], r.fogEnd = fog[1];
311
314
  r.fogColor = color(scene.fogColor) || r.fogColor;
312
315
  if (typeof scene.shadows === 'boolean')
@@ -333,7 +336,7 @@ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3
333
336
  // a vec3 of an array of three numbers, as the file has them, or the fallback
334
337
  function level3DVector(value, fallback)
335
338
  {
336
- return isArray(value) && value.length === 3 && value.every((v)=> isNumber(v)) ?
339
+ return isArray(value) && value.length === 3 && value.every(level3DFinite) ?
337
340
  vec3(value[0], value[1], value[2]) : fallback;
338
341
  }
339
342
 
@@ -349,12 +352,14 @@ function level3DProperties(type, object)
349
352
  {
350
353
  const d = type.defaults[key];
351
354
  if (isColor(d))
352
- /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) && (properties[key] = new Color().setHex(value));
355
+ level3DHexColor(value) && (properties[key] = level3DHexColor(value));
353
356
  else if (isVector3(d))
354
357
  properties[key] = level3DVector(value, properties[key]);
355
358
  else if (isVector2(d))
356
- isArray(value) && value.length === 2 && value.every((v)=> isNumber(v)) &&
359
+ isArray(value) && value.length === 2 && value.every(level3DFinite) &&
357
360
  (properties[key] = vec2(value[0], value[1]));
361
+ else if (typeof d === 'number')
362
+ level3DFinite(value) && (properties[key] = value);
358
363
  else if (d === undefined || typeof value === typeof d)
359
364
  properties[key] = value;
360
365
  }
@@ -439,6 +444,8 @@ const level3DPrefabs = new Map;
439
444
  // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
440
445
  // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
441
446
  let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
447
+ // the prefabs being made, one inside the next, so one that holds itself is caught where it does, however many times
448
+ const level3DPrefabMaking = [];
442
449
 
443
450
  /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
444
451
  * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
@@ -580,11 +587,12 @@ class Prefab3D extends EngineObject3D
580
587
  {
581
588
  const prefab = level3DPrefabs.get(this.prefabName);
582
589
  if (!prefab) return;
583
- if (level3DPrefabDepth >= 8)
590
+ if (level3DPrefabMaking.includes(this.prefabName) || level3DPrefabDepth >= 8)
584
591
  {
585
592
  debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
586
593
  return;
587
594
  }
595
+ level3DPrefabMaking.push(this.prefabName);
588
596
  ++level3DPrefabDepth;
589
597
  this.attached && ++level3DPrefabAttached;
590
598
  try
@@ -628,6 +636,7 @@ class Prefab3D extends EngineObject3D
628
636
  finally
629
637
  {
630
638
  --level3DPrefabDepth;
639
+ level3DPrefabMaking.pop();
631
640
  this.attached && --level3DPrefabAttached;
632
641
  }
633
642
  }
@@ -256,8 +256,8 @@ class VoxelMap extends EngineObject3D
256
256
 
257
257
  // a double sided block shows the face from inside too, its corners the other way round
258
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]]);
259
+ mesh.addStrip([points[0], points[2], points[1], points[3]], normal.scale(-1),
260
+ [uvs[0], uvs[2], uvs[1], uvs[3]], [colors[0], colors[2], colors[1], colors[3]]);
261
261
  }
262
262
  }
263
263
  this.chunkMeshes[index]?.dispose();
@@ -7,6 +7,7 @@
7
7
  * - Animation frames keep layout and wrap across rows as needed
8
8
  * - WebGL textures upload once per batch of loads
9
9
  * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
10
+ * - loadTiles packs separate tile images, or several tile sheets, into one tile set for tile layers and maps
10
11
  * @namespace TextureSheets
11
12
  */
12
13
 
@@ -32,6 +33,12 @@ let textureSheets = [];
32
33
  // pending loads pack through a queue so sheets fill in call order
33
34
  let textureSheetQueue = Promise.resolve();
34
35
  let textureSheetPendingCount = 0;
36
+ // what each load handed back by what it was given, so the same image loaded again is packed once
37
+ const textureSheetLoaded = new Map;
38
+ // a load's key: what it was given, a vector by its numbers
39
+ const textureSheetLoadKey = (...parts)=> parts.map((p)=> isVector2(p) ? p.x + ',' + p.y : String(p)).join('|');
40
+ // a load whose file failed is forgotten, so loading it again tries again, as when the server comes back
41
+ const textureSheetLoadFailed = (key, loaded)=> textureSheetLoaded.get(key) === loaded && textureSheetLoaded.delete(key);
35
42
 
36
43
  /**
37
44
  * Texture Sheet - A texture that images are packed into as they load
@@ -188,6 +195,8 @@ class TextureSheet
188
195
  * - Pass frameSize for animations, then step through them with TileInfo.frame
189
196
  * - Grid images keep their layout and frames wrap down to the next row
190
197
  * - Pass sourcePadding if the source image has padding baked in around frames
198
+ * - The same image loaded again with the same settings gives back what the first load did, packed once, unless
199
+ * that load failed
191
200
  * @param {string} src - Image source path
192
201
  * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels, or the whole image less its
193
202
  * source padding if not passed
@@ -208,10 +217,14 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
208
217
 
209
218
  if (isNumber(frameSize))
210
219
  frameSize = vec2(/** @type {number} */ (frameSize));
220
+ const key = textureSheetLoadKey('sprite', src, frameSize, padding, sourcePadding);
221
+ if (!headlessMode && textureSheetLoaded.has(key))
222
+ return textureSheetLoaded.get(key);
211
223
 
212
224
  // start with an empty tile that gets filled in when the image loads
213
225
  const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
214
226
  if (headlessMode) return tileInfo;
227
+ textureSheetLoaded.set(key, tileInfo);
215
228
 
216
229
  // point at a sheet right away so drawing before it loads picks up empty pixels
217
230
  tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
@@ -238,7 +251,7 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
238
251
  if (!added)
239
252
  {
240
253
  // leave the tile empty, no sheet can hold it
241
- LOG('loadSprite image is too large to fit on a texture sheet:', src);
254
+ console.warn('loadSprite image is too large to fit on a texture sheet:', src);
242
255
  return;
243
256
  }
244
257
  Object.assign(tileInfo, added.tile);
@@ -247,13 +260,80 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
247
260
  else
248
261
  {
249
262
  // leave the tile empty if the image failed to load
250
- LOG('loadSprite failed to load image:', src);
263
+ console.warn('loadSprite failed to load image:', src);
264
+ textureSheetLoadFailed(key, tileInfo);
251
265
  }
252
266
  });
253
267
 
254
268
  return tileInfo;
255
269
  }
256
270
 
271
+ /** Load tile images and pack them into texture sheets as one tile set, for tile layers and maps
272
+ * - Each image is cut into tiles of tileSize, left to right then down, so an image of one tile is one tile and a
273
+ * sheet is all of its tiles; the tiles are numbered from 0 in the order the images are given
274
+ * - Returns a tile set at once, a TileInfo whose tiles fill in as the images load; wait for them with spritesReady
275
+ * - Give it to tileLayersLoad, a TileLayer or a TileCollisionLayer as its tile info: tile n draws tiles[n],
276
+ * wherever it was packed, and the level editor's palette offers each of them; frame and index do not read the
277
+ * list, use set.tiles[n] for one tile; make the layers after spritesReady, a layer made before draws nothing
278
+ * - An image that is not a whole number of tiles gives the whole tiles in it; a sheet with gaps between its tiles
279
+ * is not read, cut it into its tiles first
280
+ * - An image that fails to load, or that no sheet can hold, adds no tiles and says so in the console, so the tiles
281
+ * of the images after it move up
282
+ * - The same images loaded again with the same settings give back what the first load did, packed once, unless
283
+ * an image of it failed
284
+ * @param {Array<string>} sources - Image source paths
285
+ * @param {Vector2|number} [tileSize] - Size of a tile in pixels
286
+ * @param {number} [padding] - How many pixels padding around each tile on the sheet
287
+ * @return {TileInfo}
288
+ * @example
289
+ * const tiles = loadTiles(['grass.png', 'dirt.png', 'water.png', 'props.png'], 16);
290
+ * await spritesReady();
291
+ * tileLayersLoad(map, tiles); // tile 0 is grass, 1 dirt, 2 water, then the tiles of props.png
292
+ * @memberof TextureSheets */
293
+ function loadTiles(sources, tileSize=tileDefaultSize, padding=textureSheetPadding)
294
+ {
295
+ ASSERT(isArray(sources) && sources.every((src)=> isStringLike(src)), 'sources must be a list of image paths');
296
+ ASSERT(isVector2(tileSize) || isNumber(tileSize), 'tileSize must be a vec2 or number');
297
+ ASSERT(isNumber(padding), 'padding must be a number');
298
+ ASSERT(engineInitialized || headlessMode, 'call loadTiles after engineInit, e.g. in gameInit');
299
+ const size = isNumber(tileSize) ? vec2(/** @type {number} */ (tileSize)) : /** @type {Vector2} */ (tileSize).copy();
300
+ const key = textureSheetLoadKey('tiles', sources.join('\n'), size, padding);
301
+ if (!headlessMode && textureSheetLoaded.has(key))
302
+ return textureSheetLoaded.get(key);
303
+ const set = new TileInfo(vec2(), size, undefined, 0, 0);
304
+ set.tiles = [];
305
+ if (headlessMode) return set;
306
+ textureSheetLoaded.set(key, set);
307
+ set.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
308
+
309
+ // every image decodes at once, and packs through the queue in the order given, so the tiles keep that order
310
+ for (const src of sources)
311
+ {
312
+ const image = new Image;
313
+ const imagePromise = new Promise((resolve)=>
314
+ {
315
+ image.onerror = image.onload = resolve;
316
+ image.crossOrigin = 'anonymous';
317
+ image.src = src;
318
+ });
319
+ textureSheetQueueJob('loadTiles ' + src, async ()=>
320
+ {
321
+ await imagePromise;
322
+ // the whole tiles of it, an edge past the last one left out
323
+ const columns = image.width / size.x | 0, rows = image.height / size.y | 0, count = columns * rows;
324
+ const added = count && textureSheetAdd(vec2(columns * size.x, rows * size.y), size, padding, 0);
325
+ image.width || textureSheetLoadFailed(key, set);
326
+ if (!added)
327
+ return console.warn('loadTiles: ' + src + (count ? ' does not fit on a texture sheet' :
328
+ ' failed to load, or is smaller than a tile') + ', its tiles are left out');
329
+ added.sheet.drawImage(image, added.tile, false); // upload once per batch
330
+ for (let k = 0; k < count; ++k)
331
+ set.tiles.push(added.tile.frame(k));
332
+ });
333
+ }
334
+ return set;
335
+ }
336
+
257
337
  /** Load a pre-packed texture atlas and repack it onto texture sheets
258
338
  * - Supports TexturePacker json (hash and array) and Aseprite json
259
339
  * - Returns an empty object which is filled with TileInfos when loaded
@@ -261,6 +341,8 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
261
341
  * - Aseprite frame tags become animations, so do names like run_0, run_1
262
342
  * - Trimmed frames are restored to their full source size when packed
263
343
  * - Rotated frames are rotated back upright when packed
344
+ * - The same atlas loaded again by its paths, with the same padding, gives back what the first load did, packed
345
+ * once, unless that load failed
264
346
  * @param {string} imageSrc - Atlas image path
265
347
  * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
266
348
  * @param {number} [padding] - How many pixels padding around each frame
@@ -278,9 +360,15 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
278
360
  ASSERT(isNumber(padding), 'padding must be a number');
279
361
  ASSERT(engineInitialized || headlessMode, 'call loadAtlas after engineInit, e.g. in gameInit');
280
362
 
363
+ // json passed in as data may be another atlas each time, only one loaded by its path is kept
364
+ const key = typeof jsonSrc === 'object' ? undefined : textureSheetLoadKey('atlas', imageSrc, jsonSrc, padding);
365
+ if (!headlessMode && textureSheetLoaded.has(key))
366
+ return textureSheetLoaded.get(key);
367
+
281
368
  /** @type {Object<string, TileInfo>} */
282
369
  const atlas = {};
283
370
  if (headlessMode) return atlas;
371
+ key && textureSheetLoaded.set(key, atlas);
284
372
 
285
373
  // start fetching the json and decoding the image right away, in parallel
286
374
  const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
@@ -309,7 +397,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
309
397
  const added = textureSheetAdd(blockSize, sourceSize, padding);
310
398
  if (!added)
311
399
  {
312
- LOG('loadAtlas frames are too large to fit on a texture sheet:', group.name);
400
+ console.warn('loadAtlas frames are too large to fit on a texture sheet:', group.name);
313
401
  continue;
314
402
  }
315
403
  const {sheet, tile} = added;
@@ -343,7 +431,8 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
343
431
  else
344
432
  {
345
433
  // leave the atlas empty if either file failed to load
346
- LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
434
+ console.warn('loadAtlas failed to load:', imageSrc, jsonSrc);
435
+ textureSheetLoadFailed(key, atlas);
347
436
  }
348
437
  });
349
438
 
@@ -401,12 +490,14 @@ function parseAtlas(data)
401
490
  const tagged = new Set;
402
491
  for (const tag of tags)
403
492
  {
404
- groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
405
- for (let i = tag.from; i <= tag.to; ++i)
493
+ // a tag reaches only frames there are, whatever its numbers say
494
+ const from = max(0, floor(tag.from) || 0), to = min(frames.length - 1, floor(tag.to) || 0);
495
+ groups.push({name: tag.name, frames: frames.slice(from, to + 1)});
496
+ for (let i = from; i <= to; ++i)
406
497
  tagged.add(i);
407
498
  }
408
499
  frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
409
- return groups;
500
+ return parseAtlasNamesCheck(groups);
410
501
  }
411
502
 
412
503
  // group frames that share a name stem with contiguous trailing numbers
@@ -435,6 +526,17 @@ function parseAtlas(data)
435
526
  else
436
527
  list.forEach(f=> groups.push({name: f.name, frames: [f]}));
437
528
  }
529
+ return parseAtlasNamesCheck(groups);
530
+ }
531
+
532
+ // an atlas's groups, with a warning for each name two of them have, as a sprite is looked up by name and the second
533
+ // takes the first's place
534
+ function parseAtlasNamesCheck(groups)
535
+ {
536
+ const names = new Set;
537
+ for (const {name} of groups)
538
+ names.has(name) ? console.warn(`parseAtlas: two sprites are named ${name}, the second takes the first's place`) :
539
+ names.add(name);
438
540
  return groups;
439
541
  }
440
542
 
@@ -379,7 +379,7 @@ function tweakNumber(parent, axis, get, set, options, save)
379
379
  const change = (input)=>
380
380
  {
381
381
  const v = parseFloat(input.value);
382
- if (!isNumber(v)) return; // a box part way through typing
382
+ if (!isFinite(v)) return; // a box part way through typing, or 1e999
383
383
  set(v);
384
384
  save();
385
385
  };
@@ -510,14 +510,14 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
510
510
  function tweenCarryOvershoot(tween)
511
511
  {
512
512
  const duration = tween.duration;
513
- tween.life = duration ? min(tween.life, 0) % duration + duration : 1e-9;
513
+ tween.life = duration > 0 ? min(tween.life, 0) % duration + duration : 1e-9;
514
514
  }
515
515
 
516
516
  // How many iterations the update that finished one ran through: that one and every whole one after it
517
517
  function tweenPassed(tween)
518
518
  {
519
519
  const duration = tween.duration;
520
- return duration ? 1 + floor(-min(tween.life, 0) / duration) : 1;
520
+ return duration > 0 ? 1 + floor(-min(tween.life, 0) / duration) : 1; // no time, or less, is one each update
521
521
  }
522
522
 
523
523
  // start the next iteration of a loop or pingPong, the time the last one ran over already spent, true
@@ -747,6 +747,8 @@ class UISystemPlugin
747
747
  showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
748
748
  {
749
749
  ASSERT(!uiSystem.confirmDialog, 'a confirm dialog is already open, check uiSystem.confirmDialog');
750
+ if (uiSystem.confirmDialog)
751
+ return uiSystem.confirmDialog; // in a release build, the one open, as a second would break navigation
750
752
 
751
753
  const savedNavigationDirection = uiSystem.navigationDirection;
752
754
  const savedNavigationObject = uiSystem.navigationObject;
@@ -1412,7 +1414,7 @@ class UITextInput extends UIObject
1412
1414
  onKeyDown(e)
1413
1415
  {
1414
1416
  // named keys by key, so numpad Enter works as Enter
1415
- const code = e.code, key = e.key;
1417
+ const code = e.code, key = e.key || ''; // autofill sends a keydown with no key
1416
1418
  if (e.repeat && (key === 'Enter' || code === 'Space'))
1417
1419
  return; // a key held when editing began repeats, it should not type or stop editing
1418
1420
  this.text += ''; // a game may have set a number
@@ -1646,12 +1648,14 @@ class UISlider extends UIObject
1646
1648
  }
1647
1649
  update()
1648
1650
  {
1651
+ // held when the frame began, so a release in the same frame as the last move still reads it
1652
+ const wasActive = this.isActiveObject();
1649
1653
  super.update();
1650
1654
  if (!this.interactive)
1651
1655
  return;
1652
1656
 
1653
1657
  const oldValue = this.value;
1654
- if (this.isActiveObject())
1658
+ if (wasActive || this.isActiveObject())
1655
1659
  {
1656
1660
  // handle horizontal or vertical slider
1657
1661
  const isHorizontal = this.size.x > this.size.y;