littlejsengine 1.25.0 → 1.26.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/FAQ.md +49 -11
  3. package/README.md +6 -2
  4. package/REFERENCE.md +129 -21
  5. package/dist/littlejs.d.ts +487 -102
  6. package/dist/littlejs.esm.js +2898 -719
  7. package/dist/littlejs.esm.min.js +3 -3
  8. package/dist/littlejs.js +2888 -716
  9. package/dist/littlejs.min.js +2 -2
  10. package/dist/littlejs.release.js +2702 -527
  11. package/package.json +4 -4
  12. package/plugins/box2d.js +110 -34
  13. package/plugins/gltf.js +74 -22
  14. package/plugins/lightSystem.js +410 -28
  15. package/plugins/math3d.js +4 -2
  16. package/plugins/medalSystem.js +19 -7
  17. package/plugins/newgrounds.js +4 -2
  18. package/plugins/parallax.js +1 -0
  19. package/plugins/particleEffects.js +44 -10
  20. package/plugins/pathFinder.js +9 -11
  21. package/plugins/pluginExport.js +9 -0
  22. package/plugins/postProcess.js +9 -3
  23. package/plugins/render3d.js +499 -2389
  24. package/plugins/render3dDebug.js +5 -8
  25. package/plugins/render3dEditor.js +81 -6
  26. package/plugins/render3dExtras.js +103 -31
  27. package/plugins/render3dLevel.js +53 -5
  28. package/plugins/render3dMesh.js +1158 -0
  29. package/plugins/render3dObject.js +1451 -0
  30. package/plugins/render3dVoxels.js +15 -3
  31. package/plugins/textureSheet.js +9 -8
  32. package/plugins/threejs.js +1 -1
  33. package/plugins/tweenSystem.js +76 -51
  34. package/plugins/uiSystem.js +37 -8
  35. package/plugins/wavedash.js +229 -0
  36. package/src/engine.js +199 -40
  37. package/src/engineAudio.js +37 -21
  38. package/src/engineBuild.mjs +6 -1
  39. package/src/engineDebug.js +22 -162
  40. package/src/engineDraw.js +142 -34
  41. package/src/engineEditor.js +79 -18
  42. package/src/engineExport.js +1 -3
  43. package/src/engineInput.js +34 -24
  44. package/src/engineMath.js +35 -14
  45. package/src/engineObject.js +60 -41
  46. package/src/engineParticles.js +58 -11
  47. package/src/engineRelease.js +1 -5
  48. package/src/engineSettings.js +48 -9
  49. package/src/engineTileLayer.js +11 -4
  50. package/src/engineUtilities.js +41 -9
  51. package/src/engineWebGL.js +37 -13
@@ -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
 
@@ -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;
@@ -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,9 +1990,9 @@ 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);
1995
+ throw new Error(`loadOBJ: could not load ${url}, ${response.status} ${response.statusText}`);
1924
1996
  const text = await response.text(), mesh = parseOBJ(text, smooth);
1925
1997
  // a mesh of nothing draws nothing, so it says why, a web page being what a dev server sends for a mistyped path
1926
1998
  mesh.points.length || console.warn('loadOBJ: ' + url + ' has no faces' +
@@ -19,7 +19,8 @@ const LEVEL3D_VERSION = 1; // the format a level's littlejs3D names
19
19
  * @typedef {Object} Level3D
20
20
  * @property {number} [littlejs3D] - The format's version, 1
21
21
  * @property {Array<Object>} objects - Each {id, type, pos, rotation, scale, properties}, all but type optional
22
- * @property {Object} [scene] - The scene block: sky, ambient, sunDirection, sunColor, fog, fogColor, shadows, lensFlare
22
+ * @property {Object} [scene] - The scene block: sky, ambient, sunDirection, sunColor, fog, fogColor, shadows, lensFlare,
23
+ * skyBox, environment
23
24
  * @property {Object} [voxels] - A block map: {pos, size, blocks}
24
25
  * @property {Object} [terrain] - A height map: {pos, size, height, color, heights, paint}
25
26
  * @property {Object} [prefabs] - The level's own prefabs by name, each {objects, attached}
@@ -94,9 +95,10 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
94
95
  * are added before its objects are made, one the game added itself keeps its place
95
96
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
96
97
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
97
- * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, and lensFlare, the
98
- * sun's lens flare; what the block leaves out stays as the game set it, and a level with no block changes
99
- * nothing
98
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, lensFlare, the
99
+ * sun's lens flare, and skyBox and environment, each six image urls as loadCubeMap takes them, which load in
100
+ * the background and are set when they have; what the block leaves out stays as the game set it, and a level
101
+ * with no block changes nothing
100
102
  * @param {Level3D} level - The level, the level editor edits this same object
101
103
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
102
104
  * @memberof Level3D */
@@ -300,6 +302,8 @@ const level3DSkies = new WeakSet;
300
302
  // and shadows; a setting the block does not have, or has wrong, stays as it is
301
303
  function level3DSceneApply(scene)
302
304
  {
305
+ // a sky box or environment an earlier scene is still loading does not land on this one's
306
+ ++level3DSceneCubeLoads.skyBox, ++level3DSceneCubeLoads.environment;
303
307
  const r = render3D;
304
308
  if (!r || !scene || typeof scene !== 'object') return;
305
309
  const color = level3DHexColor;
@@ -326,6 +330,47 @@ function level3DSceneApply(scene)
326
330
  r.shadows = scene.shadows;
327
331
  if (typeof scene.lensFlare === 'boolean')
328
332
  level3DSceneFlare(scene.lensFlare);
333
+ level3DSceneCubeMap('skyBox', scene.skyBox);
334
+ level3DSceneCubeMap('environment', scene.environment);
335
+ }
336
+
337
+ // the cube maps scene blocks loaded, by their six urls, the four used last, so the same ones load once; a load that
338
+ // failed is not kept, and is tried again by the next scene that names it
339
+ const level3DSceneCubeMaps = new Map;
340
+ const level3DSceneCubeLoaded = new WeakMap; // the cube map each of their loads gave, once it has
341
+
342
+ // which load each of render3D.skyBox and environment waits for: every scene applied, or the editor putting the
343
+ // game's back, moves it on, and a load that finishes after is dropped
344
+ const level3DSceneCubeLoads = {skyBox: 0, environment: 0};
345
+
346
+ // load the six urls a scene block gives for render3D.skyBox or environment and set it when they have loaded; a value
347
+ // that is not six strings leaves it as it is, and a failed load says so and leaves it too
348
+ function level3DSceneCubeMap(field, urls)
349
+ {
350
+ if (!isArray(urls) || urls.length !== 6 || !urls.every((url)=> typeof url === 'string')) return;
351
+ const key = urls.join('\n'), load = ++level3DSceneCubeLoads[field], before = render3D[field];
352
+ // kept by the urls, newest last: one used again moves to the end, and past four the oldest goes, its texture
353
+ // freed unless it is still on screen
354
+ let cube = level3DSceneCubeMaps.get(key);
355
+ level3DSceneCubeMaps.delete(key);
356
+ level3DSceneCubeMaps.set(key, cube ||= loadCubeMap(urls));
357
+ cube.then((map)=> level3DSceneCubeLoaded.set(cube, map), ()=> {});
358
+ for (const [oldKey, old] of level3DSceneCubeMaps)
359
+ {
360
+ if (level3DSceneCubeMaps.size <= 4) break;
361
+ // one on screen stays, so it is not loaded again and the copy on screen left behind
362
+ const map = level3DSceneCubeLoaded.get(old);
363
+ if (map && (map === render3D.skyBox || map === render3D.environment)) continue;
364
+ level3DSceneCubeMaps.delete(oldKey);
365
+ old.then((map)=> map !== render3D.skyBox && map !== render3D.environment && map.dispose(), ()=> {});
366
+ }
367
+ // set when it has loaded, unless a newer scene came, or the game set the field itself meanwhile
368
+ const landed = (map)=> load === level3DSceneCubeLoads[field] && render3D[field] === before && (render3D[field] = map);
369
+ cube.then(landed, (error)=>
370
+ {
371
+ level3DSceneCubeMaps.get(key) === cube && level3DSceneCubeMaps.delete(key);
372
+ console.warn(error.message);
373
+ });
329
374
  }
330
375
 
331
376
  // the sun's lens flare as a scene sets it: on makes one unless the sun has a flare already, the game's own or an
@@ -544,12 +589,14 @@ class Prefab3D extends EngineObject3D
544
589
  this.prefabName = prefabName;
545
590
  /** @property {boolean} - Are its parts its children, moving with it as one body */
546
591
  this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0;
547
- /** @property {Array<any>} - What the prefab's objects made, in the prefab's order */
592
+ /** @property {Array<EngineObject3D>} - What the prefab's objects made, in the prefab's order
593
+ * @type {Array<EngineObject3D>} */
548
594
  this.parts = [];
549
595
  /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for
550
596
  * separate parts, and for an attached prefab the middle of the box around its parts, where its handle
551
597
  * is, as an object's place is the middle of its body */
552
598
  this.originOffset = vec3();
599
+ /** @type {Array<{id?: number, type: string, pos?: Array<number>, rotation?: Array<number>, scale?: Array<number>|number, properties?: Object}>} */
553
600
  this.partObjects = []; // the prefab's object of each part
554
601
  this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made
555
602
  this.partsMade = false;
@@ -641,6 +688,7 @@ class Prefab3D extends EngineObject3D
641
688
  for (const part of children)
642
689
  {
643
690
  part.pos3D = part.pos3D.subtract(this.originOffset);
691
+ render3DShareSettings(part, this); // drawn with what is set on the handle, its own otherwise
644
692
  this.addChild(part);
645
693
  }
646
694
  }