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
package/src/engineDraw.js CHANGED
@@ -182,6 +182,10 @@ class TileInfo
182
182
  this.padding = padding;
183
183
  /** @property {TextureInfo} - The texture info for this tile */
184
184
  this.textureInfo = textureInfo;
185
+ /** @property {Array<TileInfo>|undefined} - A tile set's tiles, each wherever it was packed, as loadTiles makes
186
+ * them: a tile layer given this tile info draws its tile n from tiles[n] and not from a grid on one sheet
187
+ * @type {Array<TileInfo>|undefined} */
188
+ this.tiles = undefined;
185
189
  /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
186
190
  this.bleed = bleed;
187
191
  /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
@@ -868,13 +872,15 @@ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), ang
868
872
  if (useWebGL && glEnable)
869
873
  {
870
874
  ASSERT(!!glContext, 'WebGL is not enabled!');
871
- let size = vec2(1);
875
+ let sx = 1, sy = 1;
872
876
  if (screenSpace)
873
877
  {
874
878
  if (glSkipScreenSpace) return;
875
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
879
+ let size;
880
+ [pos, size, angle] = screenToWorldTransform(pos, vec2(1), angle);
881
+ sx = size.x, sy = size.y;
876
882
  }
877
- glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
883
+ glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, sx, sy, angle, wrap);
878
884
  }
879
885
  else
880
886
  {
@@ -940,17 +946,22 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
940
946
  ASSERT(isVector2(size), 'size must be a vec2');
941
947
  ASSERT(isNumber(sides), 'sides must be a number');
942
948
 
943
- // build regular polygon points
944
- const points = [];
949
+ // build regular polygon points, into vectors kept for the next, as drawPoly is done with them when it returns
950
+ const points = drawRegularPolyPoints, pool = drawRegularPolyPool;
951
+ points.length = 0;
945
952
  const sizeX = size.x/2, sizeY = size.y/2;
946
953
  for (let i=sides; i-- > 0;) // a count that is not whole, or below zero, still ends
947
954
  {
948
- const a = (i/sides)*PI*2;
949
- points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
955
+ const a = (i/sides)*PI*2, point = pool[points.length] ||= vec2();
956
+ point.x = sin(a)*sizeX, point.y = cos(a)*sizeY;
957
+ points.push(point);
950
958
  }
951
959
  drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
952
960
  }
953
961
 
962
+ // the points drawRegularPoly draws and the vectors it writes them into
963
+ const drawRegularPolyPoints = [], drawRegularPolyPool = [];
964
+
954
965
  /** Draw colored polygon using passed in points
955
966
  * - WebGL fills a polygon whose edges do not cross, concave or not; a self crossing one, like a star through its
956
967
  * outer points, fills wrong there, so draw it as its simple outline or in parts
@@ -978,15 +989,17 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
978
989
  if (useWebGL && glEnable)
979
990
  {
980
991
  ASSERT(!!glContext, 'WebGL is not enabled!');
981
- let size = vec2(1);
992
+ let sx = 1, sy = 1;
982
993
  if (screenSpace)
983
994
  {
984
995
  if (glSkipScreenSpace) return;
985
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
996
+ let size;
997
+ [pos, size, angle] = screenToWorldTransform(pos, vec2(1), angle);
998
+ sx = size.x, sy = size.y;
986
999
  }
987
- glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
1000
+ glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, sx, sy, angle);
988
1001
  if (lineWidth > 0)
989
- glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
1002
+ glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, sx, sy, angle);
990
1003
  }
991
1004
  else
992
1005
  {
@@ -1631,7 +1644,7 @@ function tintImageData(data, color, additiveColor)
1631
1644
  if (additiveColor && !isBlack(additiveColor))
1632
1645
  {
1633
1646
  // multiply + additive (slower), color.a is baked into the alpha channel here
1634
- const colorMultiply = [color.r, color.g, color.b, color.a];
1647
+ const colorMultiply = [clamp(color.r), clamp(color.g), clamp(color.b), clamp(color.a)];
1635
1648
  const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1636
1649
  additiveColor.b * 255, additiveColor.a * 255];
1637
1650
  for (let i = 0; i < data.length; ++i)
@@ -1642,9 +1655,9 @@ function tintImageData(data, color, additiveColor)
1642
1655
  // RGB only, faster — alpha left intact for the caller
1643
1656
  for (let i = 0; i < data.length; i+=4)
1644
1657
  {
1645
- data[i ] *= color.r;
1646
- data[i+1] *= color.g;
1647
- data[i+2] *= color.b;
1658
+ data[i ] *= clamp(color.r);
1659
+ data[i+1] *= clamp(color.g);
1660
+ data[i+2] *= clamp(color.b);
1648
1661
  }
1649
1662
  return false;
1650
1663
  }
@@ -1896,7 +1909,7 @@ function engineGlowTexture(falloff=1)
1896
1909
  ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
1897
1910
  const key = max(round(falloff * 10), 1) / 10;
1898
1911
  let texture = engineGlowTextures.get(key);
1899
- if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
1912
+ if (texture || !glContext || !canvasAvailable()) return texture;
1900
1913
  const size = 64, context = createCanvasContext(size), steps = 16;
1901
1914
  const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
1902
1915
  for (let i = 0; i <= steps; ++i)
@@ -453,7 +453,7 @@ function editorSetOpen(open)
453
453
  // the layer edited last, when it is still there, or the collision layer
454
454
  const layers = editorLayers();
455
455
  layers.includes(editorLayer) || (editorLayer = layers.find((layer)=> layer.live?.isSolid) ??
456
- layers.filter((layer)=> !layer.isObjects).at(-1));
456
+ layers.filter((layer)=> !layer.isObjects).pop());
457
457
  layers.includes(editorObjectLayer) || (editorObjectLayer = editorLayer ? undefined :
458
458
  layers.find((layer)=> layer.isObjects)); // a level of objects alone
459
459
  editorCall('onOpen');
@@ -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, ` +
@@ -654,7 +654,7 @@ function editorLayerRecord(live)
654
654
  function editorTilesets(live)
655
655
  {
656
656
  const tileInfo = live.tileInfo, image = tileInfo?.textureInfo?.image;
657
- if (!image) return [];
657
+ if (!image || tileInfo.tiles) return []; // a tile set is packed from many images, none of them a Tiled tileset
658
658
  const {x: tilewidth, y: tileheight} = tileInfo.size, padding = tileInfo.padding;
659
659
  const columns = tileInfo.columns || floor(image.width / (tilewidth + padding*2));
660
660
  const rows = floor(image.height / (tileheight + padding*2));
@@ -670,7 +670,17 @@ function editorJSONFetched(url, json)
670
670
  { json && typeof json === 'object' && editorFetchedURLs.set(json, String(url)); }
671
671
 
672
672
  // a map as Tiled JSON, everything it was loaded with kept, the tile data as the editor left it
673
- function editorMapJSON(record) { return JSON.stringify(record.map); }
673
+ function editorMapJSON(record)
674
+ {
675
+ // an object layer the editor made for a first object, every object of which was taken back, is left out
676
+ const map = record.map, made = map.layers.find((layer)=> editorMadeGroups.has(layer) && !layer.objects.length);
677
+ if (!made) return JSON.stringify(map);
678
+ return JSON.stringify({...map, layers: map.layers.filter((layer)=> layer !== made),
679
+ ...(map.nextlayerid === made.id + 1 && {nextlayerid: made.id})});
680
+ }
681
+
682
+ // the object layers the editor made in a map, for a map that had none
683
+ const editorMadeGroups = new WeakSet;
674
684
 
675
685
  // the files picked to save maps to, kept across reloads in IndexedDB by page and map, which is where a browser
676
686
  // lets a page keep them; each resolves to undefined when the store can not be used
@@ -870,6 +880,11 @@ function editorCopyData(data, saved)
870
880
  return true;
871
881
  }
872
882
 
883
+ // 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
884
+ // that fit it
885
+ const editorSavedFits = (saved, count, width, height)=> isArray(saved.layers) && saved.layers.length === count &&
886
+ saved.layers.every((layer)=> isArray(layer) && layer.length === width * height);
887
+
873
888
  // remember a map's tile data and objects, or forget them when they are back to the file
874
889
  function editorAutosave(record)
875
890
  {
@@ -877,7 +892,7 @@ function editorAutosave(record)
877
892
  const saves = editorSaves(), map = record.map, data = editorTileLayerData(map.layers);
878
893
  const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
879
894
  const original = record.originalObjects ?? [], kept = objects.slice();
880
- while (kept.length > original.length && !kept.at(-1).length)
895
+ while (kept.length > original.length && !kept[kept.length - 1].length)
881
896
  kept.pop(); // an Objects layer the editor made, empty again, is not an edit
882
897
  const size = record.originalSize, sameSize = map.width === size.width && map.height === size.height;
883
898
  // the map as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it
@@ -898,7 +913,7 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
898
913
  {
899
914
  if (!editorHas('onRestart')) return;
900
915
  const before = editorMapSnapshot(record);
901
- editorSetMapSnapshot(record, {width, height, layers: data, objects});
916
+ editorSetMapSnapshot(record, {width, height, layers: data, objects}, keepUnknown);
902
917
  editorMapChanged(record, before);
903
918
  return;
904
919
  }
@@ -1015,13 +1030,15 @@ function editorMapSnapshot(record)
1015
1030
  return editorObjectsCopy({width: map.width, height: map.height, layers: editorTileLayerData(map.layers), objects});
1016
1031
  }
1017
1032
 
1018
- // give a map a size, tiles and objects, the game's Restart hook makes its layers again
1019
- function editorSetMapSnapshot(record, snapshot)
1033
+ // give a map a size, tiles and objects, the game's Restart hook makes its layers again; keepUnknown leaves an object
1034
+ // layer past the snapshot's as it is, as applying an autosave from before the file had it does
1035
+ function editorSetMapSnapshot(record, snapshot, keepUnknown=false)
1020
1036
  {
1021
1037
  const map = record.map;
1022
1038
  editorResizeMap(map, snapshot.width, snapshot.height);
1023
1039
  editorCopyData(editorTileLayerData(map.layers), snapshot.layers);
1024
1040
  editorRestoreObjects(map, snapshot);
1041
+ if (!keepUnknown)
1025
1042
  for (const group of editorObjectGroups(map.layers).slice(snapshot.objects.length))
1026
1043
  group.objects = []; // an Objects layer the editor made since
1027
1044
  editorClearSelections();
@@ -1216,13 +1233,15 @@ function editorObjectLayers(record)
1216
1233
  return record.objectLayers;
1217
1234
  }
1218
1235
 
1219
- // a new Tiled object layer named Objects, on top of a map
1236
+ // a new Tiled object layer named Objects, on top of a map, with an id past every layer's, in groups too
1220
1237
  function editorNewObjectGroup(map)
1221
1238
  {
1222
- const id = map.nextlayerid ?? map.layers.length + 1, group = {draworder: 'topdown', id, name: 'Objects',
1239
+ const ids = (layers)=> (layers || []).flatMap((layer)=> [(layer.id | 0) + 1, ...ids(layer.layers)]);
1240
+ const id = max(map.nextlayerid ?? 1, ...ids(map.layers)), group = {draworder: 'topdown', id, name: 'Objects',
1223
1241
  objects: [], opacity: 1, type: 'objectgroup', visible: true, x: 0, y: 0};
1224
1242
  map.nextlayerid = id + 1;
1225
1243
  map.layers.push(group);
1244
+ editorMadeGroups.add(group);
1226
1245
  return group;
1227
1246
  }
1228
1247
 
@@ -1246,12 +1265,13 @@ function editorObjectPos(record, object)
1246
1265
  return vec2(object.x / tilewidth, height - object.y / tileheight);
1247
1266
  }
1248
1267
 
1249
- // set an object's position in the map from a world position
1268
+ // set an object's position in the map from a world position, to a millionth of a pixel, so a place turned into
1269
+ // cells and back is written as it was and not a hair off
1250
1270
  function editorObjectSetPos(record, object, pos)
1251
1271
  {
1252
1272
  const {height=0, tilewidth=1, tileheight=1} = record.map;
1253
- object.x = pos.x * tilewidth;
1254
- object.y = (height - pos.y) * tileheight;
1273
+ object.x = round(pos.x * tilewidth * 1e6) / 1e6;
1274
+ object.y = round((height - pos.y) * tileheight * 1e6) / 1e6;
1255
1275
  }
1256
1276
 
1257
1277
  // called by objectLayersLoad for each object it read, the editor keeps what the game made by the object's id
@@ -1299,8 +1319,8 @@ function editorSetObjects(layer, list)
1299
1319
  if ('pos' in made && (old.x !== object.x || old.y !== object.y))
1300
1320
  made.pos = editorObjectPos(record, object);
1301
1321
  const was = objectLayersProperties(type, old), now = objectLayersProperties(type, object);
1302
- for (const name in now)
1303
- editorSameData(was[name], now[name]) || (made[name] = now[name]);
1322
+ for (const name in now) // a method's name is left out, as objectLayersMake leaves it
1323
+ editorSameData(was[name], now[name]) || typeof made[name] === 'function' || (made[name] = now[name]);
1304
1324
  }
1305
1325
  }
1306
1326
  }
@@ -1314,7 +1334,7 @@ function editorChangeObjects(layer, change)
1314
1334
  change(after);
1315
1335
  if (editorSameData(before, after)) return false;
1316
1336
  editorSetObjects(layer, after);
1317
- const last = editorStroke?.at(-1);
1337
+ const last = editorStroke?.[editorStroke.length - 1];
1318
1338
  if (last?.objectLayer === layer)
1319
1339
  last.after = after;
1320
1340
  else
@@ -1328,15 +1348,21 @@ function editorObjectSetProperty(object, name, value, defaultValue)
1328
1348
  {
1329
1349
  if (Number.isInteger(defaultValue) && isNumber(value))
1330
1350
  value = round(value); // an integer stays one, as Tiled keeps an int
1331
- const properties = (object.properties ?? []).filter((property)=> property.name !== name);
1351
+ const list = object.properties ?? [], at = list.findIndex((property)=> property.name === name), old = list[at];
1352
+ const properties = list.filter((property)=> property.name !== name);
1332
1353
  const text = (v)=> isColor(v) ? v.toString() : isVector2(v) ? v.x + ',' + v.y : JSON.stringify(v);
1333
1354
  if (text(value) !== text(defaultValue))
1334
1355
  {
1335
1356
  const hex = isColor(value) && value.toString();
1336
- properties.push(hex ? {name, type: 'color', value: '#' + hex.slice(7, 9) + hex.slice(1, 7)} :
1357
+ const property = hex ? {name, type: 'color', value: '#' + hex.slice(7, 9) + hex.slice(1, 7)} :
1337
1358
  isVector2(value) ? {name, type: 'string', value: text(value)} :
1338
1359
  {name, type: typeof value === 'boolean' ? 'bool' : typeof value === 'string' ? 'string' :
1339
- Number.isInteger(defaultValue) ? 'int' : 'float', value});
1360
+ Number.isInteger(defaultValue) ? 'int' : 'float', value};
1361
+
1362
+ // one the map had keeps its place and its kind where the value fits it, a file stays a file, an object an object
1363
+ if (old?.type === 'file' && property.type === 'string' || old?.type === 'object' && property.type === 'int')
1364
+ property.type = old.type;
1365
+ at < 0 ? properties.push(property) : properties.splice(at, 0, property);
1340
1366
  }
1341
1367
  if (properties.length)
1342
1368
  object.properties = properties;
@@ -1422,14 +1448,15 @@ function editorObjectsIn(layer, a, b)
1422
1448
  function editorPickObject(object)
1423
1449
  { editorObjectBrush = [{type: object.type || object.class, properties: editorObjectsCopy(object.properties ?? []), offset: vec2()}]; }
1424
1450
 
1425
- // a new Tiled point object for a brush's object, at a world position, with the map's next id
1426
- function editorNewObject(record, {type, properties}, pos)
1451
+ // a new Tiled object for a brush's object, at a world position, with the map's next id: a copy of the object it was
1452
+ // copied from, its name, size, turn and the rest kept, or a point object of the brush's type and properties
1453
+ function editorNewObject(record, {type, properties, object}, pos)
1427
1454
  {
1428
- const object = {id: editorNextObjectId(record.map), name: '', type, point: true, rotation: 0, visible: true,
1429
- width: 0, height: 0, x: 0, y: 0};
1430
- properties.length && (object.properties = editorObjectsCopy(properties));
1431
- editorObjectSetPos(record, object, pos);
1432
- return object;
1455
+ const made = object ? editorObjectsCopy(object) : {id: 0, name: '', type, point: true, rotation: 0, visible: true,
1456
+ width: 0, height: 0, x: 0, y: 0, ...(properties.length && {properties: editorObjectsCopy(properties)})};
1457
+ made.id = editorNextObjectId(record.map);
1458
+ editorObjectSetPos(record, made, pos);
1459
+ return made;
1433
1460
  }
1434
1461
 
1435
1462
  // place an object brush's objects at a position plus each one's offset, as one undo
@@ -1460,8 +1487,8 @@ function editorDeleteObjects()
1460
1487
  return true;
1461
1488
  }
1462
1489
 
1463
- // the selected objects into the brush, their offsets from the lowest corner of their positions, and the selection
1464
- // cleared so the next click places them
1490
+ // the selected objects into the brush, each a copy with its offset from the lowest corner of their positions, and
1491
+ // the selection cleared so the next click places them
1465
1492
  function editorCopyObjects()
1466
1493
  {
1467
1494
  const objects = editorSelectedObjects();
@@ -1469,7 +1496,7 @@ function editorCopyObjects()
1469
1496
  const positions = objects.map((object)=> editorObjectPos(editorObjectLayer.record, object));
1470
1497
  const low = vec2(min(...positions.map((p)=> p.x)), min(...positions.map((p)=> p.y)));
1471
1498
  editorObjectBrush = editorObjectClipboard = objects.map((object, i)=> ({type: object.type || object.class,
1472
- properties: editorObjectsCopy(object.properties ?? []), offset: positions[i].subtract(low)}));
1499
+ offset: positions[i].subtract(low), object: editorObjectsCopy(object)}));
1473
1500
  editorObjectSelection.clear();
1474
1501
  return true;
1475
1502
  }
@@ -1605,12 +1632,8 @@ function editorCellClamped(live, worldPos)
1605
1632
  function editorArea(a, b)
1606
1633
  { return {min: vec2(min(a.x, b.x), min(a.y, b.y)), max: vec2(max(a.x, b.x), max(a.y, b.y))}; }
1607
1634
 
1608
- // the tile info a layer draws a tile with, as TileLayer.drawTileData picks it
1609
- function editorTileInfo(live, tile)
1610
- {
1611
- const t = live.tileInfo;
1612
- return t && (t.columns ? t.frame(tile) : t.index(tile));
1613
- }
1635
+ // the tile info a layer draws a tile with, as TileLayer.drawTileData picks it, undefined past the end of its sheet
1636
+ function editorTileInfo(live, tile) { return tileLayerTileInfo(live.tileInfo, tile); }
1614
1637
 
1615
1638
  ///////////////////////////////////////////////////////////////////////////////
1616
1639
  // stamps
@@ -1841,8 +1864,8 @@ function editorClear()
1841
1864
  editorStrokeEnd();
1842
1865
  }
1843
1866
 
1844
- // the objects of a map's object layers inside a tile area, each {group, type, properties, offset}: the index of its
1845
- // object layer, and its offset from the area's bottom left corner, as a stamp keeps them
1867
+ // the objects of a map's object layers inside a tile area, each {group, type, offset, object}: the index of its
1868
+ // object layer, its offset from the area's bottom left corner, and a copy of it, as a stamp keeps them
1846
1869
  function editorAreaObjects(layer, area)
1847
1870
  {
1848
1871
  const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1));
@@ -1850,8 +1873,8 @@ function editorAreaObjects(layer, area)
1850
1873
  {
1851
1874
  const ids = new Set(editorObjectsIn(objectLayer, corner, far));
1852
1875
  return (objectLayer.group?.objects ?? []).filter((object)=> ids.has(object.id)).map((object)=>
1853
- ({group, type: object.type || object.class, properties: editorObjectsCopy(object.properties ?? []),
1854
- offset: editorObjectPos(layer.record, object).subtract(corner)}));
1876
+ ({group, type: object.type || object.class, offset: editorObjectPos(layer.record, object).subtract(corner),
1877
+ object: editorObjectsCopy(object)}));
1855
1878
  });
1856
1879
  }
1857
1880
 
@@ -1939,12 +1962,13 @@ function editorSelectionDragTo(cell)
1939
1962
  from.arrayCheck(target.live.size) && editorPaint(target, from, 0);
1940
1963
  }
1941
1964
  editorPaintStamp(layer, editorSelection.min, stamp);
1965
+ // whole cells added to the places as written, as an object drag does, so they come back to the digit
1966
+ const {tilewidth=1, tileheight=1} = layer.record.map;
1942
1967
  for (const {objectLayer, ids} of drag.objects)
1943
1968
  editorChangeObjects(objectLayer, (list)=>
1944
1969
  {
1945
1970
  for (const object of list)
1946
- ids.has(object.id) && editorObjectSetPos(layer.record, object,
1947
- editorObjectPos(layer.record, object).add(delta));
1971
+ ids.has(object.id) && (object.x += delta.x * tilewidth, object.y -= delta.y * tileheight);
1948
1972
  });
1949
1973
  });
1950
1974
  editorStroke && editorRedraw(editorStroke);
@@ -2174,16 +2198,30 @@ function editorPaletteTiles(layer)
2174
2198
  const live = layer?.live, image = live?.tileInfo?.textureInfo?.image, list = levelEditor.paletteTiles;
2175
2199
  if (list && live)
2176
2200
  {
2177
- if (layer.palette?.list !== list)
2178
- layer.palette = {list, tiles: list.map((tile)=> ({tile, tileInfo: editorTileInfo(live, tile)}))};
2201
+ if (layer.palette?.list !== list || layer.palette.count !== live.tileInfo?.tiles?.length)
2202
+ layer.palette = {list, count: live.tileInfo?.tiles?.length, // a tile set fills in as it loads
2203
+ tiles: list.map((tile)=> ({tile, tileInfo: editorTileInfo(live, tile)}))
2204
+ .filter((t)=> t.tileInfo || !live.tileInfo)}; // a tile set, or a sheet, has only so many
2205
+ return layer.palette.tiles;
2206
+ }
2207
+ // a tile set lists its tiles, each a tile the palette offers
2208
+ const set = live?.tileInfo?.tiles;
2209
+ if (set)
2210
+ {
2211
+ if (layer.palette?.set !== set || layer.palette.tiles.length !== set.length)
2212
+ layer.palette = {set, tiles: set.map((tileInfo, tile)=> ({tile, tileInfo}))};
2179
2213
  return layer.palette.tiles;
2180
2214
  }
2181
2215
  if (!image) return [];
2182
2216
  if (layer.palette?.image === image) return layer.palette.tiles;
2183
2217
 
2184
- const tiles = [];
2218
+ // a sheet read by its columns, a tileset with a margin or a spacing, is read with frame(), which asserts the
2219
+ // frame is inside the image, so a row past the image's end is found before its frame is made
2220
+ const tiles = [], base = live.tileInfo, columns = base.columns;
2185
2221
  for (let i = 0; i < 4096; ++i)
2186
2222
  {
2223
+ if (columns && base.pos.y + (i / columns | 0) * (base.size.y + base.padding*2) + base.size.y > image.height)
2224
+ break;
2187
2225
  const t = editorTileInfo(live, i);
2188
2226
  if (t.pos.x + t.size.x > image.width || t.pos.y + t.size.y > image.height) break;
2189
2227
  tiles.push({tile: i, tileInfo: t});
@@ -2199,7 +2237,7 @@ function editorPaletteTiles(layer)
2199
2237
  size.y).data.buffer);
2200
2238
  return pixels.every((p)=> p === pixels[0]);
2201
2239
  };
2202
- while (tiles.length > 1 && blank(tiles.at(-1)))
2240
+ while (tiles.length > 1 && blank(tiles[tiles.length - 1]))
2203
2241
  tiles.pop();
2204
2242
  }
2205
2243
  catch {}
@@ -2241,7 +2279,7 @@ function editorPaletteDrawObjects(canvas)
2241
2279
  function editorPaletteDraw(canvas, layer)
2242
2280
  {
2243
2281
  const tiles = editorPaletteTiles(layer), cell = editorPaletteCell, columns = editorPaletteColumns;
2244
- const image = layer?.live.tileInfo?.textureInfo?.image, slots = tiles.length + 1;
2282
+ const slots = tiles.length + 1;
2245
2283
  canvas.style.display = tiles.length ? '' : 'none';
2246
2284
  canvas.width = columns * cell;
2247
2285
  canvas.height = ceil(slots / columns) * cell;
@@ -2256,10 +2294,13 @@ function editorPaletteDraw(canvas, layer)
2256
2294
  context.moveTo(8, 8), context.lineTo(cell - 8, cell - 8);
2257
2295
  context.moveTo(cell - 8, 8), context.lineTo(8, cell - 8);
2258
2296
  context.stroke();
2259
- tiles.forEach(({tileInfo: {pos, size}}, i)=>
2297
+ tiles.forEach(({tileInfo}, i)=>
2260
2298
  {
2261
- const slot = i + 1, x = slot % columns * cell, y = (slot / columns | 0) * cell;
2262
- context.drawImage(image, pos.x, pos.y, size.x, size.y, x + 2, y + 2, cell - 4, cell - 4);
2299
+ if (!tileInfo) return; // headless, a layer with no sheet
2300
+ const {pos, size, textureInfo} = tileInfo;
2301
+ // each from its own sheet, a tile set's may be on several
2302
+ const slot = i + 1, x = slot % columns * cell, y = (slot / columns | 0) * cell, image = textureInfo?.image;
2303
+ image && context.drawImage(image, pos.x, pos.y, size.x, size.y, x + 2, y + 2, cell - 4, cell - 4);
2263
2304
  });
2264
2305
 
2265
2306
  // the brush's slot outlined, when it is one tile the palette shows or Erase
@@ -2552,7 +2593,7 @@ function editorPropertiesUpdate(box)
2552
2593
  input.type = 'number';
2553
2594
  input.step = Number.isInteger(defaultValue) ? '1' : 'any';
2554
2595
  input.value = String(value);
2555
- input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
2596
+ input.onchange = ()=> { const v = parseFloat(input.value); isFinite(v) && set(v); }; // 1e999 is not one
2556
2597
  }
2557
2598
  else if (typeof defaultValue === 'string')
2558
2599
  {
@@ -2574,7 +2615,7 @@ function editorPropertiesUpdate(box)
2574
2615
  {
2575
2616
  // both checked before a vector is made of them, an emptied or half typed field shows its value again
2576
2617
  const px = parseFloat(input.value), py = parseFloat(y.value);
2577
- if (isNumber(px) && isNumber(py))
2618
+ if (isFinite(px) && isFinite(py))
2578
2619
  set(vec2(px, py));
2579
2620
  else
2580
2621
  input.value = String(value.x), y.value = String(value.y);
@@ -2847,8 +2888,9 @@ function editorRender()
2847
2888
  for (let y = y0; y < y1; ++y)
2848
2889
  {
2849
2890
  const t = editorGidToTile(source.data[x + (height - 1 - y) * width]);
2850
- if (t && live.getData(vec2(x, y)).tile === undefined)
2851
- drawTile(live.pos.add(vec2(x + .5, y + .5)), vec2(1), editorTileInfo(live, t.tile), ghost,
2891
+ const tileInfo = t && editorTileInfo(live, t.tile);
2892
+ if (tileInfo && live.getData(vec2(x, y)).tile === undefined)
2893
+ drawTile(live.pos.add(vec2(x + .5, y + .5)), vec2(1), tileInfo, ghost,
2852
2894
  t.direction * PI/2, t.mirror, undefined, glEnable, false);
2853
2895
  }
2854
2896
 
@@ -2887,8 +2929,9 @@ function editorRender()
2887
2929
  {
2888
2930
  const gid = grid[x + y * w], t = editorGidToTile(gid);
2889
2931
  const center = live.pos.add(editorHover).add(vec2(x + .5, y + .5));
2890
- if (t)
2891
- drawTile(center, vec2(1), editorTileInfo(live, t.tile), hsl(0, 0, 1, .7), t.direction * PI/2, t.mirror, undefined, glEnable, false);
2932
+ const tileInfo = t && editorTileInfo(live, t.tile);
2933
+ if (tileInfo)
2934
+ drawTile(center, vec2(1), tileInfo, hsl(0, 0, 1, .7), t.direction * PI/2, t.mirror, undefined, glEnable, false);
2892
2935
  else if (gid === 0)
2893
2936
  drawRect(center, vec2(1), hsl(0, 1, .5, .3), undefined, undefined, false);
2894
2937
  }
@@ -113,6 +113,7 @@ export
113
113
  touchGamepadVibration,
114
114
  vibrateEnable,
115
115
  soundEnable,
116
+ soundIgnoreSilentSwitch,
116
117
  soundVolume,
117
118
  soundDefaultRange,
118
119
  soundDefaultTaper,
@@ -175,6 +176,7 @@ export
175
176
  setTouchGamepadVibration,
176
177
  setVibrateEnable,
177
178
  setSoundEnable,
179
+ setSoundIgnoreSilentSwitch,
178
180
  setSoundVolume,
179
181
  setSoundDefaultRange,
180
182
  setSoundDefaultTaper,
@@ -586,6 +586,9 @@ function inputInit()
586
586
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
587
587
  audioContext.resume();
588
588
 
589
+ // a press in the bars around a letterboxed canvas is not one on its edge; a drag out of it still moves
590
+ if (!inCanvas(e.x, e.y))
591
+ return;
589
592
  inputData[0][e.button] = 3;
590
593
 
591
594
  const mousePosScreenLast = mousePosScreen;
@@ -739,8 +742,8 @@ function inputInit()
739
742
  }
740
743
  else if (inputWasTouching && touch.identifier === inputTouchIdentifier)
741
744
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
742
- else if (pressTouch)
743
- inputData[0][button] = 3;
745
+ else if (pressTouch && inCanvas(pressTouch.clientX, pressTouch.clientY))
746
+ inputData[0][button] = 3; // a tap in the bars around the canvas is not a press
744
747
  // the finger left after a pinch moves the mouse from where it is, with no jump and no press
745
748
  inputTouchIdentifier = pinching ? undefined : touch.identifier;
746
749
  }
@@ -771,6 +774,13 @@ function inputInit()
771
774
 
772
775
  }
773
776
 
777
+ // is a point of the window on the canvas
778
+ function inCanvas(x, y)
779
+ {
780
+ const rect = mainCanvas.getBoundingClientRect();
781
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
782
+ }
783
+
774
784
  // convert a mouse or touch event position to screen space
775
785
  function mouseEventToScreen(mousePos)
776
786
  {
@@ -941,7 +951,7 @@ function inputUpdate()
941
951
  const centered = gamepadAxisCentered[i] ?? (gamepadAxisCentered[i] = []);
942
952
  const readAxis = (j)=>
943
953
  {
944
- const v = gamepad.axes[j];
954
+ const v = isFinite(gamepad.axes[j]) ? gamepad.axes[j] : 0; // a broken axis reads as centered
945
955
  if (isStandard && j < 4)
946
956
  return v; // spec guarantees axes 0-3 are the two sticks
947
957
  if (!gamepadAxisFilterEnable)
@@ -973,6 +983,10 @@ function inputUpdate()
973
983
  if (button.pressed && (!button.value || button.value > .9))
974
984
  hadInput = true;
975
985
  }
986
+
987
+ // a different gamepad in the same slot with fewer buttons or sticks holds none of the last one's
988
+ data.length = buttonsLast.length = gamepad.buttons.length;
989
+ sticks.length = gamepad.axes.length >> 1;
976
990
 
977
991
  // set new primary gamepad if current is not connected
978
992
  if (hadInput)
package/src/engineMath.js CHANGED
@@ -200,11 +200,15 @@ function smoothStep(percent)
200
200
  * @memberof Math */
201
201
  function isPowerOfTwo(value) { return value > 0 && value % 1 === 0 && 2**round(log2(value)) === value; } // any size, not only 32 bits
202
202
 
203
- /** Returns the nearest power of two not less than the value
203
+ /** Returns the nearest whole power of two not less than the value, 1 for a value of 1 or less
204
204
  * @param {number} value
205
205
  * @return {number}
206
206
  * @memberof Math */
207
- function nearestPowerOfTwo(value) { return 2**ceil(log2(value)); }
207
+ function nearestPowerOfTwo(value)
208
+ {
209
+ const power = 2**ceil(log2(max(value, 1)));
210
+ return power < value ? power * 2 : power; // log2 of a value just above a power can round down to it
211
+ }
208
212
 
209
213
  /** Returns true if two axis aligned bounding boxes are overlapping
210
214
  * this can be used for simple collision detection between objects