littlejsengine 1.24.0 → 1.24.2
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 +86 -1
- package/EDITOR.md +2 -0
- package/FAQ.md +13 -2
- package/README.md +2 -2
- package/REFERENCE.md +27 -10
- package/dist/littlejs.d.ts +104 -29
- package/dist/littlejs.esm.js +2499 -1979
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +2496 -1979
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +2438 -1983
- package/package.json +1 -1
- package/plugins/box2d.js +1 -1
- package/plugins/gltf.js +62 -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 +134 -57
- package/plugins/render3dEditor.js +28 -12
- package/plugins/render3dExtras.js +32 -17
- package/plugins/render3dLevel.js +21 -12
- package/plugins/render3dVoxels.js +15 -8
- package/plugins/textureSheet.js +97 -3
- package/plugins/tweakables.js +1 -1
- package/plugins/tweenSystem.js +2 -2
- package/plugins/uiSystem.js +6 -2
- package/src/engine.js +34 -11
- package/src/engineAudio.js +13 -3
- package/src/engineDebug.js +4 -1
- package/src/engineDraw.js +29 -16
- package/src/engineEditor.js +99 -56
- package/src/engineExport.js +2 -0
- package/src/engineInput.js +17 -3
- package/src/engineMath.js +6 -2
- package/src/engineObject.js +675 -675
- package/src/engineParticles.js +1 -1
- package/src/engineSettings.js +21 -1
- package/src/engineTileLayer.js +142 -43
- package/src/engineUtilities.js +20 -8
- package/src/engineWebGL.js +12 -8
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
|
}
|
|
@@ -89,7 +89,7 @@ class VoxelMap extends EngineObject3D
|
|
|
89
89
|
/** @type {Array<Vector3>} */
|
|
90
90
|
this.chunkCenters = []; // a chunk mesh's points are around its center, which it is drawn at
|
|
91
91
|
this.chunksChanged = new Set;
|
|
92
|
-
/** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean}|undefined>} */
|
|
92
|
+
/** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}|undefined>} */
|
|
93
93
|
this.blockTypes = [];
|
|
94
94
|
this.tiles = new Map; // tile index to the uvs of its corners
|
|
95
95
|
|
|
@@ -161,27 +161,29 @@ class VoxelMap extends EngineObject3D
|
|
|
161
161
|
* @param {number} type - 1 to 255
|
|
162
162
|
* @param {number|Array<number>|{top?: number, side: number, bottom?: number}} faces - A tile index for every face,
|
|
163
163
|
* six in the order +x, -x, +y, -y, +z, -z, or the side's with the top and bottom's, which default to the side's
|
|
164
|
-
* @param {{seeThrough?: boolean, transparent?: boolean}} [options] - seeThrough for holes
|
|
165
|
-
* leaves, so the blocks beside it keep their faces; transparent to blend, like glass or
|
|
166
|
-
* transparent stage, and see-through too
|
|
167
|
-
|
|
164
|
+
* @param {{seeThrough?: boolean, transparent?: boolean, doubleSided?: boolean}} [options] - seeThrough for holes
|
|
165
|
+
* in its texture, like leaves, so the blocks beside it keep their faces; transparent to blend, like glass or
|
|
166
|
+
* water, drawn in the transparent stage, and see-through too; doubleSided for faces seen from inside the
|
|
167
|
+
* block as well, like water, whose surface then shows from under it */
|
|
168
|
+
setBlockType(type, faces, {seeThrough=false, transparent=false, doubleSided=false}={})
|
|
168
169
|
{
|
|
169
170
|
ASSERT(type >= 1 && type <= 255 && type % 1 === 0, 'a block type is a whole number from 1 to 255', type);
|
|
170
171
|
const f = /** @type {any} */ (faces);
|
|
171
172
|
const list = isNumber(f) ? [f, f, f, f, f, f] : isArray(f) ? f :
|
|
172
173
|
[f.side, f.side, f.top ?? f.side, f.bottom ?? f.side, f.side, f.side];
|
|
173
174
|
ASSERT(list.length === 6 && list.every(isNumber), 'faces is a tile index, six of them, or {top, side, bottom}');
|
|
174
|
-
this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent};
|
|
175
|
+
this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent, doubleSided};
|
|
175
176
|
this.rebuild();
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
/** A block type's faces and how it is seen through
|
|
179
180
|
* @param {number} type
|
|
180
|
-
* @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean}}
|
|
181
|
+
* @return {{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}}
|
|
181
182
|
* @ignore */
|
|
182
183
|
blockType(type)
|
|
183
184
|
{
|
|
184
|
-
return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false
|
|
185
|
+
return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false,
|
|
186
|
+
doubleSided: false};
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
/** Build every chunk again, after changing data directly or ambientOcclusion */
|
|
@@ -251,6 +253,11 @@ class VoxelMap extends EngineObject3D
|
|
|
251
253
|
colors[k] = ao ? RENDER3D_VOXEL_SHADES[shade[i]] : WHITE;
|
|
252
254
|
}
|
|
253
255
|
mesh.addStrip(points, normal, uvs, colors);
|
|
256
|
+
|
|
257
|
+
// a double sided block shows the face from inside too, its corners the other way round
|
|
258
|
+
if (block.doubleSided)
|
|
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]]);
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
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,10 @@ 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('|');
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* Texture Sheet - A texture that images are packed into as they load
|
|
@@ -188,6 +193,7 @@ class TextureSheet
|
|
|
188
193
|
* - Pass frameSize for animations, then step through them with TileInfo.frame
|
|
189
194
|
* - Grid images keep their layout and frames wrap down to the next row
|
|
190
195
|
* - Pass sourcePadding if the source image has padding baked in around frames
|
|
196
|
+
* - The same image loaded again with the same settings gives back what the first load did, packed once
|
|
191
197
|
* @param {string} src - Image source path
|
|
192
198
|
* @param {Vector2|number} [frameSize] - Size of each animation frame in pixels, or the whole image less its
|
|
193
199
|
* source padding if not passed
|
|
@@ -208,10 +214,14 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
|
|
|
208
214
|
|
|
209
215
|
if (isNumber(frameSize))
|
|
210
216
|
frameSize = vec2(/** @type {number} */ (frameSize));
|
|
217
|
+
const key = textureSheetLoadKey('sprite', src, frameSize, padding, sourcePadding);
|
|
218
|
+
if (!headlessMode && textureSheetLoaded.has(key))
|
|
219
|
+
return textureSheetLoaded.get(key);
|
|
211
220
|
|
|
212
221
|
// start with an empty tile that gets filled in when the image loads
|
|
213
222
|
const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
|
|
214
223
|
if (headlessMode) return tileInfo;
|
|
224
|
+
textureSheetLoaded.set(key, tileInfo);
|
|
215
225
|
|
|
216
226
|
// point at a sheet right away so drawing before it loads picks up empty pixels
|
|
217
227
|
tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
|
|
@@ -254,6 +264,70 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
|
|
|
254
264
|
return tileInfo;
|
|
255
265
|
}
|
|
256
266
|
|
|
267
|
+
/** Load tile images and pack them into texture sheets as one tile set, for tile layers and maps
|
|
268
|
+
* - Each image is cut into tiles of tileSize, left to right then down, so an image of one tile is one tile and a
|
|
269
|
+
* sheet is all of its tiles; the tiles are numbered from 0 in the order the images are given
|
|
270
|
+
* - Returns a tile set at once, a TileInfo whose tiles fill in as the images load; wait for them with spritesReady
|
|
271
|
+
* - Give it to tileLayersLoad, a TileLayer or a TileCollisionLayer as its tile info: tile n draws tiles[n],
|
|
272
|
+
* wherever it was packed, and the level editor's palette offers each of them; frame and index do not read the
|
|
273
|
+
* list, use set.tiles[n] for one tile; make the layers after spritesReady, a layer made before draws nothing
|
|
274
|
+
* - An image that is not a whole number of tiles gives the whole tiles in it; a sheet with gaps between its tiles
|
|
275
|
+
* is not read, cut it into its tiles first
|
|
276
|
+
* - An image that fails to load, or that no sheet can hold, adds no tiles and says so in the console, so the tiles
|
|
277
|
+
* of the images after it move up
|
|
278
|
+
* - The same images loaded again with the same settings give back what the first load did, packed once
|
|
279
|
+
* @param {Array<string>} sources - Image source paths
|
|
280
|
+
* @param {Vector2|number} [tileSize] - Size of a tile in pixels
|
|
281
|
+
* @param {number} [padding] - How many pixels padding around each tile on the sheet
|
|
282
|
+
* @return {TileInfo}
|
|
283
|
+
* @example
|
|
284
|
+
* const tiles = loadTiles(['grass.png', 'dirt.png', 'water.png', 'props.png'], 16);
|
|
285
|
+
* await spritesReady();
|
|
286
|
+
* tileLayersLoad(map, tiles); // tile 0 is grass, 1 dirt, 2 water, then the tiles of props.png
|
|
287
|
+
* @memberof TextureSheets */
|
|
288
|
+
function loadTiles(sources, tileSize=tileDefaultSize, padding=textureSheetPadding)
|
|
289
|
+
{
|
|
290
|
+
ASSERT(isArray(sources) && sources.every((src)=> isStringLike(src)), 'sources must be a list of image paths');
|
|
291
|
+
ASSERT(isVector2(tileSize) || isNumber(tileSize), 'tileSize must be a vec2 or number');
|
|
292
|
+
ASSERT(isNumber(padding), 'padding must be a number');
|
|
293
|
+
ASSERT(engineInitialized || headlessMode, 'call loadTiles after engineInit, e.g. in gameInit');
|
|
294
|
+
const size = isNumber(tileSize) ? vec2(/** @type {number} */ (tileSize)) : /** @type {Vector2} */ (tileSize).copy();
|
|
295
|
+
const key = textureSheetLoadKey('tiles', sources.join('\n'), size, padding);
|
|
296
|
+
if (!headlessMode && textureSheetLoaded.has(key))
|
|
297
|
+
return textureSheetLoaded.get(key);
|
|
298
|
+
const set = new TileInfo(vec2(), size, undefined, 0, 0);
|
|
299
|
+
set.tiles = [];
|
|
300
|
+
if (headlessMode) return set;
|
|
301
|
+
textureSheetLoaded.set(key, set);
|
|
302
|
+
set.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
|
|
303
|
+
|
|
304
|
+
// every image decodes at once, and packs through the queue in the order given, so the tiles keep that order
|
|
305
|
+
for (const src of sources)
|
|
306
|
+
{
|
|
307
|
+
const image = new Image;
|
|
308
|
+
const imagePromise = new Promise((resolve)=>
|
|
309
|
+
{
|
|
310
|
+
image.onerror = image.onload = resolve;
|
|
311
|
+
image.crossOrigin = 'anonymous';
|
|
312
|
+
image.src = src;
|
|
313
|
+
});
|
|
314
|
+
textureSheetQueueJob('loadTiles ' + src, async ()=>
|
|
315
|
+
{
|
|
316
|
+
await imagePromise;
|
|
317
|
+
// the whole tiles of it, an edge past the last one left out
|
|
318
|
+
const columns = image.width / size.x | 0, rows = image.height / size.y | 0, count = columns * rows;
|
|
319
|
+
const added = count && textureSheetAdd(vec2(columns * size.x, rows * size.y), size, padding, 0);
|
|
320
|
+
if (!added)
|
|
321
|
+
return console.warn('loadTiles: ' + src + (count ? ' does not fit on a texture sheet' :
|
|
322
|
+
' failed to load, or is smaller than a tile') + ', its tiles are left out');
|
|
323
|
+
added.sheet.drawImage(image, added.tile, false); // upload once per batch
|
|
324
|
+
for (let k = 0; k < count; ++k)
|
|
325
|
+
set.tiles.push(added.tile.frame(k));
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return set;
|
|
329
|
+
}
|
|
330
|
+
|
|
257
331
|
/** Load a pre-packed texture atlas and repack it onto texture sheets
|
|
258
332
|
* - Supports TexturePacker json (hash and array) and Aseprite json
|
|
259
333
|
* - Returns an empty object which is filled with TileInfos when loaded
|
|
@@ -261,6 +335,7 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
|
|
|
261
335
|
* - Aseprite frame tags become animations, so do names like run_0, run_1
|
|
262
336
|
* - Trimmed frames are restored to their full source size when packed
|
|
263
337
|
* - Rotated frames are rotated back upright when packed
|
|
338
|
+
* - The same atlas loaded again by its paths, with the same padding, gives back what the first load did, packed once
|
|
264
339
|
* @param {string} imageSrc - Atlas image path
|
|
265
340
|
* @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
|
|
266
341
|
* @param {number} [padding] - How many pixels padding around each frame
|
|
@@ -278,9 +353,15 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
|
|
|
278
353
|
ASSERT(isNumber(padding), 'padding must be a number');
|
|
279
354
|
ASSERT(engineInitialized || headlessMode, 'call loadAtlas after engineInit, e.g. in gameInit');
|
|
280
355
|
|
|
356
|
+
// json passed in as data may be another atlas each time, only one loaded by its path is kept
|
|
357
|
+
const key = typeof jsonSrc === 'object' ? undefined : textureSheetLoadKey('atlas', imageSrc, jsonSrc, padding);
|
|
358
|
+
if (!headlessMode && textureSheetLoaded.has(key))
|
|
359
|
+
return textureSheetLoaded.get(key);
|
|
360
|
+
|
|
281
361
|
/** @type {Object<string, TileInfo>} */
|
|
282
362
|
const atlas = {};
|
|
283
363
|
if (headlessMode) return atlas;
|
|
364
|
+
key && textureSheetLoaded.set(key, atlas);
|
|
284
365
|
|
|
285
366
|
// start fetching the json and decoding the image right away, in parallel
|
|
286
367
|
const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
|
|
@@ -401,12 +482,14 @@ function parseAtlas(data)
|
|
|
401
482
|
const tagged = new Set;
|
|
402
483
|
for (const tag of tags)
|
|
403
484
|
{
|
|
404
|
-
|
|
405
|
-
|
|
485
|
+
// a tag reaches only frames there are, whatever its numbers say
|
|
486
|
+
const from = max(0, floor(tag.from) || 0), to = min(frames.length - 1, floor(tag.to) || 0);
|
|
487
|
+
groups.push({name: tag.name, frames: frames.slice(from, to + 1)});
|
|
488
|
+
for (let i = from; i <= to; ++i)
|
|
406
489
|
tagged.add(i);
|
|
407
490
|
}
|
|
408
491
|
frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
|
|
409
|
-
return groups;
|
|
492
|
+
return parseAtlasNamesCheck(groups);
|
|
410
493
|
}
|
|
411
494
|
|
|
412
495
|
// group frames that share a name stem with contiguous trailing numbers
|
|
@@ -435,6 +518,17 @@ function parseAtlas(data)
|
|
|
435
518
|
else
|
|
436
519
|
list.forEach(f=> groups.push({name: f.name, frames: [f]}));
|
|
437
520
|
}
|
|
521
|
+
return parseAtlasNamesCheck(groups);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 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
|
|
525
|
+
// takes the first's place
|
|
526
|
+
function parseAtlasNamesCheck(groups)
|
|
527
|
+
{
|
|
528
|
+
const names = new Set;
|
|
529
|
+
for (const {name} of groups)
|
|
530
|
+
names.has(name) ? console.warn(`parseAtlas: two sprites are named ${name}, the second takes the first's place`) :
|
|
531
|
+
names.add(name);
|
|
438
532
|
return groups;
|
|
439
533
|
}
|
|
440
534
|
|
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;
|
package/src/engine.js
CHANGED
|
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
|
|
|
32
32
|
* @type {string}
|
|
33
33
|
* @default
|
|
34
34
|
* @memberof Engine */
|
|
35
|
-
const engineVersion = '1.24.
|
|
35
|
+
const engineVersion = '1.24.2';
|
|
36
36
|
|
|
37
37
|
/** Frames per second to update
|
|
38
38
|
* @type {number}
|
|
@@ -165,6 +165,7 @@ let timeFixedStart = 0, frameFixedStart = 0;
|
|
|
165
165
|
let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
|
|
166
166
|
let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
|
|
167
167
|
let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
|
|
168
|
+
let engineFrameErrors = 0; // errors a release build's loop went on past
|
|
168
169
|
|
|
169
170
|
// the pairs of objects asked about a collision this update, so the other's own physics does not ask again: each
|
|
170
171
|
// asker's others, with true for a pair both said to resolve, and false for one left overlapping, ignored or only
|
|
@@ -450,7 +451,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
450
451
|
o.parent || o.updateTransforms();
|
|
451
452
|
|
|
452
453
|
// objects made and destroyed while paused, like a menu's effects, still leave the list
|
|
453
|
-
engineObjects = engineObjects.filter(o=>!o.destroyed);
|
|
454
|
+
engineObjects.some(o=>o.destroyed) && (engineObjects = engineObjects.filter(o=>!o.destroyed));
|
|
454
455
|
}
|
|
455
456
|
else
|
|
456
457
|
engineObjectsUpdate();
|
|
@@ -497,7 +498,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
497
498
|
// render the game and objects
|
|
498
499
|
enginePreRender();
|
|
499
500
|
gameRender();
|
|
500
|
-
|
|
501
|
+
engineObjectsSort();
|
|
501
502
|
for (const o of engineObjects)
|
|
502
503
|
{
|
|
503
504
|
if (o.destroyed) continue;
|
|
@@ -600,7 +601,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
600
601
|
}
|
|
601
602
|
|
|
602
603
|
// the splash first, the images load under it, then the loading screen for the rest
|
|
603
|
-
showSplashScreen && await promises.
|
|
604
|
+
showSplashScreen && await promises[promises.length - 1];
|
|
604
605
|
return startEngine(promises);
|
|
605
606
|
|
|
606
607
|
// gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
|
|
@@ -723,7 +724,16 @@ function engineScheduleFrame()
|
|
|
723
724
|
const next = (frameTimeMS)=>
|
|
724
725
|
{
|
|
725
726
|
engineFrameScheduled = false;
|
|
726
|
-
|
|
727
|
+
if (engineManualStep) return;
|
|
728
|
+
if (debug) return engineUpdateInternal(frameTimeMS); // a debug build stops at an error, where it shows it
|
|
729
|
+
try { engineUpdateInternal(frameTimeMS); }
|
|
730
|
+
catch (error)
|
|
731
|
+
{
|
|
732
|
+
// a release build goes on past an error in a frame, a frozen game is the worst a player can get; only the
|
|
733
|
+
// first is logged, one every frame would flood the console
|
|
734
|
+
engineFrameErrors++ || console.error(error);
|
|
735
|
+
engineScheduleFrame();
|
|
736
|
+
}
|
|
727
737
|
};
|
|
728
738
|
if (typeof requestAnimationFrame === 'function')
|
|
729
739
|
requestAnimationFrame(next);
|
|
@@ -769,14 +779,18 @@ function engineObjectsUpdate()
|
|
|
769
779
|
++engineObjectsUpdateCount;
|
|
770
780
|
engineObjectsCollidePairs.clear();
|
|
771
781
|
// objects update in render order, which rendering keeps them in, so a headless run or a frame that rendered
|
|
772
|
-
// nothing updates them the same way
|
|
773
|
-
|
|
782
|
+
// nothing updates them the same way
|
|
783
|
+
engineObjectsSort();
|
|
774
784
|
// get list of solid objects for physics optimization, in update order, which 3D collision pairs by;
|
|
775
785
|
// 2D checks the static ones last, so a contact with a moving object can not leave something back inside a static
|
|
776
786
|
// solid it was already pushed out of
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
787
|
+
const fixed = [];
|
|
788
|
+
engineObjectsCollide = [];
|
|
789
|
+
engineObjectsCollideStaticLast = [];
|
|
790
|
+
for (const o of engineObjects)
|
|
791
|
+
o.collideSolidObjects && (engineObjectsCollide.push(o), (o.mass ? engineObjectsCollideStaticLast : fixed).push(o));
|
|
792
|
+
for (const o of fixed)
|
|
793
|
+
engineObjectsCollideStaticLast.push(o);
|
|
780
794
|
|
|
781
795
|
// update physics before object update
|
|
782
796
|
for (const o of engineObjects)
|
|
@@ -826,7 +840,16 @@ function engineObjectsUpdate()
|
|
|
826
840
|
updateTopObject(o);
|
|
827
841
|
|
|
828
842
|
// remove destroyed objects
|
|
829
|
-
engineObjects = engineObjects.filter(o=>!o.destroyed);
|
|
843
|
+
engineObjects.some(o=>o.destroyed) && (engineObjects = engineObjects.filter(o=>!o.destroyed));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// sort the objects by render order, keeping the order of equals, only when one is out of order, as they are kept
|
|
847
|
+
// sorted and most frames change none
|
|
848
|
+
function engineObjectsSort()
|
|
849
|
+
{
|
|
850
|
+
for (let i = engineObjects.length; --i > 0;)
|
|
851
|
+
if (engineObjects[i].renderOrder < engineObjects[i-1].renderOrder)
|
|
852
|
+
return void engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
|
|
830
853
|
}
|
|
831
854
|
|
|
832
855
|
/** Destroy and remove all objects
|
package/src/engineAudio.js
CHANGED
|
@@ -58,9 +58,19 @@ function audioInit()
|
|
|
58
58
|
{
|
|
59
59
|
if (!soundEnable || headlessMode) return;
|
|
60
60
|
|
|
61
|
+
audioSetSession();
|
|
61
62
|
document.addEventListener('visibilitychange', audioVisibilityChange);
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// on an iPhone the silent switch mutes the page's sound, unless soundIgnoreSilentSwitch has it play as media,
|
|
66
|
+
// Safari 16.4 and up; elsewhere there is no audioSession and nothing changes
|
|
67
|
+
function audioSetSession()
|
|
68
|
+
{
|
|
69
|
+
const session = typeof navigator != 'undefined' && /** @type {any} */ (navigator).audioSession;
|
|
70
|
+
if (session)
|
|
71
|
+
session.type = soundIgnoreSilentSwitch ? 'playback' : 'auto';
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
// a hidden page stops the game, so its sound stops too, and the audio clock with it so every sound picks up
|
|
65
75
|
// exactly where it was; only a suspend made here is undone, not one the browser holds until the first input
|
|
66
76
|
let audioSuspendedWhenHidden = false;
|
|
@@ -203,13 +213,13 @@ function audioParamRamp(param, value, fadeTime=0)
|
|
|
203
213
|
* @memberof Audio
|
|
204
214
|
* @example
|
|
205
215
|
* // load an audio asset file
|
|
206
|
-
* const
|
|
216
|
+
* const music = new Sound('sound.mp3');
|
|
207
217
|
*
|
|
208
218
|
* // create a zzfx sound
|
|
209
|
-
* const
|
|
219
|
+
* const blip = new Sound([.5,.5]);
|
|
210
220
|
*
|
|
211
221
|
* // play a sound
|
|
212
|
-
*
|
|
222
|
+
* blip.play();
|
|
213
223
|
*/
|
|
214
224
|
class Sound
|
|
215
225
|
{
|
package/src/engineDebug.js
CHANGED
|
@@ -269,7 +269,10 @@ function debugShowErrors()
|
|
|
269
269
|
{
|
|
270
270
|
// replace entire page with error message
|
|
271
271
|
document.body.style.cssText = 'background-color:#111;margin:8px';
|
|
272
|
-
|
|
272
|
+
const pre = document.createElement('pre'); // the message as text, a file name in it never markup
|
|
273
|
+
pre.style.cssText = 'color:#f00;font-size:28px;white-space:pre-wrap';
|
|
274
|
+
pre.textContent = String(message);
|
|
275
|
+
document.body.replaceChildren(pre);
|
|
273
276
|
}
|
|
274
277
|
|
|
275
278
|
const originalAssert = console.assert;
|