littlejsengine 1.25.0 → 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 (51) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/FAQ.md +49 -11
  3. package/README.md +6 -2
  4. package/REFERENCE.md +120 -18
  5. package/dist/littlejs.d.ts +464 -99
  6. package/dist/littlejs.esm.js +2853 -704
  7. package/dist/littlejs.esm.min.js +3 -3
  8. package/dist/littlejs.js +2844 -701
  9. package/dist/littlejs.min.js +2 -2
  10. package/dist/littlejs.release.js +2658 -512
  11. package/package.json +4 -4
  12. package/plugins/box2d.js +110 -34
  13. package/plugins/gltf.js +74 -22
  14. package/plugins/lightSystem.js +410 -28
  15. package/plugins/math3d.js +4 -2
  16. package/plugins/medalSystem.js +19 -7
  17. package/plugins/newgrounds.js +4 -2
  18. package/plugins/parallax.js +1 -0
  19. package/plugins/particleEffects.js +44 -10
  20. package/plugins/pathFinder.js +9 -11
  21. package/plugins/pluginExport.js +9 -0
  22. package/plugins/postProcess.js +4 -2
  23. package/plugins/render3d.js +485 -2385
  24. package/plugins/render3dDebug.js +5 -8
  25. package/plugins/render3dEditor.js +81 -6
  26. package/plugins/render3dExtras.js +103 -31
  27. package/plugins/render3dLevel.js +53 -5
  28. package/plugins/render3dMesh.js +1158 -0
  29. package/plugins/render3dObject.js +1451 -0
  30. package/plugins/render3dVoxels.js +15 -3
  31. package/plugins/textureSheet.js +9 -8
  32. package/plugins/threejs.js +1 -1
  33. package/plugins/tweenSystem.js +76 -51
  34. package/plugins/uiSystem.js +37 -8
  35. package/plugins/wavedash.js +229 -0
  36. package/src/engine.js +184 -36
  37. package/src/engineAudio.js +37 -21
  38. package/src/engineBuild.mjs +6 -1
  39. package/src/engineDebug.js +22 -162
  40. package/src/engineDraw.js +139 -34
  41. package/src/engineEditor.js +79 -18
  42. package/src/engineExport.js +0 -3
  43. package/src/engineInput.js +27 -18
  44. package/src/engineMath.js +35 -14
  45. package/src/engineObject.js +60 -41
  46. package/src/engineParticles.js +58 -11
  47. package/src/engineRelease.js +1 -5
  48. package/src/engineSettings.js +48 -9
  49. package/src/engineTileLayer.js +11 -4
  50. package/src/engineUtilities.js +41 -9
  51. package/src/engineWebGL.js +37 -13
package/src/engineDraw.js CHANGED
@@ -127,7 +127,7 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
127
127
  if (typeof size === 'number')
128
128
  {
129
129
  // if size is a number, make it a vector
130
- ASSERT(size > 0);
130
+ ASSERT(size > 0, 'tile: size must be above 0', size);
131
131
  size = new Vector2(size, size);
132
132
  }
133
133
 
@@ -138,7 +138,8 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
138
138
  {
139
139
  // no image: headless loads none and keeps the size; an image that failed to load, whose warning named it, gives
140
140
  // a tile of no size, which draws nothing; a slot never given an image is a mistake a debug build points out
141
- ASSERT(headlessMode || textureInfo instanceof TextureInfo, 'tile texture is not loaded', texture);
141
+ ASSERT(headlessMode || textureInfo instanceof TextureInfo,
142
+ 'tile: no texture ' + texture + ', pass its image to engineInit and make tiles in gameInit or later');
142
143
  return new TileInfo(new Vector2, headlessMode ? size.copy() : new Vector2, textureInfo, padding, bleed);
143
144
  }
144
145
 
@@ -209,7 +210,7 @@ class TileInfo
209
210
  */
210
211
  frame(frame)
211
212
  {
212
- ASSERT(typeof frame === 'number');
213
+ ASSERT(typeof frame === 'number', 'TileInfo.frame: frame must be a number', frame);
213
214
  const w = this.size.x + this.padding*2;
214
215
  const h = this.size.y + this.padding*2;
215
216
  const x = (this.columns ? frame % this.columns : frame) * w;
@@ -578,6 +579,12 @@ class Shader
578
579
  this.program3D = undefined;
579
580
  glShaderObjects.push(this); // a lost context drops the programs of every one
580
581
  }
582
+
583
+ /** Let go of this shader: its compiled programs are freed and it leaves the engine's list of shaders, which
584
+ * keeps every one made for a lost context, so a scene made again and again, or an editor trying snippets, does
585
+ * not keep them all; calling it again does nothing, and a draw with it afterwards compiles it again and takes it
586
+ * back, so only let go of one no object still draws with when it should stay freed */
587
+ dispose() { glShaderDispose(this); }
581
588
  }
582
589
 
583
590
  ///////////////////////////////////////////////////////////////////////////////
@@ -600,6 +607,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
600
607
  {
601
608
  ASSERT(isVector2(pos), 'pos must be a vec2');
602
609
  ASSERT(isVector2(size), 'size must be a vec2');
610
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'drawTile: tileInfo must be a TileInfo, color comes after it',
611
+ tileInfo);
603
612
  ASSERT(isColor(color), 'color is invalid');
604
613
  ASSERT(isNumber(angle), 'angle must be a number');
605
614
  ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
@@ -663,6 +672,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
663
672
  {
664
673
  // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
665
674
  context.scale(1, -1);
675
+ // smooth or pixelated as the texture says, as WebGL draws it, inside drawCanvas2D's save
676
+ textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
666
677
  // calculate uvs and render
667
678
  const x = tileInfo.pos.x, y = tileInfo.pos.y;
668
679
  const w = tileInfo.size.x, h = tileInfo.size.y;
@@ -826,12 +837,29 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
826
837
  // alpha is baked into pixels by bakeTintedImage's additive branch;
827
838
  // in that case globalAlpha must NOT also apply color.a
828
839
  const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
829
- const source = noTint
830
- ? textureInfo.image
831
- : bakeTintedImage(textureInfo.image, color, additiveColor);
840
+ let source = textureInfo.image;
841
+ if (!noTint)
842
+ {
843
+ // a bake is a pass over every pixel, so it is kept for the image until its tint changes; a canvas, which has
844
+ // getContext, may have been drawn into since, so it is baked again at every draw, its copy kept at its size
845
+ const key = color.r + ',' + color.g + ',' + color.b + ',' + color.a +
846
+ (additiveColor ? ',' + additiveColor.r + ',' + additiveColor.g + ',' + additiveColor.b + ',' + additiveColor.a : '');
847
+ let baked = drawTextureWrappedBakes.get(source);
848
+ if (baked?.key !== key || 'getContext' in source)
849
+ {
850
+ const kept = baked?.context, fits = kept?.canvas.width === (source.width|0) &&
851
+ kept?.canvas.height === (source.height|0);
852
+ const bakeContext = fits ? kept : createCanvasContext(source.width|0, source.height|0, true);
853
+ bakeTintedImage(source, color, additiveColor, bakeContext);
854
+ drawTextureWrappedBakes.set(source, baked = {key, context: bakeContext});
855
+ }
856
+ source = baked.context.canvas;
857
+ }
832
858
 
833
859
  context = context || drawContext;
834
860
  context.save();
861
+ // smooth or pixelated as the texture says, as WebGL draws it
862
+ textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
835
863
  context.translate(pos.x + .5, pos.y + .5);
836
864
  context.rotate(angle);
837
865
  context.globalAlpha = alphaBaked ? 1 : color.a;
@@ -1277,6 +1305,10 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
1277
1305
  * @memberof Draw */
1278
1306
  function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1279
1307
  {
1308
+ // checked before it is scaled, a vec2 size would come out NaN
1309
+ ASSERT(isVector2(pos), 'drawText: pos must be a vec2', pos);
1310
+ ASSERT(isNumber(size), 'drawText: size is a number, the height of the text in world units', size);
1311
+
1280
1312
  // convert to screen space
1281
1313
  pos = worldToScreen(pos);
1282
1314
  size *= cameraScale;
@@ -1288,6 +1320,73 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1288
1320
  drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
1289
1321
  }
1290
1322
 
1323
+ // the fonts a debug build has checked the canvas takes, by style and family, as a size never decides it and a zooming
1324
+ // camera draws at a new size each frame, and a font it does not, which would draw as 10px sans-serif with no word,
1325
+ // warned of once; a family name with spaces and digits, as Press Start 2P, needs quotes
1326
+ const drawFontsChecked = new Set;
1327
+ function drawFontCheck(context, fontText, font, fontStyle)
1328
+ {
1329
+ const key = fontStyle + '|' + font;
1330
+ if (drawFontsChecked.has(key)) return;
1331
+ drawFontsChecked.add(key);
1332
+ const sentinel = '1px littlejs-font-check'; // no font a game draws with
1333
+ context.font = sentinel;
1334
+ context.font = fontText;
1335
+ if (context.font === sentinel)
1336
+ console.warn(`the canvas does not take the font ${font}, so text draws as 10px sans-serif; ` +
1337
+ `a family name with spaces or digits goes in quotes, as "'Press Start 2P'"`);
1338
+ }
1339
+
1340
+ // the lines of a text, split at a Windows line ending too
1341
+ const textLines = (text)=> (text += '').includes('\r') ? text.split(/\r?\n/) : text.split('\n');
1342
+
1343
+ // how many lines a text has, counted without splitting it
1344
+ function textLineCount(text)
1345
+ {
1346
+ let count = 1;
1347
+ for (let i = -1; (i = text.indexOf('\n', i + 1)) >= 0;)
1348
+ ++count;
1349
+ return count;
1350
+ }
1351
+
1352
+ // the characters of a line of text as a reader counts them, each as its first code point: an emoji with its joiners
1353
+ // is one, and a letter with combining accents its letter; plain text takes the quick way, it is most of what is drawn,
1354
+ // so read character i with textCharacterCode
1355
+ const textIntl = /** @type {any} */ (globalThis.Intl); // Segmenter is newer than the library the source is checked with
1356
+ const textSegmenter = textIntl?.Segmenter && new textIntl.Segmenter;
1357
+ // a line with no mark, joiner or character past the 16 bit range, plain or accented letters of their own, Greek or
1358
+ // Japanese, has one code unit a character, so it is handed back as it is, read with charCodeAt, and nothing is made;
1359
+ // the class also holds what the segmenter joins that is not a mark: joiners, prepend, Thai and Lao SARA AM, Hangul
1360
+ // jamo extended and the halfwidth katakana voiced marks
1361
+ const textJoins = /[\p{M}\u200c\u200d\u0600-\u0605\u06dd\u070f\u0890\u0891\u08e2\u0d4e\u0e33\u0eb3\u1100-\u11ff\ua960-\ua97c\ud7b0-\ud7fb\uff9e\uff9f\u{10000}-\u{10ffff}]/u;
1362
+ /** @return {string|Array<number>} */
1363
+ function textCharacters(line)
1364
+ {
1365
+ if (!textJoins.test(line))
1366
+ return line;
1367
+ // where graphemes can not be split, code points with the joiners, variation selectors and accents left out
1368
+ return textSegmenter ? Array.from(textSegmenter.segment(line), (s)=> s.segment.codePointAt(0)) :
1369
+ Array.from(line, (c)=> c.codePointAt(0)).filter((c)=> c !== 0x200d && c !== 0xfe0f && !(c >= 0x300 && c < 0x370));
1370
+ }
1371
+
1372
+ // the characters of a text as a reader counts them, each a string, an emoji with its joiners one; code points where
1373
+ // graphemes can not be split, and code units for a text that needs nothing joined, the quick way
1374
+ const textGraphemes = (text)=> !textJoins.test(text) ? text.split('') :
1375
+ textSegmenter ? Array.from(textSegmenter.segment(text), (s)=> s.segment) : [...text];
1376
+
1377
+ // character i of what textCharacters gave, its code; a Latin letter with an accent of its own, as é, is its letter, as
1378
+ // an e with a combining accent is, for the fonts that draw only the plain letters
1379
+ const textCharacterCode = (characters, i)=>
1380
+ textBaseLetter(typeof characters == 'string' ? characters.charCodeAt(i) : characters[i]);
1381
+ const textBaseLetters = new Map;
1382
+ function textBaseLetter(code)
1383
+ {
1384
+ if (code < 0xc0 || code >= 0x250) return code;
1385
+ let base = textBaseLetters.get(code);
1386
+ base === undefined && textBaseLetters.set(code, base = String.fromCharCode(code).normalize('NFD').charCodeAt(0));
1387
+ return base;
1388
+ }
1389
+
1291
1390
  /** Draw text in screen space
1292
1391
  * Automatically splits new lines into rows
1293
1392
  * @param {string|number} text
@@ -1305,27 +1404,29 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1305
1404
  * @memberof Draw */
1306
1405
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1307
1406
  {
1308
- ASSERT(isStringLike(text), 'text must be a string');
1309
- ASSERT(isVector2(pos), 'pos must be a vec2');
1310
- ASSERT(isNumber(size), 'size must be a number');
1311
- ASSERT(isColor(color), 'color must be a color');
1312
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
1313
- ASSERT(isColor(lineColor), 'lineColor must be a color');
1314
- ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
1407
+ ASSERT(isStringLike(text), 'drawTextScreen: text must be a string', text);
1408
+ ASSERT(isVector2(pos), 'drawTextScreen: pos must be a vec2', pos);
1409
+ ASSERT(isNumber(size), 'drawTextScreen: size is a number, the height of the text in pixels', size);
1410
+ ASSERT(isColor(color), 'drawTextScreen: color must be a color', color);
1411
+ ASSERT(isNumber(lineWidth), 'drawTextScreen: lineWidth must be a number', lineWidth);
1412
+ ASSERT(isColor(lineColor), 'drawTextScreen: lineColor must be a color', lineColor);
1413
+ ASSERT(['left','center','right'].includes(textAlign), 'drawTextScreen: textAlign must be left, center or right', textAlign);
1315
1414
  ASSERT(isStringLike(font), 'font must be a string');
1316
1415
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
1317
1416
  ASSERT(isNumber(angle), 'angle must be a number');
1318
1417
 
1319
1418
  if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1320
1419
 
1321
- const lines = (text+'').split('\n');
1420
+ const lines = textLines(text); // a Windows line ending too
1322
1421
  // save before style mutations so caller's context state is preserved
1323
1422
  context.save();
1324
1423
  context.fillStyle = color.toString();
1325
1424
  context.strokeStyle = lineColor.toString();
1326
1425
  context.lineWidth = lineWidth;
1327
1426
  context.textAlign = textAlign;
1328
- context.font = fontStyle + ' ' + size + 'px '+ font;
1427
+ const fontText = fontStyle + ' ' + size + 'px '+ font;
1428
+ debug && drawFontCheck(context, fontText, font, fontStyle);
1429
+ context.font = fontText;
1329
1430
  context.textBaseline = 'middle';
1330
1431
  context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
1331
1432
  context.rotate(angle);
@@ -1609,7 +1710,7 @@ function setShader(shader)
1609
1710
 
1610
1711
  /** Set an extra canvas to composite behind the engine canvases when combining
1611
1712
  * Plugins that insert their own canvas below the LittleJS canvases should set
1612
- * this so it appears in screenshots and video capture
1713
+ * this so it appears in screenshots
1613
1714
  * @param {HTMLCanvasElement} [canvas]
1614
1715
  * @memberof Draw */
1615
1716
  function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
@@ -1665,24 +1766,27 @@ function tintImageData(data, color, additiveColor)
1665
1766
  return false;
1666
1767
  }
1667
1768
 
1668
- // Internal: bake a color/additive-color tint into workReadCanvas at the
1669
- // image's native resolution. Returns the work canvas, suitable for
1670
- // passing to context.createPattern. Used by drawTextureWrapped's
1671
- // Canvas2D path. Caller is responsible for short-circuiting when no
1769
+ // Internal: bake a color/additive-color tint into a context's canvas, the work
1770
+ // canvas by default, at the image's native resolution. Returns that canvas,
1771
+ // suitable for passing to context.createPattern. Used by drawTextureWrapped's
1772
+ // Canvas2D path, which keeps a canvas of its own for each image. Caller is responsible for short-circuiting when no
1672
1773
  // tint is needed (i.e. color is white and additiveColor is black/none).
1673
- function bakeTintedImage(image, color, additiveColor)
1774
+ function bakeTintedImage(image, color, additiveColor, context=workReadContext)
1674
1775
  {
1675
1776
  const w = image.width|0, h = image.height|0;
1676
- workReadCanvas.width = w;
1677
- workReadCanvas.height = h;
1678
- workReadContext.drawImage(image, 0, 0);
1777
+ context.canvas.width = w;
1778
+ context.canvas.height = h;
1779
+ context.drawImage(image, 0, 0);
1679
1780
 
1680
- const imageData = workReadContext.getImageData(0, 0, w, h);
1781
+ const imageData = context.getImageData(0, 0, w, h);
1681
1782
  tintImageData(imageData.data, color, additiveColor);
1682
- workReadContext.putImageData(imageData, 0, 0);
1683
- return workReadCanvas;
1783
+ context.putImageData(imageData, 0, 0);
1784
+ return context.canvas;
1684
1785
  }
1685
1786
 
1787
+ // the tinted bake drawTextureWrapped keeps for each image, its tint and the context it is in
1788
+ const drawTextureWrappedBakes = new WeakMap;
1789
+
1686
1790
  /** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
1687
1791
  * This is slower than normal drawImage when color is applied
1688
1792
  * @ignore
@@ -1748,10 +1852,10 @@ function toggleFullscreen()
1748
1852
  if (isFullscreen())
1749
1853
  {
1750
1854
  if (document.exitFullscreen)
1751
- document.exitFullscreen();
1855
+ document.exitFullscreen()?.catch?.(()=> {}); // refused outside a click, it stays as it is
1752
1856
  }
1753
1857
  else if (rootElement.requestFullscreen)
1754
- rootElement.requestFullscreen();
1858
+ rootElement.requestFullscreen()?.catch?.(()=> {});
1755
1859
  }
1756
1860
 
1757
1861
  /** Set the cursor style
@@ -1815,7 +1919,7 @@ class ImageFont
1815
1919
  if (typeof size === 'number')
1816
1920
  {
1817
1921
  // if size is a number, make it a vector
1818
- ASSERT(size > 0);
1922
+ ASSERT(size > 0, 'ImageFont.drawText: size must be above 0', size);
1819
1923
  size *= cameraScale;
1820
1924
  size = new Vector2(size, size);
1821
1925
  }
@@ -1853,15 +1957,16 @@ class ImageFont
1853
1957
  const tileInfo = this.tileInfo.frame(0);
1854
1958
 
1855
1959
  // draw each line of text, centered vertically like drawTextScreen when center is set
1856
- const lines = (text+'').split('\n');
1960
+ const lines = textLines(text); // a Windows line ending too
1857
1961
  const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
1858
1962
  lines.forEach((line, j)=>
1859
1963
  {
1860
- const centerOffset = center ? (line.length-1) * glyphSize.x / 2 : 0;
1861
- for (let i=line.length; i--;)
1964
+ const characters = textCharacters(line);
1965
+ const centerOffset = center ? (characters.length-1) * glyphSize.x / 2 : 0;
1966
+ for (let i=characters.length; i--;)
1862
1967
  {
1863
1968
  // get the glyph, out of range characters use the last one
1864
- const charCode = line.charCodeAt(i);
1969
+ const charCode = textCharacterCode(characters, i);
1865
1970
  this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
1866
1971
 
1867
1972
  // snap the glyph edges to whole pixels
@@ -473,6 +473,7 @@ function editorSetOpen(open)
473
473
  else
474
474
  {
475
475
  editorStrokeEnd();
476
+ editorAutosaveFlush(); // the game plays on with what the editor has kept, the stroke just ended too
476
477
  editorToolHeld = false;
477
478
  editorSelection = editorSelectionDrag = editorRightPress = editorObjectDrag = editorObjectBox = undefined;
478
479
  editorObjectSelection.clear();
@@ -544,6 +545,7 @@ const editorTileLayerData = (layers)=> editorTileLayers(layers).map((layer)=> la
544
545
  // panel to apply or drop, and one that already has the autosaved data, saved from the editor, drops it
545
546
  function editorMapRestore(map)
546
547
  {
548
+ editorAutosaveFlush(); // what an earlier map has waiting is written before this one reads the autosaves
547
549
  if (editorMapList.some((record)=> record.map === map)) return map; // loaded again, it has the changes
548
550
 
549
551
  // a map object seen before, its record retired when its layers went, gets it back as it was, its data has the
@@ -579,7 +581,8 @@ function editorMapRestore(map)
579
581
  const sameFile = saved.hash === record.hash || saved.savedHash === record.hash || !saved.width &&
580
582
  (saved.hash === editorMapHash(data, objects) || !saved.objects && saved.hash === editorMapHash(data));
581
583
  if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height &&
582
- 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)))
583
586
  editorDiscardPending(record);
584
587
  else if (!sameFile || !editorSavedFits(saved, data.length, saved.width ?? map.width, saved.height ?? map.height) ||
585
588
  !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) || !editorCopyData(data, saved.layers))
@@ -590,7 +593,7 @@ function editorMapRestore(map)
590
593
  }
591
594
  else
592
595
  {
593
- editorRestoreObjects(map, saved);
596
+ editorRestoreObjects(map, saved, true);
594
597
  console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
595
598
  'Save in the editor (Esc then 0) writes them to the file');
596
599
  saved.stale && console.warn(`LittleJS editor: they are older than the last edits to ${record.fileName}, ` +
@@ -657,6 +660,8 @@ function editorLayerRecord(live)
657
660
  const record = {map, synthetic: true, fileName: 'level.json', layers: []};
658
661
  const layer = {record, source, live, color: WHITE};
659
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));
660
665
  editorMapList.push(record);
661
666
  return layer;
662
667
  }
@@ -753,6 +758,10 @@ function editorSetBaseline(record, written)
753
758
  const objects = editorObjectGroups(written.layers).map((group)=> group.objects ?? []);
754
759
  Object.assign(record, {original: data, originalObjects: objects, savedHash: editorMapHash(data, objects,
755
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);
756
765
  record.pending || editorAutosave(record); // edits waiting to be applied keep their autosave
757
766
  }
758
767
 
@@ -764,6 +773,7 @@ async function editorSave(record, pickAgain=false)
764
773
  {
765
774
  if (!record) return;
766
775
  editorStrokeEnd();
776
+ editorAutosaveFlush();
767
777
  const text = editorMapJSON(record); // the map as it is now, later edits wait for a later save
768
778
  // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
769
779
  // the order they were asked for however long a hook takes
@@ -781,6 +791,7 @@ async function editorSave(record, pickAgain=false)
781
791
  async function editorSaveText(record, text, pickAgain)
782
792
  {
783
793
  const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
794
+ let written = false;
784
795
  if (picker)
785
796
  {
786
797
  try
@@ -796,10 +807,7 @@ async function editorSaveText(record, text, pickAgain)
796
807
  const writable = await record.fileHandle.createWritable();
797
808
  await writable.write(text);
798
809
  await writable.close();
799
- // the browser gives the picked file's name but not its folder, so the name is what says it is the map's
800
- if (record.fileHandle.name === record.fileName)
801
- editorSetBaseline(record, JSON.parse(text));
802
- return 'written';
810
+ written = true;
803
811
  }
804
812
  catch (error)
805
813
  {
@@ -809,6 +817,14 @@ async function editorSaveText(record, text, pickAgain)
809
817
  console.warn(`LittleJS editor: ${record.fileName} could not be written, downloaded instead`, error);
810
818
  }
811
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
+ }
812
828
  saveText(text, record.fileName, 'application/json');
813
829
  return 'downloaded';
814
830
  }
@@ -829,7 +845,9 @@ function editorWriteSaves(saves, key, name=editorSaveName())
829
845
  {
830
846
  try
831
847
  {
832
- 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);
833
851
  return editorSaveFailed = false;
834
852
  }
835
853
  catch { editorSaveFailed = true; }
@@ -899,22 +917,54 @@ const editorSavedFits = (saved, count, width, height)=> isArray(saved.layers) &&
899
917
  // remember a map's tile data and objects, or forget them when they are back to the file
900
918
  function editorAutosave(record)
901
919
  {
920
+ editorAutosaveWaiting.delete(record); // written now
902
921
  if (record.synthetic) return; // a layer made in code has no load to bring it back in, save it to a file
903
922
  const saves = editorSaves(), map = record.map, data = editorTileLayerData(map.layers);
904
923
  const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
905
- const original = record.originalObjects ?? [], kept = objects.slice();
906
- while (kept.length > original.length && !kept[kept.length - 1].length)
907
- kept.pop(); // an Objects layer the editor made, empty again, is not an edit
924
+ const original = record.originalObjects ?? [], kept = editorObjectsKept(objects, original.length);
908
925
  const size = record.originalSize, sameSize = map.width === size.width && map.height === size.height;
909
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
910
927
  if (sameSize && editorSameData(data, record.original) && editorSameData(kept, original) && !record.savedHash)
911
928
  delete saves[record.key];
912
929
  else
913
930
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
914
- layers: data, objects, nextobjectid: map.nextobjectid};
931
+ layers: data, objects: kept, nextobjectid: map.nextobjectid};
915
932
  editorWriteSaves(saves, record.key);
916
933
  }
917
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
+
918
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
919
969
  // object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook
920
970
  function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height, keepUnknown=false)
@@ -1019,7 +1069,9 @@ function editorResizeMap(map, width, height)
1019
1069
  }
1020
1070
  // an object on the map before and off it after is dropped, one that was already off it stays
1021
1071
  const {tilewidth=1, tileheight=1} = map, oldHeight = map.height;
1022
- 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);
1023
1075
  for (const group of editorObjectGroups(map.layers))
1024
1076
  {
1025
1077
  group.objects &&= group.objects.filter((object)=>
@@ -1205,12 +1257,13 @@ function editorUndo(redo=false)
1205
1257
  editorChanged(stroke);
1206
1258
  }
1207
1259
 
1208
- // after a change the tile layers it touched that need it draw again whole, 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
1209
1262
  function editorChanged(stroke)
1210
1263
  {
1211
1264
  editorRedraw(stroke);
1212
1265
  for (const record of new Set(stroke.map(editorEntryRecord)))
1213
- editorAutosave(record);
1266
+ editorAutosaveSoon(record);
1214
1267
  stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart();
1215
1268
  }
1216
1269
 
@@ -1387,13 +1440,17 @@ function editorObjectSetProperty(object, name, value, defaultValue)
1387
1440
  delete object.properties;
1388
1441
  }
1389
1442
 
1390
- // copy saved objects into a map's object layers, making an Objects layer for any it saved that the map lacks
1391
- 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)
1392
1447
  {
1393
1448
  const groups = editorObjectGroups(map.layers);
1394
1449
  (saved.objects ?? []).forEach((objects, i)=>
1395
1450
  {
1396
- (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);
1397
1454
  });
1398
1455
  if (saved.nextobjectid > (map.nextobjectid ?? 1))
1399
1456
  map.nextobjectid = saved.nextobjectid;
@@ -2318,7 +2375,8 @@ function editorPaletteDrawObjects(canvas)
2318
2375
  else
2319
2376
  {
2320
2377
  context.fillStyle = '#ccc';
2321
- 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);
2322
2380
  }
2323
2381
  if (editorObjectBrush?.length !== 1 || editorObjectBrush[0].type !== name) return;
2324
2382
  context.strokeStyle = '#4af';
@@ -3015,5 +3073,8 @@ debug && engineAddPlugin(editorUpdate, ()=> { editorRender(); editorRenderGame()
3015
3073
  if (debug && globalThis.document?.addEventListener)
3016
3074
  {
3017
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);
3018
3079
  document.addEventListener('wheel', editorOnWheel, {passive: true});
3019
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,