littlejsengine 1.24.1 → 1.24.3
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 +78 -1
- package/EDITOR.md +2 -0
- package/FAQ.md +13 -2
- package/README.md +1 -1
- package/REFERENCE.md +22 -7
- package/dist/littlejs.d.ts +204 -118
- package/dist/littlejs.esm.js +2479 -1983
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +2476 -1983
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +2434 -1990
- package/package.json +1 -1
- package/plugins/box2d.js +9 -5
- package/plugins/gltf.js +67 -9
- package/plugins/lightSystem.js +1 -1
- package/plugins/math3d.js +1014 -1013
- package/plugins/medalSystem.js +7 -11
- package/plugins/particleEffects.js +2 -2
- package/plugins/pathFinder.js +2 -2
- package/plugins/pluginExport.js +1 -0
- package/plugins/render3d.js +131 -55
- package/plugins/render3dEditor.js +24 -10
- package/plugins/render3dExtras.js +41 -25
- package/plugins/render3dLevel.js +21 -12
- package/plugins/render3dVoxels.js +2 -2
- package/plugins/textureSheet.js +109 -7
- package/plugins/tweakables.js +1 -1
- package/plugins/tweenSystem.js +2 -2
- package/plugins/uiSystem.js +6 -2
- package/src/engine.js +60 -23
- package/src/engineAudio.js +16 -6
- package/src/engineBuild.mjs +49 -2
- package/src/engineDebug.js +4 -1
- package/src/engineDraw.js +40 -25
- package/src/engineEditor.js +82 -50
- package/src/engineExport.js +2 -0
- package/src/engineInput.js +21 -4
- package/src/engineMath.js +6 -2
- package/src/engineObject.js +675 -675
- package/src/engineParticles.js +1 -1
- package/src/engineSettings.js +16 -0
- package/src/engineTileLayer.js +85 -22
- package/src/engineUtilities.js +20 -8
- package/src/engineWebGL.js +15 -11
|
@@ -28,7 +28,7 @@ function render3DDetach(o)
|
|
|
28
28
|
let render3DSoftDotTexture;
|
|
29
29
|
function render3DSoftDot()
|
|
30
30
|
{
|
|
31
|
-
if (render3DSoftDotTexture || !glContext ||
|
|
31
|
+
if (render3DSoftDotTexture || !glContext || !canvasAvailable()) return render3DSoftDotTexture;
|
|
32
32
|
const size = 32, context = createCanvasContext(size);
|
|
33
33
|
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
34
34
|
for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
|
|
@@ -50,7 +50,7 @@ function normalMapFromHeight(size, heightFunction, strength=1)
|
|
|
50
50
|
{
|
|
51
51
|
ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
|
|
52
52
|
const width = size.x | 0, height = size.y | 0;
|
|
53
|
-
if (
|
|
53
|
+
if (!canvasAvailable())
|
|
54
54
|
{
|
|
55
55
|
// headless, nothing to draw into; the right size so what reads it still works
|
|
56
56
|
const textureInfo = new TextureInfo(undefined, false, true);
|
|
@@ -631,9 +631,9 @@ class HeightMap extends EngineObject3D
|
|
|
631
631
|
levelSegment3D(from, to)
|
|
632
632
|
{
|
|
633
633
|
const m = this.pos3D, size = this.mapSize;
|
|
634
|
-
if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return; // off the map
|
|
634
|
+
if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return undefined; // off the map
|
|
635
635
|
const a = from.y - this.getHeight(from.x, from.z), b = to.y - this.getHeight(to.x, to.z);
|
|
636
|
-
if (a < 0 || b >= 0) return; // above all the way, or under from the start
|
|
636
|
+
if (a < 0 || b >= 0) return undefined; // above all the way, or under from the start
|
|
637
637
|
const distance = a / (a - b);
|
|
638
638
|
return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)};
|
|
639
639
|
}
|
|
@@ -1484,7 +1484,7 @@ function render3DFlareTile(shape)
|
|
|
1484
1484
|
{
|
|
1485
1485
|
if (!render3DFlareTiles)
|
|
1486
1486
|
{
|
|
1487
|
-
if (headlessMode || !glContext ||
|
|
1487
|
+
if (headlessMode || !glContext || !canvasAvailable()) return;
|
|
1488
1488
|
const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
|
|
1489
1489
|
// each shape is how see-through it is from its middle out
|
|
1490
1490
|
const alpha =
|
|
@@ -1575,7 +1575,8 @@ function render3DFlareTile(shape)
|
|
|
1575
1575
|
* with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
|
|
1576
1576
|
* give it elements of your own, which may be tiles of the game's
|
|
1577
1577
|
* - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
|
|
1578
|
-
* sun is too far off the screen for the flare to show, a
|
|
1578
|
+
* sun is too far off the screen for the flare to show, about a seventh of the screen past its edge, where nothing
|
|
1579
|
+
* is tested
|
|
1579
1580
|
* - What hides the sun is found with a ray from the camera, against the level and every object that is not see
|
|
1580
1581
|
* through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
|
|
1581
1582
|
* - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
|
|
@@ -1622,7 +1623,7 @@ class LensFlare3D extends EngineObject3D
|
|
|
1622
1623
|
this.elements = undefined;
|
|
1623
1624
|
/** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
|
|
1624
1625
|
* flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
|
|
1625
|
-
* in front of the light but its lamp, a mesh around it
|
|
1626
|
+
* in front of the light but its lamp, a mesh around it whose surface is near it, and a spotlight's
|
|
1626
1627
|
* shows from inside its beam only; a DirectionalLight3D's is far
|
|
1627
1628
|
* away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
|
|
1628
1629
|
* sets this
|
|
@@ -1670,9 +1671,11 @@ class LensFlare3D extends EngineObject3D
|
|
|
1670
1671
|
return this.made;
|
|
1671
1672
|
}
|
|
1672
1673
|
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1674
|
+
/** What the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a
|
|
1675
|
+
* directional light, which shines from its place toward the origin, and a point to find it on the screen by;
|
|
1676
|
+
* undefined with no direction, or a light that is gone or at the camera
|
|
1677
|
+
* @return {{direction: Vector3, distance: number, pos: Vector3}|undefined}
|
|
1678
|
+
* @ignore */
|
|
1676
1679
|
flareSource()
|
|
1677
1680
|
{
|
|
1678
1681
|
const camera = render3D.camera.pos, light = this.light;
|
|
@@ -1688,15 +1691,17 @@ class LensFlare3D extends EngineObject3D
|
|
|
1688
1691
|
return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
|
|
1689
1692
|
}
|
|
1690
1693
|
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
+
/** How the flare would show with nothing in the way: where its source is on the screen, how strong it is there,
|
|
1695
|
+
* fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not
|
|
1696
|
+
* show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone
|
|
1697
|
+
* @return {{sun: Vector2, center: Vector2, strength: number, tint: Color, height: number}|undefined}
|
|
1698
|
+
* @ignore */
|
|
1694
1699
|
flareLook()
|
|
1695
1700
|
{
|
|
1696
1701
|
const source = this.flareSource(), center = mainCanvasSize.scale(.5);
|
|
1697
1702
|
const sun = source && render3D.worldToScreen(source.pos);
|
|
1698
1703
|
if (!sun || !center.x || !center.y) return;
|
|
1699
|
-
// it fades as the sun leaves the screen, gone
|
|
1704
|
+
// it fades as the sun leaves the screen, gone .3 of half the screen past the edge
|
|
1700
1705
|
const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
|
|
1701
1706
|
const strength = clamp((1.3 - off) / .5) * this.intensity;
|
|
1702
1707
|
if (!(strength > 0)) return;
|
|
@@ -1762,21 +1767,30 @@ class LensFlare3D extends EngineObject3D
|
|
|
1762
1767
|
for (const map of maps)
|
|
1763
1768
|
if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
|
|
1764
1769
|
return true;
|
|
1765
|
-
// a light's lamp,
|
|
1766
|
-
//
|
|
1767
|
-
// too, and its walls do hide them from outside
|
|
1768
|
-
const
|
|
1770
|
+
// a light's lamp, a mesh around the light, does not hide it: one whose box, in its own space, the light is in,
|
|
1771
|
+
// hit close to the light, as a shade or a globe is, and small, its thinnest side no more than twice that; a
|
|
1772
|
+
// room or a whole level holds its lights too, and its walls do hide them from outside, even one close behind
|
|
1773
|
+
const inside = (o)=>
|
|
1769
1774
|
{
|
|
1770
|
-
if (!
|
|
1775
|
+
if (!o.mesh || o instanceof HeightMap) return false;
|
|
1771
1776
|
const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds();
|
|
1772
|
-
const
|
|
1773
|
-
|
|
1777
|
+
const m = matrix.m, scale = (k)=> hypot(m[k], m[k + 1], m[k + 2]); // each axis's own stretch
|
|
1778
|
+
const thinnest = min((b.max.x - b.min.x) * scale(0), (b.max.y - b.min.y) * scale(4), (b.max.z - b.min.z) * scale(8));
|
|
1779
|
+
if (!matrix.determinant() || thinnest > near * 2) return false;
|
|
1774
1780
|
const p = matrix.copy().invert().transformPoint(source.pos);
|
|
1775
1781
|
return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
|
|
1776
1782
|
p.z >= b.min.z && p.z <= b.max.z;
|
|
1777
1783
|
};
|
|
1778
|
-
const
|
|
1779
|
-
|
|
1784
|
+
const near = this.light && reach < Infinity ? min(2, this.light.radius * .4) : 0;
|
|
1785
|
+
let candidates = blockers.filter((o)=> !maps.some((map)=> map === o));
|
|
1786
|
+
for (let tries = 5; tries--;)
|
|
1787
|
+
{
|
|
1788
|
+
const hit = render3D.pick(ray, candidates);
|
|
1789
|
+
if (!hit || hit.distance >= reach) return false;
|
|
1790
|
+
if (!(reach - hit.distance <= near && inside(hit.object))) return true;
|
|
1791
|
+
candidates = candidates.filter((o)=> o !== hit.object); // its lamp, look past it
|
|
1792
|
+
}
|
|
1793
|
+
return true; // lamps on lamps, take it as hidden
|
|
1780
1794
|
}
|
|
1781
1795
|
|
|
1782
1796
|
/** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
|
|
@@ -1837,9 +1851,10 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
|
|
|
1837
1851
|
// OBJ indices count from 1, and a negative one counts back from the end of the list so far
|
|
1838
1852
|
const index = (s, list)=> { const i = parseInt(s); return i < 0 ? list.length + i : i - 1; };
|
|
1839
1853
|
const lookup = (s, list)=> list[index(s, list)];
|
|
1840
|
-
|
|
1854
|
+
// any line end, a line carried on by a backslash at its end, and a comment after what a line holds
|
|
1855
|
+
for (const line of text.replace(/\\\r?\n/g, ' ').split(/\r\n?|\n/))
|
|
1841
1856
|
{
|
|
1842
|
-
const parts = line.trim().split(/\s+/);
|
|
1857
|
+
const parts = line.replace(/#.*/, '').trim().split(/\s+/);
|
|
1843
1858
|
switch (parts[0])
|
|
1844
1859
|
{
|
|
1845
1860
|
case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
|
|
@@ -1851,6 +1866,7 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
|
|
|
1851
1866
|
if (corners.length < 3) break;
|
|
1852
1867
|
const facePoints = corners.map(c=> lookup(c[0], positions));
|
|
1853
1868
|
ASSERT(facePoints.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
|
|
1869
|
+
if (!facePoints.every(isVector3)) break; // a release build leaves the face out
|
|
1854
1870
|
const hasNormals = corners.every(c=> c[2]);
|
|
1855
1871
|
missingNormals ||= !hasNormals;
|
|
1856
1872
|
const faceNormal = hasNormals ? undefined : render3DFaceNormal(facePoints[0], facePoints[1], facePoints[2], facePoints[3]);
|
package/plugins/render3dLevel.js
CHANGED
|
@@ -198,18 +198,21 @@ function level3DTerrainShape(terrain)
|
|
|
198
198
|
{
|
|
199
199
|
if (!terrain || typeof terrain !== 'object') return;
|
|
200
200
|
const size = terrain.size, rows = terrain.heights, n = LEVEL3D_TERRAIN_SAMPLES;
|
|
201
|
-
if (!isArray(size) || size.length !== 2 || !size.every((v)=>
|
|
201
|
+
if (!isArray(size) || size.length !== 2 || !size.every((v)=> level3DFinite(v) && v > 0)) return;
|
|
202
202
|
if (!isArray(rows) || rows.length < 2 || rows.length > n || !isArray(rows[0])) return;
|
|
203
203
|
const columns = rows[0].length;
|
|
204
204
|
if (columns < 2 || columns > n || !rows.every((row)=> isArray(row) && row.length === columns)) return;
|
|
205
|
-
const heights = rows.map((row)=> row.map((v)=>
|
|
205
|
+
const heights = rows.map((row)=> row.map((v)=> level3DFinite(v) ? clamp(v) : 0));
|
|
206
206
|
return {pos: level3DVector(terrain.pos, vec3()), size: vec2(size[0], size[1]),
|
|
207
|
-
height:
|
|
207
|
+
height: level3DFinite(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
// a hex color of a level as a Color, undefined when it is not one
|
|
211
211
|
function level3DHexColor(hex)
|
|
212
|
-
{ return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
|
|
212
|
+
{ return typeof hex === 'string' && /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
|
|
213
|
+
|
|
214
|
+
// a number of a level that is a real one, not NaN or Infinity, which JSON writes as 1e999
|
|
215
|
+
const level3DFinite = (v)=> typeof v === 'number' && isFinite(v);
|
|
213
216
|
|
|
214
217
|
// the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
|
|
215
218
|
// color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
|
|
@@ -248,7 +251,7 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
|
|
|
248
251
|
let level3DVoxelPlain;
|
|
249
252
|
function level3DVoxelPlainTiles()
|
|
250
253
|
{
|
|
251
|
-
if (textureInfos[0]?.size.x || headlessMode ||
|
|
254
|
+
if (textureInfos[0]?.size.x || headlessMode || !canvasAvailable())
|
|
252
255
|
return tile();
|
|
253
256
|
if (!level3DVoxelPlain)
|
|
254
257
|
{
|
|
@@ -289,12 +292,12 @@ function level3DSceneApply(scene)
|
|
|
289
292
|
{
|
|
290
293
|
const r = render3D;
|
|
291
294
|
if (!r || !scene || typeof scene !== 'object') return;
|
|
292
|
-
const color =
|
|
295
|
+
const color = level3DHexColor;
|
|
293
296
|
const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky.map(color) : [];
|
|
294
297
|
if (sky.length && sky.every((c)=> c))
|
|
295
298
|
{
|
|
296
299
|
// as setSky does, without disposing a dome that is the game's own
|
|
297
|
-
const [top, horizon, bottom] = sky, ambient =
|
|
300
|
+
const [top, horizon, bottom] = sky, ambient = level3DFinite(scene.ambient) ? scene.ambient : .5;
|
|
298
301
|
level3DSkies.has(r.sky) && r.sky.dispose();
|
|
299
302
|
level3DSkies.add(r.sky = buildSky(top, horizon, bottom));
|
|
300
303
|
r.fogColor = horizon.copy();
|
|
@@ -306,7 +309,7 @@ function level3DSceneApply(scene)
|
|
|
306
309
|
r.sunDirection = sun;
|
|
307
310
|
r.sunColor = color(scene.sunColor) || r.sunColor;
|
|
308
311
|
const fog = scene.fog;
|
|
309
|
-
if (isArray(fog) && fog.length === 2 && fog.every(
|
|
312
|
+
if (isArray(fog) && fog.length === 2 && fog.every(level3DFinite))
|
|
310
313
|
r.fogStart = fog[0], r.fogEnd = fog[1];
|
|
311
314
|
r.fogColor = color(scene.fogColor) || r.fogColor;
|
|
312
315
|
if (typeof scene.shadows === 'boolean')
|
|
@@ -333,7 +336,7 @@ const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3
|
|
|
333
336
|
// a vec3 of an array of three numbers, as the file has them, or the fallback
|
|
334
337
|
function level3DVector(value, fallback)
|
|
335
338
|
{
|
|
336
|
-
return isArray(value) && value.length === 3 && value.every(
|
|
339
|
+
return isArray(value) && value.length === 3 && value.every(level3DFinite) ?
|
|
337
340
|
vec3(value[0], value[1], value[2]) : fallback;
|
|
338
341
|
}
|
|
339
342
|
|
|
@@ -349,12 +352,14 @@ function level3DProperties(type, object)
|
|
|
349
352
|
{
|
|
350
353
|
const d = type.defaults[key];
|
|
351
354
|
if (isColor(d))
|
|
352
|
-
|
|
355
|
+
level3DHexColor(value) && (properties[key] = level3DHexColor(value));
|
|
353
356
|
else if (isVector3(d))
|
|
354
357
|
properties[key] = level3DVector(value, properties[key]);
|
|
355
358
|
else if (isVector2(d))
|
|
356
|
-
isArray(value) && value.length === 2 && value.every(
|
|
359
|
+
isArray(value) && value.length === 2 && value.every(level3DFinite) &&
|
|
357
360
|
(properties[key] = vec2(value[0], value[1]));
|
|
361
|
+
else if (typeof d === 'number')
|
|
362
|
+
level3DFinite(value) && (properties[key] = value);
|
|
358
363
|
else if (d === undefined || typeof value === typeof d)
|
|
359
364
|
properties[key] = value;
|
|
360
365
|
}
|
|
@@ -439,6 +444,8 @@ const level3DPrefabs = new Map;
|
|
|
439
444
|
// how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
|
|
440
445
|
// are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
|
|
441
446
|
let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
|
|
447
|
+
// the prefabs being made, one inside the next, so one that holds itself is caught where it does, however many times
|
|
448
|
+
const level3DPrefabMaking = [];
|
|
442
449
|
|
|
443
450
|
/** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
|
|
444
451
|
* - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
|
|
@@ -580,11 +587,12 @@ class Prefab3D extends EngineObject3D
|
|
|
580
587
|
{
|
|
581
588
|
const prefab = level3DPrefabs.get(this.prefabName);
|
|
582
589
|
if (!prefab) return;
|
|
583
|
-
if (level3DPrefabDepth >= 8)
|
|
590
|
+
if (level3DPrefabMaking.includes(this.prefabName) || level3DPrefabDepth >= 8)
|
|
584
591
|
{
|
|
585
592
|
debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
|
|
586
593
|
return;
|
|
587
594
|
}
|
|
595
|
+
level3DPrefabMaking.push(this.prefabName);
|
|
588
596
|
++level3DPrefabDepth;
|
|
589
597
|
this.attached && ++level3DPrefabAttached;
|
|
590
598
|
try
|
|
@@ -628,6 +636,7 @@ class Prefab3D extends EngineObject3D
|
|
|
628
636
|
finally
|
|
629
637
|
{
|
|
630
638
|
--level3DPrefabDepth;
|
|
639
|
+
level3DPrefabMaking.pop();
|
|
631
640
|
this.attached && --level3DPrefabAttached;
|
|
632
641
|
}
|
|
633
642
|
}
|
|
@@ -256,8 +256,8 @@ class VoxelMap extends EngineObject3D
|
|
|
256
256
|
|
|
257
257
|
// a double sided block shows the face from inside too, its corners the other way round
|
|
258
258
|
if (block.doubleSided)
|
|
259
|
-
mesh.addStrip([points[
|
|
260
|
-
[uvs[
|
|
259
|
+
mesh.addStrip([points[0], points[2], points[1], points[3]], normal.scale(-1),
|
|
260
|
+
[uvs[0], uvs[2], uvs[1], uvs[3]], [colors[0], colors[2], colors[1], colors[3]]);
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
this.chunkMeshes[index]?.dispose();
|
package/plugins/textureSheet.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - Animation frames keep layout and wrap across rows as needed
|
|
8
8
|
* - WebGL textures upload once per batch of loads
|
|
9
9
|
* - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
|
|
10
|
+
* - loadTiles packs separate tile images, or several tile sheets, into one tile set for tile layers and maps
|
|
10
11
|
* @namespace TextureSheets
|
|
11
12
|
*/
|
|
12
13
|
|
|
@@ -32,6 +33,12 @@ let textureSheets = [];
|
|
|
32
33
|
// pending loads pack through a queue so sheets fill in call order
|
|
33
34
|
let textureSheetQueue = Promise.resolve();
|
|
34
35
|
let textureSheetPendingCount = 0;
|
|
36
|
+
// what each load handed back by what it was given, so the same image loaded again is packed once
|
|
37
|
+
const textureSheetLoaded = new Map;
|
|
38
|
+
// a load's key: what it was given, a vector by its numbers
|
|
39
|
+
const textureSheetLoadKey = (...parts)=> parts.map((p)=> isVector2(p) ? p.x + ',' + p.y : String(p)).join('|');
|
|
40
|
+
// a load whose file failed is forgotten, so loading it again tries again, as when the server comes back
|
|
41
|
+
const textureSheetLoadFailed = (key, loaded)=> textureSheetLoaded.get(key) === loaded && textureSheetLoaded.delete(key);
|
|
35
42
|
|
|
36
43
|
/**
|
|
37
44
|
* Texture Sheet - A texture that images are packed into as they load
|
|
@@ -188,6 +195,8 @@ class TextureSheet
|
|
|
188
195
|
* - Pass frameSize for animations, then step through them with TileInfo.frame
|
|
189
196
|
* - Grid images keep their layout and frames wrap down to the next row
|
|
190
197
|
* - Pass sourcePadding if the source image has padding baked in around frames
|
|
198
|
+
* - The same image loaded again with the same settings gives back what the first load did, packed once, unless
|
|
199
|
+
* that load failed
|
|
191
200
|
* @param {string} src - Image source path
|
|
192
201
|
* @param {Vector2|number} [frameSize] - Size of each animation frame in pixels, or the whole image less its
|
|
193
202
|
* source padding if not passed
|
|
@@ -208,10 +217,14 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
|
|
|
208
217
|
|
|
209
218
|
if (isNumber(frameSize))
|
|
210
219
|
frameSize = vec2(/** @type {number} */ (frameSize));
|
|
220
|
+
const key = textureSheetLoadKey('sprite', src, frameSize, padding, sourcePadding);
|
|
221
|
+
if (!headlessMode && textureSheetLoaded.has(key))
|
|
222
|
+
return textureSheetLoaded.get(key);
|
|
211
223
|
|
|
212
224
|
// start with an empty tile that gets filled in when the image loads
|
|
213
225
|
const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
|
|
214
226
|
if (headlessMode) return tileInfo;
|
|
227
|
+
textureSheetLoaded.set(key, tileInfo);
|
|
215
228
|
|
|
216
229
|
// point at a sheet right away so drawing before it loads picks up empty pixels
|
|
217
230
|
tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
|
|
@@ -238,7 +251,7 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
|
|
|
238
251
|
if (!added)
|
|
239
252
|
{
|
|
240
253
|
// leave the tile empty, no sheet can hold it
|
|
241
|
-
|
|
254
|
+
console.warn('loadSprite image is too large to fit on a texture sheet:', src);
|
|
242
255
|
return;
|
|
243
256
|
}
|
|
244
257
|
Object.assign(tileInfo, added.tile);
|
|
@@ -247,13 +260,80 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
|
|
|
247
260
|
else
|
|
248
261
|
{
|
|
249
262
|
// leave the tile empty if the image failed to load
|
|
250
|
-
|
|
263
|
+
console.warn('loadSprite failed to load image:', src);
|
|
264
|
+
textureSheetLoadFailed(key, tileInfo);
|
|
251
265
|
}
|
|
252
266
|
});
|
|
253
267
|
|
|
254
268
|
return tileInfo;
|
|
255
269
|
}
|
|
256
270
|
|
|
271
|
+
/** Load tile images and pack them into texture sheets as one tile set, for tile layers and maps
|
|
272
|
+
* - Each image is cut into tiles of tileSize, left to right then down, so an image of one tile is one tile and a
|
|
273
|
+
* sheet is all of its tiles; the tiles are numbered from 0 in the order the images are given
|
|
274
|
+
* - Returns a tile set at once, a TileInfo whose tiles fill in as the images load; wait for them with spritesReady
|
|
275
|
+
* - Give it to tileLayersLoad, a TileLayer or a TileCollisionLayer as its tile info: tile n draws tiles[n],
|
|
276
|
+
* wherever it was packed, and the level editor's palette offers each of them; frame and index do not read the
|
|
277
|
+
* list, use set.tiles[n] for one tile; make the layers after spritesReady, a layer made before draws nothing
|
|
278
|
+
* - An image that is not a whole number of tiles gives the whole tiles in it; a sheet with gaps between its tiles
|
|
279
|
+
* is not read, cut it into its tiles first
|
|
280
|
+
* - An image that fails to load, or that no sheet can hold, adds no tiles and says so in the console, so the tiles
|
|
281
|
+
* of the images after it move up
|
|
282
|
+
* - The same images loaded again with the same settings give back what the first load did, packed once, unless
|
|
283
|
+
* an image of it failed
|
|
284
|
+
* @param {Array<string>} sources - Image source paths
|
|
285
|
+
* @param {Vector2|number} [tileSize] - Size of a tile in pixels
|
|
286
|
+
* @param {number} [padding] - How many pixels padding around each tile on the sheet
|
|
287
|
+
* @return {TileInfo}
|
|
288
|
+
* @example
|
|
289
|
+
* const tiles = loadTiles(['grass.png', 'dirt.png', 'water.png', 'props.png'], 16);
|
|
290
|
+
* await spritesReady();
|
|
291
|
+
* tileLayersLoad(map, tiles); // tile 0 is grass, 1 dirt, 2 water, then the tiles of props.png
|
|
292
|
+
* @memberof TextureSheets */
|
|
293
|
+
function loadTiles(sources, tileSize=tileDefaultSize, padding=textureSheetPadding)
|
|
294
|
+
{
|
|
295
|
+
ASSERT(isArray(sources) && sources.every((src)=> isStringLike(src)), 'sources must be a list of image paths');
|
|
296
|
+
ASSERT(isVector2(tileSize) || isNumber(tileSize), 'tileSize must be a vec2 or number');
|
|
297
|
+
ASSERT(isNumber(padding), 'padding must be a number');
|
|
298
|
+
ASSERT(engineInitialized || headlessMode, 'call loadTiles after engineInit, e.g. in gameInit');
|
|
299
|
+
const size = isNumber(tileSize) ? vec2(/** @type {number} */ (tileSize)) : /** @type {Vector2} */ (tileSize).copy();
|
|
300
|
+
const key = textureSheetLoadKey('tiles', sources.join('\n'), size, padding);
|
|
301
|
+
if (!headlessMode && textureSheetLoaded.has(key))
|
|
302
|
+
return textureSheetLoaded.get(key);
|
|
303
|
+
const set = new TileInfo(vec2(), size, undefined, 0, 0);
|
|
304
|
+
set.tiles = [];
|
|
305
|
+
if (headlessMode) return set;
|
|
306
|
+
textureSheetLoaded.set(key, set);
|
|
307
|
+
set.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
|
|
308
|
+
|
|
309
|
+
// every image decodes at once, and packs through the queue in the order given, so the tiles keep that order
|
|
310
|
+
for (const src of sources)
|
|
311
|
+
{
|
|
312
|
+
const image = new Image;
|
|
313
|
+
const imagePromise = new Promise((resolve)=>
|
|
314
|
+
{
|
|
315
|
+
image.onerror = image.onload = resolve;
|
|
316
|
+
image.crossOrigin = 'anonymous';
|
|
317
|
+
image.src = src;
|
|
318
|
+
});
|
|
319
|
+
textureSheetQueueJob('loadTiles ' + src, async ()=>
|
|
320
|
+
{
|
|
321
|
+
await imagePromise;
|
|
322
|
+
// the whole tiles of it, an edge past the last one left out
|
|
323
|
+
const columns = image.width / size.x | 0, rows = image.height / size.y | 0, count = columns * rows;
|
|
324
|
+
const added = count && textureSheetAdd(vec2(columns * size.x, rows * size.y), size, padding, 0);
|
|
325
|
+
image.width || textureSheetLoadFailed(key, set);
|
|
326
|
+
if (!added)
|
|
327
|
+
return console.warn('loadTiles: ' + src + (count ? ' does not fit on a texture sheet' :
|
|
328
|
+
' failed to load, or is smaller than a tile') + ', its tiles are left out');
|
|
329
|
+
added.sheet.drawImage(image, added.tile, false); // upload once per batch
|
|
330
|
+
for (let k = 0; k < count; ++k)
|
|
331
|
+
set.tiles.push(added.tile.frame(k));
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return set;
|
|
335
|
+
}
|
|
336
|
+
|
|
257
337
|
/** Load a pre-packed texture atlas and repack it onto texture sheets
|
|
258
338
|
* - Supports TexturePacker json (hash and array) and Aseprite json
|
|
259
339
|
* - Returns an empty object which is filled with TileInfos when loaded
|
|
@@ -261,6 +341,8 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
|
|
|
261
341
|
* - Aseprite frame tags become animations, so do names like run_0, run_1
|
|
262
342
|
* - Trimmed frames are restored to their full source size when packed
|
|
263
343
|
* - Rotated frames are rotated back upright when packed
|
|
344
|
+
* - The same atlas loaded again by its paths, with the same padding, gives back what the first load did, packed
|
|
345
|
+
* once, unless that load failed
|
|
264
346
|
* @param {string} imageSrc - Atlas image path
|
|
265
347
|
* @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
|
|
266
348
|
* @param {number} [padding] - How many pixels padding around each frame
|
|
@@ -278,9 +360,15 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
|
|
|
278
360
|
ASSERT(isNumber(padding), 'padding must be a number');
|
|
279
361
|
ASSERT(engineInitialized || headlessMode, 'call loadAtlas after engineInit, e.g. in gameInit');
|
|
280
362
|
|
|
363
|
+
// json passed in as data may be another atlas each time, only one loaded by its path is kept
|
|
364
|
+
const key = typeof jsonSrc === 'object' ? undefined : textureSheetLoadKey('atlas', imageSrc, jsonSrc, padding);
|
|
365
|
+
if (!headlessMode && textureSheetLoaded.has(key))
|
|
366
|
+
return textureSheetLoaded.get(key);
|
|
367
|
+
|
|
281
368
|
/** @type {Object<string, TileInfo>} */
|
|
282
369
|
const atlas = {};
|
|
283
370
|
if (headlessMode) return atlas;
|
|
371
|
+
key && textureSheetLoaded.set(key, atlas);
|
|
284
372
|
|
|
285
373
|
// start fetching the json and decoding the image right away, in parallel
|
|
286
374
|
const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
|
|
@@ -309,7 +397,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
|
|
|
309
397
|
const added = textureSheetAdd(blockSize, sourceSize, padding);
|
|
310
398
|
if (!added)
|
|
311
399
|
{
|
|
312
|
-
|
|
400
|
+
console.warn('loadAtlas frames are too large to fit on a texture sheet:', group.name);
|
|
313
401
|
continue;
|
|
314
402
|
}
|
|
315
403
|
const {sheet, tile} = added;
|
|
@@ -343,7 +431,8 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
|
|
|
343
431
|
else
|
|
344
432
|
{
|
|
345
433
|
// leave the atlas empty if either file failed to load
|
|
346
|
-
|
|
434
|
+
console.warn('loadAtlas failed to load:', imageSrc, jsonSrc);
|
|
435
|
+
textureSheetLoadFailed(key, atlas);
|
|
347
436
|
}
|
|
348
437
|
});
|
|
349
438
|
|
|
@@ -401,12 +490,14 @@ function parseAtlas(data)
|
|
|
401
490
|
const tagged = new Set;
|
|
402
491
|
for (const tag of tags)
|
|
403
492
|
{
|
|
404
|
-
|
|
405
|
-
|
|
493
|
+
// a tag reaches only frames there are, whatever its numbers say
|
|
494
|
+
const from = max(0, floor(tag.from) || 0), to = min(frames.length - 1, floor(tag.to) || 0);
|
|
495
|
+
groups.push({name: tag.name, frames: frames.slice(from, to + 1)});
|
|
496
|
+
for (let i = from; i <= to; ++i)
|
|
406
497
|
tagged.add(i);
|
|
407
498
|
}
|
|
408
499
|
frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
|
|
409
|
-
return groups;
|
|
500
|
+
return parseAtlasNamesCheck(groups);
|
|
410
501
|
}
|
|
411
502
|
|
|
412
503
|
// group frames that share a name stem with contiguous trailing numbers
|
|
@@ -435,6 +526,17 @@ function parseAtlas(data)
|
|
|
435
526
|
else
|
|
436
527
|
list.forEach(f=> groups.push({name: f.name, frames: [f]}));
|
|
437
528
|
}
|
|
529
|
+
return parseAtlasNamesCheck(groups);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// an atlas's groups, with a warning for each name two of them have, as a sprite is looked up by name and the second
|
|
533
|
+
// takes the first's place
|
|
534
|
+
function parseAtlasNamesCheck(groups)
|
|
535
|
+
{
|
|
536
|
+
const names = new Set;
|
|
537
|
+
for (const {name} of groups)
|
|
538
|
+
names.has(name) ? console.warn(`parseAtlas: two sprites are named ${name}, the second takes the first's place`) :
|
|
539
|
+
names.add(name);
|
|
438
540
|
return groups;
|
|
439
541
|
}
|
|
440
542
|
|
package/plugins/tweakables.js
CHANGED
|
@@ -379,7 +379,7 @@ function tweakNumber(parent, axis, get, set, options, save)
|
|
|
379
379
|
const change = (input)=>
|
|
380
380
|
{
|
|
381
381
|
const v = parseFloat(input.value);
|
|
382
|
-
if (!
|
|
382
|
+
if (!isFinite(v)) return; // a box part way through typing, or 1e999
|
|
383
383
|
set(v);
|
|
384
384
|
save();
|
|
385
385
|
};
|
package/plugins/tweenSystem.js
CHANGED
|
@@ -510,14 +510,14 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
|
|
|
510
510
|
function tweenCarryOvershoot(tween)
|
|
511
511
|
{
|
|
512
512
|
const duration = tween.duration;
|
|
513
|
-
tween.life = duration ? min(tween.life, 0) % duration + duration : 1e-9;
|
|
513
|
+
tween.life = duration > 0 ? min(tween.life, 0) % duration + duration : 1e-9;
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
// How many iterations the update that finished one ran through: that one and every whole one after it
|
|
517
517
|
function tweenPassed(tween)
|
|
518
518
|
{
|
|
519
519
|
const duration = tween.duration;
|
|
520
|
-
return duration ? 1 + floor(-min(tween.life, 0) / duration) : 1;
|
|
520
|
+
return duration > 0 ? 1 + floor(-min(tween.life, 0) / duration) : 1; // no time, or less, is one each update
|
|
521
521
|
}
|
|
522
522
|
|
|
523
523
|
// start the next iteration of a loop or pingPong, the time the last one ran over already spent, true
|
package/plugins/uiSystem.js
CHANGED
|
@@ -747,6 +747,8 @@ class UISystemPlugin
|
|
|
747
747
|
showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
|
|
748
748
|
{
|
|
749
749
|
ASSERT(!uiSystem.confirmDialog, 'a confirm dialog is already open, check uiSystem.confirmDialog');
|
|
750
|
+
if (uiSystem.confirmDialog)
|
|
751
|
+
return uiSystem.confirmDialog; // in a release build, the one open, as a second would break navigation
|
|
750
752
|
|
|
751
753
|
const savedNavigationDirection = uiSystem.navigationDirection;
|
|
752
754
|
const savedNavigationObject = uiSystem.navigationObject;
|
|
@@ -1412,7 +1414,7 @@ class UITextInput extends UIObject
|
|
|
1412
1414
|
onKeyDown(e)
|
|
1413
1415
|
{
|
|
1414
1416
|
// named keys by key, so numpad Enter works as Enter
|
|
1415
|
-
const code = e.code, key = e.key;
|
|
1417
|
+
const code = e.code, key = e.key || ''; // autofill sends a keydown with no key
|
|
1416
1418
|
if (e.repeat && (key === 'Enter' || code === 'Space'))
|
|
1417
1419
|
return; // a key held when editing began repeats, it should not type or stop editing
|
|
1418
1420
|
this.text += ''; // a game may have set a number
|
|
@@ -1646,12 +1648,14 @@ class UISlider extends UIObject
|
|
|
1646
1648
|
}
|
|
1647
1649
|
update()
|
|
1648
1650
|
{
|
|
1651
|
+
// held when the frame began, so a release in the same frame as the last move still reads it
|
|
1652
|
+
const wasActive = this.isActiveObject();
|
|
1649
1653
|
super.update();
|
|
1650
1654
|
if (!this.interactive)
|
|
1651
1655
|
return;
|
|
1652
1656
|
|
|
1653
1657
|
const oldValue = this.value;
|
|
1654
|
-
if (this.isActiveObject())
|
|
1658
|
+
if (wasActive || this.isActiveObject())
|
|
1655
1659
|
{
|
|
1656
1660
|
// handle horizontal or vertical slider
|
|
1657
1661
|
const isHorizontal = this.size.x > this.size.y;
|