littlejsengine 1.24.0 → 1.24.2

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 (43) hide show
  1. package/CHANGELOG.md +86 -1
  2. package/EDITOR.md +2 -0
  3. package/FAQ.md +13 -2
  4. package/README.md +2 -2
  5. package/REFERENCE.md +27 -10
  6. package/dist/littlejs.d.ts +104 -29
  7. package/dist/littlejs.esm.js +2499 -1979
  8. package/dist/littlejs.esm.min.js +3 -3
  9. package/dist/littlejs.js +2496 -1979
  10. package/dist/littlejs.min.js +2 -2
  11. package/dist/littlejs.release.js +2438 -1983
  12. package/package.json +1 -1
  13. package/plugins/box2d.js +1 -1
  14. package/plugins/gltf.js +62 -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 +134 -57
  22. package/plugins/render3dEditor.js +28 -12
  23. package/plugins/render3dExtras.js +32 -17
  24. package/plugins/render3dLevel.js +21 -12
  25. package/plugins/render3dVoxels.js +15 -8
  26. package/plugins/textureSheet.js +97 -3
  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 +34 -11
  31. package/src/engineAudio.js +13 -3
  32. package/src/engineDebug.js +4 -1
  33. package/src/engineDraw.js +29 -16
  34. package/src/engineEditor.js +99 -56
  35. package/src/engineExport.js +2 -0
  36. package/src/engineInput.js +17 -3
  37. package/src/engineMath.js +6 -2
  38. package/src/engineObject.js +675 -675
  39. package/src/engineParticles.js +1 -1
  40. package/src/engineSettings.js +21 -1
  41. package/src/engineTileLayer.js +142 -43
  42. package/src/engineUtilities.js +20 -8
  43. package/src/engineWebGL.js +12 -8
@@ -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
  }
@@ -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[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]]);
254
261
  }
255
262
  }
256
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,10 @@ 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('|');
35
40
 
36
41
  /**
37
42
  * Texture Sheet - A texture that images are packed into as they load
@@ -188,6 +193,7 @@ class TextureSheet
188
193
  * - Pass frameSize for animations, then step through them with TileInfo.frame
189
194
  * - Grid images keep their layout and frames wrap down to the next row
190
195
  * - Pass sourcePadding if the source image has padding baked in around frames
196
+ * - The same image loaded again with the same settings gives back what the first load did, packed once
191
197
  * @param {string} src - Image source path
192
198
  * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels, or the whole image less its
193
199
  * source padding if not passed
@@ -208,10 +214,14 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
208
214
 
209
215
  if (isNumber(frameSize))
210
216
  frameSize = vec2(/** @type {number} */ (frameSize));
217
+ const key = textureSheetLoadKey('sprite', src, frameSize, padding, sourcePadding);
218
+ if (!headlessMode && textureSheetLoaded.has(key))
219
+ return textureSheetLoaded.get(key);
211
220
 
212
221
  // start with an empty tile that gets filled in when the image loads
213
222
  const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
214
223
  if (headlessMode) return tileInfo;
224
+ textureSheetLoaded.set(key, tileInfo);
215
225
 
216
226
  // point at a sheet right away so drawing before it loads picks up empty pixels
217
227
  tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
@@ -254,6 +264,70 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
254
264
  return tileInfo;
255
265
  }
256
266
 
267
+ /** Load tile images and pack them into texture sheets as one tile set, for tile layers and maps
268
+ * - Each image is cut into tiles of tileSize, left to right then down, so an image of one tile is one tile and a
269
+ * sheet is all of its tiles; the tiles are numbered from 0 in the order the images are given
270
+ * - Returns a tile set at once, a TileInfo whose tiles fill in as the images load; wait for them with spritesReady
271
+ * - Give it to tileLayersLoad, a TileLayer or a TileCollisionLayer as its tile info: tile n draws tiles[n],
272
+ * wherever it was packed, and the level editor's palette offers each of them; frame and index do not read the
273
+ * list, use set.tiles[n] for one tile; make the layers after spritesReady, a layer made before draws nothing
274
+ * - An image that is not a whole number of tiles gives the whole tiles in it; a sheet with gaps between its tiles
275
+ * is not read, cut it into its tiles first
276
+ * - An image that fails to load, or that no sheet can hold, adds no tiles and says so in the console, so the tiles
277
+ * of the images after it move up
278
+ * - The same images loaded again with the same settings give back what the first load did, packed once
279
+ * @param {Array<string>} sources - Image source paths
280
+ * @param {Vector2|number} [tileSize] - Size of a tile in pixels
281
+ * @param {number} [padding] - How many pixels padding around each tile on the sheet
282
+ * @return {TileInfo}
283
+ * @example
284
+ * const tiles = loadTiles(['grass.png', 'dirt.png', 'water.png', 'props.png'], 16);
285
+ * await spritesReady();
286
+ * tileLayersLoad(map, tiles); // tile 0 is grass, 1 dirt, 2 water, then the tiles of props.png
287
+ * @memberof TextureSheets */
288
+ function loadTiles(sources, tileSize=tileDefaultSize, padding=textureSheetPadding)
289
+ {
290
+ ASSERT(isArray(sources) && sources.every((src)=> isStringLike(src)), 'sources must be a list of image paths');
291
+ ASSERT(isVector2(tileSize) || isNumber(tileSize), 'tileSize must be a vec2 or number');
292
+ ASSERT(isNumber(padding), 'padding must be a number');
293
+ ASSERT(engineInitialized || headlessMode, 'call loadTiles after engineInit, e.g. in gameInit');
294
+ const size = isNumber(tileSize) ? vec2(/** @type {number} */ (tileSize)) : /** @type {Vector2} */ (tileSize).copy();
295
+ const key = textureSheetLoadKey('tiles', sources.join('\n'), size, padding);
296
+ if (!headlessMode && textureSheetLoaded.has(key))
297
+ return textureSheetLoaded.get(key);
298
+ const set = new TileInfo(vec2(), size, undefined, 0, 0);
299
+ set.tiles = [];
300
+ if (headlessMode) return set;
301
+ textureSheetLoaded.set(key, set);
302
+ set.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
303
+
304
+ // every image decodes at once, and packs through the queue in the order given, so the tiles keep that order
305
+ for (const src of sources)
306
+ {
307
+ const image = new Image;
308
+ const imagePromise = new Promise((resolve)=>
309
+ {
310
+ image.onerror = image.onload = resolve;
311
+ image.crossOrigin = 'anonymous';
312
+ image.src = src;
313
+ });
314
+ textureSheetQueueJob('loadTiles ' + src, async ()=>
315
+ {
316
+ await imagePromise;
317
+ // the whole tiles of it, an edge past the last one left out
318
+ const columns = image.width / size.x | 0, rows = image.height / size.y | 0, count = columns * rows;
319
+ const added = count && textureSheetAdd(vec2(columns * size.x, rows * size.y), size, padding, 0);
320
+ if (!added)
321
+ return console.warn('loadTiles: ' + src + (count ? ' does not fit on a texture sheet' :
322
+ ' failed to load, or is smaller than a tile') + ', its tiles are left out');
323
+ added.sheet.drawImage(image, added.tile, false); // upload once per batch
324
+ for (let k = 0; k < count; ++k)
325
+ set.tiles.push(added.tile.frame(k));
326
+ });
327
+ }
328
+ return set;
329
+ }
330
+
257
331
  /** Load a pre-packed texture atlas and repack it onto texture sheets
258
332
  * - Supports TexturePacker json (hash and array) and Aseprite json
259
333
  * - Returns an empty object which is filled with TileInfos when loaded
@@ -261,6 +335,7 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
261
335
  * - Aseprite frame tags become animations, so do names like run_0, run_1
262
336
  * - Trimmed frames are restored to their full source size when packed
263
337
  * - Rotated frames are rotated back upright when packed
338
+ * - The same atlas loaded again by its paths, with the same padding, gives back what the first load did, packed once
264
339
  * @param {string} imageSrc - Atlas image path
265
340
  * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
266
341
  * @param {number} [padding] - How many pixels padding around each frame
@@ -278,9 +353,15 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
278
353
  ASSERT(isNumber(padding), 'padding must be a number');
279
354
  ASSERT(engineInitialized || headlessMode, 'call loadAtlas after engineInit, e.g. in gameInit');
280
355
 
356
+ // json passed in as data may be another atlas each time, only one loaded by its path is kept
357
+ const key = typeof jsonSrc === 'object' ? undefined : textureSheetLoadKey('atlas', imageSrc, jsonSrc, padding);
358
+ if (!headlessMode && textureSheetLoaded.has(key))
359
+ return textureSheetLoaded.get(key);
360
+
281
361
  /** @type {Object<string, TileInfo>} */
282
362
  const atlas = {};
283
363
  if (headlessMode) return atlas;
364
+ key && textureSheetLoaded.set(key, atlas);
284
365
 
285
366
  // start fetching the json and decoding the image right away, in parallel
286
367
  const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
@@ -401,12 +482,14 @@ function parseAtlas(data)
401
482
  const tagged = new Set;
402
483
  for (const tag of tags)
403
484
  {
404
- groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
405
- for (let i = tag.from; i <= tag.to; ++i)
485
+ // a tag reaches only frames there are, whatever its numbers say
486
+ const from = max(0, floor(tag.from) || 0), to = min(frames.length - 1, floor(tag.to) || 0);
487
+ groups.push({name: tag.name, frames: frames.slice(from, to + 1)});
488
+ for (let i = from; i <= to; ++i)
406
489
  tagged.add(i);
407
490
  }
408
491
  frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
409
- return groups;
492
+ return parseAtlasNamesCheck(groups);
410
493
  }
411
494
 
412
495
  // group frames that share a name stem with contiguous trailing numbers
@@ -435,6 +518,17 @@ function parseAtlas(data)
435
518
  else
436
519
  list.forEach(f=> groups.push({name: f.name, frames: [f]}));
437
520
  }
521
+ return parseAtlasNamesCheck(groups);
522
+ }
523
+
524
+ // 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
525
+ // takes the first's place
526
+ function parseAtlasNamesCheck(groups)
527
+ {
528
+ const names = new Set;
529
+ for (const {name} of groups)
530
+ names.has(name) ? console.warn(`parseAtlas: two sprites are named ${name}, the second takes the first's place`) :
531
+ names.add(name);
438
532
  return groups;
439
533
  }
440
534
 
@@ -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;
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.2';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -165,6 +165,7 @@ let timeFixedStart = 0, frameFixedStart = 0;
165
165
  let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
166
166
  let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
167
167
  let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
168
+ let engineFrameErrors = 0; // errors a release build's loop went on past
168
169
 
169
170
  // the pairs of objects asked about a collision this update, so the other's own physics does not ask again: each
170
171
  // asker's others, with true for a pair both said to resolve, and false for one left overlapping, ignored or only
@@ -450,7 +451,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
450
451
  o.parent || o.updateTransforms();
451
452
 
452
453
  // objects made and destroyed while paused, like a menu's effects, still leave the list
453
- engineObjects = engineObjects.filter(o=>!o.destroyed);
454
+ engineObjects.some(o=>o.destroyed) && (engineObjects = engineObjects.filter(o=>!o.destroyed));
454
455
  }
455
456
  else
456
457
  engineObjectsUpdate();
@@ -497,7 +498,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
497
498
  // render the game and objects
498
499
  enginePreRender();
499
500
  gameRender();
500
- engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
501
+ engineObjectsSort();
501
502
  for (const o of engineObjects)
502
503
  {
503
504
  if (o.destroyed) continue;
@@ -600,7 +601,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
600
601
  }
601
602
 
602
603
  // the splash first, the images load under it, then the loading screen for the rest
603
- showSplashScreen && await promises.at(-1);
604
+ showSplashScreen && await promises[promises.length - 1];
604
605
  return startEngine(promises);
605
606
 
606
607
  // gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
@@ -723,7 +724,16 @@ function engineScheduleFrame()
723
724
  const next = (frameTimeMS)=>
724
725
  {
725
726
  engineFrameScheduled = false;
726
- engineManualStep || engineUpdateInternal(frameTimeMS);
727
+ if (engineManualStep) return;
728
+ if (debug) return engineUpdateInternal(frameTimeMS); // a debug build stops at an error, where it shows it
729
+ try { engineUpdateInternal(frameTimeMS); }
730
+ catch (error)
731
+ {
732
+ // a release build goes on past an error in a frame, a frozen game is the worst a player can get; only the
733
+ // first is logged, one every frame would flood the console
734
+ engineFrameErrors++ || console.error(error);
735
+ engineScheduleFrame();
736
+ }
727
737
  };
728
738
  if (typeof requestAnimationFrame === 'function')
729
739
  requestAnimationFrame(next);
@@ -769,14 +779,18 @@ function engineObjectsUpdate()
769
779
  ++engineObjectsUpdateCount;
770
780
  engineObjectsCollidePairs.clear();
771
781
  // objects update in render order, which rendering keeps them in, so a headless run or a frame that rendered
772
- // nothing updates them the same way; the sort is stable, and nearly free on a list that is already sorted
773
- engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
782
+ // nothing updates them the same way
783
+ engineObjectsSort();
774
784
  // get list of solid objects for physics optimization, in update order, which 3D collision pairs by;
775
785
  // 2D checks the static ones last, so a contact with a moving object can not leave something back inside a static
776
786
  // solid it was already pushed out of
777
- engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
778
- engineObjectsCollideStaticLast = engineObjectsCollide.filter(o=>o.mass)
779
- .concat(engineObjectsCollide.filter(o=>!o.mass));
787
+ const fixed = [];
788
+ engineObjectsCollide = [];
789
+ engineObjectsCollideStaticLast = [];
790
+ for (const o of engineObjects)
791
+ o.collideSolidObjects && (engineObjectsCollide.push(o), (o.mass ? engineObjectsCollideStaticLast : fixed).push(o));
792
+ for (const o of fixed)
793
+ engineObjectsCollideStaticLast.push(o);
780
794
 
781
795
  // update physics before object update
782
796
  for (const o of engineObjects)
@@ -826,7 +840,16 @@ function engineObjectsUpdate()
826
840
  updateTopObject(o);
827
841
 
828
842
  // remove destroyed objects
829
- engineObjects = engineObjects.filter(o=>!o.destroyed);
843
+ engineObjects.some(o=>o.destroyed) && (engineObjects = engineObjects.filter(o=>!o.destroyed));
844
+ }
845
+
846
+ // sort the objects by render order, keeping the order of equals, only when one is out of order, as they are kept
847
+ // sorted and most frames change none
848
+ function engineObjectsSort()
849
+ {
850
+ for (let i = engineObjects.length; --i > 0;)
851
+ if (engineObjects[i].renderOrder < engineObjects[i-1].renderOrder)
852
+ return void engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
830
853
  }
831
854
 
832
855
  /** Destroy and remove all objects
@@ -58,9 +58,19 @@ function audioInit()
58
58
  {
59
59
  if (!soundEnable || headlessMode) return;
60
60
 
61
+ audioSetSession();
61
62
  document.addEventListener('visibilitychange', audioVisibilityChange);
62
63
  }
63
64
 
65
+ // on an iPhone the silent switch mutes the page's sound, unless soundIgnoreSilentSwitch has it play as media,
66
+ // Safari 16.4 and up; elsewhere there is no audioSession and nothing changes
67
+ function audioSetSession()
68
+ {
69
+ const session = typeof navigator != 'undefined' && /** @type {any} */ (navigator).audioSession;
70
+ if (session)
71
+ session.type = soundIgnoreSilentSwitch ? 'playback' : 'auto';
72
+ }
73
+
64
74
  // a hidden page stops the game, so its sound stops too, and the audio clock with it so every sound picks up
65
75
  // exactly where it was; only a suspend made here is undone, not one the browser holds until the first input
66
76
  let audioSuspendedWhenHidden = false;
@@ -203,13 +213,13 @@ function audioParamRamp(param, value, fadeTime=0)
203
213
  * @memberof Audio
204
214
  * @example
205
215
  * // load an audio asset file
206
- * const sound_example = new Sound('sound.mp3');
216
+ * const music = new Sound('sound.mp3');
207
217
  *
208
218
  * // create a zzfx sound
209
- * const sound_example = new Sound([.5,.5]);
219
+ * const blip = new Sound([.5,.5]);
210
220
  *
211
221
  * // play a sound
212
- * sound_example.play();
222
+ * blip.play();
213
223
  */
214
224
  class Sound
215
225
  {
@@ -269,7 +269,10 @@ function debugShowErrors()
269
269
  {
270
270
  // replace entire page with error message
271
271
  document.body.style.cssText = 'background-color:#111;margin:8px';
272
- document.body.innerHTML = `<pre style=color:#f00;font-size:28px;white-space:pre-wrap>` + message;
272
+ const pre = document.createElement('pre'); // the message as text, a file name in it never markup
273
+ pre.style.cssText = 'color:#f00;font-size:28px;white-space:pre-wrap';
274
+ pre.textContent = String(message);
275
+ document.body.replaceChildren(pre);
273
276
  }
274
277
 
275
278
  const originalAssert = console.assert;