littlejsengine 1.24.3 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/EDITOR.md +2 -2
  3. package/FAQ.md +55 -11
  4. package/README.md +6 -2
  5. package/REFERENCE.md +203 -57
  6. package/dist/littlejs.d.ts +933 -313
  7. package/dist/littlejs.esm.js +5143 -2419
  8. package/dist/littlejs.esm.min.js +38 -24
  9. package/dist/littlejs.js +5132 -2416
  10. package/dist/littlejs.min.js +37 -23
  11. package/dist/littlejs.release.js +4905 -2232
  12. package/package.json +4 -4
  13. package/plugins/box2d.js +141 -56
  14. package/plugins/drawUtilities.js +374 -380
  15. package/plugins/gltf.js +383 -60
  16. package/plugins/lightSystem.js +412 -30
  17. package/plugins/math3d.js +1015 -1014
  18. package/plugins/medalSystem.js +19 -7
  19. package/plugins/newgrounds.js +5 -6
  20. package/plugins/parallax.js +1 -0
  21. package/plugins/particleEffects.js +47 -14
  22. package/plugins/pathFinder.js +60 -38
  23. package/plugins/pluginExport.js +11 -0
  24. package/plugins/postProcess.js +200 -67
  25. package/plugins/render3d.js +493 -2391
  26. package/plugins/render3dDebug.js +5 -8
  27. package/plugins/render3dEditor.js +84 -9
  28. package/plugins/render3dExtras.js +122 -33
  29. package/plugins/render3dLevel.js +67 -8
  30. package/plugins/render3dMesh.js +1158 -0
  31. package/plugins/render3dObject.js +1451 -0
  32. package/plugins/render3dVoxels.js +15 -3
  33. package/plugins/textureSheet.js +9 -8
  34. package/plugins/threejs.js +1 -1
  35. package/plugins/tweenSystem.js +117 -87
  36. package/plugins/uiSystem.js +62 -28
  37. package/plugins/wavedash.js +229 -0
  38. package/src/engine.js +192 -40
  39. package/src/engineAudio.js +47 -27
  40. package/src/engineBuild.mjs +6 -1
  41. package/src/engineDebug.js +23 -162
  42. package/src/engineDraw.js +148 -43
  43. package/src/engineEditor.js +153 -40
  44. package/src/engineExport.js +0 -3
  45. package/src/engineInput.js +31 -25
  46. package/src/engineMath.js +38 -20
  47. package/src/engineObject.js +68 -50
  48. package/src/engineParticles.js +66 -18
  49. package/src/engineRelease.js +18 -15
  50. package/src/engineSettings.js +48 -9
  51. package/src/engineTileLayer.js +44 -18
  52. package/src/engineUtilities.js +56 -11
  53. package/src/engineWebGL.js +68 -22
@@ -14,7 +14,7 @@
14
14
 
15
15
  /**
16
16
  * @callback EditorPlayFromCallback - Puts the player at a world position, for the level editor's Play from mouse
17
- * @param {Vector2} pos - Where to start playing
17
+ * @param {any} pos - Where to start playing, a Vector2 from the 2D editor and a Vector3 from the 3D one
18
18
  * @memberof Editor
19
19
  */
20
20
 
@@ -32,25 +32,29 @@
32
32
  */
33
33
 
34
34
  /**
35
- * @typedef {Object} EditorToolAt - What the level editor gives the callbacks of a tool of the game's own
36
- * @property {any} pos - Where the mouse is in the level: a Vector2 in the 2D editor; in the 3D one a Vector3 on
35
+ * @template [P=Vector2]
36
+ * @typedef {Object} EditorToolAt - What the level editor gives the callbacks of a tool of the game's own; P is
37
+ * the type of pos, a Vector2 for a 2D tool, the default, and Vector3|undefined for a 3D one
38
+ * @property {P} pos - Where the mouse is in the level: a Vector2 in the 2D editor; in the 3D one a Vector3 on
37
39
  * the level or the ground, undefined when the mouse is over the panel
38
40
  * @property {Vector2|undefined} cell - 2D: the tile cell under the mouse on the selected tile layer
39
- * @property {any} ray - 3D: the mouse's Ray3D
41
+ * @property {Ray3D|undefined} ray - 3D: the mouse's Ray3D
40
42
  * @property {boolean} shift - Is Shift held
41
43
  * @property {boolean} ctrl - Is Ctrl held
42
44
  * @memberof Editor
43
45
  */
44
46
 
45
47
  /**
46
- * @typedef {Object} EditorTool - A tool of the game's own for the level editor, see LevelEditor.addTool
48
+ * @template [P=Vector2]
49
+ * @typedef {Object} EditorTool - A tool of the game's own for the level editor, see LevelEditor.addTool; P is the
50
+ * type of its pos, as EditorToolAt has it
47
51
  * @property {string} [key] - The key that picks it, as addKey spells one
48
52
  * @property {string} [hint] - The hint line while it is on
49
- * @property {function(EditorToolAt): any} [onPress] - The left button went down in the level; returning false
53
+ * @property {function(EditorToolAt<P>): any} [onPress] - The left button went down in the level; returning false
50
54
  * says the press was not the tool's
51
- * @property {function(EditorToolAt): any} [onDrag] - Each frame it is held
52
- * @property {function(EditorToolAt): any} [onRelease] - It was let go
53
- * @property {function(EditorToolAt): any} [onDraw] - Each frame the tool is on, to draw its cursor or preview
55
+ * @property {function(EditorToolAt<P>): any} [onDrag] - Each frame it is held
56
+ * @property {function(EditorToolAt<P>): any} [onRelease] - It was let go
57
+ * @property {function(EditorToolAt<P>): any} [onDraw] - Each frame the tool is on, to draw its cursor or preview
54
58
  * @memberof Editor
55
59
  */
56
60
 
@@ -143,7 +147,7 @@ class LevelEditor
143
147
  this.keys = {};
144
148
  /** @type {Array<{label: string, onClick: Function, title: string}>} */
145
149
  this.buttons = [];
146
- /** @type {Object<string, EditorTool>} */
150
+ /** @type {Object<string, EditorTool<any>>} */
147
151
  this.tools = {};
148
152
  }
149
153
 
@@ -161,7 +165,7 @@ class LevelEditor
161
165
 
162
166
  /** Put the player at a position, a Vector2 in the 2D editor and a Vector3 in the 3D one; set it or override it
163
167
  * and the editor has Play from mouse, which starts play there
164
- * @param {any} pos */
168
+ * @param {any} pos - A Vector2 from the 2D editor, a Vector3 from the 3D one */
165
169
  onPlayFrom(pos) {}
166
170
 
167
171
  /** Called when the editor opens, to set or override */
@@ -244,8 +248,9 @@ class LevelEditor
244
248
  * in 3D
245
249
  * - What the callbacks change through edit2D or edit3D is one undo: the editor ends the stroke at the release,
246
250
  * and takes it back when the right button or Escape ends the press
251
+ * @template [P=Vector2]
247
252
  * @param {string} name
248
- * @param {EditorTool} tool - {key, hint, onPress, onDrag, onRelease, onDraw}, each optional; onPress returning
253
+ * @param {EditorTool<P>} tool - {key, hint, onPress, onDrag, onRelease, onDraw}, each optional; onPress returning
249
254
  * false says the press was not the tool's */
250
255
  addTool(name, tool)
251
256
  {
@@ -260,7 +265,10 @@ class LevelEditor
260
265
  open()
261
266
  {
262
267
  if (editorOther?.wanted())
263
- return editorOther.open();
268
+ {
269
+ editorOther.open();
270
+ return;
271
+ }
264
272
  if (!editorSession)
265
273
  editorCameraScale = cameraScale; // a new session starts at the game's zoom, a return keeps the editor's
266
274
  editorSession = true;
@@ -272,7 +280,10 @@ class LevelEditor
272
280
  close()
273
281
  {
274
282
  if (editorOther?.active())
275
- return editorOther.close();
283
+ {
284
+ editorOther.close();
285
+ return;
286
+ }
276
287
  editorSession = false;
277
288
  editorSetOpen(false);
278
289
  }
@@ -462,6 +473,7 @@ function editorSetOpen(open)
462
473
  else
463
474
  {
464
475
  editorStrokeEnd();
476
+ editorAutosaveFlush(); // the game plays on with what the editor has kept, the stroke just ended too
465
477
  editorToolHeld = false;
466
478
  editorSelection = editorSelectionDrag = editorRightPress = editorObjectDrag = editorObjectBox = undefined;
467
479
  editorObjectSelection.clear();
@@ -533,6 +545,7 @@ const editorTileLayerData = (layers)=> editorTileLayers(layers).map((layer)=> la
533
545
  // panel to apply or drop, and one that already has the autosaved data, saved from the editor, drops it
534
546
  function editorMapRestore(map)
535
547
  {
548
+ editorAutosaveFlush(); // what an earlier map has waiting is written before this one reads the autosaves
536
549
  if (editorMapList.some((record)=> record.map === map)) return map; // loaded again, it has the changes
537
550
 
538
551
  // a map object seen before, its record retired when its layers went, gets it back as it was, its data has the
@@ -568,7 +581,8 @@ function editorMapRestore(map)
568
581
  const sameFile = saved.hash === record.hash || saved.savedHash === record.hash || !saved.width &&
569
582
  (saved.hash === editorMapHash(data, objects) || !saved.objects && saved.hash === editorMapHash(data));
570
583
  if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height &&
571
- editorSameData(saved.layers, data) && editorSameData(saved.objects ?? [], objects))
584
+ editorSameData(saved.layers, data) && editorSameData(editorObjectsKept(saved.objects ?? [], objects.length), objects)
585
+ && !(saved.nextobjectid > (map.nextobjectid ?? 1)))
572
586
  editorDiscardPending(record);
573
587
  else if (!sameFile || !editorSavedFits(saved, data.length, saved.width ?? map.width, saved.height ?? map.height) ||
574
588
  !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) || !editorCopyData(data, saved.layers))
@@ -579,7 +593,7 @@ function editorMapRestore(map)
579
593
  }
580
594
  else
581
595
  {
582
- editorRestoreObjects(map, saved);
596
+ editorRestoreObjects(map, saved, true);
583
597
  console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
584
598
  'Save in the editor (Esc then 0) writes them to the file');
585
599
  saved.stale && console.warn(`LittleJS editor: they are older than the last edits to ${record.fileName}, ` +
@@ -646,6 +660,8 @@ function editorLayerRecord(live)
646
660
  const record = {map, synthetic: true, fileName: 'level.json', layers: []};
647
661
  const layer = {record, source, live, color: WHITE};
648
662
  record.layers.push(layer);
663
+ // the records of layers made in code that are gone, as a game that makes them again on a restart leaves them
664
+ editorRetire((other)=> other.synthetic && other.layers.every((l)=> l.live.destroyed));
649
665
  editorMapList.push(record);
650
666
  return layer;
651
667
  }
@@ -742,6 +758,10 @@ function editorSetBaseline(record, written)
742
758
  const objects = editorObjectGroups(written.layers).map((group)=> group.objects ?? []);
743
759
  Object.assign(record, {original: data, originalObjects: objects, savedHash: editorMapHash(data, objects,
744
760
  editorMapLayout(written)), originalSize: {width: written.width, height: written.height}});
761
+ // an Objects layer the editor made is the file's own once a Save wrote it, so it stays when emptied after
762
+ const writtenIds = new Set(editorObjectGroups(written.layers).map((group)=> group.id));
763
+ for (const group of editorObjectGroups(record.map.layers))
764
+ writtenIds.has(group.id) && editorMadeGroups.delete(group);
745
765
  record.pending || editorAutosave(record); // edits waiting to be applied keep their autosave
746
766
  }
747
767
 
@@ -753,6 +773,7 @@ async function editorSave(record, pickAgain=false)
753
773
  {
754
774
  if (!record) return;
755
775
  editorStrokeEnd();
776
+ editorAutosaveFlush();
756
777
  const text = editorMapJSON(record); // the map as it is now, later edits wait for a later save
757
778
  // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
758
779
  // the order they were asked for however long a hook takes
@@ -770,6 +791,7 @@ async function editorSave(record, pickAgain=false)
770
791
  async function editorSaveText(record, text, pickAgain)
771
792
  {
772
793
  const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
794
+ let written = false;
773
795
  if (picker)
774
796
  {
775
797
  try
@@ -785,10 +807,7 @@ async function editorSaveText(record, text, pickAgain)
785
807
  const writable = await record.fileHandle.createWritable();
786
808
  await writable.write(text);
787
809
  await writable.close();
788
- // the browser gives the picked file's name but not its folder, so the name is what says it is the map's
789
- if (record.fileHandle.name === record.fileName)
790
- editorSetBaseline(record, JSON.parse(text));
791
- return 'written';
810
+ written = true;
792
811
  }
793
812
  catch (error)
794
813
  {
@@ -798,6 +817,14 @@ async function editorSaveText(record, text, pickAgain)
798
817
  console.warn(`LittleJS editor: ${record.fileName} could not be written, downloaded instead`, error);
799
818
  }
800
819
  }
820
+ if (written)
821
+ {
822
+ // outside the write's catch, so an error here is its own and not a failed write that downloads a copy;
823
+ // the browser gives the picked file's name but not its folder, so the name is what says it is the map's
824
+ if (record.fileHandle.name === record.fileName)
825
+ editorSetBaseline(record, JSON.parse(text));
826
+ return 'written';
827
+ }
801
828
  saveText(text, record.fileName, 'application/json');
802
829
  return 'downloaded';
803
830
  }
@@ -818,7 +845,9 @@ function editorWriteSaves(saves, key, name=editorSaveName())
818
845
  {
819
846
  try
820
847
  {
821
- localStorage.setItem(name, JSON.stringify(saves));
848
+ const text = JSON.stringify(saves);
849
+ editorAutosaveSize = text.length; // how much an autosave writes, which says whether the next one waits
850
+ localStorage.setItem(name, text);
822
851
  return editorSaveFailed = false;
823
852
  }
824
853
  catch { editorSaveFailed = true; }
@@ -888,22 +917,54 @@ const editorSavedFits = (saved, count, width, height)=> isArray(saved.layers) &&
888
917
  // remember a map's tile data and objects, or forget them when they are back to the file
889
918
  function editorAutosave(record)
890
919
  {
920
+ editorAutosaveWaiting.delete(record); // written now
891
921
  if (record.synthetic) return; // a layer made in code has no load to bring it back in, save it to a file
892
922
  const saves = editorSaves(), map = record.map, data = editorTileLayerData(map.layers);
893
923
  const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
894
- const original = record.originalObjects ?? [], kept = objects.slice();
895
- while (kept.length > original.length && !kept[kept.length - 1].length)
896
- kept.pop(); // an Objects layer the editor made, empty again, is not an edit
924
+ const original = record.originalObjects ?? [], kept = editorObjectsKept(objects, original.length);
897
925
  const size = record.originalSize, sameSize = map.width === size.width && map.height === size.height;
898
926
  // the map as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it
899
927
  if (sameSize && editorSameData(data, record.original) && editorSameData(kept, original) && !record.savedHash)
900
928
  delete saves[record.key];
901
929
  else
902
930
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
903
- layers: data, objects, nextobjectid: map.nextobjectid};
931
+ layers: data, objects: kept, nextobjectid: map.nextobjectid};
904
932
  editorWriteSaves(saves, record.key);
905
933
  }
906
934
 
935
+ // a map's lists of objects with the empty ones past count left off: an Objects layer the editor made, empty again,
936
+ // is not an edit, and a Save leaves it out of the file too
937
+ function editorObjectsKept(objects, count)
938
+ {
939
+ const kept = objects.slice();
940
+ while (kept.length > count && !kept[kept.length - 1]?.length)
941
+ kept.pop();
942
+ return kept;
943
+ }
944
+
945
+ // an autosave writing more than this many characters, a big map's, waits until the edits stop for a moment, since
946
+ // writing it after every stroke would cost more than the stroke; a small map's is written at once, as always
947
+ const EDITOR_AUTOSAVE_WAIT_SIZE = 1e5;
948
+ let editorAutosaveSize = 0, editorAutosaveTimer;
949
+ const editorAutosaveWaiting = new Set;
950
+
951
+ // autosave a map after an edit: at once when its autosave is small, or a second after the last of a run of edits
952
+ function editorAutosaveSoon(record)
953
+ {
954
+ if (editorAutosaveSize < EDITOR_AUTOSAVE_WAIT_SIZE)
955
+ return editorAutosave(record);
956
+ editorAutosaveWaiting.add(record);
957
+ clearTimeout(editorAutosaveTimer);
958
+ editorAutosaveTimer = setTimeout(editorAutosaveFlush, 1e3);
959
+ }
960
+
961
+ // write every autosave that is waiting, as the editor closes, the page hides, a map is saved or another loads
962
+ function editorAutosaveFlush()
963
+ {
964
+ clearTimeout(editorAutosaveTimer);
965
+ [...editorAutosaveWaiting].forEach(editorAutosave);
966
+ }
967
+
907
968
  // paint every cell of a map's layers from a list of tile data, the tile layers of the map in order, and set its
908
969
  // object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook
909
970
  function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height, keepUnknown=false)
@@ -1008,7 +1069,9 @@ function editorResizeMap(map, width, height)
1008
1069
  }
1009
1070
  // an object on the map before and off it after is dropped, one that was already off it stays
1010
1071
  const {tilewidth=1, tileheight=1} = map, oldHeight = map.height;
1011
- const inside = (object, w, h)=> object.x >= 0 && object.x < w * tilewidth && object.y > 0 && object.y <= h * tileheight;
1072
+ // a tile object's y is its bottom, a point's or a rectangle's its top
1073
+ const inside = (object, w, h)=> object.x >= 0 && object.x < w * tilewidth && (object.gid === undefined ?
1074
+ object.y >= 0 && object.y < h * tileheight : object.y > 0 && object.y <= h * tileheight);
1012
1075
  for (const group of editorObjectGroups(map.layers))
1013
1076
  {
1014
1077
  group.objects &&= group.objects.filter((object)=>
@@ -1084,6 +1147,8 @@ let editorStroke;
1084
1147
 
1085
1148
  // during a big edit, the layers whose cell by cell redraws are held off, with the redraw each had of its own
1086
1149
  let editorHeldRedraws;
1150
+ // the live layers a change can only show by drawing them again whole, drawn when it is done
1151
+ const editorRedrawWhole = new Set;
1087
1152
 
1088
1153
  // make a big edit, a fill, clear, undo, apply or revert, with each layer's cell by cell redraws held off, a game's
1089
1154
  // tile callback's included; the stroke's end draws each layer it touched again whole
@@ -1110,6 +1175,11 @@ function editorSetCell(layer, pos, gid)
1110
1175
  source.data[index] = gid;
1111
1176
  if (live.destroyed) return before; // a layer the game let go of, the map still has the change
1112
1177
 
1178
+ // a plain cell draws itself here; held draws, a game's onTile, which may change the cells around, and its own
1179
+ // drawing over the layer show only when the whole layer draws again, once the change is done
1180
+ if (editorHeldRedraws || editorHas('onTile') || live.onRedraw !== TileLayer.prototype.onRedraw)
1181
+ editorRedrawWhole.add(live);
1182
+
1113
1183
  if (editorHeldRedraws && !editorHeldRedraws.has(live))
1114
1184
  {
1115
1185
  editorHeldRedraws.set(live, live.hasOwnProperty('redrawTileData') ? live.redrawTileData : undefined);
@@ -1187,21 +1257,21 @@ function editorUndo(redo=false)
1187
1257
  editorChanged(stroke);
1188
1258
  }
1189
1259
 
1190
- // after a change the tile layers it touched draw again whole, so a game's onRedraw decoration sees the new tiles,
1191
- // and the maps it touched are autosaved; a resized map is made again by the game, with the editor staying open
1260
+ // after a change the tile layers it touched that need it draw again whole, and the maps it touched are autosaved;
1261
+ // a resized map is made again by the game, with the editor staying open
1192
1262
  function editorChanged(stroke)
1193
1263
  {
1194
1264
  editorRedraw(stroke);
1195
1265
  for (const record of new Set(stroke.map(editorEntryRecord)))
1196
- editorAutosave(record);
1266
+ editorAutosaveSoon(record);
1197
1267
  stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart();
1198
1268
  }
1199
1269
 
1200
- // the tile layers a stroke touched draw again whole
1270
+ // the tile layers a stroke touched that need it draw again whole
1201
1271
  function editorRedraw(stroke)
1202
1272
  {
1203
1273
  for (const layer of new Set(stroke.filter((entry)=> entry.layer).map((entry)=> entry.layer)))
1204
- layer.live.destroyed || layer.live.redraw();
1274
+ editorRedrawWhole.delete(layer.live) && !layer.live.destroyed && layer.live.redraw();
1205
1275
  }
1206
1276
 
1207
1277
  ///////////////////////////////////////////////////////////////////////////////
@@ -1370,13 +1440,17 @@ function editorObjectSetProperty(object, name, value, defaultValue)
1370
1440
  delete object.properties;
1371
1441
  }
1372
1442
 
1373
- // copy saved objects into a map's object layers, making an Objects layer for any it saved that the map lacks
1374
- function editorRestoreObjects(map, saved)
1443
+ // copy saved objects into a map's object layers, making an Objects layer for any it saved that the map lacks; from
1444
+ // an autosave, which keeps no empty list of a layer the editor made and no Save wrote, an empty one is of a layer a
1445
+ // Save wrote, the file's own, which stays when empty; an undo's snapshot keeps every list, so it does not say that
1446
+ function editorRestoreObjects(map, saved, fromAutosave=false)
1375
1447
  {
1376
1448
  const groups = editorObjectGroups(map.layers);
1377
1449
  (saved.objects ?? []).forEach((objects, i)=>
1378
1450
  {
1379
- (groups[i] ?? editorNewObjectGroup(map)).objects = editorObjectsCopy(objects);
1451
+ const group = groups[i] ?? editorNewObjectGroup(map);
1452
+ group.objects = editorObjectsCopy(objects);
1453
+ fromAutosave && !objects.length && editorMadeGroups.delete(group);
1380
1454
  });
1381
1455
  if (saved.nextobjectid > (map.nextobjectid ?? 1))
1382
1456
  map.nextobjectid = saved.nextobjectid;
@@ -1708,6 +1782,40 @@ function editorGidMirror(gid)
1708
1782
  return t ? editorTileToGid(t.tile, (4 - t.direction) % 4, !t.mirror) : gid;
1709
1783
  }
1710
1784
 
1785
+ // a stamp's object turned a quarter clockwise with it: its place by the stamp, its body by its own rotation, which
1786
+ // Tiled turns clockwise about the object's place, so the place needs no other move
1787
+ function editorStampObjectTurn(entry, width)
1788
+ {
1789
+ const offset = vec2(entry.offset.y, width - entry.offset.x), object = entry.object;
1790
+ return {...entry, offset, ...(object && {object: {...object, rotation: ((object.rotation || 0) + 90) % 360}})};
1791
+ }
1792
+
1793
+ // a stamp's object mirrored left to right with it: its rotation the other way, a polygon's points mirrored about its
1794
+ // place, a tile object's image flipped, and a box, ellipse, text or tile, placed by a corner, placed by the corner
1795
+ // that becomes it, its width along its turn from the mirrored place; mirrored twice it is as it was
1796
+ function editorStampObjectMirror(entry, width)
1797
+ {
1798
+ const offset = vec2(width - entry.offset.x, entry.offset.y), object = entry.object;
1799
+ if (!object) return {...entry, offset};
1800
+ const mirrored = {...object, rotation: object.rotation ? -object.rotation : 0};
1801
+ const points = (list)=> list.map((p)=> ({...p, x: -p.x}));
1802
+ if (object.polygon || object.polyline)
1803
+ {
1804
+ object.polygon && (mirrored.polygon = points(object.polygon));
1805
+ object.polyline && (mirrored.polyline = points(object.polyline));
1806
+ }
1807
+ else
1808
+ {
1809
+ // Tiled's pixels run down, the stamp's cells up
1810
+ const angle = (object.rotation || 0) * PI / 180, w = object.width || 0, {x: tw, y: th} = entry.tileSize ?? vec2(1);
1811
+ offset.x -= w * cos(angle) / tw;
1812
+ offset.y -= w * sin(angle) / th;
1813
+ }
1814
+ if (object.gid)
1815
+ mirrored.gid = (object.gid ^ 0x80000000) >>> 0; // Tiled's flip across
1816
+ return {...entry, offset, object: mirrored};
1817
+ }
1818
+
1711
1819
  // a stamp turned a quarter turn clockwise on screen, or back, its cells, their tiles and its objects together; cell
1712
1820
  // (x, y) goes to (y, width - 1 - x), so the bottom row becomes the left column, its left end at the top
1713
1821
  function editorStampTurn(stamp, back=false)
@@ -1715,7 +1823,7 @@ function editorStampTurn(stamp, back=false)
1715
1823
  for (let turns = back ? 3 : 1; turns--;)
1716
1824
  {
1717
1825
  const {width, height} = stamp;
1718
- const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(object.offset.y, width - object.offset.x)}));
1826
+ const objects = stamp.objects?.map((entry)=> editorStampObjectTurn(entry, width));
1719
1827
  stamp = {width: height, height: width, grids: stamp.grids.map((grid)=>
1720
1828
  {
1721
1829
  const turned = [];
@@ -1732,7 +1840,7 @@ function editorStampTurn(stamp, back=false)
1732
1840
  function editorStampMirror(stamp)
1733
1841
  {
1734
1842
  const {width, height} = stamp;
1735
- const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(width - object.offset.x, object.offset.y)}));
1843
+ const objects = stamp.objects?.map((entry)=> editorStampObjectMirror(entry, width));
1736
1844
  return {width, height, grids: stamp.grids.map((grid)=>
1737
1845
  {
1738
1846
  const mirrored = [];
@@ -1864,8 +1972,9 @@ function editorClear()
1864
1972
  editorStrokeEnd();
1865
1973
  }
1866
1974
 
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
1975
+ // the objects of a map's object layers inside a tile area, each {group, type, offset, object, tileSize}: the index of
1976
+ // its object layer, its offset from the area's bottom left corner, a copy of it, and the map's tile size in pixels,
1977
+ // which a mirror reads its width in cells by, as a stamp keeps them
1869
1978
  function editorAreaObjects(layer, area)
1870
1979
  {
1871
1980
  const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1));
@@ -1874,7 +1983,7 @@ function editorAreaObjects(layer, area)
1874
1983
  const ids = new Set(editorObjectsIn(objectLayer, corner, far));
1875
1984
  return (objectLayer.group?.objects ?? []).filter((object)=> ids.has(object.id)).map((object)=>
1876
1985
  ({group, type: object.type || object.class, offset: editorObjectPos(layer.record, object).subtract(corner),
1877
- object: editorObjectsCopy(object)}));
1986
+ object: editorObjectsCopy(object), tileSize: vec2(layer.record.map.tilewidth || 1, layer.record.map.tileheight || 1)}));
1878
1987
  });
1879
1988
  }
1880
1989
 
@@ -2266,7 +2375,8 @@ function editorPaletteDrawObjects(canvas)
2266
2375
  else
2267
2376
  {
2268
2377
  context.fillStyle = '#ccc';
2269
- context.fillText(name.slice(0, 4), x + cell / 2, y + cell / 2);
2378
+ const label = textGraphemes(name).slice(0, 4).join(''); // whole characters, never half an emoji
2379
+ context.fillText(label, x + cell / 2, y + cell / 2);
2270
2380
  }
2271
2381
  if (editorObjectBrush?.length !== 1 || editorObjectBrush[0].type !== name) return;
2272
2382
  context.strokeStyle = '#4af';
@@ -2963,5 +3073,8 @@ debug && engineAddPlugin(editorUpdate, ()=> { editorRender(); editorRenderGame()
2963
3073
  if (debug && globalThis.document?.addEventListener)
2964
3074
  {
2965
3075
  document.addEventListener('keydown', editorOnKeyDown);
3076
+ // a page going away writes what is waiting, the last event it is sure to get
3077
+ document.addEventListener('visibilitychange', ()=> document.hidden && editorAutosaveFlush());
3078
+ globalThis.addEventListener?.('pagehide', editorAutosaveFlush);
2966
3079
  document.addEventListener('wheel', editorOnWheel, {passive: true});
2967
3080
  }
@@ -53,9 +53,6 @@ export
53
53
  debugClear,
54
54
  debugScreenshot,
55
55
  debugShowErrors,
56
- debugVideoCaptureStart,
57
- debugVideoCaptureStop,
58
- debugVideoCaptureIsActive,
59
56
 
60
57
  // Settings
61
58
  cameraPos,
@@ -198,9 +198,11 @@ function inputClear()
198
198
  * @memberof Input */
199
199
  function keyIsDown(key, device=0)
200
200
  {
201
- ASSERT(isStringLike(key), 'key must be a number or string');
202
- ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
203
- ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
201
+ ASSERT(isStringLike(key), 'keyIsDown: key must be a code like \'KeyA\' or a number', key);
202
+ ASSERT(typeof key !== 'string' || key.length > 1,
203
+ "keyIsDown: keys are codes like 'KeyW' or 'Space', not characters", key);
204
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5,
205
+ "keyIsDown: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key);
204
206
  return !!(inputData[device]?.[key] & 1) && !inputCaptureHides(device);
205
207
  }
206
208
 
@@ -211,9 +213,11 @@ function keyIsDown(key, device=0)
211
213
  * @memberof Input */
212
214
  function keyWasPressed(key, device=0)
213
215
  {
214
- ASSERT(isStringLike(key), 'key must be a number or string');
215
- ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
216
- ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
216
+ ASSERT(isStringLike(key), 'keyWasPressed: key must be a code like \'KeyA\' or a number', key);
217
+ ASSERT(typeof key !== 'string' || key.length > 1,
218
+ "keyWasPressed: keys are codes like 'KeyW' or 'Space', not characters", key);
219
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5,
220
+ "keyWasPressed: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key);
217
221
  return !!(inputData[device]?.[key] & 2) && !inputCaptureHides(device);
218
222
  }
219
223
 
@@ -224,9 +228,11 @@ function keyWasPressed(key, device=0)
224
228
  * @memberof Input */
225
229
  function keyWasReleased(key, device=0)
226
230
  {
227
- ASSERT(isStringLike(key), 'key must be a number or string');
228
- ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
229
- ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
231
+ ASSERT(isStringLike(key), 'keyWasReleased: key must be a code like \'KeyA\' or a number', key);
232
+ ASSERT(typeof key !== 'string' || key.length > 1,
233
+ "keyWasReleased: keys are codes like 'KeyW' or 'Space', not characters", key);
234
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5,
235
+ "keyWasReleased: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key);
230
236
  return !!(inputData[device]?.[key] & 4) && !inputCaptureHides(device);
231
237
  }
232
238
 
@@ -248,35 +254,32 @@ function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='A
248
254
  }
249
255
 
250
256
  /** Returns true if mouse button is down
251
- * @function
252
257
  * @param {number} button
253
258
  * @return {boolean}
254
259
  * @memberof Input */
255
260
  function mouseIsDown(button)
256
261
  {
257
- ASSERT(isNumber(button), 'mouse button must be a number');
262
+ ASSERT(isNumber(button), 'mouseIsDown: button is 0 (left), 1 (middle) or 2 (right)', button);
258
263
  return keyIsDown(button);
259
264
  }
260
265
 
261
266
  /** Returns true if mouse button was pressed
262
- * @function
263
267
  * @param {number} button
264
268
  * @return {boolean}
265
269
  * @memberof Input */
266
270
  function mouseWasPressed(button)
267
271
  {
268
- ASSERT(isNumber(button), 'mouse button must be a number');
272
+ ASSERT(isNumber(button), 'mouseWasPressed: button is 0 (left), 1 (middle) or 2 (right)', button);
269
273
  return keyWasPressed(button);
270
274
  }
271
275
 
272
276
  /** Returns true if mouse button was released
273
- * @function
274
277
  * @param {number} button
275
278
  * @return {boolean}
276
279
  * @memberof Input */
277
280
  function mouseWasReleased(button)
278
281
  {
279
- ASSERT(isNumber(button), 'mouse button must be a number');
282
+ ASSERT(isNumber(button), 'mouseWasReleased: button is 0 (left), 1 (middle) or 2 (right)', button);
280
283
  return keyWasReleased(button);
281
284
  }
282
285
 
@@ -498,21 +501,24 @@ function inputInit()
498
501
  {
499
502
  // fix stalled audio requiring user interaction, a keyboard only game has no other gesture
500
503
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
501
- audioContext.resume();
504
+ audioResume();
502
505
 
503
506
  // keys typed into an html text field are the player's typing, not game input;
504
507
  // a key already down still releases on keyup, which only lets go of keys that are down
505
508
  const typing = isTextInput(e.target) || isTextInput(document.activeElement);
506
- if (!e.repeat && !typing)
509
+ // a key already held, a keydown the browser did not mark as a repeat, is not pressed again, but with Cmd down
510
+ // it is: a Mac sends no keyup for a key let go while Cmd is held, so each tap of Cmd+Z comes as a keydown alone
511
+ if (!e.repeat && !typing && (!inputKeysHeld.has(e.code) || e.metaKey))
507
512
  {
508
513
  inputKeysHeld.add(e.code);
509
- // an arrow its alias already holds down is not pressed again, like its release waits for both
514
+ // an arrow its alias already holds down is not pressed again, like its release waits for both; a release
515
+ // earlier this frame is kept, so a quick release and press reads as both
510
516
  if (!(inputWASDEmulateDirection && inputKeysHeld.has(inputArrowToWASD[e.code]) && inputData[0][e.code] & 1))
511
- inputData[0][e.code] = 3;
517
+ inputData[0][e.code] = 3 | inputData[0][e.code] & 4;
512
518
  // an alias presses its arrow's slot too, unless the arrow itself already holds it down
513
519
  const remap = remapKey(e.code);
514
520
  if (remap !== e.code && !(inputData[0][remap] & 1))
515
- inputData[0][remap] = 3;
521
+ inputData[0][remap] = 3 | inputData[0][remap] & 4;
516
522
  }
517
523
 
518
524
  // try to prevent default browser handling of input
@@ -584,13 +590,13 @@ function inputInit()
584
590
 
585
591
  // fix stalled audio requiring user interaction
586
592
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
587
- audioContext.resume();
593
+ audioResume();
588
594
 
589
595
  // a press in the bars around a letterboxed canvas is not one on its edge; a drag out of it still moves; under
590
596
  // pointer lock the mouse stays where the lock began, which may be in a bar, and every click is the game's
591
597
  if (!pointerLockIsActive() && !inCanvas(e.x, e.y))
592
598
  return;
593
- inputData[0][e.button] = 3;
599
+ inputData[0][e.button] = 3 | inputData[0][e.button] & 4; // a release earlier this frame is kept
594
600
 
595
601
  const mousePosScreenLast = mousePosScreen;
596
602
  mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
@@ -684,7 +690,7 @@ function inputInit()
684
690
 
685
691
  // fix stalled audio requiring user interaction
686
692
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
687
- audioContext.resume();
693
+ audioResume();
688
694
 
689
695
  // when the touch gamepad is enabled it owns touch input: suppress the
690
696
  // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
@@ -744,7 +750,7 @@ function inputInit()
744
750
  else if (inputWasTouching && touch.identifier === inputTouchIdentifier)
745
751
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
746
752
  else if (pressTouch && inCanvas(pressTouch.clientX, pressTouch.clientY))
747
- inputData[0][button] = 3; // a tap in the bars around the canvas is not a press
753
+ inputData[0][button] = 3 | inputData[0][button] & 4; // a tap in the bars is not a press
748
754
  // the finger left after a pinch moves the mouse from where it is, with no jump and no press
749
755
  inputTouchIdentifier = pinching ? undefined : touch.identifier;
750
756
  }
@@ -1449,7 +1455,7 @@ function touchGamepadPointerDown(e, zone)
1449
1455
 
1450
1456
  // resume audio on first interaction
1451
1457
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
1452
- audioContext.resume();
1458
+ audioResume();
1453
1459
 
1454
1460
  // while paused, any touch is the start button; a control belongs to the first finger on it until that
1455
1461
  // finger lifts, so a second finger landing on the same one neither takes it over nor lets it go