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.
- package/CHANGELOG.md +99 -0
- package/FAQ.md +49 -11
- package/README.md +6 -2
- package/REFERENCE.md +129 -21
- package/dist/littlejs.d.ts +487 -102
- package/dist/littlejs.esm.js +2898 -719
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +2888 -716
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +2702 -527
- package/package.json +4 -4
- package/plugins/box2d.js +110 -34
- package/plugins/gltf.js +74 -22
- package/plugins/lightSystem.js +410 -28
- package/plugins/math3d.js +4 -2
- package/plugins/medalSystem.js +19 -7
- package/plugins/newgrounds.js +4 -2
- package/plugins/parallax.js +1 -0
- package/plugins/particleEffects.js +44 -10
- package/plugins/pathFinder.js +9 -11
- package/plugins/pluginExport.js +9 -0
- package/plugins/postProcess.js +9 -3
- package/plugins/render3d.js +499 -2389
- package/plugins/render3dDebug.js +5 -8
- package/plugins/render3dEditor.js +81 -6
- package/plugins/render3dExtras.js +103 -31
- package/plugins/render3dLevel.js +53 -5
- package/plugins/render3dMesh.js +1158 -0
- package/plugins/render3dObject.js +1451 -0
- package/plugins/render3dVoxels.js +15 -3
- package/plugins/textureSheet.js +9 -8
- package/plugins/threejs.js +1 -1
- package/plugins/tweenSystem.js +76 -51
- package/plugins/uiSystem.js +37 -8
- package/plugins/wavedash.js +229 -0
- package/src/engine.js +199 -40
- package/src/engineAudio.js +37 -21
- package/src/engineBuild.mjs +6 -1
- package/src/engineDebug.js +22 -162
- package/src/engineDraw.js +142 -34
- package/src/engineEditor.js +79 -18
- package/src/engineExport.js +1 -3
- package/src/engineInput.js +34 -24
- package/src/engineMath.js +35 -14
- package/src/engineObject.js +60 -41
- package/src/engineParticles.js +58 -11
- package/src/engineRelease.js +1 -5
- package/src/engineSettings.js +48 -9
- package/src/engineTileLayer.js +11 -4
- package/src/engineUtilities.js +41 -9
- 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,
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|
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), '
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1669
|
-
// image's native resolution. Returns
|
|
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
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1780
|
+
context.canvas.width = w;
|
|
1781
|
+
context.canvas.height = h;
|
|
1782
|
+
context.drawImage(image, 0, 0);
|
|
1679
1783
|
|
|
1680
|
-
const imageData =
|
|
1784
|
+
const imageData = context.getImageData(0, 0, w, h);
|
|
1681
1785
|
tintImageData(imageData.data, color, additiveColor);
|
|
1682
|
-
|
|
1683
|
-
return
|
|
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
|
|
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
|
|
1861
|
-
|
|
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 =
|
|
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
|
package/src/engineEditor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/engineExport.js
CHANGED
|
@@ -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,
|