littlejsengine 1.24.1 → 1.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/EDITOR.md +2 -0
  3. package/FAQ.md +13 -2
  4. package/README.md +1 -1
  5. package/REFERENCE.md +20 -6
  6. package/dist/littlejs.d.ts +77 -13
  7. package/dist/littlejs.esm.js +2395 -1939
  8. package/dist/littlejs.esm.min.js +3 -3
  9. package/dist/littlejs.js +2392 -1939
  10. package/dist/littlejs.min.js +2 -2
  11. package/dist/littlejs.release.js +2356 -1952
  12. package/package.json +1 -1
  13. package/plugins/gltf.js +62 -9
  14. package/plugins/lightSystem.js +1 -1
  15. package/plugins/math3d.js +1014 -1013
  16. package/plugins/medalSystem.js +7 -11
  17. package/plugins/particleEffects.js +2 -2
  18. package/plugins/pathFinder.js +2 -2
  19. package/plugins/pluginExport.js +1 -0
  20. package/plugins/render3d.js +131 -55
  21. package/plugins/render3dEditor.js +24 -10
  22. package/plugins/render3dExtras.js +29 -17
  23. package/plugins/render3dLevel.js +21 -12
  24. package/plugins/render3dVoxels.js +2 -2
  25. package/plugins/textureSheet.js +97 -3
  26. package/plugins/tweakables.js +1 -1
  27. package/plugins/tweenSystem.js +2 -2
  28. package/plugins/uiSystem.js +6 -2
  29. package/src/engine.js +34 -11
  30. package/src/engineAudio.js +13 -3
  31. package/src/engineDebug.js +4 -1
  32. package/src/engineDraw.js +29 -16
  33. package/src/engineEditor.js +82 -50
  34. package/src/engineExport.js +2 -0
  35. package/src/engineInput.js +17 -3
  36. package/src/engineMath.js +6 -2
  37. package/src/engineObject.js +675 -675
  38. package/src/engineParticles.js +1 -1
  39. package/src/engineSettings.js +16 -0
  40. package/src/engineTileLayer.js +85 -22
  41. package/src/engineUtilities.js +20 -8
  42. package/src/engineWebGL.js +12 -8
@@ -47,7 +47,6 @@ const medals = {};
47
47
 
48
48
  // Engine internal variables not exposed to documentation
49
49
  let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast, medalsRenderAdded;
50
- let medalsLoadWaiting = false; // medalsInit came before any medal, each one made reads its own unlock
51
50
 
52
51
  ///////////////////////////////////////////////////////////////////////////////
53
52
 
@@ -72,9 +71,7 @@ function medalsInit(saveName)
72
71
  // check which local medals are unlocked in the save, and write the catalog back
73
72
  function medalsLoad()
74
73
  {
75
- // with no medals made yet, the save is left as it is for them, a game that calls medalsInit first keeps its unlocks;
76
- // it keeps waiting from then on, so loading again (a dropped Newgrounds session) does not drop medals still to come
77
- medalsLoadWaiting ||= !Object.keys(medals).length;
74
+ // the saved unlocks of medals not made yet stay for them, a medal made late or behind a flag keeps its unlock
78
75
  if (debugMedals || !medalsSaveName) return;
79
76
  const saved = readSaveData(medalsSaveName);
80
77
  ASSERT(Object.keys(saved).every(key=> isNumber(+key)),
@@ -131,7 +128,7 @@ function medalsForEach(callback)
131
128
  function medalsReset()
132
129
  {
133
130
  medalsForEach(medal=> medal.isLocal() && (medal.unlocked = false));
134
- if (medalsLoadWaiting && medalsSaveName && !debugMedals)
131
+ if (medalsSaveName && !debugMedals)
135
132
  {
136
133
  // the saved unlocks of medals not made yet are cleared too, they are read when those medals are made
137
134
  const saved = readSaveData(medalsSaveName);
@@ -147,11 +144,10 @@ function medalsReset()
147
144
  function medalsSave()
148
145
  {
149
146
  if (debugMedals || !medalsSaveName) return;
150
- // while medalsInit waits for medals made later, their saved entries are kept for them
147
+ // every saved entry stays: a medal not made this time keeps its unlock for when it is, since a player given a
148
+ // medal back is better than one who lost it, and what is not a medal is the game's own, under the same name
151
149
  const saved = readSaveData(medalsSaveName);
152
- const data = medalsLoadWaiting ? {...saved} : {};
153
- for (const key in saved) // what is not a medal is the game's own, saved under the same name, and stays
154
- isNumber(+key) || (data[key] = saved[key]);
150
+ const data = {...saved};
155
151
  medalsForEach(medal=> {
156
152
  if (!medal.isLocal())
157
153
  {
@@ -220,9 +216,9 @@ class Medal
220
216
  if (src)
221
217
  (this.image = new Image).src = src;
222
218
 
223
- // add this to list of medals, unlocked if the save says so when medalsInit came before any medal
219
+ // add this to list of medals, unlocked if the save says so when medalsInit came first
224
220
  medals[id] = this;
225
- if (medalsLoadWaiting && !debugMedals && this.isLocal())
221
+ if (medalsSaveName && !debugMedals && this.isLocal())
226
222
  this.unlocked = !!readSaveData(medalsSaveName)[id]?.unlocked;
227
223
  }
228
224
 
@@ -160,7 +160,7 @@ function particleEffectSanitize(raw)
160
160
  {
161
161
  // one line of text, line breaks and control characters made spaces
162
162
  const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : '';
163
- const name = text ? text.slice(0, 60) : 'Effect';
163
+ const name = text ? text.slice(0, 60).trim() : 'Effect'; // no space left where it was cut
164
164
  const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {};
165
165
  input.collideLevel ??= input.collideTiles; // its name before 1.20
166
166
  // a library saved before shapes names its tile and no shape, it keeps its tile
@@ -301,7 +301,7 @@ function particleEffectShapeTile(name)
301
301
  {
302
302
  if (!particleEffectShapeTiles)
303
303
  {
304
- if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
304
+ if (headlessMode || !glContext || !canvasAvailable()) return;
305
305
  // drawn at 4 times 32 pixel cells, so a big soft shape stays smooth when textures are pixelated
306
306
  const cell = 32, r = 15, count = particleEffectShapes.length, res = 4;
307
307
  const context = createCanvasContext(cell * count * res, cell * res);
@@ -83,8 +83,8 @@ class PathFinderNode
83
83
  * const path = pf.findPath(player.pos, mousePos);
84
84
  *
85
85
  * // Bare grid with custom walkability:
86
- * const pf = new PathFinder(vec2(50, 50));
87
- * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
86
+ * const gridFinder = new PathFinder(vec2(50, 50));
87
+ * gridFinder.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
88
88
  */
89
89
  class PathFinder
90
90
  {
@@ -215,6 +215,7 @@ export
215
215
  textureSheets,
216
216
  TextureSheet,
217
217
  loadSprite,
218
+ loadTiles,
218
219
  loadAtlas,
219
220
  parseAtlas,
220
221
  spritesReady,
@@ -637,6 +637,7 @@ class Render3DPlugin
637
637
  this.viewProjection = new Matrix4;
638
638
  /** @property {Matrix4} - This frame's light view projection for the shadow map */
639
639
  this.shadowMatrix = new Matrix4;
640
+ this.gelAxes = [1, 0, 0, 1]; // the shadow light's right and up in its map's, for its gel
640
641
  /** @property {Vector3} - Camera right axis this frame */
641
642
  this.cameraRight = vec3(1, 0, 0);
642
643
  /** @property {Vector3} - Camera up axis this frame */
@@ -1240,8 +1241,14 @@ class Render3DPlugin
1240
1241
  // the near plane stays in front of the far one however small the light
1241
1242
  const far = caster.radius, near = min(max(far / 500, .02), far / 2);
1242
1243
  const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
1243
- this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
1244
- Matrix4.lookAt(pos, pos.add(forward), up).invert());
1244
+ const view = Matrix4.lookAt(pos, pos.add(forward), up);
1245
+ this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(view.copy().invert());
1246
+ // the map's own right and up are not the light's, which a gel is upright by: the four numbers that
1247
+ // turn the one into the other
1248
+ const v = view.m, l = render3DObjectMatrix(caster).m;
1249
+ const right = vec3(l[0], l[1], l[2]).normalize(), lightUp = vec3(l[4], l[5], l[6]).normalize();
1250
+ const mapRight = vec3(v[0], v[1], v[2]), mapUp = vec3(v[4], v[5], v[6]);
1251
+ this.gelAxes = [right.dot(mapRight), right.dot(mapUp), lightUp.dot(mapRight), lightUp.dot(mapUp)];
1245
1252
  this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
1246
1253
  // depth is not even with perspective: the lookup divides this by the distance squared, which makes
1247
1254
  // the bias the same distance in the world near the light and far from it
@@ -1702,8 +1709,8 @@ function render3DFragmentSource(fragmentCode)
1702
1709
  'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
1703
1710
  'uniform int extraLightCount;' +
1704
1711
  'uniform vec3 cameraPos;' +
1705
- 'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
1706
- 'uniform sampler2D tex,normalTex,emissiveTex;' +
1712
+ 'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom,gelAxes;' +
1713
+ 'uniform sampler2D tex,normalTex,emissiveTex,gelTex;' +
1707
1714
  'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
1708
1715
  'uniform highp sampler2DShadow shadowMap;' +
1709
1716
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
@@ -1720,6 +1727,12 @@ function render3DFragmentSource(fragmentCode)
1720
1727
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
1721
1728
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
1722
1729
  'return s/9.;}' +
1730
+ // the gel the shadow light shines through, at this fragment's place in its view, upright as it looks out;
1731
+ // white with no gel, so a light without one is exactly as before
1732
+ 'vec3 gel(){' +
1733
+ 'if(S.w<=0.)return vec3(1);' +
1734
+ 'vec2 g=S.xy/S.w,u=vec2(dot(gelAxes.xy,g),dot(gelAxes.zw,g))*.5+.5;' +
1735
+ 'return texture(gelTex,vec2(u.x,1.-u.y)).rgb;}' +
1723
1736
  // the normal map's normal here, in the frame that the position and texture coordinate change along across the
1724
1737
  // screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v;
1725
1738
  // a mesh with no texture coordinates has no frame and keeps its own normal
@@ -1765,7 +1778,7 @@ function render3DFragmentSource(fragmentCode)
1765
1778
  'vec4 K=extraLightCones[i];' +
1766
1779
  'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' +
1767
1780
  'lc*=k*k*(3.-2.*k);' +
1768
- 'if(i==si)lc*=sh;' +
1781
+ 'if(i==si)lc*=sh*gel();' +
1769
1782
  'l+=lc*max(0.,ln);' +
1770
1783
  'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
1771
1784
  '}' +
@@ -1813,6 +1826,8 @@ function render3DUseProgram(program)
1813
1826
  gl.uniform1i(render3DUniform('shadowMap'), 1);
1814
1827
  gl.uniform1i(render3DUniform('normalTex'), 2);
1815
1828
  gl.uniform1i(render3DUniform('emissiveTex'), 3);
1829
+ gl.uniform1i(render3DUniform('gelTex'), 4);
1830
+ gl.uniform4fv(render3DUniform('gelAxes'), r.gelAxes);
1816
1831
  const c = r.camera.pos;
1817
1832
  gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
1818
1833
  gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
@@ -2273,8 +2288,16 @@ function render3DRenderPass(after2D)
2273
2288
  // may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
2274
2289
  gl.activeTexture(gl.TEXTURE1);
2275
2290
  gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
2276
- // the material maps' units start white, a 2D plugin may have left its own textures there
2291
+ // the material maps' units start white, a 2D plugin may have left its own textures there, and the shadow
2292
+ // light's gel goes on its unit for the pass, white when it has none
2277
2293
  render3DSetMapUnits(r.whiteTexture);
2294
+ const gel = render3DTextureOf(render3DShadowCaster()?.gel)?.glTexture;
2295
+ if (gel)
2296
+ {
2297
+ gl.activeTexture(gl.TEXTURE4);
2298
+ gl.bindTexture(gl.TEXTURE_2D, gel);
2299
+ gl.activeTexture(gl.TEXTURE0);
2300
+ }
2278
2301
  gl.depthMask(true);
2279
2302
  gl.clear(gl.DEPTH_BUFFER_BIT);
2280
2303
 
@@ -2313,12 +2336,12 @@ function render3DRenderPass(after2D)
2313
2336
  }
2314
2337
  }
2315
2338
 
2316
- // put a texture on both material map units with no sampler, white at the start of the pass and none at its end, and
2317
- // forget what the map cache thought was bound; unit 0 is active after
2339
+ // put a texture on the material map units and the gel's with no sampler, white at the start of the pass and none at
2340
+ // its end, and forget what the map cache thought was bound; unit 0 is active after
2318
2341
  function render3DSetMapUnits(texture)
2319
2342
  {
2320
2343
  const gl = glContext;
2321
- for (const unit of [2, 3])
2344
+ for (const unit of [2, 3, 4])
2322
2345
  {
2323
2346
  gl.activeTexture(gl.TEXTURE0 + unit);
2324
2347
  gl.bindTexture(gl.TEXTURE_2D, texture);
@@ -2682,17 +2705,63 @@ const render3DMeshBuffers = typeof FinalizationRegistry == 'undefined' ? undefin
2682
2705
  glContext.deleteBuffer(indexBuffer);
2683
2706
  });
2684
2707
 
2685
- // the place a point is at, to a hundred thousandth, which smooth normals are summed by
2686
- function render3DPlaceKey(p) { return `${round(p.x * 1e5)},${round(p.y * 1e5)},${round(p.z * 1e5)}`; }
2708
+ // the points at one place, to a hundred thousandth, which smooth normals are summed by: each point's group, numbered
2709
+ // from 0, found by a number made from its place and checked against the place, so no text is made for a point
2710
+ function render3DPlaceGroups(points)
2711
+ {
2712
+ const groups = new Int32Array(points.length), first = new Map, next = [], places = [];
2713
+ for (let i = 0; i < points.length; ++i)
2714
+ {
2715
+ const p = points[i], x = round(p.x * 1e5), y = round(p.y * 1e5), z = round(p.z * 1e5);
2716
+ const hash = Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791);
2717
+ let group = first.get(hash);
2718
+ while (group !== undefined && !(places[group*3] === x && places[group*3+1] === y && places[group*3+2] === z))
2719
+ group = next[group];
2720
+ if (group === undefined)
2721
+ {
2722
+ group = next.length;
2723
+ next.push(first.get(hash));
2724
+ first.set(hash, group);
2725
+ places.push(x, y, z);
2726
+ }
2727
+ groups[i] = group;
2728
+ }
2729
+ return {groups, count: next.length};
2730
+ }
2687
2731
 
2688
- // add a face normal to the sum at a triangle's corner a, weighted by the angle there, so a cube corner averages its
2689
- // three faces evenly however it is cut into triangles
2690
- function render3DAddCornerNormal(sums, a, b, c, normal)
2732
+ // the face normals meeting at each place added up, each weighted by its corner angle so a cube corner averages its
2733
+ // three faces evenly however they are cut into triangles; of a strip, every other triangle turned back, with no
2734
+ // indices, or of the index list; in numbers, with no vector made for a triangle or a corner
2735
+ function render3DSmoothNormalSums(points, indices)
2691
2736
  {
2692
- const u = b.subtract(a), v = c.subtract(a);
2693
- const angle = Math.acos(clamp(u.dot(v) / (u.length() * v.length() || 1), -1, 1));
2694
- const k = render3DPlaceKey(a);
2695
- sums.set(k, (sums.get(k) || vec3()).add(normal.scale(angle)));
2737
+ const strip = !indices, triangles = strip ? max(points.length - 2, 0) : indices.length / 3 | 0;
2738
+ const {groups, count} = render3DPlaceGroups(points);
2739
+ const sums = new Float64Array(count * 3), touched = new Uint8Array(count);
2740
+ let nx = 0, ny = 0, nz = 0;
2741
+ const addCorner = (a, b, c, group)=>
2742
+ {
2743
+ // the angle at corner a, then the face normal scaled by it added to the sum at a's place
2744
+ const ux = b.x - a.x, uy = b.y - a.y, uz = b.z - a.z, vx = c.x - a.x, vy = c.y - a.y, vz = c.z - a.z;
2745
+ const dot = ux*vx + uy*vy + uz*vz, lengths = (ux**2 + uy**2 + uz**2)**.5 * (vx**2 + vy**2 + vz**2)**.5;
2746
+ const angle = Math.acos(clamp(dot / (lengths || 1), -1, 1)), s = group * 3;
2747
+ sums[s] += nx * angle, sums[s+1] += ny * angle, sums[s+2] += nz * angle;
2748
+ touched[group] = 1;
2749
+ };
2750
+ for (let t = 0; t < triangles; ++t)
2751
+ {
2752
+ const i0 = strip ? t : indices[t*3], i1 = strip ? t + 1 : indices[t*3+1], i2 = strip ? t + 2 : indices[t*3+2];
2753
+ const a = points[i0], b = points[i1], c = points[i2];
2754
+ const ux = b.x - a.x, uy = b.y - a.y, uz = b.z - a.z, vx = c.x - a.x, vy = c.y - a.y, vz = c.z - a.z;
2755
+ const cx = uy*vz - uz*vy, cy = uz*vx - ux*vz, cz = ux*vy - uy*vx;
2756
+ const lengthSquared = cx**2 + cy**2 + cz**2;
2757
+ if (!lengthSquared) continue; // no area, like a triangle joining two strips
2758
+ const k = (strip && !(t & 1) ? -1 : 1) / lengthSquared**.5;
2759
+ nx = cx * k, ny = cy * k, nz = cz * k;
2760
+ addCorner(a, b, c, groups[i0]);
2761
+ addCorner(b, c, a, groups[i1]);
2762
+ addCorner(c, a, b, groups[i2]);
2763
+ }
2764
+ return {groups, sums, touched};
2696
2765
  }
2697
2766
 
2698
2767
  /**
@@ -3077,42 +3146,52 @@ class Mesh
3077
3146
  // the triangles are listed: smooth normals add up around each position, weighted by the corner angle
3078
3147
  // like the strip's, so vertices split apart at one place smooth back together and a mesh can go flat and
3079
3148
  // smooth again; flat ones need a vertex per corner, so the vertices are split up first
3080
- if (!smooth)
3149
+ if (smooth)
3081
3150
  {
3082
- const split = (a)=> this.indices.map(i=> a[i]);
3083
- this.points = split(this.points), this.normals = split(this.normals);
3084
- this.uvs = split(this.uvs), this.colors = split(this.colors);
3085
- this.indices = this.indices.map((_, i)=> i);
3151
+ const {groups, sums, touched} = render3DSmoothNormalSums(this.points, this.indices);
3152
+ this.normals = this.points.map((p, i)=>
3153
+ {
3154
+ const s = groups[i] * 3, x = sums[s], y = sums[s+1], z = sums[s+2];
3155
+ const lengthSquared = x**2 + y**2 + z**2, k = 1 / lengthSquared**.5;
3156
+ return touched[groups[i]] && lengthSquared ? vec3(x * k, y * k, z * k) : RENDER3D_DEFAULT_NORMAL;
3157
+ });
3158
+ this.dirty = true;
3159
+ return this;
3086
3160
  }
3087
- const points = this.points, indices = this.indices, sums = new Map;
3161
+ const split = (a)=> this.indices.map(i=> a[i]);
3162
+ this.points = split(this.points), this.normals = split(this.normals);
3163
+ this.uvs = split(this.uvs), this.colors = split(this.colors);
3164
+ this.indices = this.indices.map((_, i)=> i);
3165
+ const points = this.points, indices = this.indices;
3088
3166
  const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
3089
3167
  for (let t = 0; t < indices.length; t += 3)
3090
3168
  {
3091
3169
  const a = points[indices[t]], b = points[indices[t+1]], c = points[indices[t+2]];
3092
3170
  const cross = b.subtract(a).cross(c.subtract(a));
3093
- if (!cross.lengthSquared()) continue;
3094
- const normal = cross.normalize();
3095
- if (!smooth)
3096
- {
3097
- // its own three corners
3098
- normals[indices[t]] = normals[indices[t+1]] = normals[indices[t+2]] = normal;
3099
- continue;
3100
- }
3101
- for (let j = 0; j < 3; ++j)
3102
- render3DAddCornerNormal(sums, points[indices[t+j]], points[indices[t+(j+1)%3]],
3103
- points[indices[t+(j+2)%3]], normal);
3171
+ if (cross.lengthSquared()) // its own three corners
3172
+ normals[indices[t]] = normals[indices[t+1]] = normals[indices[t+2]] = cross.normalize();
3104
3173
  }
3105
- this.normals = !smooth ? normals : points.map(p=>
3174
+ this.normals = normals;
3175
+ this.dirty = true;
3176
+ return this;
3177
+ }
3178
+
3179
+ // smooth normals are the sums at each place made unit length, or straight up for a place with none
3180
+ const points = this.points, n = points.length;
3181
+ if (smooth)
3182
+ {
3183
+ const {groups, sums, touched} = render3DSmoothNormalSums(points);
3184
+ this.normals = points.map((p, i)=>
3106
3185
  {
3107
- const s = sums.get(render3DPlaceKey(p));
3108
- return s && s.lengthSquared() ? s.normalize() : RENDER3D_DEFAULT_NORMAL;
3186
+ const s = groups[i] * 3, x = sums[s], y = sums[s+1], z = sums[s+2], l = (x**2 + y**2 + z**2)**.5;
3187
+ return !touched[groups[i]] ? vec3(0, 1, 0) : l ? vec3(x * (1/l), y * (1/l), z * (1/l)) : vec3();
3109
3188
  });
3189
+ this.vertexKeys = undefined;
3110
3190
  this.dirty = true;
3111
3191
  return this;
3112
3192
  }
3113
3193
 
3114
3194
  // the outward normal of each triangle in the strip
3115
- const points = this.points, n = points.length;
3116
3195
  const faceNormals = [];
3117
3196
  for (let i = 0; i + 2 < n; ++i)
3118
3197
  {
@@ -3123,22 +3202,10 @@ class Mesh
3123
3202
  faceNormals.push(normal.lengthSquared() ? normal.normalize(i & 1 ? 1 : -1) : undefined);
3124
3203
  }
3125
3204
 
3126
- // then hand those to the vertices, shared around a position or kept per face
3205
+ // then hand those to the vertices: every triangle writes its own three corners, so the only vertices left
3206
+ // with the default are the repeats at the ends of a strip, which no triangle with any area uses
3127
3207
  const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
3128
- if (smooth)
3129
- {
3130
- // add up the face normals meeting at each position, each weighted by its corner angle so a cube
3131
- // corner averages its three faces evenly however the strips cut them, then normalize
3132
- const sums = new Map;
3133
- faceNormals.forEach((f, i)=> f && [0, 1, 2].forEach(j=>
3134
- render3DAddCornerNormal(sums, points[i+j], points[i+(j+1)%3], points[i+(j+2)%3], f)));
3135
- for (let i = 0; i < n; ++i)
3136
- normals[i] = (sums.get(render3DPlaceKey(points[i])) || RENDER3D_DEFAULT_NORMAL).normalize();
3137
- }
3138
- else
3139
- // every triangle writes its own three corners, so the only vertices left with the default
3140
- // are the repeats at the ends of a strip, which no triangle with any area uses
3141
- faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));
3208
+ faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));
3142
3209
 
3143
3210
  this.normals = normals;
3144
3211
  this.vertexKeys = undefined; // flat normals tell entries at one place apart
@@ -4285,6 +4352,7 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
4285
4352
 
4286
4353
  // 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
4354
  // and a sprite as the quad it draws
4355
+ const render3DRaycastCenter = vec3();
4288
4356
  function render3DRaycastObject(ray, o)
4289
4357
  {
4290
4358
  if (o.destroyed || !(o instanceof EngineObject3D)) return;
@@ -4297,7 +4365,8 @@ function render3DRaycastObject(ray, o)
4297
4365
  // a mesh that changed since it was measured is measured again, an upload may not have come yet
4298
4366
  const radius = (mesh.dirty || !mesh.radius ? mesh.computeRadius() : mesh.radius) * render3DMaxStretch(matrix.m);
4299
4367
  if (!(radius > 0)) return; // nothing to hit
4300
- const center = matrix.getTranslation();
4368
+ // its place read from the matrix into a vector kept for this, a pick tests every object and most miss
4369
+ const m = matrix.m, center = render3DRaycastCenter.set(m[12], m[13], m[14]);
4301
4370
  const distance = raycastSphere(ray, center, radius);
4302
4371
  if (distance === undefined) return;
4303
4372
 
@@ -4766,6 +4835,13 @@ class Light3D extends EngineObject3D
4766
4835
  /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
4767
4836
  * bright core */
4768
4837
  this.glowFalloff = 1;
4838
+ /** @property {TextureInfo|TileInfo|undefined} - A gel: a picture the light shines through, like a stained glass
4839
+ * window or the leaves of a tree, cast along its cone in its colors, upright as the light looks out, the whole
4840
+ * texture of a TileInfo; only the spotlight that casts the shadows has one, with render3D.shadows on and it
4841
+ * as render3D.shadowLight, and only on what takes its shadows: an object with receiveShadow off is lit
4842
+ * without the gel; its alpha is not read, see through panes are dark
4843
+ * @type {TextureInfo|TileInfo|undefined} */
4844
+ this.gel = undefined;
4769
4845
  this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
4770
4846
  /** @type {LensFlare3D|undefined} */
4771
4847
  this.flareObject = undefined;
@@ -664,17 +664,27 @@ function editor3DSetPart(name, part)
664
664
  function editor3DPrefabsShow()
665
665
  {
666
666
  if (editor3DPrefabLevels.has(editor3DLevel)) return; // inside a prefab, the prefabs are the level's
667
- const prefabs = editor3DLevelPart('prefabs') ?? {};
667
+ const prefabs = editor3DLevelPart('prefabs') ?? {}, removed = new Set;
668
668
  for (const [name, prefab] of level3DPrefabs)
669
669
  {
670
670
  if (!prefab.fromLevel || prefabs[name]) continue;
671
671
  level3DPrefabs.delete(name);
672
672
  level3DTypes.delete(name);
673
+ removed.add(name);
673
674
  }
674
675
  level3DPrefabsAdd(prefabs);
675
- // every object of a prefab's type, one whose type was not there when the objects were set too
676
+ // every object of a prefab's type, one whose type was not there when the objects were set too; an instance of a
677
+ // prefab taken out leaves the game, its object waiting in the level for the prefab to come back
676
678
  for (const object of editor3DObjects())
677
- level3DPrefabs.has(object.type) && editor3DMakeInstance(object);
679
+ {
680
+ if (level3DPrefabs.has(object.type))
681
+ editor3DMakeInstance(object);
682
+ else if (removed.has(object.type))
683
+ {
684
+ editor3DInstances.get(object.id)?.destroy?.();
685
+ editor3DInstances.delete(object.id);
686
+ }
687
+ }
678
688
  editor3DShadowLight();
679
689
  }
680
690
 
@@ -915,7 +925,9 @@ function editor3DTerrainStore()
915
925
  {
916
926
  const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
917
927
  if (!map || !terrain) return;
918
- terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3));
928
+ // the heights changed to a thousandth, the rest as the level has them, so a stroke of paint rounds none
929
+ const before = terrain.heights;
930
+ terrain.heights = map.heights.map((row, z)=> row.map((v, x)=> v === before?.[z]?.[x] ? v : round(v * 1e3) / 1e3));
919
931
  const paint = editor3DTerrainPaints.get(map);
920
932
  if (!paint) return; // nothing painted since the level's was read
921
933
  const used = paint.colors.map((hex, i)=> paint.cells.includes(i + 1));
@@ -1791,10 +1803,12 @@ function editor3DJSON(value)
1791
1803
  // a level as the file has it: its version, what else it holds, and its objects, one to a line
1792
1804
  function editor3DLevelJSON(level=editor3DLevel)
1793
1805
  {
1806
+ // the level's parts after the rest, in one order, so the same level is the same file however its parts were made
1794
1807
  const {littlejs3D, objects, ...rest} = level;
1795
1808
  const lines = [`"littlejs3D": ${LEVEL3D_VERSION}`];
1796
- for (const [key, value] of Object.entries(rest))
1797
- lines.push(JSON.stringify(key) + ': ' + editor3DJSON(value));
1809
+ const keys = Object.keys(rest).filter((key)=> !editor3DLevelPartNames.includes(key));
1810
+ for (const key of [...keys, ...editor3DLevelPartNames.filter((name)=> name in rest)])
1811
+ lines.push(JSON.stringify(key) + ': ' + editor3DJSON(rest[key]));
1798
1812
  const list = (isArray(objects) ? objects : []).map((o)=> ' ' + editor3DJSON(o));
1799
1813
  lines.push('"objects": [' + (list.length ? '\n' + list.join(',\n') + '\n ]' : ']'));
1800
1814
  return '{\n ' + lines.join(',\n ') + '\n}\n';
@@ -2612,7 +2626,7 @@ function editor3DSetSelectedProperty(name, value)
2612
2626
  {
2613
2627
  const selected = editor3DSelected(), id = selected[0]?.id;
2614
2628
  const defaults = selected[0] && level3DTypes.get(selected[0].type)?.defaults;
2615
- const d = defaults && Object.hasOwn(defaults, name) ? defaults[name] : undefined;
2629
+ const d = defaults && Object.prototype.hasOwnProperty.call(defaults, name) ? defaults[name] : undefined;
2616
2630
  const sameType = isColor(d) ? isColor(value) : isVector3(d) ? isVector3(value) :
2617
2631
  isVector2(d) ? isVector2(value) : typeof value === typeof d;
2618
2632
  if (selected.length !== 1 || !editor3DPropertyEditable(d) || !sameType) return false;
@@ -3335,7 +3349,7 @@ function editor3DSceneUpdate(box)
3335
3349
  input.onchange = ()=>
3336
3350
  {
3337
3351
  const n = parseFloat(input.value);
3338
- isNumber(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again
3352
+ isFinite(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again
3339
3353
  editor3DStrokeEnd();
3340
3354
  input.blur();
3341
3355
  };
@@ -3424,7 +3438,7 @@ function editor3DPropertiesUpdate(box)
3424
3438
  input.onchange = ()=>
3425
3439
  {
3426
3440
  const v = inputs.map((i)=> parseFloat(i.value));
3427
- if (v.every((n)=> isNumber(n)))
3441
+ if (v.every((n)=> isFinite(n)))
3428
3442
  set(axes.length > 2 ? vec3(v[0], v[1], v[2]) : vec2(v[0], v[1]));
3429
3443
  else
3430
3444
  inputs.forEach((i, k)=> i.value = editor3DRound(value[axes[k]]) + '');
@@ -3466,7 +3480,7 @@ function editor3DPropertiesUpdate(box)
3466
3480
  input.type = 'number';
3467
3481
  input.step = 'any';
3468
3482
  input.value = value + '';
3469
- input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
3483
+ input.onchange = ()=> { const v = parseFloat(input.value); isFinite(v) && set(v); };
3470
3484
  }
3471
3485
  else if (typeof defaultValue === 'string')
3472
3486
  {
@@ -28,7 +28,7 @@ function render3DDetach(o)
28
28
  let render3DSoftDotTexture;
29
29
  function render3DSoftDot()
30
30
  {
31
- if (render3DSoftDotTexture || !glContext || typeof OffscreenCanvas == 'undefined') return render3DSoftDotTexture;
31
+ if (render3DSoftDotTexture || !glContext || !canvasAvailable()) return render3DSoftDotTexture;
32
32
  const size = 32, context = createCanvasContext(size);
33
33
  const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
34
34
  for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
@@ -50,7 +50,7 @@ function normalMapFromHeight(size, heightFunction, strength=1)
50
50
  {
51
51
  ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
52
52
  const width = size.x | 0, height = size.y | 0;
53
- if (typeof OffscreenCanvas == 'undefined')
53
+ if (!canvasAvailable())
54
54
  {
55
55
  // headless, nothing to draw into; the right size so what reads it still works
56
56
  const textureInfo = new TextureInfo(undefined, false, true);
@@ -1484,7 +1484,7 @@ function render3DFlareTile(shape)
1484
1484
  {
1485
1485
  if (!render3DFlareTiles)
1486
1486
  {
1487
- if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
1487
+ if (headlessMode || !glContext || !canvasAvailable()) return;
1488
1488
  const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
1489
1489
  // each shape is how see-through it is from its middle out
1490
1490
  const alpha =
@@ -1575,7 +1575,8 @@ function render3DFlareTile(shape)
1575
1575
  * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
1576
1576
  * give it elements of your own, which may be tiles of the game's
1577
1577
  * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
1578
- * sun is too far off the screen for the flare to show, a third of the screen past its edge, where nothing is tested
1578
+ * sun is too far off the screen for the flare to show, about a seventh of the screen past its edge, where nothing
1579
+ * is tested
1579
1580
  * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
1580
1581
  * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
1581
1582
  * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
@@ -1622,7 +1623,7 @@ class LensFlare3D extends EngineObject3D
1622
1623
  this.elements = undefined;
1623
1624
  /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
1624
1625
  * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
1625
- * in front of the light but its lamp, a mesh around it no wider than half its radius, and a spotlight's
1626
+ * in front of the light but its lamp, a mesh around it whose surface is near it, and a spotlight's
1626
1627
  * shows from inside its beam only; a DirectionalLight3D's is far
1627
1628
  * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
1628
1629
  * sets this
@@ -1696,7 +1697,7 @@ class LensFlare3D extends EngineObject3D
1696
1697
  const source = this.flareSource(), center = mainCanvasSize.scale(.5);
1697
1698
  const sun = source && render3D.worldToScreen(source.pos);
1698
1699
  if (!sun || !center.x || !center.y) return;
1699
- // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
1700
+ // it fades as the sun leaves the screen, gone .3 of half the screen past the edge
1700
1701
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
1701
1702
  const strength = clamp((1.3 - off) / .5) * this.intensity;
1702
1703
  if (!(strength > 0)) return;
@@ -1762,21 +1763,30 @@ class LensFlare3D extends EngineObject3D
1762
1763
  for (const map of maps)
1763
1764
  if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
1764
1765
  return true;
1765
- // a light's lamp, the mesh the light is inside, does not hide it: one whose box, in its own space, the light
1766
- // is in, and no wider than half the light's reach, since a room or a whole level has a box around its lights
1767
- // too, and its walls do hide them from outside
1768
- const lamp = (o)=>
1766
+ // a light's lamp, a mesh around the light, does not hide it: one whose box, in its own space, the light is in,
1767
+ // hit close to the light, as a shade or a globe is, and small, its thinnest side no more than twice that; a
1768
+ // room or a whole level holds its lights too, and its walls do hide them from outside, even one close behind
1769
+ const inside = (o)=>
1769
1770
  {
1770
- if (!this.light || reach === Infinity || !o.mesh || o instanceof HeightMap) return false;
1771
+ if (!o.mesh || o instanceof HeightMap) return false;
1771
1772
  const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds();
1772
- const side = max(b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z) * render3DMaxStretch(matrix.m);
1773
- if (!matrix.determinant() || side > this.light.radius / 2) return false;
1773
+ const m = matrix.m, scale = (k)=> hypot(m[k], m[k + 1], m[k + 2]); // each axis's own stretch
1774
+ const thinnest = min((b.max.x - b.min.x) * scale(0), (b.max.y - b.min.y) * scale(4), (b.max.z - b.min.z) * scale(8));
1775
+ if (!matrix.determinant() || thinnest > near * 2) return false;
1774
1776
  const p = matrix.copy().invert().transformPoint(source.pos);
1775
1777
  return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
1776
1778
  p.z >= b.min.z && p.z <= b.max.z;
1777
1779
  };
1778
- const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !lamp(o)));
1779
- return !!hit && hit.distance < reach;
1780
+ const near = this.light && reach < Infinity ? min(2, this.light.radius * .4) : 0;
1781
+ let candidates = blockers.filter((o)=> !maps.some((map)=> map === o));
1782
+ for (let tries = 5; tries--;)
1783
+ {
1784
+ const hit = render3D.pick(ray, candidates);
1785
+ if (!hit || hit.distance >= reach) return false;
1786
+ if (!(reach - hit.distance <= near && inside(hit.object))) return true;
1787
+ candidates = candidates.filter((o)=> o !== hit.object); // its lamp, look past it
1788
+ }
1789
+ return true; // lamps on lamps, take it as hidden
1780
1790
  }
1781
1791
 
1782
1792
  /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
@@ -1837,9 +1847,10 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
1837
1847
  // OBJ indices count from 1, and a negative one counts back from the end of the list so far
1838
1848
  const index = (s, list)=> { const i = parseInt(s); return i < 0 ? list.length + i : i - 1; };
1839
1849
  const lookup = (s, list)=> list[index(s, list)];
1840
- for (const line of text.split('\n'))
1850
+ // any line end, a line carried on by a backslash at its end, and a comment after what a line holds
1851
+ for (const line of text.replace(/\\\r?\n/g, ' ').split(/\r\n?|\n/))
1841
1852
  {
1842
- const parts = line.trim().split(/\s+/);
1853
+ const parts = line.replace(/#.*/, '').trim().split(/\s+/);
1843
1854
  switch (parts[0])
1844
1855
  {
1845
1856
  case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
@@ -1851,6 +1862,7 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
1851
1862
  if (corners.length < 3) break;
1852
1863
  const facePoints = corners.map(c=> lookup(c[0], positions));
1853
1864
  ASSERT(facePoints.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
1865
+ if (!facePoints.every(isVector3)) break; // a release build leaves the face out
1854
1866
  const hasNormals = corners.every(c=> c[2]);
1855
1867
  missingNormals ||= !hasNormals;
1856
1868
  const faceNormal = hasNormals ? undefined : render3DFaceNormal(facePoints[0], facePoints[1], facePoints[2], facePoints[3]);