littlejsengine 1.25.0 → 1.26.1

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 +99 -0
  2. package/FAQ.md +49 -11
  3. package/README.md +6 -2
  4. package/REFERENCE.md +129 -21
  5. package/dist/littlejs.d.ts +487 -102
  6. package/dist/littlejs.esm.js +2898 -719
  7. package/dist/littlejs.esm.min.js +3 -3
  8. package/dist/littlejs.js +2888 -716
  9. package/dist/littlejs.min.js +2 -2
  10. package/dist/littlejs.release.js +2702 -527
  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 +9 -3
  23. package/plugins/render3d.js +499 -2389
  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 +199 -40
  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 +142 -34
  41. package/src/engineEditor.js +79 -18
  42. package/src/engineExport.js +1 -3
  43. package/src/engineInput.js +34 -24
  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;
@@ -547,6 +548,9 @@ class SpriteAnimator
547
548
  * across the sprite or the mesh's own uv
548
549
  * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
549
550
  * lightCount, lights[i], lightColors[i] and shadow()
551
+ * - In 3D the snippet may also define void mainNormal(inout vec3 n), given the normal facing the camera after the
552
+ * normal map, in world space, to bend it per pixel for waves or ripples; the lighting, specular, reflection and
553
+ * fog then all use it, as they use a normal map
550
554
  * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
551
555
  * parts of a surface still shadows with the whole of it
552
556
  * @example
@@ -578,6 +582,12 @@ class Shader
578
582
  this.program3D = undefined;
579
583
  glShaderObjects.push(this); // a lost context drops the programs of every one
580
584
  }
585
+
586
+ /** Let go of this shader: its compiled programs are freed and it leaves the engine's list of shaders, which
587
+ * keeps every one made for a lost context, so a scene made again and again, or an editor trying snippets, does
588
+ * not keep them all; calling it again does nothing, and a draw with it afterwards compiles it again and takes it
589
+ * back, so only let go of one no object still draws with when it should stay freed */
590
+ dispose() { glShaderDispose(this); }
581
591
  }
582
592
 
583
593
  ///////////////////////////////////////////////////////////////////////////////
@@ -600,6 +610,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
600
610
  {
601
611
  ASSERT(isVector2(pos), 'pos must be a vec2');
602
612
  ASSERT(isVector2(size), 'size must be a vec2');
613
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'drawTile: tileInfo must be a TileInfo, color comes after it',
614
+ tileInfo);
603
615
  ASSERT(isColor(color), 'color is invalid');
604
616
  ASSERT(isNumber(angle), 'angle must be a number');
605
617
  ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
@@ -663,6 +675,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
663
675
  {
664
676
  // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
665
677
  context.scale(1, -1);
678
+ // smooth or pixelated as the texture says, as WebGL draws it, inside drawCanvas2D's save
679
+ textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
666
680
  // calculate uvs and render
667
681
  const x = tileInfo.pos.x, y = tileInfo.pos.y;
668
682
  const w = tileInfo.size.x, h = tileInfo.size.y;
@@ -826,12 +840,29 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
826
840
  // alpha is baked into pixels by bakeTintedImage's additive branch;
827
841
  // in that case globalAlpha must NOT also apply color.a
828
842
  const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
829
- const source = noTint
830
- ? textureInfo.image
831
- : bakeTintedImage(textureInfo.image, color, additiveColor);
843
+ let source = textureInfo.image;
844
+ if (!noTint)
845
+ {
846
+ // a bake is a pass over every pixel, so it is kept for the image until its tint changes; a canvas, which has
847
+ // getContext, may have been drawn into since, so it is baked again at every draw, its copy kept at its size
848
+ const key = color.r + ',' + color.g + ',' + color.b + ',' + color.a +
849
+ (additiveColor ? ',' + additiveColor.r + ',' + additiveColor.g + ',' + additiveColor.b + ',' + additiveColor.a : '');
850
+ let baked = drawTextureWrappedBakes.get(source);
851
+ if (baked?.key !== key || 'getContext' in source)
852
+ {
853
+ const kept = baked?.context, fits = kept?.canvas.width === (source.width|0) &&
854
+ kept?.canvas.height === (source.height|0);
855
+ const bakeContext = fits ? kept : createCanvasContext(source.width|0, source.height|0, true);
856
+ bakeTintedImage(source, color, additiveColor, bakeContext);
857
+ drawTextureWrappedBakes.set(source, baked = {key, context: bakeContext});
858
+ }
859
+ source = baked.context.canvas;
860
+ }
832
861
 
833
862
  context = context || drawContext;
834
863
  context.save();
864
+ // smooth or pixelated as the texture says, as WebGL draws it
865
+ textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
835
866
  context.translate(pos.x + .5, pos.y + .5);
836
867
  context.rotate(angle);
837
868
  context.globalAlpha = alphaBaked ? 1 : color.a;
@@ -1277,6 +1308,10 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
1277
1308
  * @memberof Draw */
1278
1309
  function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1279
1310
  {
1311
+ // checked before it is scaled, a vec2 size would come out NaN
1312
+ ASSERT(isVector2(pos), 'drawText: pos must be a vec2', pos);
1313
+ ASSERT(isNumber(size), 'drawText: size is a number, the height of the text in world units', size);
1314
+
1280
1315
  // convert to screen space
1281
1316
  pos = worldToScreen(pos);
1282
1317
  size *= cameraScale;
@@ -1288,6 +1323,73 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1288
1323
  drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
1289
1324
  }
1290
1325
 
1326
+ // the fonts a debug build has checked the canvas takes, by style and family, as a size never decides it and a zooming
1327
+ // camera draws at a new size each frame, and a font it does not, which would draw as 10px sans-serif with no word,
1328
+ // warned of once; a family name with spaces and digits, as Press Start 2P, needs quotes
1329
+ const drawFontsChecked = new Set;
1330
+ function drawFontCheck(context, fontText, font, fontStyle)
1331
+ {
1332
+ const key = fontStyle + '|' + font;
1333
+ if (drawFontsChecked.has(key)) return;
1334
+ drawFontsChecked.add(key);
1335
+ const sentinel = '1px littlejs-font-check'; // no font a game draws with
1336
+ context.font = sentinel;
1337
+ context.font = fontText;
1338
+ if (context.font === sentinel)
1339
+ console.warn(`the canvas does not take the font ${font}, so text draws as 10px sans-serif; ` +
1340
+ `a family name with spaces or digits goes in quotes, as "'Press Start 2P'"`);
1341
+ }
1342
+
1343
+ // the lines of a text, split at a Windows line ending too
1344
+ const textLines = (text)=> (text += '').includes('\r') ? text.split(/\r?\n/) : text.split('\n');
1345
+
1346
+ // how many lines a text has, counted without splitting it
1347
+ function textLineCount(text)
1348
+ {
1349
+ let count = 1;
1350
+ for (let i = -1; (i = text.indexOf('\n', i + 1)) >= 0;)
1351
+ ++count;
1352
+ return count;
1353
+ }
1354
+
1355
+ // the characters of a line of text as a reader counts them, each as its first code point: an emoji with its joiners
1356
+ // is one, and a letter with combining accents its letter; plain text takes the quick way, it is most of what is drawn,
1357
+ // so read character i with textCharacterCode
1358
+ const textIntl = /** @type {any} */ (globalThis.Intl); // Segmenter is newer than the library the source is checked with
1359
+ const textSegmenter = textIntl?.Segmenter && new textIntl.Segmenter;
1360
+ // a line with no mark, joiner or character past the 16 bit range, plain or accented letters of their own, Greek or
1361
+ // Japanese, has one code unit a character, so it is handed back as it is, read with charCodeAt, and nothing is made;
1362
+ // the class also holds what the segmenter joins that is not a mark: joiners, prepend, Thai and Lao SARA AM, Hangul
1363
+ // jamo extended and the halfwidth katakana voiced marks
1364
+ 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;
1365
+ /** @return {string|Array<number>} */
1366
+ function textCharacters(line)
1367
+ {
1368
+ if (!textJoins.test(line))
1369
+ return line;
1370
+ // where graphemes can not be split, code points with the joiners, variation selectors and accents left out
1371
+ return textSegmenter ? Array.from(textSegmenter.segment(line), (s)=> s.segment.codePointAt(0)) :
1372
+ Array.from(line, (c)=> c.codePointAt(0)).filter((c)=> c !== 0x200d && c !== 0xfe0f && !(c >= 0x300 && c < 0x370));
1373
+ }
1374
+
1375
+ // the characters of a text as a reader counts them, each a string, an emoji with its joiners one; code points where
1376
+ // graphemes can not be split, and code units for a text that needs nothing joined, the quick way
1377
+ const textGraphemes = (text)=> !textJoins.test(text) ? text.split('') :
1378
+ textSegmenter ? Array.from(textSegmenter.segment(text), (s)=> s.segment) : [...text];
1379
+
1380
+ // character i of what textCharacters gave, its code; a Latin letter with an accent of its own, as é, is its letter, as
1381
+ // an e with a combining accent is, for the fonts that draw only the plain letters
1382
+ const textCharacterCode = (characters, i)=>
1383
+ textBaseLetter(typeof characters == 'string' ? characters.charCodeAt(i) : characters[i]);
1384
+ const textBaseLetters = new Map;
1385
+ function textBaseLetter(code)
1386
+ {
1387
+ if (code < 0xc0 || code >= 0x250) return code;
1388
+ let base = textBaseLetters.get(code);
1389
+ base === undefined && textBaseLetters.set(code, base = String.fromCharCode(code).normalize('NFD').charCodeAt(0));
1390
+ return base;
1391
+ }
1392
+
1291
1393
  /** Draw text in screen space
1292
1394
  * Automatically splits new lines into rows
1293
1395
  * @param {string|number} text
@@ -1305,27 +1407,29 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1305
1407
  * @memberof Draw */
1306
1408
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1307
1409
  {
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');
1410
+ ASSERT(isStringLike(text), 'drawTextScreen: text must be a string', text);
1411
+ ASSERT(isVector2(pos), 'drawTextScreen: pos must be a vec2', pos);
1412
+ ASSERT(isNumber(size), 'drawTextScreen: size is a number, the height of the text in pixels', size);
1413
+ ASSERT(isColor(color), 'drawTextScreen: color must be a color', color);
1414
+ ASSERT(isNumber(lineWidth), 'drawTextScreen: lineWidth must be a number', lineWidth);
1415
+ ASSERT(isColor(lineColor), 'drawTextScreen: lineColor must be a color', lineColor);
1416
+ ASSERT(['left','center','right'].includes(textAlign), 'drawTextScreen: textAlign must be left, center or right', textAlign);
1315
1417
  ASSERT(isStringLike(font), 'font must be a string');
1316
1418
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
1317
1419
  ASSERT(isNumber(angle), 'angle must be a number');
1318
1420
 
1319
1421
  if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1320
1422
 
1321
- const lines = (text+'').split('\n');
1423
+ const lines = textLines(text); // a Windows line ending too
1322
1424
  // save before style mutations so caller's context state is preserved
1323
1425
  context.save();
1324
1426
  context.fillStyle = color.toString();
1325
1427
  context.strokeStyle = lineColor.toString();
1326
1428
  context.lineWidth = lineWidth;
1327
1429
  context.textAlign = textAlign;
1328
- context.font = fontStyle + ' ' + size + 'px '+ font;
1430
+ const fontText = fontStyle + ' ' + size + 'px '+ font;
1431
+ debug && drawFontCheck(context, fontText, font, fontStyle);
1432
+ context.font = fontText;
1329
1433
  context.textBaseline = 'middle';
1330
1434
  context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
1331
1435
  context.rotate(angle);
@@ -1609,7 +1713,7 @@ function setShader(shader)
1609
1713
 
1610
1714
  /** Set an extra canvas to composite behind the engine canvases when combining
1611
1715
  * Plugins that insert their own canvas below the LittleJS canvases should set
1612
- * this so it appears in screenshots and video capture
1716
+ * this so it appears in screenshots
1613
1717
  * @param {HTMLCanvasElement} [canvas]
1614
1718
  * @memberof Draw */
1615
1719
  function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
@@ -1665,24 +1769,27 @@ function tintImageData(data, color, additiveColor)
1665
1769
  return false;
1666
1770
  }
1667
1771
 
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
1772
+ // Internal: bake a color/additive-color tint into a context's canvas, the work
1773
+ // canvas by default, at the image's native resolution. Returns that canvas,
1774
+ // suitable for passing to context.createPattern. Used by drawTextureWrapped's
1775
+ // Canvas2D path, which keeps a canvas of its own for each image. Caller is responsible for short-circuiting when no
1672
1776
  // tint is needed (i.e. color is white and additiveColor is black/none).
1673
- function bakeTintedImage(image, color, additiveColor)
1777
+ function bakeTintedImage(image, color, additiveColor, context=workReadContext)
1674
1778
  {
1675
1779
  const w = image.width|0, h = image.height|0;
1676
- workReadCanvas.width = w;
1677
- workReadCanvas.height = h;
1678
- workReadContext.drawImage(image, 0, 0);
1780
+ context.canvas.width = w;
1781
+ context.canvas.height = h;
1782
+ context.drawImage(image, 0, 0);
1679
1783
 
1680
- const imageData = workReadContext.getImageData(0, 0, w, h);
1784
+ const imageData = context.getImageData(0, 0, w, h);
1681
1785
  tintImageData(imageData.data, color, additiveColor);
1682
- workReadContext.putImageData(imageData, 0, 0);
1683
- return workReadCanvas;
1786
+ context.putImageData(imageData, 0, 0);
1787
+ return context.canvas;
1684
1788
  }
1685
1789
 
1790
+ // the tinted bake drawTextureWrapped keeps for each image, its tint and the context it is in
1791
+ const drawTextureWrappedBakes = new WeakMap;
1792
+
1686
1793
  /** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
1687
1794
  * This is slower than normal drawImage when color is applied
1688
1795
  * @ignore
@@ -1748,10 +1855,10 @@ function toggleFullscreen()
1748
1855
  if (isFullscreen())
1749
1856
  {
1750
1857
  if (document.exitFullscreen)
1751
- document.exitFullscreen();
1858
+ document.exitFullscreen()?.catch?.(()=> {}); // refused outside a click, it stays as it is
1752
1859
  }
1753
1860
  else if (rootElement.requestFullscreen)
1754
- rootElement.requestFullscreen();
1861
+ rootElement.requestFullscreen()?.catch?.(()=> {});
1755
1862
  }
1756
1863
 
1757
1864
  /** Set the cursor style
@@ -1815,7 +1922,7 @@ class ImageFont
1815
1922
  if (typeof size === 'number')
1816
1923
  {
1817
1924
  // if size is a number, make it a vector
1818
- ASSERT(size > 0);
1925
+ ASSERT(size > 0, 'ImageFont.drawText: size must be above 0', size);
1819
1926
  size *= cameraScale;
1820
1927
  size = new Vector2(size, size);
1821
1928
  }
@@ -1853,15 +1960,16 @@ class ImageFont
1853
1960
  const tileInfo = this.tileInfo.frame(0);
1854
1961
 
1855
1962
  // draw each line of text, centered vertically like drawTextScreen when center is set
1856
- const lines = (text+'').split('\n');
1963
+ const lines = textLines(text); // a Windows line ending too
1857
1964
  const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
1858
1965
  lines.forEach((line, j)=>
1859
1966
  {
1860
- const centerOffset = center ? (line.length-1) * glyphSize.x / 2 : 0;
1861
- for (let i=line.length; i--;)
1967
+ const characters = textCharacters(line);
1968
+ const centerOffset = center ? (characters.length-1) * glyphSize.x / 2 : 0;
1969
+ for (let i=characters.length; i--;)
1862
1970
  {
1863
1971
  // get the glyph, out of range characters use the last one
1864
- const charCode = line.charCodeAt(i);
1972
+ const charCode = textCharacterCode(characters, i);
1865
1973
  this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
1866
1974
 
1867
1975
  // 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
  }
@@ -15,6 +15,7 @@ export
15
15
  frame,
16
16
  time,
17
17
  timeReal,
18
+ averageFPS,
18
19
  paused,
19
20
  getPaused,
20
21
  setPaused,
@@ -53,9 +54,6 @@ export
53
54
  debugClear,
54
55
  debugScreenshot,
55
56
  debugShowErrors,
56
- debugVideoCaptureStart,
57
- debugVideoCaptureStop,
58
- debugVideoCaptureIsActive,
59
57
 
60
58
  // Settings
61
59
  cameraPos,