littlejsengine 1.24.2 → 1.24.3

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/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.24.2';
35
+ const engineVersion = '1.24.3';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -165,7 +165,7 @@ let timeFixedStart = 0, frameFixedStart = 0;
165
165
  let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
166
166
  let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
167
167
  let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
168
- let engineFrameErrors = 0; // errors a release build's loop went on past
168
+ let engineFrameErrorLast; // the last error a release build's loop went on past, as text
169
169
 
170
170
  // the pairs of objects asked about a collision this update, so the other's own physics does not ask again: each
171
171
  // asker's others, with true for a pair both said to resolve, and false for one left overlapping, ignored or only
@@ -419,20 +419,30 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
419
419
  frameTimeBufferMS = 0;
420
420
  }
421
421
 
422
- // update multiple frames if necessary in case of slow framerate
423
- for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
422
+ // update multiple frames if necessary in case of slow framerate; a tick that throws still spends its time
423
+ // and the smoothing still comes back, so a release build going on past an error each tick keeps its rate
424
+ try
424
425
  {
425
- // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
426
- const frozenTick = paused || !(timeScale * debugScale);
427
-
428
- // increment frame and update time, frozen does not advance time
429
- if (!frozenTick)
430
- time = timeFixedStart + (frame++ - frameFixedStart) / frameRate;
431
- engineTick(frozenTick);
426
+ while (frameTimeBufferMS >= 0)
427
+ {
428
+ try
429
+ {
430
+ // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
431
+ const frozenTick = paused || !(timeScale * debugScale);
432
+
433
+ // increment frame and update time, frozen does not advance time
434
+ if (!frozenTick)
435
+ time = timeFixedStart + (frame++ - frameFixedStart) / frameRate;
436
+ engineTick(frozenTick);
437
+ }
438
+ finally { frameTimeBufferMS -= 1e3 / frameRate; }
439
+ }
440
+ }
441
+ finally
442
+ {
443
+ // add the time smoothing back in
444
+ frameTimeBufferMS += deltaSmooth;
432
445
  }
433
-
434
- // add the time smoothing back in
435
- frameTimeBufferMS += deltaSmooth;
436
446
  }
437
447
 
438
448
  // one tick of the loop: update game and objects, when frozen update everything except them
@@ -729,9 +739,13 @@ function engineScheduleFrame()
729
739
  try { engineUpdateInternal(frameTimeMS); }
730
740
  catch (error)
731
741
  {
732
- // a release build goes on past an error in a frame, a frozen game is the worst a player can get; only the
733
- // first is logged, one every frame would flood the console
734
- engineFrameErrors++ || console.error(error);
742
+ // a release build goes on past an error in a frame, a frozen game is the worst a player can get; an error
743
+ // is logged when it is not the last one again, one every frame would flood the console
744
+ const text = String(error);
745
+ text === engineFrameErrorLast || console.error(error);
746
+ engineFrameErrorLast = text;
747
+ // the frame's input is cleared as its tick would have, or a key press that threw would be pressed again
748
+ inputUpdatePost();
735
749
  engineScheduleFrame();
736
750
  }
737
751
  };
@@ -232,7 +232,7 @@ class Sound
232
232
  */
233
233
  constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
234
234
  {
235
- if (!soundEnable || headlessMode)
235
+ if (!soundEnable || headlessMode || !audioContext) // a browser with no audio makes none either
236
236
  {
237
237
  // no sound is made: it counts as loaded, so a game that waits for its sounds goes on
238
238
  this.loadedPercent = 1;
@@ -302,8 +302,8 @@ class Sound
302
302
  // load the audio file, a URL object as bundlers give works like its string;
303
303
  // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
304
304
  const filename = asset + '';
305
- engineAddLoad(this.loadSound(filename).catch(e=>
306
- LOG('Sound load failed for', filename, '-', e.message))); // startup waits for it
305
+ engineAddLoad(this.loadSound(filename).catch(e=> // startup waits for it, and a release build says why too
306
+ console.warn('Sound load failed for', filename, '-', e?.message ?? e)));
307
307
  }
308
308
  }
309
309
 
@@ -289,17 +289,64 @@ function typeScriptBuildStep(filename)
289
289
  try
290
290
  {
291
291
  const tsFilename = join(BUILD_FOLDER, `${ENGINE_NAME}.d.ts`);
292
- execSync(`npx -p typescript tsc "${filename}" --declaration --allowJs --emitDeclarationOnly --outFile "${tsFilename}"`);
292
+ // strictNullChecks keeps undefined in the types, where tsc would drop it from accessors and object types
293
+ // whatever the JSDoc says, so a game in strict mode can set render3D.shader = undefined and is told a
294
+ // flare may be missing; a game not in strict mode reads the same types as before
295
+ execSync(`npx -p typescript tsc "${filename}" --declaration --allowJs --emitDeclarationOnly --strictNullChecks --outFile "${tsFilename}"`);
293
296
 
294
297
  // Make declare module part use the package name littlejsengine
295
298
  let fileContent = fs.readFileSync(tsFilename, 'utf8');
296
299
  fileContent = fileContent.replace(`${ENGINE_NAME}\.esm`, 'littlejsengine')
297
- fs.writeFileSync(tsFilename, fileContent);
300
+ fs.writeFileSync(tsFilename, typeScriptOptionalTidy(fileContent));
298
301
 
299
302
  }
300
303
  catch (e) { handleError(e, 'Failed to run TypeScript build step!'); }
301
304
  };
302
305
 
306
+ // strictNullChecks writes an optional parameter or field as x?: T | undefined, which is the same as x?: T, so the
307
+ // redundant | undefined is taken off: each ?: is followed to the end of its type, the first , ; ) or line end outside
308
+ // brackets, and only that type is changed; a required x: T | undefined keeps it
309
+ function typeScriptOptionalTidy(text)
310
+ {
311
+ let out = '', i = 0;
312
+ for (let at; (at = text.indexOf('?: ', i)) >= 0;)
313
+ {
314
+ const start = at + 3;
315
+ let end = start, depth = 0;
316
+ for (; end < text.length; ++end)
317
+ {
318
+ const c = text[end];
319
+ if (c === '=' && text[end + 1] === '>') { ++end; continue; } // an arrow is not a closing bracket
320
+ if ('([{<'.includes(c)) ++depth;
321
+ else if (')]}>'.includes(c) && depth) --depth;
322
+ else if (!depth && (',;)\n'.includes(c))) break;
323
+ }
324
+ let type = typeScriptOptionalTidy(text.slice(start, end)); // and the optional fields of a record type in it
325
+ if (type.endsWith(' | undefined'))
326
+ {
327
+ type = type.slice(0, -12);
328
+ // a function type was wrapped for the union, (() => void) | undefined, and needs it no more
329
+ if (type[0] === '(' && typeScriptCloseOf(type, 0) === type.length - 1)
330
+ type = type.slice(1, -1);
331
+ }
332
+ out += text.slice(i, start) + type;
333
+ i = end;
334
+ }
335
+ return out + text.slice(i);
336
+ }
337
+
338
+ // the index of the bracket that closes the one at start
339
+ function typeScriptCloseOf(text, start)
340
+ {
341
+ let depth = 0;
342
+ for (let k = start; k < text.length; ++k)
343
+ {
344
+ if (text[k] === '(') ++depth;
345
+ else if (text[k] === ')' && !--depth) return k;
346
+ }
347
+ return -1;
348
+ }
349
+
303
350
  // display the error and exit
304
351
  function handleError(e,message)
305
352
  {
package/src/engineDraw.js CHANGED
@@ -134,10 +134,13 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
134
134
  // create tile info object
135
135
  const textureInfo = typeof texture === 'number' ?
136
136
  textureInfos[texture] : texture;
137
- if (headlessMode && !textureInfo?.size.x)
138
- return new TileInfo(new Vector2, size.copy(), textureInfo, padding, bleed); // no image loaded, no place in it
139
- ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
140
- ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
137
+ if (!textureInfo?.size.x)
138
+ {
139
+ // no image: headless loads none and keeps the size; an image that failed to load, whose warning named it, gives
140
+ // a tile of no size, which draws nothing; a slot never given an image is a mistake a debug build points out
141
+ ASSERT(headlessMode || textureInfo instanceof TextureInfo, 'tile texture is not loaded', texture);
142
+ return new TileInfo(new Vector2, headlessMode ? size.copy() : new Vector2, textureInfo, padding, bleed);
143
+ }
141
144
 
142
145
  // get the position of the tile
143
146
  const sizePaddedX = size.x + padding*2;
@@ -605,8 +608,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
605
608
  if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
606
609
 
607
610
  const textureInfo = tileInfo?.textureInfo;
608
- if (textureInfo && !(tileInfo.size.x && tileInfo.size.y))
609
- return; // a tile with no area draws nothing, like a sprite still loading
611
+ if (textureInfo && !(tileInfo.size.x && tileInfo.size.y && textureInfo.size.x))
612
+ return; // a tile with no area draws nothing, like a sprite still loading, and nor does an image that failed
610
613
  const bleed = tileInfo?.bleed ?? 0;
611
614
  if (useWebGL && glEnable)
612
615
  {
@@ -700,7 +703,7 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
700
703
  * @param {boolean} [screenSpace=drawScreenSpace]
701
704
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
702
705
  * @memberof Draw */
703
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
706
+ function drawRectGradient(pos, size=vec2(1), colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
704
707
  {
705
708
  ASSERT(isVector2(pos), 'pos must be a vec2');
706
709
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -1695,8 +1698,7 @@ function bakeTintedImage(image, color, additiveColor)
1695
1698
  * @param {number} dHeight
1696
1699
  * @param {Color} color
1697
1700
  * @param {Color} [additiveColor]
1698
- * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1699
- * @memberof Draw */
1701
+ * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding */
1700
1702
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1701
1703
  {
1702
1704
  const sx2 = bleed;
@@ -586,8 +586,9 @@ function inputInit()
586
586
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
587
587
  audioContext.resume();
588
588
 
589
- // a press in the bars around a letterboxed canvas is not one on its edge; a drag out of it still moves
590
- if (!inCanvas(e.x, e.y))
589
+ // a press in the bars around a letterboxed canvas is not one on its edge; a drag out of it still moves; under
590
+ // pointer lock the mouse stays where the lock began, which may be in a bar, and every click is the game's
591
+ if (!pointerLockIsActive() && !inCanvas(e.x, e.y))
591
592
  return;
592
593
  inputData[0][e.button] = 3;
593
594
 
@@ -1004,9 +1005,11 @@ function inputUpdate()
1004
1005
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
1005
1006
  }
1006
1007
 
1007
- // copy dpad to left analog stick when pressed
1008
+ // copy dpad to left analog stick when pressed; a gamepad with no sticks has that one, at rest when not
1008
1009
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
1009
1010
  sticks[0] = dpad.clampLength();
1011
+ else if (gamepadDirectionEmulateStick && !sticks.length)
1012
+ sticks[0] = vec2();
1010
1013
  }
1011
1014
 
1012
1015
  // disable touch gamepad if using real gamepad
@@ -436,7 +436,7 @@ function glSetTextureWrap(texture, wrap=true)
436
436
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
437
437
  * @param {string} source
438
438
  * @param {number} type
439
- * @return {WebGLShader}
439
+ * @return {WebGLShader|undefined} - undefined with no WebGL
440
440
  * @memberof WebGL */
441
441
  function glCompileShader(source, type)
442
442
  {
@@ -456,7 +456,7 @@ function glCompileShader(source, type)
456
456
  /** Create WebGL program with given shaders
457
457
  * @param {string} vsSource
458
458
  * @param {string} fsSource
459
- * @return {WebGLProgram}
459
+ * @return {WebGLProgram|undefined} - undefined with no WebGL
460
460
  * @memberof WebGL */
461
461
  function glCreateProgram(vsSource, fsSource)
462
462
  {
@@ -504,7 +504,7 @@ function glShaderProgram(shader)
504
504
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} [image]
505
505
  * @param {boolean|Array<number>} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across and down
506
506
  * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated
507
- * @return {WebGLTexture}
507
+ * @return {WebGLTexture|undefined} - undefined with no WebGL
508
508
  * @memberof WebGL */
509
509
  function glCreateTexture(image, wrap=false, pixelated=tilesPixelated)
510
510
  {