littlejsengine 1.23.0 → 1.24.0

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.24.0",
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,10 @@
39
39
  "plugins/",
40
40
  "!plugins/box2d.wasm.*",
41
41
  "COPYRIGHT.txt",
42
- "FAQ.md"
42
+ "FAQ.md",
43
+ "CHANGELOG.md",
44
+ "REFERENCE.md",
45
+ "EDITOR.md"
43
46
  ],
44
47
  "scripts": {
45
48
  "build": "node src/engineBuild.mjs",
@@ -53,7 +56,6 @@
53
56
  },
54
57
  "devDependencies": {
55
58
  "bestzip": "^2.2.1",
56
- "clean-jsdoc-theme": "~4.2.9",
57
59
  "electron": "^38.2.0",
58
60
  "electron-packager": "^17.1.2",
59
61
  "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
  }
@@ -44,10 +44,6 @@ export
44
44
  LightSystemPlugin,
45
45
  Light,
46
46
 
47
- // ZzFXMusic
48
- ZzFXMusic,
49
- zzfxM,
50
-
51
47
  // Audio Effects
52
48
  AudioEffect,
53
49
  AudioFilter,
@@ -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} */
@@ -910,8 +915,8 @@ class Render3DPlugin
910
915
  }
911
916
 
912
917
  /** Find the nearest object under a screen position or along a ray, for clicking on things
913
- * - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
914
- * not triangle by triangle
918
+ * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided
919
+ * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface
915
920
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
916
921
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
917
922
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -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
  }
@@ -4253,8 +4283,8 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
4253
4283
  return collected;
4254
4284
  }
4255
4285
 
4256
- // how far along a ray an object is hit, or undefined for a miss; each one is tested as the box around its mesh in
4257
- // its own space, or a sprite as the quad it draws, not triangle by triangle
4286
+ // how far along a ray an object is hit, or undefined for a miss; a mesh is hit on its triangles, in its own space,
4287
+ // and a sprite as the quad it draws
4258
4288
  function render3DRaycastObject(ray, o)
4259
4289
  {
4260
4290
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -4271,25 +4301,55 @@ function render3DRaycastObject(ray, o)
4271
4301
  const distance = raycastSphere(ray, center, radius);
4272
4302
  if (distance === undefined) return;
4273
4303
 
4274
- // the sphere is a quick reject, a mesh is hit where the ray meets its box in its own space, since a wide floor's
4275
- // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
4304
+ // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested
4305
+ // against the triangles; the direction is not made unit length, so the distance holds in the world;
4276
4306
  // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
4277
4307
  if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
4278
4308
  const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
4279
4309
  const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
4280
4310
  const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
4281
- if (hit !== 0) return hit;
4311
+ if (hit === undefined || !ray.direction.lengthSquared())
4312
+ return hit; // a ray of no length is where it starts, inside the box
4313
+ return render3DRaycastMesh(local, mesh);
4314
+ }
4282
4315
 
4283
- // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
4284
- // inside comes first
4285
- let exit = Infinity;
4286
- for (const k of ['x', 'y', 'z'])
4287
- {
4288
- const d = local.direction[k];
4289
- if (d)
4290
- exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
4291
- }
4292
- return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
4316
+ // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face
4317
+ // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's
4318
+ // triangle i is (i-2, i-1, i), the odd ones read the other way, and a join between its pieces has no area and is
4319
+ // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now
4320
+ function render3DRaycastMesh(ray, mesh)
4321
+ {
4322
+ const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided;
4323
+ const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
4324
+ const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
4325
+ const count = indices ? indices.length / 3 | 0 : points.length - 2;
4326
+ let nearest;
4327
+ for (let i = 0; i < count; ++i)
4328
+ {
4329
+ // the corners clockwise from the front: an indexed list is written the other way round
4330
+ let a, b, c;
4331
+ if (indices)
4332
+ a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]];
4333
+ else
4334
+ a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2];
4335
+
4336
+ // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front
4337
+ const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
4338
+ const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
4339
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
4340
+ const det = e1x * hx + e1y * hy + e1z * hz;
4341
+ if (both ? !det : !(det < 0)) continue;
4342
+ const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z;
4343
+ const u = (sx * hx + sy * hy + sz * hz) / det;
4344
+ if (!(u >= 0 && u <= 1)) continue;
4345
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
4346
+ const v = (dx * qx + dy * qy + dz * qz) / det;
4347
+ if (!(v >= 0 && u + v <= 1)) continue;
4348
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
4349
+ if (t >= 0 && !(t >= nearest))
4350
+ nearest = t;
4351
+ }
4352
+ return nearest;
4293
4353
  }
4294
4354
 
4295
4355
  // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
@@ -4667,10 +4727,12 @@ class InstancedMesh3D extends EngineObject3D
4667
4727
  * - radius is a world distance, so scale3D does not change it
4668
4728
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
4669
4729
  * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
4730
+ * - addFlare gives it a lens flare, see LensFlare3D
4670
4731
  * @extends EngineObject3D
4671
4732
  * @memberof Render3D
4672
4733
  * @example
4673
4734
  * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
4735
+ * torch.addFlare(); // light in the lens when the torch is in view
4674
4736
  */
4675
4737
  class Light3D extends EngineObject3D
4676
4738
  {
@@ -4705,6 +4767,49 @@ class Light3D extends EngineObject3D
4705
4767
  * bright core */
4706
4768
  this.glowFalloff = 1;
4707
4769
  this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
4770
+ /** @type {LensFlare3D|undefined} */
4771
+ this.flareObject = undefined;
4772
+ }
4773
+
4774
+ /** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had
4775
+ * @param {number} [size] - Scales every part of it
4776
+ * @param {number} [count] - How many ghosts there are, besides the glow at the light
4777
+ * @param {number} [intensity] - How bright it is
4778
+ * @param {number} [saturation] - How colorful the ghosts are
4779
+ * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
4780
+ addFlare(size, count, intensity, saturation)
4781
+ {
4782
+ const flare = new LensFlare3D(size, count, intensity, saturation);
4783
+ this.flare = flare;
4784
+ return flare;
4785
+ }
4786
+
4787
+ /** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and
4788
+ * destroying the flare takes it away
4789
+ * - The flare is the light's: it is attached to the light as its child, so it stays through a scene change
4790
+ * when the light does, and is destroyed with the light or when another takes its place
4791
+ * @type {LensFlare3D|undefined} */
4792
+ get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; }
4793
+
4794
+ set flare(flare)
4795
+ {
4796
+ const old = this.flare; // a flare that was destroyed on its own is no flare
4797
+ if (flare === old) return;
4798
+ old && old.destroy();
4799
+ if (flare)
4800
+ {
4801
+ flare.light = this;
4802
+ flare.parent || this.addChild(flare);
4803
+ }
4804
+ this.flareObject = flare || undefined;
4805
+ }
4806
+
4807
+ /** Destroy the light, and its flare with it
4808
+ * @param {boolean} [immediate] */
4809
+ destroy(immediate)
4810
+ {
4811
+ this.flare = undefined;
4812
+ super.destroy(immediate);
4708
4813
  }
4709
4814
 
4710
4815
  /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
@@ -354,7 +354,7 @@ function editor3DSceneState()
354
354
  const r = render3D;
355
355
  return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
356
356
  fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
357
- sunColor: r.sunColor.copy(), shadows: r.shadows};
357
+ sunColor: r.sunColor.copy(), shadows: r.shadows, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed};
358
358
  }
359
359
  function editor3DSceneRestore(state)
360
360
  {
@@ -369,6 +369,7 @@ function editor3DSceneRestore(state)
369
369
  r.sunDirection = state.sunDirection.copy();
370
370
  r.sunColor = state.sunColor.copy();
371
371
  r.shadows = state.shadows;
372
+ level3DSceneFlare(state.sunFlare); // a flare an earlier level's scene gave the sun, never the game's own
372
373
  }
373
374
 
374
375
  // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
@@ -390,6 +391,7 @@ function editor3DSceneFromView()
390
391
  scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
391
392
  scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
392
393
  scene.shadows = !!r.shadows;
394
+ scene.lensFlare = level3DSunHasFlare();
393
395
  return scene;
394
396
  }
395
397
 
@@ -528,7 +530,7 @@ function editor3DLevelLoaded(level)
528
530
  const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
529
531
  const hash = editor3DContentHash(original, originalParts);
530
532
  // 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,
533
+ const record = {fileName: editorFileName(url, 'level3D.json'), key: url ?? 'level #' + hash, original,
532
534
  originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
533
535
  fileHandle: undefined, undo: [], redo: []};
534
536
  editor3DRecords.set(level, record);
@@ -629,6 +631,7 @@ function editor3DChange(change)
629
631
  const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
630
632
  change(after);
631
633
  if (editor3DSame(before, after)) return false;
634
+ editor3DFixIds(after); // an object added with no id, or with one in use, gets one of its own
632
635
  editor3DSetObjects(after);
633
636
  editor3DStroke ||= {before, parts: editor3DLevelParts()};
634
637
  return true;
@@ -1402,7 +1405,7 @@ function editor3DPrefabJSON(name)
1402
1405
 
1403
1406
  // the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
1404
1407
  // 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}>} */
1408
+ /** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string}>} */
1406
1409
  const editor3DPrefabStack = [];
1407
1410
  // the levels that are a prefab being edited, they have no autosave of their own
1408
1411
  const editor3DPrefabLevels = new WeakSet;
@@ -1411,6 +1414,28 @@ const editor3DPrefabLevels = new WeakSet;
1411
1414
  let editor3DPrefabEdits = {};
1412
1415
  // the prefabs of the game's own that were edited and are not in a file yet
1413
1416
  const editor3DPrefabDirty = new Set;
1417
+ // what is known of each prefab's file, by its name, so it outlasts the prefab being closed and opened again: the
1418
+ // save being written, for the next to wait on, what the file has since a Save, and what the prefab holds now
1419
+ /** @type {Map<string, {saving?: Promise<any>, saved?: string, content?: string}>} */
1420
+ const editor3DPrefabFiles = new Map;
1421
+ function editor3DPrefabFile(name)
1422
+ {
1423
+ editor3DPrefabFiles.has(name) || editor3DPrefabFiles.set(name, {});
1424
+ return editor3DPrefabFiles.get(name);
1425
+ }
1426
+
1427
+ // 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
1428
+ // when what it holds is what was written; before any Save it is not once it was edited
1429
+ function editor3DPrefabFileCheck(name, content, edited=false)
1430
+ {
1431
+ const file = editor3DPrefabFile(name);
1432
+ file.content = content;
1433
+ if (level3DPrefabs.get(name)?.fromLevel) return; // one of the level's own is saved with the level
1434
+ if (file.saved !== undefined)
1435
+ content === file.saved ? editor3DPrefabDirty.delete(name) : editor3DPrefabDirty.add(name);
1436
+ else if (edited)
1437
+ editor3DPrefabDirty.add(name);
1438
+ }
1414
1439
 
1415
1440
  // destroy what the level being edited has in the world, its objects' and its maps'
1416
1441
  function editor3DPrefabClear()
@@ -1464,9 +1489,10 @@ function editor3DPrefabBack()
1464
1489
  level3DPrefabSet(frame.name, prefab, fromLevel);
1465
1490
  if (fromLevel)
1466
1491
  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
1492
  }
1493
+ // 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
1494
+ // was opened after a Save, it is not what the file has
1495
+ editor3DPrefabFileCheck(frame.name, JSON.stringify(objects), changed);
1470
1496
 
1471
1497
  // the level it was opened from is the editor's again, with its own undo
1472
1498
  const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
@@ -1794,8 +1820,10 @@ function editor3DAutosave(level=editor3DLevel, known)
1794
1820
  if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
1795
1821
  edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
1796
1822
  });
1797
- if (root && Object.keys(edits).length)
1798
- editor3DAutosave({...root, prefabs: {...editor3DLevelPart('prefabs', root), ...edits}}, editor3DRecords.get(root));
1823
+ // with nothing edited any more, an undo back to how the prefab was, the level's autosave is the level's own
1824
+ // again, which takes away one of an edit that was undone and keeps the level's other edits
1825
+ const prefabs = {...editor3DLevelPart('prefabs', root), ...edits};
1826
+ root && editor3DAutosave(Object.keys(prefabs).length ? {...root, prefabs} : root, editor3DRecords.get(root));
1799
1827
  return;
1800
1828
  }
1801
1829
  const record = known ?? editor3DRecords.get(level);
@@ -1809,12 +1837,9 @@ function editor3DAutosave(level=editor3DLevel, known)
1809
1837
  else
1810
1838
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
1811
1839
  ...parts};
1812
- try
1813
- {
1814
- localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
1815
- editor3DSaveFailed = false;
1816
- }
1817
- catch { editor3DSaveFailed = true; }
1840
+ const failed = editorSaveFailed; // the 2D editor's own flag is its own
1841
+ editor3DSaveFailed = editorWriteSaves(saves, record.key, editor3DSaveName());
1842
+ editorSaveFailed = failed;
1818
1843
  }
1819
1844
 
1820
1845
  // put the autosaved edits of a file that changed into the level, as one undo
@@ -1871,16 +1896,23 @@ async function editor3DSave(pickAgain=false)
1871
1896
  // inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
1872
1897
  const text = editor3DLevelJSON(level), name = open.name + '.json';
1873
1898
  const written = JSON.stringify(editor3DObjects()); // as the file has it
1899
+ // its place in line is the prefab's, by its name: closed and opened again it is a new level here, and its
1900
+ // saves still go in the order they were asked for
1901
+ const file = editor3DPrefabFile(open.name), before = file.saving;
1874
1902
  const saved = (async ()=>
1875
1903
  {
1876
- await record.saving; // in the order asked for, as a level's saves are
1904
+ await before;
1877
1905
  const kept = await editorCall('onSave', text, name) === true;
1878
1906
  kept || saveText(text, name, 'application/json');
1879
- editor3DPrefabDirty.delete(open.name);
1880
- open.saved = written;
1907
+ file.saved = written;
1908
+ // it is in its file when what it holds now is what was written: edited again while the save was
1909
+ // kept, or gone back from with more edits, it still waits
1910
+ const at = editor3DPrefabStack.indexOf(open);
1911
+ const now = at < 0 ? file.content : JSON.stringify((editor3DPrefabStack[at + 1]?.level ?? editor3DLevel).objects);
1912
+ editor3DPrefabFileCheck(open.name, now ?? written);
1881
1913
  return kept ? 'kept' : 'downloaded';
1882
1914
  })();
1883
- record.saving = saved.catch(()=> {});
1915
+ file.saving = saved.catch(()=> {});
1884
1916
  return saved;
1885
1917
  }
1886
1918
  // what is written, kept as it is now: the level can change while the file is picked and written, and those
@@ -3348,6 +3380,8 @@ function editor3DSceneUpdate(box)
3348
3380
  color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
3349
3381
  scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
3350
3382
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
3383
+ toggle('Lens flare', 'The sun flares in the lens when it is in view', !!value('lensFlare'), (on)=>
3384
+ change('lensFlare', on));
3351
3385
  }
3352
3386
 
3353
3387
  // the properties box: the position, rotation and scale of the one selected object and an input for each default