littlejsengine 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -228,20 +228,34 @@ class LightSystemPlugin
228
228
  'uniform vec4 color;'+
229
229
  'uniform sampler2D shadowTexture;'+ // this light's shadow, white where its rays reach
230
230
  'uniform bool useShadow;'+
231
+ 'uniform vec2 forward;'+ // the light's up, the way its cone and gel look
232
+ 'uniform vec2 cone;'+ // the cone's factor and its edge times it, (0, -1) for none
233
+ 'uniform sampler2D gelTexture;'+
234
+ 'uniform vec4 gelRect;'+ // the gel's tile in its texture, corner and size
235
+ 'uniform bool useGel;'+
231
236
  'in vec2 vWorldPos;'+
232
237
  'in vec2 vUV;'+
233
238
  'out vec4 c;'+
234
239
  'void main(){'+
235
- 'float dist=distance(vWorldPos,lightPos);'+
240
+ 'vec2 d=vWorldPos-lightPos;'+
241
+ 'float dist=length(d);'+
236
242
  'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
237
243
  'c=vec4(color.rgb*t*color.a,1.);'+
238
244
  'if(useShadow)c.rgb*=texture(shadowTexture,vUV).rgb;'+
245
+ // the cone: full where its fade starts, nothing at its edge, smooth between, as a 3D spotlight's
246
+ 'float k=clamp(dot(forward,d/max(dist,1e-6))*cone.x-cone.y,0.,1.);'+
247
+ 'c.rgb*=k*k*(3.-2.*k);'+
248
+ // the gel across the light's square, turned with it, its top the way the light looks
249
+ 'if(useGel){'+
250
+ 'vec2 g=clamp(vec2(dot(d,vec2(forward.y,-forward.x)),dot(d,forward))/(radius*2.)+.5,0.,1.);'+
251
+ 'c.rgb*=texture(gelTexture,gelRect.xy+vec2(g.x,1.-g.y)*gelRect.zw).rgb;}'+
239
252
  '}'
240
253
  );
241
254
  if (lightSystemShadersFailed(lightSystem.lightShader)) return;
242
- // the shadow texture is on unit 1, the engine's tracked texture stays on unit 0
255
+ // the shadow texture is on unit 1 and the gel on 2, the engine's tracked texture stays on unit 0
243
256
  glContext.useProgram(lightSystem.lightShader);
244
257
  glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'shadowTexture'), 1);
258
+ glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'gelTexture'), 2);
245
259
 
246
260
  // composite shader: fullscreen quad, samples the lightmap
247
261
  lightSystem.compositeShader = glCreateProgram(
@@ -814,6 +828,19 @@ class LightSystemPlugin
814
828
  [drawContext, glCustomShader] = saved;
815
829
  }
816
830
  }
831
+ catch (error)
832
+ {
833
+ // a renderLight or renderEmissive threw: what it queued goes to the lightmap, then the screen is the
834
+ // target again with its viewport and blend, so a game that catches the error draws to the screen; a
835
+ // flush that throws too is let go of, the first error is the one that counts
836
+ try { glFlush(); } catch (e) {}
837
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
838
+ glContext.viewport(0, 0, glCanvas.width, glCanvas.height);
839
+ glActiveTexture && glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
840
+ glSetInstancedMode(true);
841
+ setAdditiveBlendMode(prevAdditive);
842
+ throw error;
843
+ }
817
844
  finally
818
845
  {
819
846
  glRenderTargetBase = undefined;
@@ -927,6 +954,18 @@ class LightSystemPlugin
927
954
  const c = light.color;
928
955
  glContext.uniform4f(glUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
929
956
 
957
+ // the cone and the gel look along the light's up
958
+ const forward = light.getUp();
959
+ glContext.uniform2f(glUniformLocation(ls, 'forward'), forward.x, forward.y);
960
+ const [coneFactor, coneEdge] = lightSystemCone(light);
961
+ glContext.uniform2f(glUniformLocation(ls, 'cone'), coneFactor, coneEdge);
962
+ const gel = lightSystemGel(light);
963
+ glContext.uniform1i(glUniformLocation(ls, 'useGel'), gel ? 1 : 0);
964
+ glContext.activeTexture(glContext.TEXTURE2);
965
+ glContext.bindTexture(glContext.TEXTURE_2D, gel ? gel.texture : null);
966
+ glContext.activeTexture(glContext.TEXTURE0);
967
+ gel && glContext.uniform4fv(glUniformLocation(ls, 'gelRect'), gel.rect);
968
+
930
969
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
931
970
 
932
971
  // restore engine's instanced shader+VAO so subsequent renderLight()
@@ -1004,6 +1043,31 @@ class LightSystemPlugin
1004
1043
 
1005
1044
  ///////////////////////////////////////////////////////////////////////////////
1006
1045
 
1046
+ // a light's cone for its shader, the factor and its edge times it: the dot of its up and a direction times the factor,
1047
+ // less the edge, is 1 where the fade starts and 0 at the edge; (0, -1) for no cone, which leaves the light as it was
1048
+ function lightSystemCone(light)
1049
+ {
1050
+ const angle = min(light.coneAngle, PI);
1051
+ if (!(angle > 0)) return [0, -1];
1052
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
1053
+ const k = 1 / max(inner - outer, 1e-4);
1054
+ return [k, outer * k];
1055
+ }
1056
+
1057
+ // a light's gel for its shader, its texture and its tile's corner and size in it, a half texel in so the tiles beside
1058
+ // it do not bleed in; undefined for none or one not loaded yet
1059
+ function lightSystemGel(light)
1060
+ {
1061
+ const gel = light.gel;
1062
+ if (!gel) return;
1063
+ const isTile = gel instanceof TileInfo, textureInfo = isTile ? gel.textureInfo : gel;
1064
+ const texture = textureInfo?.glTexture, size = textureInfo?.size;
1065
+ if (!texture || !(size?.x > 0)) return;
1066
+ const pos = isTile ? gel.pos : vec2(), tileSize = isTile ? gel.size : size;
1067
+ return {texture, rect: [(pos.x + .5) / size.x, (pos.y + .5) / size.y,
1068
+ (tileSize.x - 1) / size.x, (tileSize.y - 1) / size.y]};
1069
+ }
1070
+
1007
1071
  /**
1008
1072
  * A Light is an EngineObject that contributes a soft additive blob of color
1009
1073
  * to the LightSystem plugin's lightmap.
@@ -1012,11 +1076,16 @@ class LightSystemPlugin
1012
1076
  * - A light inside a caster is blocked entirely, so the object that holds it, its lamp, a torch, the player
1013
1077
  * carrying it, needs a shadowCore that reaches past it, castShadow = false, or a renderShadow that leaves
1014
1078
  * the light's spot out
1079
+ * - A coneAngle makes it a cone along its up, turned by its angle, and a gel is a picture it shines through; both
1080
+ * are applied over the round light with its shadows, so they cost next to nothing
1015
1081
  * @extends EngineObject
1016
1082
  * @memberof LightSystem
1017
1083
  * @example
1018
1084
  * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
1019
1085
  * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
1086
+ * const flashlight = new Light(vec2(), 10); // a flashlight looking right, its beam 60 degrees across
1087
+ * flashlight.coneAngle = PI/6;
1088
+ * flashlight.angle = PI/2;
1020
1089
  */
1021
1090
  class Light extends EngineObject
1022
1091
  {
@@ -1049,6 +1118,19 @@ class Light extends EngineObject
1049
1118
  this.glowFalloff = 1;
1050
1119
  /** @type {TileInfo|undefined} */
1051
1120
  this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
1121
+ /** @property {number} - Makes it a cone, like a flashlight or headlight: the angle in radians from its up out
1122
+ * to the edge of the cone, so the beam is twice this across; it looks along getUp(), turned by its angle and
1123
+ * by what it is attached to; 0 for a light that shines every way */
1124
+ this.coneAngle = 0;
1125
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
1126
+ * fifth, 1 fading all the way from the middle of the beam */
1127
+ this.coneSoftness = .2;
1128
+ /** @property {TileInfo|TextureInfo|undefined} - A gel, a picture the light shines through, called a cookie in
1129
+ * some engines: stretched across the light's square, twice its radius, its top the way the light looks,
1130
+ * turned with it, and multiplied into its color, so one light can be a car's headlights and tail lights;
1131
+ * a TileInfo's own tile, or a whole TextureInfo; shadows still fall through it
1132
+ * @type {TileInfo|TextureInfo|undefined} */
1133
+ this.gel = undefined;
1052
1134
  }
1053
1135
 
1054
1136
  /** Lights are invisible in the main render pass — they only contribute
@@ -163,6 +163,8 @@ class NewgroundsPlugin
163
163
  /** @property {Array<Object>} - Scoreboards fetched from Newgrounds, empty until ready
164
164
  * @type {Array<Object>} */
165
165
  this.scoreboards = [];
166
+ /** @type {Map<number, 'failed'|'notSave'>} */
167
+ this.loadFailures = new Map; // why each slot's last load gave undefined, see loadFailure
166
168
  /** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in
167
169
  * @type {{id: number, name: string, url: string, supporter: boolean}|null} */
168
170
  this.user = null;
@@ -332,6 +334,96 @@ class NewgroundsPlugin
332
334
  return this.call('ScoreBoard.getScores', parameters, session_id);
333
335
  }
334
336
 
337
+ /** Save a value to one of the player's cloud save slots, which needs a logged in player
338
+ * - Any value JSON can hold; the slots are numbered from 1, as many as the app's Newgrounds settings give it
339
+ * @param {number} slot - The slot number
340
+ * @param {*} data - The value to save
341
+ * @return {Promise<boolean>} - Whether it saved, false when not logged in
342
+ * @example
343
+ * newgrounds.cloudSave(1, {level, coins}); */
344
+ async cloudSave(slot, data)
345
+ {
346
+ ASSERT(isNumber(slot), 'Newgrounds cloudSave: slot must be a number', slot);
347
+ if (!this.session_id) return false;
348
+ let text;
349
+ try { text = JSON.stringify(data); } catch (error) {}
350
+ if (text === undefined)
351
+ return console.warn('Newgrounds cloudSave: slot ' + slot + ' was given a value JSON can not hold'), false;
352
+ const response = await this.call('CloudSave.setData', {'id':slot, 'data':text});
353
+ newgroundsSessionLost(response) && this.dropSession();
354
+ return !!response?.result?.data?.['success'];
355
+ }
356
+
357
+ /** Load the value a cloud save slot holds: null when Newgrounds says it holds none, undefined when it could not be
358
+ * loaded or the player is not logged in, said in the console when logged in
359
+ * - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
360
+ * it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
361
+ * - A saved null loads as null, as an empty slot does
362
+ * @param {number} slot - The slot number
363
+ * @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
364
+ * @example
365
+ * const save = await newgrounds.cloudLoad(1); */
366
+ async cloudLoad(slot)
367
+ {
368
+ ASSERT(isNumber(slot), 'Newgrounds cloudLoad: slot must be a number', slot);
369
+ const {value, failure} = await this.loadSlot(slot);
370
+ failure ? this.loadFailures.set(+slot, failure) : this.loadFailures.delete(+slot);
371
+ return value;
372
+ }
373
+
374
+ /** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
375
+ * game may offer to start over, 'failed' when it could not be loaded, as when not logged in, where a game must
376
+ * not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
377
+ * @param {number} slot - The slot number
378
+ * @return {'failed'|'notSave'|undefined} */
379
+ loadFailure(slot) { return this.loadFailures.get(+slot); }
380
+
381
+ /** A slot's load, its value and why it failed, if it did
382
+ * @param {number} slot
383
+ * @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
384
+ * @ignore */
385
+ async loadSlot(slot)
386
+ {
387
+ const failed = (message)=>
388
+ {
389
+ message && console.warn('Newgrounds could not load slot ' + slot + message);
390
+ return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
391
+ };
392
+ if (!this.session_id) return failed('');
393
+ const response = await this.call('CloudSave.loadSlot', {'id':slot});
394
+ newgroundsSessionLost(response) && this.dropSession();
395
+ const slotData = response?.result?.data?.['slot'];
396
+ if (!slotData) return failed('.');
397
+ const url = slotData['url']; // where the saved text is, none for an empty slot
398
+ if (!url) return {value: null, failure: undefined};
399
+ let text;
400
+ try
401
+ {
402
+ const signal = globalThis.AbortSignal?.timeout?.(newgroundsTimeoutMS);
403
+ const saved = await fetch(url, {'cache':'no-store', 'signal':signal});
404
+ if (!saved.ok) // an error page is not the save, even one that is JSON
405
+ return failed(': ' + saved.status);
406
+ text = await saved.text();
407
+ }
408
+ catch(e) { return failed(': ' + e); }
409
+ if (!text) return failed(', it came back empty'); // a save is never empty
410
+ try { return {value: JSON.parse(text), failure: undefined}; }
411
+ catch(e)
412
+ {
413
+ console.warn('Newgrounds cloudLoad: slot ' + slot + ' holds a file that is not a save');
414
+ return {value: undefined, failure: 'notSave'};
415
+ }
416
+ }
417
+
418
+ /** Count an event of the game's own on its Newgrounds stats page, like a level finished or a button pressed
419
+ * @param {string} name - The event's name
420
+ * @return {Promise<Object>} - The response JSON object, undefined when the call failed */
421
+ logEvent(name)
422
+ {
423
+ ASSERT(typeof name === 'string' && name !== '', 'Newgrounds logEvent: name must be a string', name);
424
+ return this.call('Event.logEvent', {'event_name':name, 'host':this.host});
425
+ }
426
+
335
427
  /** Encrypt text the way the Newgrounds gateway expects, AES-128 CBC with a random iv in front, as Base64
336
428
  * @param {string} text
337
429
  * @return {Promise<string>} */
@@ -39,7 +39,9 @@ class ParallaxLayer extends CanvasLayer
39
39
  * and 1 with the screen, a Vector2 to follow x and y by different amounts
40
40
  * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
41
41
  * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
42
- * the image, given the canvas context, its size in pixels and the layer; mountains when not given
42
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given; called
43
+ * once when the layer is made and on redraw(), not each frame, so it draws with the canvas 2D context, not
44
+ * the engine's draws like drawTile
43
45
  * @param {Vector2} [canvasSize] - Size of the image in pixels */
44
46
  constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
45
47
  canvasSize=vec2(512, 256))
@@ -49,6 +49,10 @@ class PostProcessPlugin
49
49
  /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */
50
50
  this.shaderCode = shaderCode || postProcessEffects(); // no code passes the frame through
51
51
 
52
+ /** @property {boolean} - Is the pass on? Off, the frame shows as the engine drew it, the shader is kept,
53
+ * and the feedback texture holds the last frame drawn with it on */
54
+ this.enabled = true;
55
+
52
56
  /** @property {Object<string, number|Array<number>>} - The game's own values for the shader, a uniform each
53
57
  * by its name, a number a float and a list of 2 to 4 numbers a vector, set every frame as they are; an
54
58
  * effect setting can be one of these names, so it changes every frame without making the shader again, all
@@ -134,7 +138,7 @@ class PostProcessPlugin
134
138
  }
135
139
  function postProcessRender()
136
140
  {
137
- if (headlessMode || !glEnable) return;
141
+ if (headlessMode || !glEnable || !postProcess.enabled) return;
138
142
 
139
143
  // clear out the buffer, before anything here binds its own
140
144
  glFlush();
@@ -5,7 +5,8 @@
5
5
  * - EngineObject3D is an EngineObject with a 3D position, rotation and mesh
6
6
  * - The 3D scene draws under the 2D sprites, so HUD and text land on top
7
7
  * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
8
- * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to
8
+ * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to, and a
9
+ * mainNormal in it bends the normal the lighting uses
9
10
  * - Meshes and the basic builders are in render3dMesh.js, EngineObject3D, instancing and lights in
10
11
  * render3dObject.js, both right after this one; the other builders, terrain, particles, camera controls and the
11
12
  * OBJ loader are in the Render3D Extras plugin, which goes after those
@@ -585,7 +586,9 @@ class Render3DPlugin
585
586
  /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
586
587
  * @type {Vector3|undefined} */
587
588
  this.shadowCenter = undefined;
588
- /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off */
589
+ /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off;
590
+ * a share of the shadow map's depth, twice shadowRange for the sun and a spotlight's radius, so the gap behind
591
+ * a caster grows with the range as the map's texels do, and the speckles they make stay away */
589
592
  this.shadowBias = .003;
590
593
  /** @property {number} - How much to blur the shadow edges */
591
594
  this.shadowSoftness = 1;
@@ -1619,6 +1622,9 @@ class Camera3D
1619
1622
  /** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites;
1620
1623
  * it lines up with the 2D view of the main canvas, whatever canvas size a screenToRay is given */
1621
1624
  this.align2D = false;
1625
+ /** @property {number} - The z of the plane align2D lines up with the 2D view, the camera sitting its
1626
+ * distance in front of it */
1627
+ this.align2DZ = 0;
1622
1628
  }
1623
1629
 
1624
1630
  /** Returns the camera's world transform
@@ -1689,7 +1695,7 @@ class Camera3D
1689
1695
  ASSERT(!canvasHeight || distance < this.far,
1690
1696
  'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance);
1691
1697
  this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height
1692
- this.pos = vec3(cameraPos.x, cameraPos.y, distance);
1698
+ this.pos = vec3(cameraPos.x, cameraPos.y, this.align2DZ + distance);
1693
1699
  this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way
1694
1700
  }
1695
1701
  }
@@ -1741,7 +1747,8 @@ const RENDER3D_SNIPPET_NAMES =
1741
1747
  '#define lights extraLights\n' +
1742
1748
  '#define lightColors extraLightColors\n';
1743
1749
 
1744
- // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same
1750
+ // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same,
1751
+ // and a mainNormal in it bends the normal after the normal map, before all the lighting reads it
1745
1752
  // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
1746
1753
  // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
1747
1754
  // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
@@ -1750,6 +1757,7 @@ const RENDER3D_SNIPPET_NAMES =
1750
1757
  // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
1751
1758
  function render3DFragmentSource(fragmentCode)
1752
1759
  {
1760
+ const hasNormal = /\bmainNormal\s*\(/.test(fragmentCode || '');
1753
1761
  return '#version 300 es\n' +
1754
1762
  'precision highp float;' +
1755
1763
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
@@ -1797,6 +1805,7 @@ function render3DFragmentSource(fragmentCode)
1797
1805
  'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
1798
1806
  // a snippet's mainImage is declared here and written after main, so a define in it can not reach main
1799
1807
  (fragmentCode ? 'void mainImage(out vec4,vec2);' : '') +
1808
+ (hasNormal ? 'void mainNormal(inout vec3);' : '') +
1800
1809
  'void main(){' +
1801
1810
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
1802
1811
  'if(premultipliedTexture&&t.a>0.)t.rgb/=t.a;' + // back to straight color, what the lighting and blend expect
@@ -1806,6 +1815,7 @@ function render3DFragmentSource(fragmentCode)
1806
1815
  'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
1807
1816
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
1808
1817
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
1818
+ (hasNormal ? 'mainNormal(n);n=normalize(n);' : '') +
1809
1819
  'float nl=dot(n,-lightDir.xyz);' +
1810
1820
  // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
1811
1821
  'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
@@ -36,8 +36,9 @@ function editor3DCameraStart()
36
36
  camera.pos = from.pos.copy();
37
37
  camera.rotation = vec3(from.rotation.x, from.rotation.y, 0);
38
38
  camera.fov = from.fov;
39
- camera.near = from.near;
40
39
  camera.far = from.far;
40
+ // an orthographic camera's near may sit behind it, which a perspective view can not use, so it keeps its own
41
+ from.orthographic || (camera.near = from.near);
41
42
  }
42
43
 
43
44
  // turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on
@@ -92,6 +93,7 @@ function editor3DWithView(fn)
92
93
  // called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera
93
94
  function editor3DCameraBegin()
94
95
  {
96
+ editor3DCameraEnd(); // a frame that threw before its End gives the game its camera back first, not the editor's
95
97
  if (!editor3DViewOn() || !editor3DCamera) return;
96
98
  const r = render3D, camera = editor3DGameCamera = r.camera;
97
99
  r.camera = editor3DCamera;
@@ -424,8 +426,17 @@ function editor3DSize(object)
424
426
  const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made);
425
427
  if (box) return box.size;
426
428
  if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
427
- const s = made.size3D, k = made.scale3D;
428
- return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
429
+ const s = made.size3D, k = made.scale3D, size = vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
430
+ if (!editor3DTurned(object)) return size;
431
+
432
+ // a turned one's box is the box around it turned, so it lands, drops and pastes by what it covers
433
+ const matrix = buildMatrix(vec3(), editor3DRotation(object).scale(PI / 180), size), high = vec3();
434
+ for (let i = 8; i--;)
435
+ {
436
+ const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
437
+ high.x = max(high.x, p.x), high.y = max(high.y, p.y), high.z = max(high.z, p.z);
438
+ }
439
+ return vec3(editor3DRound(high.x * 2), editor3DRound(high.y * 2), editor3DRound(high.z * 2));
429
440
  }
430
441
 
431
442
  // the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size;
@@ -1637,6 +1648,7 @@ function editor3DSetOpen(open)
1637
1648
  if (open)
1638
1649
  {
1639
1650
  editor3DSetFreeCamera(false);
1651
+ pointerLockIsActive() && pointerLockExit(); // the game's captured mouse is let go, the editor needs it
1640
1652
  editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
1641
1653
  editor3DCamera || editor3DCameraStart();
1642
1654
  editor3DGamePaused = paused;
@@ -2334,10 +2346,11 @@ function editor3DDragTo(drag, mouse, ray, snap)
2334
2346
  }
2335
2347
  if (factor)
2336
2348
  {
2349
+ // snapped and kept from nothing by its size, its sign kept, so a mirrored axis stays mirrored
2337
2350
  const size = snap ? editor3DScaleStep : 0, least = size || .01;
2351
+ const fit = (v)=> (v < 0 ? -1 : 1) * max(editor3DSnap(abs(v), size), least);
2338
2352
  const scale = s.scale.multiply(factor);
2339
- editor3DSetTransform(object, undefined, undefined, vec3(max(editor3DSnap(scale.x, size), least),
2340
- max(editor3DSnap(scale.y, size), least), max(editor3DSnap(scale.z, size), least)));
2353
+ editor3DSetTransform(object, undefined, undefined, vec3(fit(scale.x), fit(scale.y), fit(scale.z)));
2341
2354
  }
2342
2355
  }
2343
2356
  });
@@ -2858,8 +2871,9 @@ function editor3DDrawLabels()
2858
2871
  const drag = editor3DDrag;
2859
2872
  if (drag?.kind === 'box')
2860
2873
  {
2874
+ // on the main canvas, over the WebGL one, so a 3D pass drawn after the 2D scene does not cover it
2861
2875
  const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
2862
- corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, glEnable, true));
2876
+ corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, false, true));
2863
2877
  }
2864
2878
  }
2865
2879
 
@@ -1307,6 +1307,7 @@ class ParticleEmitter3D extends EngineObject3D
1307
1307
  const out = this.particleData;
1308
1308
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
1309
1309
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
1310
+ out[k+16] = view.lifeTime, out[k+17] = view.age;
1310
1311
  if (view.destroyed)
1311
1312
  out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
1312
1313
  return result;