littlejsengine 1.26.0 → 1.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.26.0';
38
+ const engineVersion = '1.26.1';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -69,6 +69,8 @@ let engineObjectsCollideStaticLast = [];
69
69
  let frame = 0;
70
70
 
71
71
  /** Current engine time since start in seconds
72
+ * - Below 20 FPS it falls behind the clock, since a frame catches up at most 50 ms of updates, so a timer in
73
+ * real seconds, one that must keep up on a slow device, goes by timeReal
72
74
  * @type {number}
73
75
  * @memberof Engine */
74
76
  let time = 0;
@@ -94,8 +96,13 @@ function getPaused() { return paused; }
94
96
  * @memberof Engine */
95
97
  function setPaused(isPaused=true) { paused = isPaused; }
96
98
 
99
+ /** Frames drawn per second, smoothed over the last few seconds, in release builds too
100
+ * @type {number}
101
+ * @memberof Engine */
102
+ let averageFPS = 0;
103
+
97
104
  // Engine internal variables
98
- let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
105
+ let frameTimeLastMS = 0, frameTimeBufferMS = 0;
99
106
 
100
107
  // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
101
108
  // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
@@ -436,7 +443,9 @@ function engineLoadingScreenDraw(elapsed)
436
443
  * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
437
444
  * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
438
445
  * @param {Array<string>|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path
439
- * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
446
+ * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body; it is
447
+ * styled for a game (no scroll bars or selection, touch-action none),
448
+ * where its own inline style does not say otherwise
440
449
  * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
441
450
  * so set canvasFixedSize or canvasMaxSize to fit a smaller element
442
451
  * @example
@@ -499,8 +508,10 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
499
508
  // by ~page-load-time when RAF starts handing real timestamps
500
509
  if (!frameTimeLastMS) frameTimeDeltaMS = 0;
501
510
  frameTimeLastMS = frameTimeMS;
502
- if (debug || debugWatermark)
503
- averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
511
+ // the first frame's rate seeds the average, so it does not start out climbing from 0; a gap of over a
512
+ // second, a hidden tab coming back, is not a frame
513
+ if (frameTimeDeltaMS && frameTimeDeltaMS < 1e3)
514
+ averageFPS = averageFPS ? lerp(averageFPS, 1e3/frameTimeDeltaMS, .05) : 1e3/frameTimeDeltaMS;
504
515
  // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
505
516
  if (!manualStepAtStart)
506
517
  frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
@@ -5014,6 +5025,9 @@ class SpriteAnimator
5014
5025
  * across the sprite or the mesh's own uv
5015
5026
  * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
5016
5027
  * lightCount, lights[i], lightColors[i] and shadow()
5028
+ * - In 3D the snippet may also define void mainNormal(inout vec3 n), given the normal facing the camera after the
5029
+ * normal map, in world space, to bend it per pixel for waves or ripples; the lighting, specular, reflection and
5030
+ * fog then all use it, as they use a normal map
5017
5031
  * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
5018
5032
  * parts of a surface still shadows with the whole of it
5019
5033
  * @example
@@ -7165,8 +7179,9 @@ function inputInit()
7165
7179
  }
7166
7180
  function onContextMenu(e)
7167
7181
  {
7168
- // prevent right click menu, but a text field keeps its copy and paste menu
7169
- if (inputPreventDefault && !isTextInput(e.target))
7182
+ // prevent right click menu, but a text field keeps its copy and paste menu and a link its open in new tab
7183
+ const target = /** @type {HTMLElement} */ (e.target);
7184
+ if (inputPreventDefault && !isTextInput(target) && !target?.closest?.('a[href]'))
7170
7185
  e.preventDefault();
7171
7186
  }
7172
7187
  function onBlur()
@@ -7187,6 +7202,10 @@ function inputInit()
7187
7202
  // handle all touch events the same way
7188
7203
  function handleTouch(e)
7189
7204
  {
7205
+ // fix stalled audio requiring user interaction, with touch input off too since the page's sound needs it
7206
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
7207
+ audioResume();
7208
+
7190
7209
  if (!touchInputEnable)
7191
7210
  {
7192
7211
  // turned off mid touch, the finger that drove the mouse lets go of it
@@ -7197,10 +7216,6 @@ function inputInit()
7197
7216
  }
7198
7217
  inputLastTouchTime = performance.now();
7199
7218
 
7200
- // fix stalled audio requiring user interaction
7201
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
7202
- audioResume();
7203
-
7204
7219
  // when the touch gamepad is enabled it owns touch input: suppress the
7205
7220
  // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
7206
7221
  // (its own zones drive gameplay via pointer events)
@@ -13459,6 +13474,10 @@ class PostProcessPlugin
13459
13474
  /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */
13460
13475
  this.shaderCode = shaderCode || postProcessEffects(); // no code passes the frame through
13461
13476
 
13477
+ /** @property {boolean} - Is the pass on? Off, the frame shows as the engine drew it, the shader is kept,
13478
+ * and the feedback texture holds the last frame drawn with it on */
13479
+ this.enabled = true;
13480
+
13462
13481
  /** @property {Object<string, number|Array<number>>} - The game's own values for the shader, a uniform each
13463
13482
  * by its name, a number a float and a list of 2 to 4 numbers a vector, set every frame as they are; an
13464
13483
  * effect setting can be one of these names, so it changes every frame without making the shader again, all
@@ -13544,7 +13563,7 @@ class PostProcessPlugin
13544
13563
  }
13545
13564
  function postProcessRender()
13546
13565
  {
13547
- if (headlessMode || !glEnable) return;
13566
+ if (headlessMode || !glEnable || !postProcess.enabled) return;
13548
13567
 
13549
13568
  // clear out the buffer, before anything here binds its own
13550
13569
  glFlush();
@@ -23974,7 +23993,8 @@ function raycastBox(ray, pos, size, rotation)
23974
23993
  * - EngineObject3D is an EngineObject with a 3D position, rotation and mesh
23975
23994
  * - The 3D scene draws under the 2D sprites, so HUD and text land on top
23976
23995
  * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
23977
- * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to
23996
+ * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to, and a
23997
+ * mainNormal in it bends the normal the lighting uses
23978
23998
  * - Meshes and the basic builders are in render3dMesh.js, EngineObject3D, instancing and lights in
23979
23999
  * render3dObject.js, both right after this one; the other builders, terrain, particles, camera controls and the
23980
24000
  * OBJ loader are in the Render3D Extras plugin, which goes after those
@@ -24552,7 +24572,9 @@ class Render3DPlugin
24552
24572
  /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
24553
24573
  * @type {Vector3|undefined} */
24554
24574
  this.shadowCenter = undefined;
24555
- /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off */
24575
+ /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off;
24576
+ * a share of the shadow map's depth, twice shadowRange for the sun and a spotlight's radius, so the gap behind
24577
+ * a caster grows with the range as the map's texels do, and the speckles they make stay away */
24556
24578
  this.shadowBias = .003;
24557
24579
  /** @property {number} - How much to blur the shadow edges */
24558
24580
  this.shadowSoftness = 1;
@@ -25586,6 +25608,9 @@ class Camera3D
25586
25608
  /** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites;
25587
25609
  * it lines up with the 2D view of the main canvas, whatever canvas size a screenToRay is given */
25588
25610
  this.align2D = false;
25611
+ /** @property {number} - The z of the plane align2D lines up with the 2D view, the camera sitting its
25612
+ * distance in front of it */
25613
+ this.align2DZ = 0;
25589
25614
  }
25590
25615
 
25591
25616
  /** Returns the camera's world transform
@@ -25656,7 +25681,7 @@ class Camera3D
25656
25681
  false&&ASSERT(!canvasHeight || distance < this.far,
25657
25682
  'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance);
25658
25683
  this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height
25659
- this.pos = vec3(cameraPos.x, cameraPos.y, distance);
25684
+ this.pos = vec3(cameraPos.x, cameraPos.y, this.align2DZ + distance);
25660
25685
  this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way
25661
25686
  }
25662
25687
  }
@@ -25708,7 +25733,8 @@ const RENDER3D_SNIPPET_NAMES =
25708
25733
  '#define lights extraLights\n' +
25709
25734
  '#define lightColors extraLightColors\n';
25710
25735
 
25711
- // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same
25736
+ // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same,
25737
+ // and a mainNormal in it bends the normal after the normal map, before all the lighting reads it
25712
25738
  // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
25713
25739
  // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
25714
25740
  // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
@@ -25717,6 +25743,7 @@ const RENDER3D_SNIPPET_NAMES =
25717
25743
  // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
25718
25744
  function render3DFragmentSource(fragmentCode)
25719
25745
  {
25746
+ const hasNormal = /\bmainNormal\s*\(/.test(fragmentCode || '');
25720
25747
  return '#version 300 es\n' +
25721
25748
  'precision highp float;' +
25722
25749
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
@@ -25764,6 +25791,7 @@ function render3DFragmentSource(fragmentCode)
25764
25791
  'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
25765
25792
  // a snippet's mainImage is declared here and written after main, so a define in it can not reach main
25766
25793
  (fragmentCode ? 'void mainImage(out vec4,vec2);' : '') +
25794
+ (hasNormal ? 'void mainNormal(inout vec3);' : '') +
25767
25795
  'void main(){' +
25768
25796
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
25769
25797
  'if(premultipliedTexture&&t.a>0.)t.rgb/=t.a;' + // back to straight color, what the lighting and blend expect
@@ -25773,6 +25801,7 @@ function render3DFragmentSource(fragmentCode)
25773
25801
  'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
25774
25802
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
25775
25803
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
25804
+ (hasNormal ? 'mainNormal(n);n=normalize(n);' : '') +
25776
25805
  'float nl=dot(n,-lightDir.xyz);' +
25777
25806
  // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
25778
25807
  'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.26.0",
3
+ "version": "1.26.1",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -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.;' +
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.26.0';
35
+ const engineVersion = '1.26.1';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -66,6 +66,8 @@ let engineObjectsCollideStaticLast = [];
66
66
  let frame = 0;
67
67
 
68
68
  /** Current engine time since start in seconds
69
+ * - Below 20 FPS it falls behind the clock, since a frame catches up at most 50 ms of updates, so a timer in
70
+ * real seconds, one that must keep up on a slow device, goes by timeReal
69
71
  * @type {number}
70
72
  * @memberof Engine */
71
73
  let time = 0;
@@ -91,8 +93,13 @@ function getPaused() { return paused; }
91
93
  * @memberof Engine */
92
94
  function setPaused(isPaused=true) { paused = isPaused; }
93
95
 
96
+ /** Frames drawn per second, smoothed over the last few seconds, in release builds too
97
+ * @type {number}
98
+ * @memberof Engine */
99
+ let averageFPS = 0;
100
+
94
101
  // Engine internal variables
95
- let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
102
+ let frameTimeLastMS = 0, frameTimeBufferMS = 0;
96
103
 
97
104
  // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
98
105
  // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
@@ -433,7 +440,9 @@ function engineLoadingScreenDraw(elapsed)
433
440
  * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
434
441
  * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
435
442
  * @param {Array<string>|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path
436
- * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
443
+ * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body; it is
444
+ * styled for a game (no scroll bars or selection, touch-action none),
445
+ * where its own inline style does not say otherwise
437
446
  * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
438
447
  * so set canvasFixedSize or canvasMaxSize to fit a smaller element
439
448
  * @example
@@ -496,8 +505,10 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
496
505
  // by ~page-load-time when RAF starts handing real timestamps
497
506
  if (!frameTimeLastMS) frameTimeDeltaMS = 0;
498
507
  frameTimeLastMS = frameTimeMS;
499
- if (debug || debugWatermark)
500
- averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
508
+ // the first frame's rate seeds the average, so it does not start out climbing from 0; a gap of over a
509
+ // second, a hidden tab coming back, is not a frame
510
+ if (frameTimeDeltaMS && frameTimeDeltaMS < 1e3)
511
+ averageFPS = averageFPS ? lerp(averageFPS, 1e3/frameTimeDeltaMS, .05) : 1e3/frameTimeDeltaMS;
501
512
  // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
502
513
  if (!manualStepAtStart)
503
514
  frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
package/src/engineDraw.js CHANGED
@@ -548,6 +548,9 @@ class SpriteAnimator
548
548
  * across the sprite or the mesh's own uv
549
549
  * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
550
550
  * lightCount, lights[i], lightColors[i] and shadow()
551
+ * - In 3D the snippet may also define void mainNormal(inout vec3 n), given the normal facing the camera after the
552
+ * normal map, in world space, to bend it per pixel for waves or ripples; the lighting, specular, reflection and
553
+ * fog then all use it, as they use a normal map
551
554
  * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
552
555
  * parts of a surface still shadows with the whole of it
553
556
  * @example
@@ -15,6 +15,7 @@ export
15
15
  frame,
16
16
  time,
17
17
  timeReal,
18
+ averageFPS,
18
19
  paused,
19
20
  getPaused,
20
21
  setPaused,
@@ -656,8 +656,9 @@ function inputInit()
656
656
  }
657
657
  function onContextMenu(e)
658
658
  {
659
- // prevent right click menu, but a text field keeps its copy and paste menu
660
- if (inputPreventDefault && !isTextInput(e.target))
659
+ // prevent right click menu, but a text field keeps its copy and paste menu and a link its open in new tab
660
+ const target = /** @type {HTMLElement} */ (e.target);
661
+ if (inputPreventDefault && !isTextInput(target) && !target?.closest?.('a[href]'))
661
662
  e.preventDefault();
662
663
  }
663
664
  function onBlur()
@@ -678,6 +679,10 @@ function inputInit()
678
679
  // handle all touch events the same way
679
680
  function handleTouch(e)
680
681
  {
682
+ // fix stalled audio requiring user interaction, with touch input off too since the page's sound needs it
683
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
684
+ audioResume();
685
+
681
686
  if (!touchInputEnable)
682
687
  {
683
688
  // turned off mid touch, the finger that drove the mouse lets go of it
@@ -688,10 +693,6 @@ function inputInit()
688
693
  }
689
694
  inputLastTouchTime = performance.now();
690
695
 
691
- // fix stalled audio requiring user interaction
692
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
693
- audioResume();
694
-
695
696
  // when the touch gamepad is enabled it owns touch input: suppress the
696
697
  // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
697
698
  // (its own zones drive gameplay via pointer events)