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.
- package/CHANGELOG.md +59 -1
- package/EDITOR.md +2 -0
- package/FAQ.md +13 -2
- package/README.md +1 -1
- package/REFERENCE.md +20 -6
- package/dist/littlejs.d.ts +77 -13
- package/dist/littlejs.esm.js +2395 -1939
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +2392 -1939
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +2356 -1952
- package/package.json +1 -1
- package/plugins/gltf.js +62 -9
- package/plugins/lightSystem.js +1 -1
- package/plugins/math3d.js +1014 -1013
- package/plugins/medalSystem.js +7 -11
- package/plugins/particleEffects.js +2 -2
- package/plugins/pathFinder.js +2 -2
- package/plugins/pluginExport.js +1 -0
- package/plugins/render3d.js +131 -55
- package/plugins/render3dEditor.js +24 -10
- package/plugins/render3dExtras.js +29 -17
- package/plugins/render3dLevel.js +21 -12
- package/plugins/render3dVoxels.js +2 -2
- package/plugins/textureSheet.js +97 -3
- package/plugins/tweakables.js +1 -1
- package/plugins/tweenSystem.js +2 -2
- package/plugins/uiSystem.js +6 -2
- package/src/engine.js +34 -11
- package/src/engineAudio.js +13 -3
- package/src/engineDebug.js +4 -1
- package/src/engineDraw.js +29 -16
- package/src/engineEditor.js +82 -50
- package/src/engineExport.js +2 -0
- package/src/engineInput.js +17 -3
- package/src/engineMath.js +6 -2
- package/src/engineObject.js +675 -675
- package/src/engineParticles.js +1 -1
- package/src/engineSettings.js +16 -0
- package/src/engineTileLayer.js +85 -22
- package/src/engineUtilities.js +20 -8
- package/src/engineWebGL.js +12 -8
package/plugins/medalSystem.js
CHANGED
|
@@ -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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
219
|
+
// add this to list of medals, unlocked if the save says so when medalsInit came first
|
|
224
220
|
medals[id] = this;
|
|
225
|
-
if (
|
|
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 ||
|
|
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);
|
package/plugins/pathFinder.js
CHANGED
|
@@ -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
|
|
87
|
-
*
|
|
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
|
{
|
package/plugins/pluginExport.js
CHANGED
package/plugins/render3d.js
CHANGED
|
@@ -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
|
-
|
|
1244
|
-
|
|
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
|
|
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
|
|
2686
|
-
|
|
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
|
-
//
|
|
2689
|
-
// three faces evenly however
|
|
2690
|
-
|
|
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
|
|
2693
|
-
const
|
|
2694
|
-
const
|
|
2695
|
-
|
|
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 (
|
|
3149
|
+
if (smooth)
|
|
3081
3150
|
{
|
|
3082
|
-
const
|
|
3083
|
-
this.
|
|
3084
|
-
|
|
3085
|
-
|
|
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
|
|
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 (
|
|
3094
|
-
|
|
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 =
|
|
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
|
|
3108
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
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.
|
|
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
|
-
|
|
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)=>
|
|
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);
|
|
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 ||
|
|
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 (
|
|
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 ||
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
1766
|
-
//
|
|
1767
|
-
// too, and its walls do hide them from outside
|
|
1768
|
-
const
|
|
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 (!
|
|
1771
|
+
if (!o.mesh || o instanceof HeightMap) return false;
|
|
1771
1772
|
const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds();
|
|
1772
|
-
const
|
|
1773
|
-
|
|
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
|
|
1779
|
-
|
|
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
|
-
|
|
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]);
|