littlejsengine 1.23.0 → 1.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.23.0",
3
+ "version": "1.23.1",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -39,7 +39,9 @@
39
39
  "plugins/",
40
40
  "!plugins/box2d.wasm.*",
41
41
  "COPYRIGHT.txt",
42
- "FAQ.md"
42
+ "FAQ.md",
43
+ "REFERENCE.md",
44
+ "EDITOR.md"
43
45
  ],
44
46
  "scripts": {
45
47
  "build": "node src/engineBuild.mjs",
@@ -53,7 +55,6 @@
53
55
  },
54
56
  "devDependencies": {
55
57
  "bestzip": "^2.2.1",
56
- "clean-jsdoc-theme": "~4.2.9",
57
58
  "electron": "^38.2.0",
58
59
  "electron-packager": "^17.1.2",
59
60
  "google-closure-compiler": "~20230502.0.0",
package/plugins/gltf.js CHANGED
@@ -405,6 +405,11 @@ async function parseGLTF(data, baseUrl='')
405
405
  for (const name of json.extensionsRequired || [])
406
406
  if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
407
407
  throw new Error(`glTF with ${name} is not read, export it uncompressed`);
408
+ else if (name === 'KHR_texture_basisu')
409
+ throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg');
410
+ // one that only may use them has other images to fall back on, or none: said, since it loads with them left out
411
+ if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu'))
412
+ console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg');
408
413
 
409
414
  // the buffers: the GLB's own, a data uri, or a file beside the model
410
415
  const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
@@ -447,7 +452,8 @@ async function parseGLTF(data, baseUrl='')
447
452
  const bitmap = normalTextures.has(index) ?
448
453
  await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
449
454
  opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
450
- return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
455
+ // REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are
456
+ return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728);
451
457
  }
452
458
  catch (e) { LOG('glTF image not loaded', e); }
453
459
  }));
@@ -557,17 +563,25 @@ function gltfOpaqueImage(image)
557
563
  {
558
564
  const gl = glContext, {width, height} = image;
559
565
  if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
560
- const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
561
- gl.bindTexture(gl.TEXTURE_2D, texture);
562
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
563
- gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
564
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
566
+ // the room to read into first: for a huge image it can fail, and must not with the framebuffer bound
565
567
  const data = new Uint8ClampedArray(width * height * 4);
566
- gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
567
- gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
568
- gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
569
- gl.deleteFramebuffer(framebuffer);
570
- gl.deleteTexture(texture);
568
+ const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
569
+ try
570
+ {
571
+ gl.bindTexture(gl.TEXTURE_2D, texture);
572
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
573
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
574
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
575
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
576
+ }
577
+ finally
578
+ {
579
+ // the 2D renderer's own bindings back, whatever happened
580
+ gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
581
+ gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
582
+ gl.deleteFramebuffer(framebuffer);
583
+ gl.deleteTexture(texture);
584
+ }
571
585
  for (let i = 3; i < data.length; i += 4)
572
586
  data[i] = 255;
573
587
  image.close();
@@ -704,7 +718,7 @@ function gltfAccessor(json, buffers, index)
704
718
  }
705
719
  if (scale !== 1)
706
720
  for (let i = 0; i < out.length; ++i)
707
- out[i] /= scale;
721
+ out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says
708
722
  return {data: out, components, count: a.count};
709
723
  }
710
724
 
@@ -768,8 +782,10 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
768
782
  const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
769
783
  if (er || eg || eb)
770
784
  {
785
+ // KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender
786
+ const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1;
771
787
  part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
772
- part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
788
+ part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength);
773
789
  }
774
790
  return part;
775
791
  }
@@ -158,8 +158,11 @@ class PostProcessPlugin
158
158
  {
159
159
  // copy main canvas to work canvas at the backing store size,
160
160
  // mainCanvasSize is css pixels so it would lose resolution
161
- workCanvas.width = mainCanvas.width;
162
- workCanvas.height = mainCanvas.height;
161
+ // sized again only when the canvas changed, setting a size remakes the canvas even to the same one
162
+ if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height)
163
+ workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height;
164
+ else
165
+ workContext.clearRect(0, 0, workCanvas.width, workCanvas.height);
163
166
  glCopyToContext(workContext);
164
167
  workContext.drawImage(mainCanvas, 0, 0);
165
168
  // clear the main canvas with clearRect, resizing it would also
@@ -302,17 +302,19 @@ function render3DInstance(mesh, matrix, tileInfo, color)
302
302
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
303
303
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
304
304
  // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
305
- // so it never sets the draw state for them, which would move the state's version at every draw
306
- function render3DInstanceSlot(mesh, textureInfo, mirrored)
305
+ // so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard
306
+ // passes that its batch is unlit, for the same reason
307
+ function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false)
307
308
  {
308
309
  const r = render3D;
309
310
  if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
310
- || render3DStateChanged(mesh.instanceState)))
311
+ || mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState)))
311
312
  render3DFlushInstances(mesh);
312
313
  if (!mesh.instanceCount)
313
314
  {
314
315
  mesh.instanceTextureInfo = textureInfo;
315
316
  mesh.instanceMirrored = mirrored;
317
+ mesh.instanceUnlit = unlit;
316
318
  mesh.instanceState = render3DCaptureBatchState();
317
319
  r.instanceMeshes.push(mesh);
318
320
  }
@@ -346,6 +348,8 @@ function render3DFlushInstances(only)
346
348
  const state = mesh.instanceState;
347
349
  if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
348
350
  state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
351
+ if (mesh.instanceUnlit) // a batch of sprites
352
+ state.lighting = false;
349
353
  render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
350
354
  }
351
355
  if (!only)
@@ -702,6 +706,7 @@ class Render3DPlugin
702
706
  /** @type {TextureInfo|undefined} */
703
707
  this.streamTileInfo = undefined;
704
708
  this.streamState = undefined; // captured state the pending batch was drawn under
709
+ this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are
705
710
  /** @type {Mesh|undefined} */
706
711
  this.capture = undefined; // the mesh a bake is filling
707
712
  /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
@@ -1092,6 +1097,8 @@ class Render3DPlugin
1092
1097
  {
1093
1098
  if (!this.streamCount || !render3DCanDraw()) return;
1094
1099
  const gl = glContext;
1100
+ if (this.streamUnlit)
1101
+ this.streamState.lighting = false; // its own copy of the state
1095
1102
  render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
1096
1103
  render3DBindVertexBuffer(this.streamBuffer);
1097
1104
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
@@ -1326,13 +1333,34 @@ class Render3DPlugin
1326
1333
  return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
1327
1334
  }
1328
1335
 
1329
- // the quad's six stream vertices written straight in with no vectors made, unlit, for sprites, and for
1330
- // particles when instancing is off
1331
- const lit = this.lighting;
1332
- this.lighting = this.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides
1333
- let uvRect;
1334
- try { uvRect = render3DBeginStrip(6, tileInfo); }
1335
- finally { this.lighting = lit; }
1336
+ // unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not
1337
+ // set, which would move its version at every sprite
1338
+ const unlit = !this.shadowPass;
1339
+ if (!this.blend && this.depthTest && this.instancing)
1340
+ {
1341
+ // an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a
1342
+ // sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader
1343
+ // drops its see through texels, so it needs no sorting
1344
+ if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow
1345
+ if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2))
1346
+ return;
1347
+ const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo);
1348
+ render3DMeshUpload(quad);
1349
+ // the quad's half axes, doubled for its unit square
1350
+ const a = render3DBillboardAxes(size, angle, upright);
1351
+ const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData;
1352
+ data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
1353
+ data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
1354
+ data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
1355
+ data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1;
1356
+ data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
1357
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
1358
+ return;
1359
+ }
1360
+
1361
+ // one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no
1362
+ // vectors made, for sprites that blend, and for particles when instancing is off
1363
+ const uvRect = render3DBeginStrip(6, tileInfo, unlit);
1336
1364
  if (!uvRect) return;
1337
1365
  const a = render3DBillboardAxes(size, angle, upright);
1338
1366
  const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
@@ -2444,8 +2472,9 @@ function render3DRenderDepth()
2444
2472
  // which way they face, so keeping the count even keeps every strip facing out.
2445
2473
 
2446
2474
  // make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
2447
- // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
2448
- function render3DBeginStrip(count, tileInfo)
2475
+ // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws
2476
+ // the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw
2477
+ function render3DBeginStrip(count, tileInfo, unlit=false)
2449
2478
  {
2450
2479
  const r = render3D;
2451
2480
  if (!render3DCanDraw()) return;
@@ -2453,12 +2482,13 @@ function render3DBeginStrip(count, tileInfo)
2453
2482
  ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
2454
2483
  if (count > RENDER3D_MAX_STREAM_VERTS) return;
2455
2484
  const textureInfo = render3DTextureOf(tileInfo);
2456
- if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
2457
- || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
2485
+ if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit
2486
+ || render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
2458
2487
  r.flush();
2459
2488
  render3DFlushBeforeOverlay();
2460
2489
  if (!r.streamCount)
2461
2490
  r.streamState = render3DCaptureBatchState();
2491
+ r.streamUnlit = unlit;
2462
2492
  r.streamTileInfo = textureInfo;
2463
2493
  return render3DGetTileUVs(tileInfo);
2464
2494
  }
@@ -528,7 +528,7 @@ function editor3DLevelLoaded(level)
528
528
  const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
529
529
  const hash = editor3DContentHash(original, originalParts);
530
530
  // sceneBase is the scene the game set, taken before the level's own block is applied
531
- const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
531
+ const record = {fileName: editorFileName(url, 'level3D.json'), key: url ?? 'level #' + hash, original,
532
532
  originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
533
533
  fileHandle: undefined, undo: [], redo: []};
534
534
  editor3DRecords.set(level, record);
@@ -629,6 +629,7 @@ function editor3DChange(change)
629
629
  const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
630
630
  change(after);
631
631
  if (editor3DSame(before, after)) return false;
632
+ editor3DFixIds(after); // an object added with no id, or with one in use, gets one of its own
632
633
  editor3DSetObjects(after);
633
634
  editor3DStroke ||= {before, parts: editor3DLevelParts()};
634
635
  return true;
@@ -1402,7 +1403,7 @@ function editor3DPrefabJSON(name)
1402
1403
 
1403
1404
  // the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
1404
1405
  // and the ids of that level's objects that had something made for them
1405
- /** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string, saved?: string}>} */
1406
+ /** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string}>} */
1406
1407
  const editor3DPrefabStack = [];
1407
1408
  // the levels that are a prefab being edited, they have no autosave of their own
1408
1409
  const editor3DPrefabLevels = new WeakSet;
@@ -1411,6 +1412,28 @@ const editor3DPrefabLevels = new WeakSet;
1411
1412
  let editor3DPrefabEdits = {};
1412
1413
  // the prefabs of the game's own that were edited and are not in a file yet
1413
1414
  const editor3DPrefabDirty = new Set;
1415
+ // what is known of each prefab's file, by its name, so it outlasts the prefab being closed and opened again: the
1416
+ // save being written, for the next to wait on, what the file has since a Save, and what the prefab holds now
1417
+ /** @type {Map<string, {saving?: Promise<any>, saved?: string, content?: string}>} */
1418
+ const editor3DPrefabFiles = new Map;
1419
+ function editor3DPrefabFile(name)
1420
+ {
1421
+ editor3DPrefabFiles.has(name) || editor3DPrefabFiles.set(name, {});
1422
+ return editor3DPrefabFiles.get(name);
1423
+ }
1424
+
1425
+ // say if a prefab of the game's own is in its file, now that it holds this: once a Save has written it, it is
1426
+ // when what it holds is what was written; before any Save it is not once it was edited
1427
+ function editor3DPrefabFileCheck(name, content, edited=false)
1428
+ {
1429
+ const file = editor3DPrefabFile(name);
1430
+ file.content = content;
1431
+ if (level3DPrefabs.get(name)?.fromLevel) return; // one of the level's own is saved with the level
1432
+ if (file.saved !== undefined)
1433
+ content === file.saved ? editor3DPrefabDirty.delete(name) : editor3DPrefabDirty.add(name);
1434
+ else if (edited)
1435
+ editor3DPrefabDirty.add(name);
1436
+ }
1414
1437
 
1415
1438
  // destroy what the level being edited has in the world, its objects' and its maps'
1416
1439
  function editor3DPrefabClear()
@@ -1464,9 +1487,10 @@ function editor3DPrefabBack()
1464
1487
  level3DPrefabSet(frame.name, prefab, fromLevel);
1465
1488
  if (fromLevel)
1466
1489
  editor3DPrefabEdits[frame.name] = prefab;
1467
- else if (JSON.stringify(objects) !== frame.saved)
1468
- editor3DPrefabDirty.add(frame.name); // not what Save wrote while it was open
1469
1490
  }
1491
+ // one of the game's own is in its file or not by what it holds now, edited here or not: undone back to how it
1492
+ // was opened after a Save, it is not what the file has
1493
+ editor3DPrefabFileCheck(frame.name, JSON.stringify(objects), changed);
1470
1494
 
1471
1495
  // the level it was opened from is the editor's again, with its own undo
1472
1496
  const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
@@ -1794,8 +1818,10 @@ function editor3DAutosave(level=editor3DLevel, known)
1794
1818
  if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
1795
1819
  edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
1796
1820
  });
1797
- if (root && Object.keys(edits).length)
1798
- editor3DAutosave({...root, prefabs: {...editor3DLevelPart('prefabs', root), ...edits}}, editor3DRecords.get(root));
1821
+ // with nothing edited any more, an undo back to how the prefab was, the level's autosave is the level's own
1822
+ // again, which takes away one of an edit that was undone and keeps the level's other edits
1823
+ const prefabs = {...editor3DLevelPart('prefabs', root), ...edits};
1824
+ root && editor3DAutosave(Object.keys(prefabs).length ? {...root, prefabs} : root, editor3DRecords.get(root));
1799
1825
  return;
1800
1826
  }
1801
1827
  const record = known ?? editor3DRecords.get(level);
@@ -1809,12 +1835,9 @@ function editor3DAutosave(level=editor3DLevel, known)
1809
1835
  else
1810
1836
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
1811
1837
  ...parts};
1812
- try
1813
- {
1814
- localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
1815
- editor3DSaveFailed = false;
1816
- }
1817
- catch { editor3DSaveFailed = true; }
1838
+ const failed = editorSaveFailed; // the 2D editor's own flag is its own
1839
+ editor3DSaveFailed = editorWriteSaves(saves, record.key, editor3DSaveName());
1840
+ editorSaveFailed = failed;
1818
1841
  }
1819
1842
 
1820
1843
  // put the autosaved edits of a file that changed into the level, as one undo
@@ -1871,16 +1894,23 @@ async function editor3DSave(pickAgain=false)
1871
1894
  // inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
1872
1895
  const text = editor3DLevelJSON(level), name = open.name + '.json';
1873
1896
  const written = JSON.stringify(editor3DObjects()); // as the file has it
1897
+ // its place in line is the prefab's, by its name: closed and opened again it is a new level here, and its
1898
+ // saves still go in the order they were asked for
1899
+ const file = editor3DPrefabFile(open.name), before = file.saving;
1874
1900
  const saved = (async ()=>
1875
1901
  {
1876
- await record.saving; // in the order asked for, as a level's saves are
1902
+ await before;
1877
1903
  const kept = await editorCall('onSave', text, name) === true;
1878
1904
  kept || saveText(text, name, 'application/json');
1879
- editor3DPrefabDirty.delete(open.name);
1880
- open.saved = written;
1905
+ file.saved = written;
1906
+ // it is in its file when what it holds now is what was written: edited again while the save was
1907
+ // kept, or gone back from with more edits, it still waits
1908
+ const at = editor3DPrefabStack.indexOf(open);
1909
+ const now = at < 0 ? file.content : JSON.stringify((editor3DPrefabStack[at + 1]?.level ?? editor3DLevel).objects);
1910
+ editor3DPrefabFileCheck(open.name, now ?? written);
1881
1911
  return kept ? 'kept' : 'downloaded';
1882
1912
  })();
1883
- record.saving = saved.catch(()=> {});
1913
+ file.saving = saved.catch(()=> {});
1884
1914
  return saved;
1885
1915
  }
1886
1916
  // what is written, kept as it is now: the level can change while the file is picked and written, and those
@@ -116,7 +116,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
116
116
 
117
117
  /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
118
118
  * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
119
- * a sheet is given here; a block's type shows that tile of the sheet on every face
119
+ * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
120
+ * image gets plain blocks, a color for each type
120
121
  * - setup is called with each map a level makes, to give block types their own faces or make them see-through
121
122
  * - Call it before level3DLoad; with no arguments the defaults are back
122
123
  * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
@@ -178,7 +179,7 @@ function level3DVoxelsMake(voxels)
178
179
  {
179
180
  const shape = level3DVoxelsShape(voxels);
180
181
  if (!shape || typeof VoxelMap == 'undefined') return;
181
- const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
182
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
182
183
  level3DVoxelsDecode(voxels.blocks, map.data);
183
184
  level3DVoxelSetupMap?.(map);
184
185
  map.rebuild();
@@ -240,6 +241,31 @@ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain
240
241
  map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
241
242
  }
242
243
 
244
+ // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
245
+ // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
246
+ // block map in a game with no art can still be seen and painted
247
+ let level3DVoxelPlain;
248
+ function level3DVoxelPlainTiles()
249
+ {
250
+ if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
251
+ return tile();
252
+ if (!level3DVoxelPlain)
253
+ {
254
+ // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
255
+ const context = createCanvasContext(256);
256
+ for (let i = 0; i < 256; ++i)
257
+ {
258
+ const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
259
+ context.fillStyle = hsl(hue, .45, .4).toString();
260
+ context.fillRect(x, y, 16, 16);
261
+ context.fillStyle = hsl(hue, .5, .55).toString();
262
+ context.fillRect(x + 1, y + 1, 14, 14);
263
+ }
264
+ level3DVoxelPlain = new TextureInfo(context.canvas);
265
+ }
266
+ return tile(0, 16, level3DVoxelPlain, 0);
267
+ }
268
+
243
269
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
244
270
  function level3DTerrainMake(terrain)
245
271
  {
@@ -303,6 +303,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
303
303
  for (const group of parseAtlas(data))
304
304
  {
305
305
  // reserve a block of full size cells, one per frame
306
+ if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
306
307
  const sourceSize = group.frames[0].sourceSize;
307
308
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
308
309
  const added = textureSheetAdd(blockSize, sourceSize, padding);
@@ -593,45 +593,50 @@ function tweenUpdate(gameDelta, realDelta)
593
593
  const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
594
594
  for (const t of tweenActive)
595
595
  list.push(t);
596
- for (let i = list.length; i--;)
596
+ // the list is let go however the walk ends: a callback that throws must not leave it held, or every
597
+ // update after would take it for an update still going and make a list of its own
598
+ try
597
599
  {
598
- const t = list[i];
599
- // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
600
- // counts on from this one
601
- if (!t.active || t.activePass >= pass) continue;
602
- let dt;
603
- if (enginePath)
600
+ for (let i = list.length; i--;)
604
601
  {
605
- // a paused tween keeps count too, so it does not jump when resumed
606
- dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
607
- t.lastTime = time;
608
- t.lastTimeReal = timeReal;
609
- }
610
- else
611
- dt = t.useRealTime ? realDelta : gameDelta;
612
- if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
613
- if (t.paused || dt <= 0) continue;
602
+ const t = list[i];
603
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
604
+ // counts on from this one
605
+ if (!t.active || t.activePass >= pass) continue;
606
+ let dt;
607
+ if (enginePath)
608
+ {
609
+ // a paused tween keeps count too, so it does not jump when resumed
610
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
611
+ t.lastTime = time;
612
+ t.lastTimeReal = timeReal;
613
+ }
614
+ else
615
+ dt = t.useRealTime ? realDelta : gameDelta;
616
+ if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
617
+ if (t.paused || dt <= 0) continue;
614
618
 
615
- t.life -= dt;
616
- if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
617
- {
618
- t.callback(t.interp(t.life));
619
- }
620
- else
621
- {
622
- // Completion: fire end value, remove from active, start the next iteration
623
- // of a loop or pingPong, or when there is none it has completed, fire onComplete
624
- t.callback(t.interp(0));
625
- if (!t.active || t.activePass >= pass)
626
- continue; // stopped or restarted by its own callback, the run it was on ends without completing
627
- tweenDeactivate(t);
628
- const next = t.thenCallback;
629
- t.thenCallback = undefined;
630
- if (!(next && next()) && t.onComplete)
631
- t.onComplete();
619
+ t.life -= dt;
620
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
621
+ {
622
+ t.callback(t.interp(t.life));
623
+ }
624
+ else
625
+ {
626
+ // Completion: fire end value, remove from active, start the next iteration
627
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
628
+ t.callback(t.interp(0));
629
+ if (!t.active || t.activePass >= pass)
630
+ continue; // stopped or restarted by its own callback, the run it was on ends without completing
631
+ tweenDeactivate(t);
632
+ const next = t.thenCallback;
633
+ t.thenCallback = undefined;
634
+ if (!(next && next()) && t.onComplete)
635
+ t.onComplete();
636
+ }
632
637
  }
633
638
  }
634
- list.length = 0;
639
+ finally { list.length = 0; }
635
640
  }
636
641
 
637
642
  /** Stop every active tween, ending loops too, without calling their then-callbacks.
@@ -615,8 +615,7 @@ class UISystemPlugin
615
615
  }
616
616
  }
617
617
 
618
- /** Destroy and remove all objects
619
- * @memberof UISystem */
618
+ /** Destroy and remove all objects */
620
619
  destroyObjects()
621
620
  {
622
621
  for (const o of this.uiObjects)
@@ -1137,8 +1136,10 @@ class UIObject
1137
1136
  return void inputClearKey(0, 0, false, true, false);
1138
1137
  }
1139
1138
  }
1139
+ // the object that was the active one going into this update: one pressed and let go inside a
1140
+ // frame is clicked on the next, with its release, not on both
1140
1141
  if (!uiSystem.activateOnPress)
1141
- if (!mouseDown && this.isActiveObject() && this.interactive)
1142
+ if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
1142
1143
  this.click();
1143
1144
  if (this.destroyed) return;
1144
1145
  }
@@ -1881,7 +1882,10 @@ class UIVideo extends UIObject
1881
1882
  if (this.destroyed)
1882
1883
  return;
1883
1884
 
1885
+ // let go of the media too, a paused video keeps what it has loaded
1884
1886
  this.video.pause();
1887
+ this.video.removeAttribute?.('src');
1888
+ this.video.load?.();
1885
1889
  this.video.remove();
1886
1890
  super.destroy();
1887
1891
  }