littlejsengine 1.24.3 → 1.26.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/EDITOR.md +2 -2
  3. package/FAQ.md +55 -11
  4. package/README.md +6 -2
  5. package/REFERENCE.md +203 -57
  6. package/dist/littlejs.d.ts +933 -313
  7. package/dist/littlejs.esm.js +5143 -2419
  8. package/dist/littlejs.esm.min.js +38 -24
  9. package/dist/littlejs.js +5132 -2416
  10. package/dist/littlejs.min.js +37 -23
  11. package/dist/littlejs.release.js +4905 -2232
  12. package/package.json +4 -4
  13. package/plugins/box2d.js +141 -56
  14. package/plugins/drawUtilities.js +374 -380
  15. package/plugins/gltf.js +383 -60
  16. package/plugins/lightSystem.js +412 -30
  17. package/plugins/math3d.js +1015 -1014
  18. package/plugins/medalSystem.js +19 -7
  19. package/plugins/newgrounds.js +5 -6
  20. package/plugins/parallax.js +1 -0
  21. package/plugins/particleEffects.js +47 -14
  22. package/plugins/pathFinder.js +60 -38
  23. package/plugins/pluginExport.js +11 -0
  24. package/plugins/postProcess.js +200 -67
  25. package/plugins/render3d.js +493 -2391
  26. package/plugins/render3dDebug.js +5 -8
  27. package/plugins/render3dEditor.js +84 -9
  28. package/plugins/render3dExtras.js +122 -33
  29. package/plugins/render3dLevel.js +67 -8
  30. package/plugins/render3dMesh.js +1158 -0
  31. package/plugins/render3dObject.js +1451 -0
  32. package/plugins/render3dVoxels.js +15 -3
  33. package/plugins/textureSheet.js +9 -8
  34. package/plugins/threejs.js +1 -1
  35. package/plugins/tweenSystem.js +117 -87
  36. package/plugins/uiSystem.js +62 -28
  37. package/plugins/wavedash.js +229 -0
  38. package/src/engine.js +192 -40
  39. package/src/engineAudio.js +47 -27
  40. package/src/engineBuild.mjs +6 -1
  41. package/src/engineDebug.js +23 -162
  42. package/src/engineDraw.js +148 -43
  43. package/src/engineEditor.js +153 -40
  44. package/src/engineExport.js +0 -3
  45. package/src/engineInput.js +31 -25
  46. package/src/engineMath.js +38 -20
  47. package/src/engineObject.js +68 -50
  48. package/src/engineParticles.js +66 -18
  49. package/src/engineRelease.js +18 -15
  50. package/src/engineSettings.js +48 -9
  51. package/src/engineTileLayer.js +44 -18
  52. package/src/engineUtilities.js +56 -11
  53. package/src/engineWebGL.js +68 -22
@@ -18,15 +18,12 @@ function render3DRenderDebug()
18
18
  {
19
19
  if (!render3DDebugPrimitives.length) return;
20
20
  render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.clearCount === debugClearCount);
21
- if (!debugVideoCaptureIsActive()) // hidden from a video capture like the 2D ones, but they still expire
21
+ render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false, shader: undefined,
22
+ emissiveMap: undefined}, ()=>
22
23
  {
23
- render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false, shader: undefined,
24
- emissiveMap: undefined}, ()=>
25
- {
26
- for (const p of render3DDebugPrimitives)
27
- p.draw();
28
- });
29
- }
24
+ for (const p of render3DDebugPrimitives)
25
+ p.draw();
26
+ });
30
27
  render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.timer < 0); // a Timer compares as negative until it elapses
31
28
  }
32
29
 
@@ -354,7 +354,8 @@ 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, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed};
357
+ sunColor: r.sunColor.copy(), shadows: r.shadows, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed,
358
+ skyBox: r.skyBox, environment: r.environment};
358
359
  }
359
360
  function editor3DSceneRestore(state)
360
361
  {
@@ -370,6 +371,9 @@ function editor3DSceneRestore(state)
370
371
  r.sunColor = state.sunColor.copy();
371
372
  r.shadows = state.shadows;
372
373
  level3DSceneFlare(state.sunFlare); // a flare an earlier level's scene gave the sun, never the game's own
374
+ // the cube maps, and a scene's load still running for them lands no more
375
+ r.skyBox = state.skyBox, r.environment = state.environment;
376
+ ++level3DSceneCubeLoads.skyBox, ++level3DSceneCubeLoads.environment;
373
377
  }
374
378
 
375
379
  // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
@@ -486,6 +490,7 @@ function editor3DHash(text)
486
490
  // puts in the edits it autosaved for the file, or keeps them waiting when the file changed since
487
491
  function editor3DLevelLoaded(level)
488
492
  {
493
+ editor3DAutosaveFlush(); // what the last level has waiting is written before this one reads the autosaves
489
494
  if (editor3DLevel !== level)
490
495
  {
491
496
  // another level, the selection and the edit being made were the last one's
@@ -1147,7 +1152,7 @@ function editor3DStrokeEnd()
1147
1152
  partsBefore: stroke.parts, partsAfter: parts});
1148
1153
  editor3DUndoList.length > 100 && editor3DUndoList.shift();
1149
1154
  editor3DRedoList.length = 0;
1150
- editor3DAutosave();
1155
+ editor3DAutosaveSoon();
1151
1156
  }
1152
1157
 
1153
1158
  // take the edit being made back, with nothing to undo
@@ -1171,7 +1176,7 @@ function editor3DUndo(redo=false)
1171
1176
  (redo ? editor3DUndoList : editor3DRedoList).push(entry);
1172
1177
  editor3DSetObjects(redo ? entry.after : entry.before);
1173
1178
  editor3DSetParts(redo ? entry.partsAfter : entry.partsBefore);
1174
- editor3DAutosave();
1179
+ editor3DAutosaveSoon();
1175
1180
  return true;
1176
1181
  }
1177
1182
 
@@ -1261,7 +1266,7 @@ function editor3DPrefabHolds(type, name, depth=0)
1261
1266
  !!level3DPrefabs.get(type)?.objects.some((o)=> editor3DPrefabHolds(o.type, name, depth + 1));
1262
1267
  }
1263
1268
 
1264
- // make the selected objects a prefab of the level's own, about the bottom centre of their box, and put one
1269
+ // make the selected objects a prefab of the level's own, about the bottom center of their box, and put one
1265
1270
  // instance of it where they were, as one undo; a name the level's prefabs have replaces that prefab, and every
1266
1271
  // instance of it changes; true, or why not, which the panel shows
1267
1272
  function editor3DMakePrefab(name='')
@@ -1280,7 +1285,7 @@ function editor3DMakePrefab(name='')
1280
1285
  if (selected.some((o)=> editor3DPrefabHolds(o.type, name)))
1281
1286
  return refuse(name + ' can not hold itself');
1282
1287
 
1283
- // its origin is the bottom centre of the selection, so an instance stands on the ground
1288
+ // its origin is the bottom center of the selection, so an instance stands on the ground
1284
1289
  const low = vec3(Infinity), high = vec3(-Infinity);
1285
1290
  for (const object of selected)
1286
1291
  editor3DWorldBox(object, low, high);
@@ -1465,6 +1470,7 @@ function editor3DPrefabClear()
1465
1470
  // origin, with an undo of its own, and the level it is in put away; false when it is not a prefab's instance
1466
1471
  function editor3DPrefabEnter(id)
1467
1472
  {
1473
+ editor3DAutosaveFlush(); // the level's waiting autosave, before another level is the one edited
1468
1474
  const selected = editor3DSelected();
1469
1475
  id ??= selected.length === 1 ? selected[0].id : undefined;
1470
1476
  const made = editor3DInstances.get(id), from = editor3DLevel;
@@ -1489,7 +1495,7 @@ function editor3DPrefabEnter(id)
1489
1495
  // no prefab is open
1490
1496
  function editor3DPrefabBack()
1491
1497
  {
1492
- const frame = editor3DPrefabStack.pop(), edited = editor3DLevel;
1498
+ const frame = editor3DPrefabStack.pop();
1493
1499
  if (!frame) return false;
1494
1500
  editor3DStrokeEnd();
1495
1501
  editor3DDrag = editor3DHover = undefined;
@@ -1647,6 +1653,7 @@ function editor3DSetOpen(open)
1647
1653
  editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
1648
1654
  editor3DStrokeEnd();
1649
1655
  while (editor3DPrefabBack()); // the game plays the level, not a prefab that was open
1656
+ editor3DAutosaveFlush(); // the game plays on with what the editor has kept
1650
1657
  editor3DToolHeld = false;
1651
1658
  editor3DIsOpen = false;
1652
1659
  setPaused(editor3DGamePaused);
@@ -1842,6 +1849,7 @@ function editor3DAutosave(level=editor3DLevel, known)
1842
1849
  root && editor3DAutosave(Object.keys(prefabs).length ? {...root, prefabs} : root, editor3DRecords.get(root));
1843
1850
  return;
1844
1851
  }
1852
+ editor3DAutosaveWaiting.delete(level); // written now
1845
1853
  const record = known ?? editor3DRecords.get(level);
1846
1854
  if (!record || record.pending) return; // edits waiting to be applied keep their autosave
1847
1855
  const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
@@ -1853,9 +1861,35 @@ function editor3DAutosave(level=editor3DLevel, known)
1853
1861
  else
1854
1862
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
1855
1863
  ...parts};
1856
- const failed = editorSaveFailed; // the 2D editor's own flag is its own
1864
+ const failed = editorSaveFailed, size = editorAutosaveSize; // the 2D editor's flag and size are its own
1857
1865
  editor3DSaveFailed = editorWriteSaves(saves, record.key, editor3DSaveName());
1858
- editorSaveFailed = failed;
1866
+ editor3DAutosaveSize = editorAutosaveSize;
1867
+ editorSaveFailed = failed, editorAutosaveSize = size;
1868
+ }
1869
+
1870
+ // a level whose autosave writes more than EDITOR_AUTOSAVE_WAIT_SIZE characters, a big terrain's or block map's,
1871
+ // waits until the edits stop for a moment, as the 2D editor's map does, since writing it after every stroke would
1872
+ // cost more than the stroke; a small one is written at once
1873
+ let editor3DAutosaveSize = 0, editor3DAutosaveTimer;
1874
+ const editor3DAutosaveWaiting = new Set;
1875
+
1876
+ // autosave a level after an edit: at once when its autosave is small, or a second after the last of a run of edits;
1877
+ // inside a prefab at once, since its autosave is the level it is in, found through the prefabs open, which Back
1878
+ // changes
1879
+ function editor3DAutosaveSoon(level=editor3DLevel)
1880
+ {
1881
+ if (editor3DAutosaveSize < EDITOR_AUTOSAVE_WAIT_SIZE || editor3DPrefabLevels.has(level))
1882
+ return editor3DAutosave(level);
1883
+ editor3DAutosaveWaiting.add(level);
1884
+ clearTimeout(editor3DAutosaveTimer);
1885
+ editor3DAutosaveTimer = setTimeout(editor3DAutosaveFlush, 1e3);
1886
+ }
1887
+
1888
+ // write every autosave that is waiting, as the editor closes, the page hides or another level loads
1889
+ function editor3DAutosaveFlush()
1890
+ {
1891
+ clearTimeout(editor3DAutosaveTimer);
1892
+ [...editor3DAutosaveWaiting].forEach((level)=> editor3DAutosave(level));
1859
1893
  }
1860
1894
 
1861
1895
  // put the autosaved edits of a file that changed into the level, as one undo
@@ -2549,11 +2583,16 @@ function editor3DEditorUpdate(seconds)
2549
2583
 
2550
2584
  // Ctrl with a letter the editor uses is not the browser's while the editor is open, Ctrl+D would bookmark the page
2551
2585
  if (debug && globalThis.document?.addEventListener)
2586
+ {
2552
2587
  document.addEventListener('keydown', (e)=>
2553
2588
  {
2554
2589
  if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVDG]$/.test(e.code) && !editorIsTextField(e.target))
2555
2590
  e.preventDefault();
2556
2591
  });
2592
+ // a page going away writes what is waiting, the last event it is sure to get
2593
+ document.addEventListener('visibilitychange', ()=> document.hidden && editor3DAutosaveFlush());
2594
+ globalThis.addEventListener?.('pagehide', editor3DAutosaveFlush);
2595
+ }
2557
2596
 
2558
2597
  ///////////////////////////////////////////////////////////////////////////////
2559
2598
  // what the panel does to the level, and what it says
@@ -3060,7 +3099,8 @@ function editor3DPrefabBoxUpdate(box)
3060
3099
  unpack.title = 'Ctrl+Shift+G: turn this instance into the objects it is made of';
3061
3100
  unpack.onclick = ()=> { editor3DUnpack(); unpack.blur(); };
3062
3101
  const save = editorElement('button', line, press, 'Export');
3063
- save.title = 'Save the prefab as a file of its own, to load with level3DLoadPrefab';
3102
+ save.title = 'Save the prefab as a file of its own, to load with level3DLoadPrefab; a prefab used inside it ' +
3103
+ 'is named by its type, and the game adds that one too';
3064
3104
  save.onclick = ()=>
3065
3105
  {
3066
3106
  saveText(editor3DPrefabJSON(instance.prefabName), instance.prefabName + '.json', 'application/json');
@@ -3398,6 +3438,41 @@ function editor3DSceneUpdate(box)
3398
3438
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
3399
3439
  toggle('Lens flare', 'The sun flares in the lens when it is in view', !!value('lensFlare'), (on)=>
3400
3440
  change('lensFlare', on));
3441
+
3442
+ // a sky box of six images, their urls in one field by commas, and whether shiny things reflect it; the
3443
+ // environment follows the sky box while it is the same images
3444
+ const urls = (list)=> isArray(list) && list.length === 6 ? list.join(', ') : '';
3445
+ const skyBox = editorElement('input', line('Sky box', 'Six image urls by commas, +x, -x, +y, -y, +z and -z, a ' +
3446
+ 'comma and a space between data urls, loaded as a cube map for the sky; empty for none'), field + ';width:120px');
3447
+ skyBox.value = urls(scene.skyBox);
3448
+ skyBox.onchange = ()=>
3449
+ {
3450
+ // by a comma and a space, so a data url keeps its own comma, or by commas alone when that is not six
3451
+ const value = skyBox.value.replace(/[,\s]+$/, ''); // a comma left at the end is not part of the last url
3452
+ const split = (by)=> value.split(by).map((url)=> url.trim()).filter((url)=> url);
3453
+ let list = split(/,\s+/);
3454
+ list.length === 6 || (list = split(','));
3455
+ if (list.length && list.length !== 6)
3456
+ skyBox.value = urls(editor3DScene()?.skyBox); // not six, what it has now shows again
3457
+ else editor3DChangeScene((s={})=>
3458
+ {
3459
+ const {skyBox, environment, ...rest} = s, reflected = !!urls(skyBox) && urls(environment) === urls(skyBox);
3460
+ return {...rest, ...list.length && {skyBox: list},
3461
+ ...(reflected ? list.length && {environment: [...list]} : environment && {environment})};
3462
+ });
3463
+ editor3DStrokeEnd();
3464
+ skyBox.blur();
3465
+ };
3466
+ if (urls(scene.skyBox))
3467
+ toggle('Reflect it', 'Shiny things reflect the sky box, as render3D.environment',
3468
+ urls(scene.environment) === urls(scene.skyBox), (on)=> editor3DChangeScene((s={})=>
3469
+ {
3470
+ const {environment, ...rest} = s;
3471
+ return on && isArray(s.skyBox) ? {...rest, environment: [...s.skyBox]} : rest;
3472
+ }));
3473
+ else if (urls(scene.environment)) // one of its own, with no sky box, can still be taken out
3474
+ toggle('Environment', 'The level\'s own environment, six images that shiny things reflect; off takes it out',
3475
+ true, (on)=> on || editor3DChangeScene((s={})=> { const {environment, ...rest} = s; return rest; }));
3401
3476
  }
3402
3477
 
3403
3478
  // the properties box: the position, rotation and scale of the one selected object and an input for each default
@@ -14,14 +14,14 @@
14
14
  // the way the 2D font does, but extruded glyphs seen from an angle then overlap the line below
15
15
  const RENDER3D_TEXT_LEADING = 1.3;
16
16
 
17
- // let go of the parent but stay where the object was in the world, which removeChild keeps by itself; a destroyed
18
- // parent has already let go, so the position remembered by the last update stands in
17
+ // let go of the parent but stay where and as the object was in the world, which removeChild keeps by itself; a
18
+ // destroyed parent has already let go, so the world matrix last built with it in stands in, scale and turn as well
19
19
  function render3DDetach(o)
20
20
  {
21
21
  if (o.parent)
22
22
  o.parent.removeChild(o);
23
- else if (o.worldPos3D)
24
- o.pos3D = o.worldPos3D;
23
+ else if (o.matrixParent)
24
+ render3DTakeWorld(o, o.worldMatrix);
25
25
  }
26
26
 
27
27
  // a soft white dot for untextured particles, made once from a canvas, undefined headless or without a canvas
@@ -404,13 +404,14 @@ function buildText3D(text, size=1, depth=.2, font=engineImageFont)
404
404
  let glyphs = render3DGlyphCache.get(font); // unit sized, scaled when combined
405
405
  glyphs || render3DGlyphCache.set(font, glyphs = new Map);
406
406
  const charSize = vec2(size * tileInfo.size.x / tileInfo.size.y, size);
407
- const mesh = new Mesh, lines = (text + '').split('\n');
407
+ const mesh = new Mesh, lines = textLines(text); // a Windows line ending too
408
408
  lines.forEach((line, j)=>
409
409
  {
410
410
  const y = ((lines.length - 1) / 2 - j) * charSize.y * RENDER3D_TEXT_LEADING;
411
- for (let i = 0; i < line.length; ++i)
411
+ const characters = textCharacters(line); // as ImageFont reads them, an emoji one box
412
+ for (let i = 0; i < characters.length; ++i)
412
413
  {
413
- const charCode = line.charCodeAt(i);
414
+ const charCode = textCharacterCode(characters, i);
414
415
  const index = charCode < 32 || charCode > 127 ? 95 : charCode - 32; // like ImageFont
415
416
  if (!index) continue; // space
416
417
  let glyph = glyphs.get(index);
@@ -419,7 +420,7 @@ function buildText3D(text, size=1, depth=.2, font=engineImageFont)
419
420
  const pos = font.getGlyphPos(index); // where ImageFont finds it
420
421
  glyphs.set(index, glyph = buildExtrude(new TileInfo(pos, tileInfo.size, tileInfo.textureInfo)));
421
422
  }
422
- const x = (i - (line.length - 1) / 2) * charSize.x;
423
+ const x = (i - (characters.length - 1) / 2) * charSize.x;
423
424
  mesh.combine(glyph, buildMatrix(vec3(x, y, 0), undefined, vec3(charSize.x, charSize.y, depth)));
424
425
  }
425
426
  });
@@ -630,11 +631,15 @@ class HeightMap extends EngineObject3D
630
631
  * @ignore */
631
632
  levelSegment3D(from, to)
632
633
  {
633
- const m = this.pos3D, size = this.mapSize;
634
- if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return undefined; // off the map
635
- const a = from.y - this.getHeight(from.x, from.z), b = to.y - this.getHeight(to.x, to.z);
636
- if (a < 0 || b >= 0) return undefined; // above all the way, or under from the start
637
- const distance = a / (a - b);
634
+ // a move above the highest the ground goes misses it, which is most particles most of the time
635
+ const top = this.pos3D.y + max(this.height, 0);
636
+ if (from.y > top && to.y > top) return undefined;
637
+ if (from.y < this.getHeight(from.x, from.z)) return undefined; // under from the start, let go
638
+ // the ground is flat in each triangle, not between the ends, so a ridge both ends are above is still met:
639
+ // the exact raycast along the move, which walks every triangle it crosses, the first one it goes under
640
+ const distance = this.raycast(new Ray3D(from, to.subtract(from)));
641
+ // over the move, and not a touch where it starts on the surface and leaves it
642
+ if (distance === undefined || distance > 1 || !distance && to.y >= this.getHeight(to.x, to.z)) return undefined;
638
643
  return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)};
639
644
  }
640
645
 
@@ -647,11 +652,26 @@ class HeightMap extends EngineObject3D
647
652
  {
648
653
  const p = o.pos3D, m = this.pos3D, size = this.mapSize;
649
654
  if (abs(p.x - m.x) > size.x / 2 || abs(p.z - m.z) > size.y / 2) return; // off the map
650
- const half = o.size3D.y * abs(o.scale3D.y) / 2, ground = this.getHeight(p.x, p.z);
651
- // one that stood on it and is not rising keeps to it going downhill, as far down as it moved across, so it
652
- // stays grounded down a slope as steep as 45 degrees instead of falling in small hops
653
- const follow = wasOn && o.velocity3D.y <= 0 ? hypot(p.x - oldPos.x, p.z - oldPos.z) : 0;
654
- if (p.y - half > ground + follow) return;
655
+ const half = o.size3D.y * abs(o.scale3D.y) / 2;
656
+ let ground = this.getHeight(p.x, p.z);
657
+ // only the ends of a move are kept above the ground, so a ridge it goes over is met by a sweep, as a
658
+ // particle's move is, a quarter of its height up from its foot, from where it was to where it would stand: a
659
+ // bump lower than that is walked over as before, and it goes up on the near side of anything taller
660
+ const lift = half / 2, endFoot = max(p.y - half, ground) + lift;
661
+ const hit = oldPos && this.levelSegment3D(vec3(oldPos.x, oldPos.y - half + lift, oldPos.z), vec3(p.x, endFoot, p.z));
662
+ if (hit && hit.distance)
663
+ {
664
+ p.x = oldPos.x + (p.x - oldPos.x) * hit.distance;
665
+ p.z = oldPos.z + (p.z - oldPos.z) * hit.distance;
666
+ ground = this.getHeight(p.x, p.z);
667
+ }
668
+ else
669
+ {
670
+ // one that stood on it and is not rising keeps to it going downhill, as far down as it moved across, so
671
+ // it stays grounded down a slope as steep as 45 degrees instead of falling in small hops
672
+ const follow = wasOn && o.velocity3D.y <= 0 ? hypot(p.x - oldPos.x, p.z - oldPos.z) : 0;
673
+ if (p.y - half > ground + follow) return;
674
+ }
655
675
  p.y = ground + half;
656
676
  const v = o.velocity3D;
657
677
  if (v.y < 0)
@@ -1003,7 +1023,8 @@ class ParticleEmitter3D extends EngineObject3D
1003
1023
  /** @property {boolean} - Flatten the spawn area across the way it emits, its own up: a sphere becomes a disc
1004
1024
  * and a box a flat rectangle, for rain from a sheet of sky or flames from a patch of ground */
1005
1025
  this.emitFlat = false;
1006
- /** @property {number} - How long to keep emitting, 0 is forever */
1026
+ /** @property {number} - How long to keep emitting, 0 is forever; raised while its particles are still alive,
1027
+ * it emits again for the added time */
1007
1028
  this.emitTime = emitTime;
1008
1029
  /** @property {number} - Particles per second, 0 does not emit */
1009
1030
  this.emitRate = emitRate;
@@ -1081,6 +1102,12 @@ class ParticleEmitter3D extends EngineObject3D
1081
1102
  * @type {Vector3|undefined} */
1082
1103
  this.worldPos3D = undefined;
1083
1104
  this.emitTimeBuffer = 1; // the first particle comes at once, as the 2D emitter's does
1105
+ this.emitElapsed = 0; // seconds of its emit time it has emitted for, counted by its updates
1106
+ // where it was last update, and how far back toward it the particle being made starts, 0 to 1: a moving
1107
+ // emitter spreads an update's particles along its move, as the 2D emitter does
1108
+ /** @type {Vector3|undefined} */
1109
+ this.emitFrom = undefined;
1110
+ this.emitBehind = 0;
1084
1111
  }
1085
1112
 
1086
1113
  /** Spawn new particles, move the live ones, and go away when done */
@@ -1088,21 +1115,37 @@ class ParticleEmitter3D extends EngineObject3D
1088
1115
  {
1089
1116
  // one transform for the frame: where the emitter is, and how big the effect it makes is
1090
1117
  const matrix = render3DObjectMatrix(this); // the object's own, read only
1118
+ this.emitFrom = this.worldPos3D;
1091
1119
  this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
1092
1120
  const scale = render3DMaxScale(matrix.m);
1093
1121
  this.particleView.scale = scale; // the callbacks read it from the view instead of working it out each time
1094
1122
 
1095
1123
  // emit until the emit time is up, then wait for the last particle and go away
1096
- if (!this.emitTime || this.getAliveTime() <= this.emitTime)
1124
+ if (!this.emitTime || this.emitElapsed < this.emitTime)
1097
1125
  {
1098
1126
  // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
1099
- // neither is a reason to stop counting down the emit time
1127
+ // neither is a reason to stop counting down the emit time; the part of this update inside the emit time,
1128
+ // counted by its own updates, as the 2D emitter does, so a one shot gives the same count on any frame
1100
1129
  const rate = this.emitRate * particleEmitRateScale;
1130
+ const step = this.emitTime ? min(timeDelta, this.emitTime - this.emitElapsed) : timeDelta;
1131
+ this.emitElapsed += timeDelta;
1101
1132
  if (rate > 0 && rate < Infinity)
1102
1133
  {
1103
- this.emitTimeBuffer += rate * timeDelta;
1104
- for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
1105
- this.emitParticle();
1134
+ // the update that ends the emit time adds a hair, as the steps' sum comes out a rounding under it
1135
+ this.emitTimeBuffer += rate * step + (this.emitTime && this.emitElapsed >= this.emitTime ? 1e-6 : 0);
1136
+ // spread along the move since the last update, the last one where it is now; a jump further than
1137
+ // anything travels in an update, twice objectMaxSpeed, a teleport or a respawn, spreads nothing
1138
+ const travelled = this.emitFrom && this.worldPos3D.distance(this.emitFrom) <= 2 * objectMaxSpeed;
1139
+ const count = floor(this.emitTimeBuffer);
1140
+ try
1141
+ {
1142
+ for (let i = count; i--; --this.emitTimeBuffer)
1143
+ {
1144
+ this.emitBehind = travelled ? i / count : 0;
1145
+ this.emitParticle();
1146
+ }
1147
+ }
1148
+ finally { this.emitBehind = 0; } // a create callback that throws leaves no offset for the next
1106
1149
  }
1107
1150
  }
1108
1151
  else if (!this.particleCount)
@@ -1129,7 +1172,7 @@ class ParticleEmitter3D extends EngineObject3D
1129
1172
  const damping = this.damping, angleDamping = this.angleDamping, g = render3D.gravity, share = this.gravityScale;
1130
1173
  const gravityX = g.x * share * scale, gravityY = (this.gravity + g.y * share) * scale;
1131
1174
  const gravityZ = g.z * share * scale;
1132
- const collideLevel = this.collideLevel && render3DLevel.length;
1175
+ const collideLevel = this.collideLevel && enablePhysicsSolver && render3DLevel.length; // as 2D particles
1133
1176
  const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
1134
1177
  for (let i = this.particleCount; i--;)
1135
1178
  {
@@ -1204,6 +1247,12 @@ class ParticleEmitter3D extends EngineObject3D
1204
1247
  const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
1205
1248
 
1206
1249
  const pos = matrix.transformPoint(offset), speed = this.speed * random() * scale;
1250
+ const from = this.emitFrom, behind = this.emitBehind; // back along its move, as the update spreads them
1251
+ if (from && behind)
1252
+ {
1253
+ const here = this.worldPos3D;
1254
+ pos.x += (from.x - here.x) * behind, pos.y += (from.y - here.y) * behind, pos.z += (from.z - here.z) * behind;
1255
+ }
1207
1256
  const colorStart = randColor(this.colorStartA, this.colorStartB, true), colorEnd = randColor(this.colorEndA, this.colorEndB, true);
1208
1257
 
1209
1258
  // room for one more, doubling as the set grows, the trails along with it
@@ -1233,11 +1282,13 @@ class ParticleEmitter3D extends EngineObject3D
1233
1282
  data[k+18] = this.angleSpeed ? rand(2*PI) : 0;
1234
1283
  data[k+19] = this.angleSpeed ? this.angleSpeed * random() * randSign() : 0;
1235
1284
  data[k+20] = 0; // trail points
1285
+ this.particleView.scale = scale; // the scale it was made at, emitted before the first update too
1236
1286
  this.particleCreateCallback && this.particleCall(this.particleCreateCallback, k);
1237
1287
  }
1238
1288
 
1239
1289
  // call a particle callback with the particle at k: the emitter's one Particle3D is set from its numbers, and
1240
1290
  // what the callback changes is written back; a particle it destroys has lived its life, the update removes it
1291
+ /** @private */
1241
1292
  particleCall(callback, k, level, pos)
1242
1293
  {
1243
1294
  // a callback that emits runs the create callback inside itself, which gets a view of its own so this one
@@ -1245,6 +1296,7 @@ class ParticleEmitter3D extends EngineObject3D
1245
1296
  // emit into a full emitter grows them
1246
1297
  const nested = render3DParticlesCalling.has(this);
1247
1298
  const view = nested ? render3DParticleView(this) : this.particleView, data = this.particleData;
1299
+ view.scale = this.particleView.scale; // a nested view's too
1248
1300
  view.pos.set(data[k], data[k+1], data[k+2]);
1249
1301
  view.velocity.set(data[k+3], data[k+4], data[k+5]);
1250
1302
  view.age = data[k+17], view.lifeTime = data[k+16], view.destroyed = false;
@@ -1264,6 +1316,7 @@ class ParticleEmitter3D extends EngineObject3D
1264
1316
  // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
1265
1317
  // collide callback lets it through; one that starts inside is let go, as a 2D one is; true when the callback
1266
1318
  // destroyed it
1319
+ /** @private */
1267
1320
  particleCollide(k, x, y, z)
1268
1321
  {
1269
1322
  let data = this.particleData;
@@ -1634,8 +1687,9 @@ class LensFlare3D extends EngineObject3D
1634
1687
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
1635
1688
  this.fadeTime = .15;
1636
1689
  /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in
1637
- * plain view, eased */
1638
- this.visible = 1;
1690
+ * plain view, eased; the first update sets it at once, so a sun hidden from the start never shows */
1691
+ this.visible = 0;
1692
+ this.visibleFound = false; // the first update has found it
1639
1693
  this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
1640
1694
  this.madeKey = '';
1641
1695
  /** @type {Array<LensFlareElement>} */
@@ -1736,6 +1790,13 @@ class LensFlare3D extends EngineObject3D
1736
1790
  * tileInfo: TileInfo|undefined, angle: number}>} */
1737
1791
  getScreenElements()
1738
1792
  {
1793
+ // one made while the game is paused, as the 3D editor makes them, has had no update to find it, and while
1794
+ // paused nothing updates it, so it is found at once each time it is drawn, as the camera turns
1795
+ if (!this.visibleFound || paused)
1796
+ {
1797
+ this.visibleFound = false;
1798
+ this.updateVisible(0);
1799
+ }
1739
1800
  const look = this.visible > 0 && this.flareLook();
1740
1801
  if (!look) return [];
1741
1802
  const {sun, center, tint, height} = look, strength = look.strength * this.visible;
@@ -1759,7 +1820,7 @@ class LensFlare3D extends EngineObject3D
1759
1820
  if (!source) return true;
1760
1821
  const ray = new Ray3D(render3D.camera.pos, source.direction), reach = source.distance;
1761
1822
  const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.destroyed &&
1762
- !o.transparent && !o.additive);
1823
+ !o.transparent && !render3DSetting(o, 'additive'));
1763
1824
  // a voxel map says block by block what is see through: glass, water and leaves let the sun by, and the
1764
1825
  // ray goes on to the blocks behind them
1765
1826
  const maps = /** @type {Array<VoxelMap>} */ (typeof VoxelMap == 'undefined' ? [] :
@@ -1800,9 +1861,20 @@ class LensFlare3D extends EngineObject3D
1800
1861
  super.update();
1801
1862
  if (this.light?.destroyed)
1802
1863
  return this.destroy(); // the flare of a light that is gone
1864
+ this.updateVisible(timeDelta);
1865
+ }
1866
+
1867
+ /** Ease visible toward whether the sun shows over some seconds; the first time it is found at once, from the
1868
+ * camera as it is, its matrices made, by the first update or the first draw, whichever comes first
1869
+ * @param {number} seconds
1870
+ * @ignore */
1871
+ updateVisible(seconds)
1872
+ {
1873
+ this.visibleFound || render3D.updateMatrices();
1803
1874
  const shows = !!this.flareLook() && !(this.occlusion && this.isHidden());
1804
- const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
1875
+ const step = this.fadeTime > 0 && this.visibleFound ? seconds / this.fadeTime : 1;
1805
1876
  this.visible = clamp(this.visible + (shows ? step : -step));
1877
+ this.visibleFound = true;
1806
1878
  }
1807
1879
 
1808
1880
  /** Draw the flare over the 3D scene, added onto it, called automatically in the 2D pass */
@@ -1918,10 +1990,14 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
1918
1990
  */
1919
1991
  async function loadOBJ(url, smooth=render3D?.smoothShading)
1920
1992
  {
1921
- const response = await fetch(url);
1993
+ const response = await loadFetch(url, 'loadOBJ');
1922
1994
  if (!response.ok)
1923
- throw new Error('loadOBJ failed: ' + url);
1924
- return parseOBJ(await response.text(), smooth);
1995
+ throw new Error(`loadOBJ: could not load ${url}, ${response.status} ${response.statusText}`);
1996
+ const text = await response.text(), mesh = parseOBJ(text, smooth);
1997
+ // a mesh of nothing draws nothing, so it says why, a web page being what a dev server sends for a mistyped path
1998
+ mesh.points.length || console.warn('loadOBJ: ' + url + ' has no faces' +
1999
+ (loadIsWebPage(text) ? ', it is a web page, so the path may be wrong' : ''));
2000
+ return mesh;
1925
2001
  }
1926
2002
 
1927
2003
  ///////////////////////////////////////////////////////////////////////////////
@@ -2092,8 +2168,22 @@ function render3DCSGNear(polygons, polygonsOther)
2092
2168
  const near = [], far = [];
2093
2169
  const faces = [[vec3(1, 0, 0), hi.x], [vec3(-1, 0, 0), -lo.x], [vec3(0, 1, 0), hi.y],
2094
2170
  [vec3(0, -1, 0), -lo.y], [vec3(0, 0, 1), hi.z], [vec3(0, 0, -1), -lo.z]];
2171
+ const e = RENDER3D_CSG_EPSILON;
2095
2172
  for (const polygon of polygons)
2096
2173
  {
2174
+ // wholly beyond a face, as most of a mesh cut many times is, it is far as it is: splitting it at the faces
2175
+ // before would only cut it into parts that all end up far too
2176
+ let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
2177
+ for (const {pos} of polygon.vertices)
2178
+ {
2179
+ x0 = min(x0, pos.x); y0 = min(y0, pos.y); z0 = min(z0, pos.z);
2180
+ x1 = max(x1, pos.x); y1 = max(y1, pos.y); z1 = max(z1, pos.z);
2181
+ }
2182
+ if (x0 > hi.x + e || x1 < lo.x - e || y0 > hi.y + e || y1 < lo.y - e || z0 > hi.z + e || z1 < lo.z - e)
2183
+ {
2184
+ far.push(polygon);
2185
+ continue;
2186
+ }
2097
2187
  let inside = [polygon];
2098
2188
  for (const [normal, w] of faces)
2099
2189
  {
@@ -2249,7 +2339,6 @@ function render3DCSGMesh(polygons)
2249
2339
  };
2250
2340
 
2251
2341
  // each loop with the points along its edges put in, as a fan
2252
- const flat = (a, b, c)=> b.subtract(a).cross(c.subtract(a)).lengthSquared() < 1e-18;
2253
2342
  for (const loop of loops)
2254
2343
  {
2255
2344
  if (loop.length < 3) continue;