littlejsengine 1.18.7 → 1.18.12

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/dist/littlejs.js CHANGED
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.7';
38
+ const engineVersion = '1.18.12';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -207,7 +207,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
207
207
  const combinedScale = timeScale * debugScale;
208
208
  frameTimeDeltaMS *= combinedScale;
209
209
  frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
210
- if (debugScale <= 1)
210
+ if (combinedScale <= 1)
211
211
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
212
212
 
213
213
  let wasUpdated = false;
@@ -294,6 +294,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
294
294
  glFlush();
295
295
  debugRenderPost();
296
296
  drawCount = 0;
297
+ primitiveCount = 0;
297
298
  }
298
299
  }
299
300
 
@@ -645,7 +646,7 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, s
645
646
  {
646
647
  ASSERT(isVector2(pos), 'pos must be a vec2');
647
648
  ASSERT(isVector2(size), 'size must be a vec2');
648
- ASSERT(isString(color) || isColor(color), 'color is invalid');
649
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
649
650
  ASSERT(isNumber(time), 'time must be a number');
650
651
  ASSERT(isNumber(angle), 'angle must be a number');
651
652
 
@@ -672,7 +673,7 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screen
672
673
  {
673
674
  ASSERT(isVector2(pos), 'pos must be a vec2');
674
675
  ASSERT(isArray(points), 'points must be an array');
675
- ASSERT(isString(color) || isColor(color), 'color is invalid');
676
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
676
677
  ASSERT(isNumber(time), 'time must be a number');
677
678
  ASSERT(isNumber(angle), 'angle must be a number');
678
679
 
@@ -696,7 +697,7 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=f
696
697
  {
697
698
  ASSERT(isVector2(pos), 'pos must be a vec2');
698
699
  ASSERT(isNumber(size), 'size must be a number');
699
- ASSERT(isString(color) || isColor(color), 'color is invalid');
700
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
700
701
  ASSERT(isNumber(time), 'time must be a number');
701
702
 
702
703
  if (isColor(color))
@@ -774,13 +775,13 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
774
775
  * @memberof Debug */
775
776
  function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=false)
776
777
  {
777
- ASSERT(isString(text), 'text must be a string');
778
+ ASSERT(isStringLike(text), 'text must be a string');
778
779
  ASSERT(isVector2(pos), 'pos must be a vec2');
779
780
  ASSERT(isNumber(size), 'size must be a number');
780
- ASSERT(isString(color) || isColor(color), 'color is invalid');
781
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
781
782
  ASSERT(isNumber(time), 'time must be a number');
782
783
  ASSERT(isNumber(angle), 'angle must be a number');
783
- ASSERT(isString(font), 'font must be a string');
784
+ ASSERT(isStringLike(font), 'font must be a string');
784
785
 
785
786
  if (isColor(color))
786
787
  color = color.toString();
@@ -1068,7 +1069,8 @@ function debugRender()
1068
1069
  debugContext.fillText('FPS: ' + averageFPS.toFixed(1) + (glEnable?' WebGL':' Canvas2D'),
1069
1070
  x, y += h);
1070
1071
  debugContext.fillText('Objects: ' + engineObjects.length, x, y += h);
1071
- debugContext.fillText('Draw Count: ' + drawCount, x, y += h);
1072
+ debugContext.fillText('Draw Calls: ' + drawCount, x, y += h);
1073
+ debugContext.fillText('Primitives: ' + primitiveCount, x, y += h);
1072
1074
  debugContext.fillText('---------', x, y += h);
1073
1075
  debugContext.fillStyle = '#f00';
1074
1076
  debugContext.fillText('ESC: Debug Overlay', x, y += h);
@@ -1139,7 +1141,8 @@ function debugRenderPost()
1139
1141
  mainContext.font = '1em monospace';
1140
1142
  mainContext.fillStyle = '#000';
1141
1143
  const text = engineName + ' v' + engineVersion + ' / '
1142
- + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1)
1144
+ + drawCount + ' / ' + primitiveCount + ' / '
1145
+ + engineObjects.length + ' / ' + averageFPS.toFixed(1)
1143
1146
  + (glEnable ? ' GL' : ' 2D') ;
1144
1147
  mainContext.fillText(text, mainCanvas.width-3, 3);
1145
1148
  mainContext.fillStyle = '#fff';
@@ -1580,13 +1583,15 @@ function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
1580
1583
  function isNumber(n) { return typeof n === 'number' && !isNaN(n); }
1581
1584
 
1582
1585
  /**
1583
- * Check if object can be converted to a string (has a toString method)
1586
+ * Check if a value is stringifiable i.e. it has a toString that returns
1587
+ * a string. Use this for ASSERTs and inputs that will be coerced to text;
1588
+ * use `typeof x === 'string'` inline if you need strict-string semantics.
1584
1589
  * - Returns true for strings, numbers, and most objects
1585
1590
  * - Returns false for null and undefined
1586
1591
  * @param {any} s
1587
1592
  * @return {boolean}
1588
1593
  * @memberof Math */
1589
- function isString(s) { return s != null && typeof s?.toString() === 'string'; }
1594
+ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'; }
1590
1595
 
1591
1596
  /**
1592
1597
  * Check if object is an array
@@ -2330,7 +2335,7 @@ class Color
2330
2335
  * @return {Color} */
2331
2336
  setHex(hex)
2332
2337
  {
2333
- ASSERT(isString(hex), 'Color hex code must be a string');
2338
+ ASSERT(isStringLike(hex), 'Color hex code must be a string');
2334
2339
  ASSERT(hex[0] === '#', 'Color hex code must start with #');
2335
2340
  ASSERT([4,5,7,9].includes(hex.length), 'Invalid hex');
2336
2341
 
@@ -2447,6 +2452,7 @@ const MAGENTA = debugProtectConstant(rgb(1,0,1));
2447
2452
  * - File saving (text, canvas, data URLs)
2448
2453
  * - Native share dialog support
2449
2454
  * - Local storage save data management
2455
+ * - Gradient noise (1D and 2D)
2450
2456
  * @namespace Utilities
2451
2457
  */
2452
2458
 
@@ -2594,8 +2600,8 @@ function saveCanvas(canvas, filename='screenshot', type='image/png')
2594
2600
  * @memberof Utilities */
2595
2601
  function saveDataURL(url, filename='download', revokeTime)
2596
2602
  {
2597
- ASSERT(isString(url), 'saveDataURL requires url string');
2598
- ASSERT(isString(filename), 'saveDataURL requires filename string');
2603
+ ASSERT(isStringLike(url), 'saveDataURL requires url string');
2604
+ ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
2599
2605
 
2600
2606
  // create link for saving screenshots
2601
2607
  const link = document.createElement('a');
@@ -2613,8 +2619,8 @@ function saveDataURL(url, filename='download', revokeTime)
2613
2619
  * @memberof Utilities */
2614
2620
  function shareURL(title, url, callback)
2615
2621
  {
2616
- ASSERT(isString(title), 'shareURL requires title string');
2617
- ASSERT(isString(url), 'shareURL requires url string');
2622
+ ASSERT(isStringLike(title), 'shareURL requires title string');
2623
+ ASSERT(isStringLike(url), 'shareURL requires url string');
2618
2624
  navigator.share?.({title, url}).then(()=>callback?.());
2619
2625
  }
2620
2626
 
@@ -2627,7 +2633,7 @@ function shareURL(title, url, callback)
2627
2633
  * @memberof Utilities */
2628
2634
  function readSaveData(saveName, defaultSaveData)
2629
2635
  {
2630
- ASSERT(isString(saveName), 'loadData requires saveName string');
2636
+ ASSERT(isStringLike(saveName), 'loadData requires saveName string');
2631
2637
 
2632
2638
  // replace undefined values with defaults; tolerate corrupt JSON
2633
2639
  const data = localStorage[saveName];
@@ -2646,8 +2652,50 @@ function readSaveData(saveName, defaultSaveData)
2646
2652
  * @memberof Utilities */
2647
2653
  function writeSaveData(saveName, saveData)
2648
2654
  {
2649
- ASSERT(isString(saveName), 'saveData requires saveName string');
2655
+ ASSERT(isStringLike(saveName), 'saveData requires saveName string');
2650
2656
  localStorage[saveName] = JSON.stringify(saveData);
2657
+ }
2658
+
2659
+ ///////////////////////////////////////////////////////////////////////////////
2660
+
2661
+ // Deterministic well-distributed hash of an integer lattice index to [0, 1).
2662
+ // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
2663
+ function noiseHash(i)
2664
+ {
2665
+ let h = (i | 0) ^ 0x9e3779b9;
2666
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
2667
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
2668
+ h ^= h >>> 16;
2669
+ return (h >>> 0) / 2**32;
2670
+ }
2671
+
2672
+ /** 1D gradient noise — returns a smooth value in [0, 1] for any real x.
2673
+ * Integer inputs land on deterministic lattice values; non-integer inputs
2674
+ * are interpolated with smoothStep for C1 continuity.
2675
+ * @param {number} x
2676
+ * @return {number}
2677
+ * @memberof Utilities */
2678
+ function noise1D(x)
2679
+ {
2680
+ const i = floor(x);
2681
+ return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
2682
+ }
2683
+
2684
+ /** 2D gradient noise — returns a smooth value in [0, 1] for any real (x, y).
2685
+ * @param {number} x
2686
+ * @param {number} y
2687
+ * @return {number}
2688
+ * @memberof Utilities */
2689
+ function noise2D(x, y)
2690
+ {
2691
+ const ix = floor(x), iy = floor(y);
2692
+ const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
2693
+ // large prime decorrelates neighboring rows
2694
+ const h = (a, b) => noiseHash(a + b * 374761393);
2695
+ return lerp(
2696
+ lerp(h(ix, iy ), h(ix + 1, iy ), fx),
2697
+ lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
2698
+ fy);
2651
2699
  }
2652
2700
  /**
2653
2701
  * LittleJS Engine Settings
@@ -3854,6 +3902,12 @@ let textureInfos = [];
3854
3902
  * @memberof Draw */
3855
3903
  let drawCount;
3856
3904
 
3905
+ /** Keeps track of how many primitives were drawn each frame for debugging
3906
+ * A single draw call can render many primitives (e.g. a WebGL sprite batch).
3907
+ * @type {number}
3908
+ * @memberof Draw */
3909
+ let primitiveCount;
3910
+
3857
3911
  // internal predicates for tint short-circuiting in canvas2D draw paths
3858
3912
  // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
3859
3913
  // isBlack includes alpha so additive colors that only contribute alpha are not skipped
@@ -4094,19 +4148,19 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4094
4148
  }
4095
4149
  else
4096
4150
  {
4097
- // if no tile info, force untextured by zeroing rgba (so whatever
4098
- // texture is bound doesn't leak in) and folding color+additive
4099
- // into the additive slot matches the Canvas2D path's
4100
- // color.add(additiveColor) on line ~337.
4151
+ // untextured: glDrawUntextured picks the optimal path (poly
4152
+ // tristrip if already in poly mode, otherwise instanced with
4153
+ // uvs/rgba zeroed). Color+additive are folded together to match
4154
+ // the Canvas2D path's color.add(additiveColor) on line ~337.
4101
4155
  const combined = additiveColor ? color.add(additiveColor) : color;
4102
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
4103
- 0, combined.rgbaInt());
4156
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
4104
4157
  }
4105
4158
  }
4106
4159
  else
4107
4160
  {
4108
4161
  // normal canvas 2D rendering method (slower)
4109
4162
  ++drawCount;
4163
+ ++primitiveCount;
4110
4164
  size = new Vector2(size.x, -size.y); // flip upside down sprites
4111
4165
  drawCanvas2D(pos, size, angle, mirror, (context)=>
4112
4166
  {
@@ -4146,13 +4200,13 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
4146
4200
  * @param {Vector2} pos
4147
4201
  * @param {Vector2} [size=vec2(1)]
4148
4202
  * @param {Color} [colorTop=WHITE]
4149
- * @param {Color} [colorBottom=BLACK]
4203
+ * @param {Color} [colorBottom=CLEAR_WHITE]
4150
4204
  * @param {number} [angle]
4151
4205
  * @param {boolean} [useWebGL=glEnable]
4152
4206
  * @param {boolean} [screenSpace]
4153
4207
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4154
4208
  * @memberof Draw */
4155
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
4209
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
4156
4210
  {
4157
4211
  ASSERT(isVector2(pos), 'pos must be a vec2');
4158
4212
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -4192,6 +4246,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
4192
4246
  {
4193
4247
  // normal canvas 2D rendering method (slower)
4194
4248
  ++drawCount;
4249
+ ++primitiveCount;
4195
4250
  size = new Vector2(size.x, -size.y); // fix upside down sprites
4196
4251
  drawCanvas2D(pos, size, angle, false, (context)=>
4197
4252
  {
@@ -4254,8 +4309,9 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4254
4309
  return;
4255
4310
  }
4256
4311
 
4257
- // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
4312
+ // Canvas2D path — increment counts here (WebGL counts via glFlush)
4258
4313
  ++drawCount;
4314
+ ++primitiveCount;
4259
4315
 
4260
4316
  if (!screenSpace)
4261
4317
  {
@@ -4329,6 +4385,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
4329
4385
  {
4330
4386
  // normal canvas 2D rendering method (slower)
4331
4387
  ++drawCount;
4388
+ ++primitiveCount;
4332
4389
  drawCanvas2D(pos, vec2(1), angle, false, (context)=>
4333
4390
  {
4334
4391
  context.strokeStyle = color.toString();
@@ -4509,6 +4566,77 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
4509
4566
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
4510
4567
  }
4511
4568
 
4569
+ /** Draw a circle filled with a radial gradient from the center to the rim
4570
+ * - Best when batched with other untextured polys
4571
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4572
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
4573
+ * @param {Vector2} pos
4574
+ * @param {number} [size=1] - Diameter
4575
+ * @param {Color} [colorInner=WHITE]
4576
+ * @param {Color} [colorOuter=CLEAR_WHITE]
4577
+ * @param {boolean} [useWebGL=glEnable]
4578
+ * @param {boolean} [screenSpace]
4579
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4580
+ * @memberof Draw */
4581
+ let drawCircleGradientOffset = 0;
4582
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4583
+ {
4584
+ ASSERT(isVector2(pos), 'pos must be a vec2');
4585
+ ASSERT(isNumber(size), 'size must be a number');
4586
+ ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
4587
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4588
+
4589
+ if (headlessMode) return;
4590
+
4591
+ if (useWebGL && glEnable)
4592
+ {
4593
+ ASSERT(!!glContext, 'WebGL is not enabled!');
4594
+ if (screenSpace)
4595
+ {
4596
+ // convert to world space
4597
+ pos = screenToWorld(pos);
4598
+ size /= cameraScale;
4599
+ }
4600
+ // fan as tristrip; rotate the boundary vertex by one slice per call
4601
+ // so back-to-back gradients at the same position have their hole
4602
+ // (from gpu edge-rule on the boundary line-degen) at different rim
4603
+ // verts and don't visibly stack
4604
+ const sides = glCircleSides;
4605
+ const radius = size/2;
4606
+ const innerInt = colorInner.rgbaInt();
4607
+ const outerInt = colorOuter.rgbaInt();
4608
+ const offset = drawCircleGradientOffset++;
4609
+ const startA = (offset%sides)/sides*PI*2;
4610
+ const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
4611
+ const colors = [outerInt];
4612
+ for (let i=sides; i--;)
4613
+ {
4614
+ const a = ((i+offset)%sides)/sides*PI*2;
4615
+ points.push(pos);
4616
+ colors.push(innerInt);
4617
+ points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
4618
+ colors.push(outerInt);
4619
+ }
4620
+ glDrawColoredPoints(points, colors);
4621
+ }
4622
+ else
4623
+ {
4624
+ // normal canvas 2D rendering method (slower)
4625
+ ++drawCount;
4626
+ ++primitiveCount;
4627
+ drawCanvas2D(pos, vec2(size), 0, false, (context)=>
4628
+ {
4629
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
4630
+ gradient.addColorStop(0, colorInner.toString());
4631
+ gradient.addColorStop(1, colorOuter.toString());
4632
+ context.fillStyle = gradient;
4633
+ context.beginPath();
4634
+ context.ellipse(0, 0, .5, .5, 0, 0, 9);
4635
+ context.fill();
4636
+ }, screenSpace, context);
4637
+ }
4638
+ }
4639
+
4512
4640
  /**
4513
4641
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
4514
4642
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -4592,15 +4720,15 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
4592
4720
  * @memberof Draw */
4593
4721
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
4594
4722
  {
4595
- ASSERT(isString(text), 'text must be a string');
4723
+ ASSERT(isStringLike(text), 'text must be a string');
4596
4724
  ASSERT(isVector2(pos), 'pos must be a vec2');
4597
4725
  ASSERT(isNumber(size), 'size must be a number');
4598
4726
  ASSERT(isColor(color), 'color must be a color');
4599
4727
  ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
4600
4728
  ASSERT(isColor(lineColor), 'lineColor must be a color');
4601
4729
  ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
4602
- ASSERT(isString(font), 'font must be a string');
4603
- ASSERT(isString(fontStyle), 'fontStyle must be a string');
4730
+ ASSERT(isStringLike(font), 'font must be a string');
4731
+ ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4604
4732
  ASSERT(isNumber(angle), 'angle must be a number');
4605
4733
 
4606
4734
  context.fillStyle = color.toString();
@@ -4637,7 +4765,7 @@ async function loadTexture(textureIndex, src)
4637
4765
  {
4638
4766
  ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
4639
4767
  ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
4640
- ASSERT(!src || isString(src), 'image src must be a string');
4768
+ ASSERT(!src || isStringLike(src), 'image src must be a string');
4641
4769
 
4642
4770
  const image = new Image;
4643
4771
  if (src)
@@ -5025,7 +5153,7 @@ class FontImage
5025
5153
  */
5026
5154
  drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
5027
5155
  {
5028
- ASSERT(isString(text), 'text must be a string');
5156
+ ASSERT(isStringLike(text), 'text must be a string');
5029
5157
  ASSERT(isVector2(pos), 'pos must be a vec2');
5030
5158
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
5031
5159
  ASSERT(isColor(color), 'color must be a color');
@@ -5186,7 +5314,7 @@ function inputClear()
5186
5314
  * @memberof Input */
5187
5315
  function keyIsDown(key, device=0)
5188
5316
  {
5189
- ASSERT(isString(key), 'key must be a number or string');
5317
+ ASSERT(isStringLike(key), 'key must be a number or string');
5190
5318
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
5191
5319
  return !!(inputData[device]?.[key] & 1);
5192
5320
  }
@@ -5198,7 +5326,7 @@ function keyIsDown(key, device=0)
5198
5326
  * @memberof Input */
5199
5327
  function keyWasPressed(key, device=0)
5200
5328
  {
5201
- ASSERT(isString(key), 'key must be a number or string');
5329
+ ASSERT(isStringLike(key), 'key must be a number or string');
5202
5330
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
5203
5331
  return !!(inputData[device]?.[key] & 2);
5204
5332
  }
@@ -5210,7 +5338,7 @@ function keyWasPressed(key, device=0)
5210
5338
  * @memberof Input */
5211
5339
  function keyWasReleased(key, device=0)
5212
5340
  {
5213
- ASSERT(isString(key), 'key must be a number or string');
5341
+ ASSERT(isStringLike(key), 'key must be a number or string');
5214
5342
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
5215
5343
  return !!(inputData[device]?.[key] & 4);
5216
5344
  }
@@ -5224,10 +5352,10 @@ function keyWasReleased(key, device=0)
5224
5352
  * @memberof Input */
5225
5353
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
5226
5354
  {
5227
- ASSERT(isString(up), 'up key must be a string');
5228
- ASSERT(isString(down), 'down key must be a string');
5229
- ASSERT(isString(left), 'left key must be a string');
5230
- ASSERT(isString(right), 'right key must be a string');
5355
+ ASSERT(isStringLike(up), 'up key must be a string');
5356
+ ASSERT(isStringLike(down), 'down key must be a string');
5357
+ ASSERT(isStringLike(left), 'left key must be a string');
5358
+ ASSERT(isStringLike(right), 'right key must be a string');
5231
5359
  const k = (key)=> keyIsDown(key) ? 1 : 0;
5232
5360
  return vec2(k(right) - k(left), k(up) - k(down));
5233
5361
  }
@@ -5945,12 +6073,10 @@ function touchGamepadButtonCenter()
5945
6073
  * @namespace Audio
5946
6074
  */
5947
6075
 
5948
- /** Audio context used by the engine. Created lazily in audioInit() to avoid
5949
- * browser autoplay warnings about constructing an AudioContext before any
5950
- * user gesture.
6076
+ /** Audio context used by the engine
5951
6077
  * @type {AudioContext}
5952
6078
  * @memberof Audio */
5953
- let audioContext;
6079
+ let audioContext = new AudioContext;
5954
6080
 
5955
6081
  /** Master gain node for all audio to pass through
5956
6082
  * @type {GainNode}
@@ -5966,13 +6092,12 @@ const audioDefaultSampleRate = 44100;
5966
6092
  * @return {boolean} - True if the audio context is running
5967
6093
  * @memberof Audio */
5968
6094
  function audioIsRunning()
5969
- { return audioContext?.state === 'running'; }
6095
+ { return audioContext.state === 'running'; }
5970
6096
 
5971
6097
  function audioInit()
5972
6098
  {
5973
6099
  if (!soundEnable || headlessMode) return;
5974
6100
 
5975
- audioContext = new AudioContext;
5976
6101
  audioMasterGain = audioContext.createGain();
5977
6102
  audioMasterGain.connect(audioContext.destination);
5978
6103
  audioMasterGain.gain.value = soundVolume; // set starting value
@@ -6018,7 +6143,7 @@ class Sound
6018
6143
  {
6019
6144
  if (!soundEnable || headlessMode) return;
6020
6145
 
6021
- ASSERT(!asset || isArray(asset) || isString(asset), 'asset must be a file name or zzfx array');
6146
+ ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
6022
6147
  ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
6023
6148
  ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
6024
6149
  ASSERT(isNumber(range), 'range must be a number');
@@ -6357,14 +6482,15 @@ class SoundInstance
6357
6482
 
6358
6483
  /** Speak text with passed in settings
6359
6484
  * @param {string} text - The text to speak
6360
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
6361
6485
  * @param {number} [volume] - How much to scale volume by
6362
6486
  * @param {number} [rate] - How quickly to speak
6363
6487
  * @param {number} [pitch] - How much to change the pitch by
6488
+ * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
6364
6489
  * @return {SpeechSynthesisUtterance} - The utterance that was spoken
6365
6490
  * @memberof Audio */
6366
- function speak(text, language='', volume=1, rate=1, pitch=1)
6491
+ function speak(text, volume=1, rate=1, pitch=1, language='')
6367
6492
  {
6493
+ ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
6368
6494
  if (!soundEnable || headlessMode) return;
6369
6495
  if (!speechSynthesis) return;
6370
6496
 
@@ -8357,7 +8483,8 @@ function glFlush()
8357
8483
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, glBatchCount);
8358
8484
  else
8359
8485
  glContext.drawArraysInstanced(glContext.TRIANGLE_STRIP, 0, 4, glBatchCount);
8360
- drawCount += glBatchCount;
8486
+ ++drawCount;
8487
+ primitiveCount += glBatchCount;
8361
8488
  glBatchCount = 0;
8362
8489
  }
8363
8490
  glBatchAdditive = glAdditive;
@@ -8418,6 +8545,67 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
8418
8545
  glPositionData[offset++] = angle;
8419
8546
  }
8420
8547
 
8548
+ /** Add an untextured rect to the gl draw list
8549
+ * Picks the optimal path: if already in poly mode, emits a tristrip rect
8550
+ * so it batches with surrounding polys; otherwise uses the instanced path
8551
+ * with uvs and rgba zeroed so the color falls through the additive slot.
8552
+ * @param {number} x
8553
+ * @param {number} y
8554
+ * @param {number} sizeX
8555
+ * @param {number} sizeY
8556
+ * @param {number} angle
8557
+ * @param {number} rgba - color as 32-bit integer
8558
+ * @memberof WebGL */
8559
+ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
8560
+ {
8561
+ if (glPolyMode)
8562
+ {
8563
+ // batch with surrounding polys as a 4-vertex tristrip rect
8564
+ const vertCount = 6; // 4 corners + 2 degenerate verts
8565
+ if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8566
+ glFlush();
8567
+
8568
+ // compute rotated corners in world space (matches glDrawPointsTransform rotation)
8569
+ const hx = sizeX*.5, hy = sizeY*.5;
8570
+ const c = cos(angle), s = sin(angle);
8571
+ const chx = c*hx, shx = s*hx, chy = c*hy, shy = s*hy;
8572
+ const x0 = x - chx - shy, y0 = y + shx - chy; // (-hx,-hy)
8573
+ const x1 = x + chx - shy, y1 = y - shx - chy; // ( hx,-hy)
8574
+ const x2 = x - chx + shy, y2 = y + shx + chy; // (-hx, hy)
8575
+ const x3 = x + chx + shy, y3 = y - shx + chy; // ( hx, hy)
8576
+
8577
+ // write tristrip with leading/trailing degenerate verts
8578
+ let offset = glBatchCount * gl_INDICES_PER_POLY_VERTEX;
8579
+ glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
8580
+ glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
8581
+ glPositionData[offset++] = x1; glPositionData[offset++] = y1; glColorData[offset++] = rgba;
8582
+ glPositionData[offset++] = x2; glPositionData[offset++] = y2; glColorData[offset++] = rgba;
8583
+ glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
8584
+ glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
8585
+ glBatchCount += vertCount;
8586
+ return;
8587
+ }
8588
+
8589
+ // instanced path: zero uvs and rgba so the texture contribution is killed,
8590
+ // then carry the real color in the additive slot
8591
+ if (glBatchCount >= gl_MAX_INSTANCES || glBatchAdditive !== glAdditive)
8592
+ glFlush();
8593
+ glSetInstancedMode();
8594
+
8595
+ let offset = glBatchCount++ * gl_INDICES_PER_INSTANCE;
8596
+ glPositionData[offset++] = x;
8597
+ glPositionData[offset++] = y;
8598
+ glPositionData[offset++] = sizeX;
8599
+ glPositionData[offset++] = sizeY;
8600
+ glPositionData[offset++] = 0;
8601
+ glPositionData[offset++] = 0;
8602
+ glPositionData[offset++] = 0;
8603
+ glPositionData[offset++] = 0;
8604
+ glColorData[offset++] = 0;
8605
+ glColorData[offset++] = rgba;
8606
+ glPositionData[offset++] = angle;
8607
+ }
8608
+
8421
8609
  /** Transform and add a polygon to the gl draw list
8422
8610
  * @param {Array<Vector2>} points - Array of Vector2 points
8423
8611
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -9054,7 +9242,8 @@ class Medal
9054
9242
  /** @property {boolean} - Is the medal unlocked? */
9055
9243
  this.unlocked = false;
9056
9244
 
9057
- // load the source image if provided
9245
+ /** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any */
9246
+ this.image = undefined;
9058
9247
  if (src)
9059
9248
  (this.image = new Image).src = src;
9060
9249
 
@@ -9217,13 +9406,18 @@ class NewgroundsPlugin
9217
9406
  ASSERT(!cipher || cryptoJS, 'must provide cryptojs if there is a cipher');
9218
9407
 
9219
9408
  newgrounds = this; // set global newgrounds object
9409
+ /** @property {string} - The newgrounds App ID */
9220
9410
  this.app_id = app_id;
9411
+ /** @property {string|undefined} - AES-128/Base64 encryption key, if any */
9221
9412
  this.cipher = cipher;
9413
+ /** @property {Object|undefined} - CryptoJS instance used when cipher is set */
9222
9414
  this.cryptoJS = cryptoJS;
9415
+ /** @property {string} - Hostname used when logging views */
9223
9416
  this.host = location ? location.hostname : '';
9224
9417
 
9225
9418
  // get session id from url search params
9226
9419
  const url = new URL(location.href);
9420
+ /** @property {string|null} - Newgrounds session id from the URL (null when not logged in) */
9227
9421
  this.session_id = url.searchParams.get('ngio_session_id');
9228
9422
 
9229
9423
  if (!this.session_id)
@@ -9231,6 +9425,7 @@ class NewgroundsPlugin
9231
9425
 
9232
9426
  // get medals
9233
9427
  const medalsResult = this.call('Medal.getList');
9428
+ /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
9234
9429
  this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
9235
9430
  debugMedals && LOG(this.medals);
9236
9431
  for (const newgroundsMedal of this.medals)
@@ -9254,6 +9449,7 @@ class NewgroundsPlugin
9254
9449
 
9255
9450
  // get scoreboards
9256
9451
  const scoreboardResult = this.call('ScoreBoard.getBoards');
9452
+ /** @property {Array} - Scoreboards fetched from Newgrounds */
9257
9453
  this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
9258
9454
  debugMedals && LOG(this.scoreboards);
9259
9455
 
@@ -9777,12 +9973,31 @@ class UISystemPlugin
9777
9973
 
9778
9974
  engineAddPlugin(uiUpdate, uiRender);
9779
9975
 
9780
- // set object position in parent space
9976
+ // set object position based on anchor target (parent box, or canvas for roots),
9977
+ // self-pivot, and localPos offset
9781
9978
  function updateTransforms(o)
9782
9979
  {
9783
- if (!o.parent) return;
9784
- o.pos.x = o.localPos.x + o.parent.pos.x;
9785
- o.pos.y = o.localPos.y + o.parent.pos.y;
9980
+ let targetPos, targetSize;
9981
+ if (o.parent)
9982
+ {
9983
+ targetPos = o.parent.pos;
9984
+ targetSize = o.parent.size;
9985
+ }
9986
+ else
9987
+ {
9988
+ // anchor to canvas in native coords (handles nativeHeight if set)
9989
+ targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9990
+ targetSize = uiSystem.nativeHeight
9991
+ ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
9992
+ uiSystem.nativeHeight)
9993
+ : mainCanvasSize;
9994
+ }
9995
+
9996
+ const a = o.anchor;
9997
+ o.pos = targetPos
9998
+ .add(targetSize.multiply(a).scale(.5)) // anchor point on target
9999
+ .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
10000
+ .add(o.localPos); // user offset
9786
10001
  }
9787
10002
 
9788
10003
  // setup recursive update and render
@@ -10249,9 +10464,8 @@ class UISystemPlugin
10249
10464
  // confirm menu
10250
10465
  const confirmMenu = new UIObject(vec2(), size);
10251
10466
  uiSystem.confirmDialog = confirmMenu;
10252
- confirmMenu.onRender = ()=>
10467
+ confirmMenu.onRender = ()=>
10253
10468
  {
10254
- confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
10255
10469
  const backgroundColor = hsl(0,0,0,.7);
10256
10470
  uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
10257
10471
  }
@@ -10384,7 +10598,11 @@ class UIObject
10384
10598
  this.navigationIndex = undefined;
10385
10599
  /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
10386
10600
  this.navigationAutoSelect = false;
10387
-
10601
+ /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
10602
+ * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
10603
+ * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
10604
+ this.anchor = vec2();
10605
+
10388
10606
  uiSystem.uiObjects.push(this);
10389
10607
  }
10390
10608
 
@@ -10637,9 +10855,9 @@ class UIText extends UIObject
10637
10855
  {
10638
10856
  super(pos, size);
10639
10857
 
10640
- ASSERT(isString(text), 'ui text must be a string');
10858
+ ASSERT(isStringLike(text), 'ui text must be a string');
10641
10859
  ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
10642
- ASSERT(isString(font), 'ui text font must be a string');
10860
+ ASSERT(isStringLike(font), 'ui text font must be a string');
10643
10861
 
10644
10862
  // set properties
10645
10863
  this.text = text;
@@ -10687,7 +10905,7 @@ class UITextInput extends UIObject
10687
10905
  {
10688
10906
  super(pos, size);
10689
10907
 
10690
- ASSERT(isString(text), 'ui text must be a string');
10908
+ ASSERT(isStringLike(text), 'ui text must be a string');
10691
10909
 
10692
10910
  /** @property {number} - Max length of input (0 = no limit) */
10693
10911
  this.maxLength = 0;
@@ -10824,7 +11042,7 @@ class UIButton extends UIObject
10824
11042
  {
10825
11043
  super(pos, size);
10826
11044
 
10827
- ASSERT(isString(text), 'ui button must be a string');
11045
+ ASSERT(isStringLike(text), 'ui button must be a string');
10828
11046
  ASSERT(isColor(color), 'ui button color must be a color');
10829
11047
 
10830
11048
  /** @property {Vector2} - Text offset for the button */
@@ -10865,7 +11083,7 @@ class UICheckbox extends UIObject
10865
11083
  {
10866
11084
  super(pos, size);
10867
11085
 
10868
- ASSERT(isString(text), 'ui checkbox must be a string');
11086
+ ASSERT(isStringLike(text), 'ui checkbox must be a string');
10869
11087
  ASSERT(isColor(color), 'ui checkbox color must be a color');
10870
11088
 
10871
11089
  /** @property {boolean} - Current percentage value of this slider 0-1 */
@@ -10922,7 +11140,7 @@ class UISlider extends UIObject
10922
11140
  super(pos, size);
10923
11141
 
10924
11142
  ASSERT(isNumber(value), 'ui slider value must be a number');
10925
- ASSERT(isString(text), 'ui slider must be a string');
11143
+ ASSERT(isStringLike(text), 'ui slider must be a string');
10926
11144
  ASSERT(isColor(color), 'ui slider color must be a color');
10927
11145
  ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
10928
11146
 
@@ -11040,7 +11258,7 @@ class UIVideo extends UIObject
11040
11258
  {
11041
11259
  super(pos, size || vec2());
11042
11260
 
11043
- ASSERT(isString(src), 'video src must be a string');
11261
+ ASSERT(isStringLike(src), 'video src must be a string');
11044
11262
  ASSERT(isNumber(volume), 'video volume must be a number');
11045
11263
 
11046
11264
  this.color = BLACK; // default to black background
@@ -13544,13 +13762,21 @@ class Tween
13544
13762
  }
13545
13763
  ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
13546
13764
 
13765
+ /** @property {function(number|Vector2|Color):void} - Called with the interpolated value each frame */
13547
13766
  this.callback = callback;
13767
+ /** @property {number|Vector2|Color} - Starting value */
13548
13768
  this.start = start;
13769
+ /** @property {number|Vector2|Color} - Ending value */
13549
13770
  this.end = end;
13771
+ /** @property {number} - Total duration in seconds */
13550
13772
  this.duration = duration;
13773
+ /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
13551
13774
  this.life = duration;
13775
+ /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
13552
13776
  this.ease = options.ease || Ease.LINEAR;
13777
+ /** @property {boolean} - If true, advance even when the game is paused */
13553
13778
  this.useRealTime = !!options.useRealTime;
13779
+ /** @property {boolean} - If true, stop advancing until cleared */
13554
13780
  this.paused = !!options.paused;
13555
13781
 
13556
13782
  /** @private completion callback set by then(), loop(), pingPong(). */
@@ -13890,7 +14116,7 @@ const Ease =
13890
14116
  function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
13891
14117
  {
13892
14118
  ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
13893
- ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
14119
+ ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13894
14120
 
13895
14121
  const parts = propertyPath.split('.');
13896
14122
  const lastKey = parts.pop();
@@ -14083,7 +14309,9 @@ class PathFinder
14083
14309
  // .size + .getCollisionData.
14084
14310
  if (isVector2(source))
14085
14311
  {
14312
+ /** @property {Vector2} - Grid dimensions in tiles */
14086
14313
  this.size = source.floor();
14314
+ /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any */
14087
14315
  this.tileLayer = undefined;
14088
14316
  }
14089
14317
  else
@@ -14095,13 +14323,18 @@ class PathFinder
14095
14323
  }
14096
14324
 
14097
14325
  // Tunables (public, freely re-assignable).
14326
+ /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */
14098
14327
  this.heuristicWeight = 1;
14099
- this.maxLoop = 500;
14328
+ /** @property {number} - Maximum A* expansions before giving up */
14329
+ this.maxLoop = 1e3;
14330
+ /** @property {boolean} - If true, post-process paths with two-pass smoothing */
14100
14331
  this.smoothPath = true;
14332
+ /** @property {boolean} - If true, draw debug visualization during findPath */
14101
14333
  this.debug = false;
14102
- this.debugTime = 2;
14334
+ /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
14335
+ this.debugTime = 1;
14103
14336
 
14104
- // Pre-allocate the node array one node per tile, reused across calls.
14337
+ /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes */
14105
14338
  this.nodes = new Array(this.size.x * this.size.y);
14106
14339
  for (let y = 0; y < this.size.y; ++y)
14107
14340
  for (let x = 0; x < this.size.x; ++x)
@@ -14275,9 +14508,12 @@ class PathFinder
14275
14508
  // Best path so far through neighbor — record it.
14276
14509
  neighbor.parent = current;
14277
14510
  neighbor.g = tentativeG;
14278
- const gdx = endNode.pos.x - neighbor.pos.x;
14279
- const gdy = endNode.pos.y - neighbor.pos.y;
14280
- neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
14511
+ // Octile heuristic tightest admissible distance for an
14512
+ // 8-connected grid with cardinal cost 1 and diagonal cost √2.
14513
+ const adx = abs(endNode.pos.x - neighbor.pos.x);
14514
+ const ady = abs(endNode.pos.y - neighbor.pos.y);
14515
+ const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady);
14516
+ neighbor.f = neighbor.g + h * this.heuristicWeight;
14281
14517
  }
14282
14518
  }
14283
14519
 
@@ -14559,6 +14795,24 @@ class PathFinder
14559
14795
  path.push(original[original.length - 1]);
14560
14796
  }
14561
14797
 
14798
+ /** Drop any middle node that lies exactly on the line through its two
14799
+ * neighbors. Backstop for the smoothing passes — the corners pass
14800
+ * intentionally keeps truly-straight runs, and the string-pulling pass
14801
+ * checks collinearity against the original path, not the in-progress
14802
+ * result, so it can leave 3+ collinear nodes in some edge cases.
14803
+ * @param {PathFinderNode[]} path
14804
+ * @private */
14805
+ dropCollinearNodes(path)
14806
+ {
14807
+ for (let i = path.length - 2; i >= 1; --i)
14808
+ {
14809
+ const a = path[i - 1], b = path[i], c = path[i + 1];
14810
+ if ((b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) ===
14811
+ (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x))
14812
+ path.splice(i, 1);
14813
+ }
14814
+ }
14815
+
14562
14816
  /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
14563
14817
  * and clear (walkable, zero-cost). Used by isLineClear's hot path.
14564
14818
  * @param {number} x
@@ -14726,6 +14980,7 @@ class PathFinder
14726
14980
  {
14727
14981
  this.smoothPathCorners(nodePath);
14728
14982
  this.smoothPathStringPull(nodePath);
14983
+ this.dropCollinearNodes(nodePath);
14729
14984
  }
14730
14985
 
14731
14986
  // Convert to world-space Vector2 path. Return copies, not live node