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.
@@ -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
 
@@ -900,13 +901,15 @@ function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
900
901
  function isNumber(n) { return typeof n === 'number' && !isNaN(n); }
901
902
 
902
903
  /**
903
- * Check if object can be converted to a string (has a toString method)
904
+ * Check if a value is stringifiable i.e. it has a toString that returns
905
+ * a string. Use this for ASSERTs and inputs that will be coerced to text;
906
+ * use `typeof x === 'string'` inline if you need strict-string semantics.
904
907
  * - Returns true for strings, numbers, and most objects
905
908
  * - Returns false for null and undefined
906
909
  * @param {any} s
907
910
  * @return {boolean}
908
911
  * @memberof Math */
909
- function isString(s) { return s != null && typeof s?.toString() === 'string'; }
912
+ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'; }
910
913
 
911
914
  /**
912
915
  * Check if object is an array
@@ -1650,7 +1653,7 @@ class Color
1650
1653
  * @return {Color} */
1651
1654
  setHex(hex)
1652
1655
  {
1653
- ASSERT(isString(hex), 'Color hex code must be a string');
1656
+ ASSERT(isStringLike(hex), 'Color hex code must be a string');
1654
1657
  ASSERT(hex[0] === '#', 'Color hex code must start with #');
1655
1658
  ASSERT([4,5,7,9].includes(hex.length), 'Invalid hex');
1656
1659
 
@@ -1767,6 +1770,7 @@ const MAGENTA = debugProtectConstant(rgb(1,0,1));
1767
1770
  * - File saving (text, canvas, data URLs)
1768
1771
  * - Native share dialog support
1769
1772
  * - Local storage save data management
1773
+ * - Gradient noise (1D and 2D)
1770
1774
  * @namespace Utilities
1771
1775
  */
1772
1776
 
@@ -1914,8 +1918,8 @@ function saveCanvas(canvas, filename='screenshot', type='image/png')
1914
1918
  * @memberof Utilities */
1915
1919
  function saveDataURL(url, filename='download', revokeTime)
1916
1920
  {
1917
- ASSERT(isString(url), 'saveDataURL requires url string');
1918
- ASSERT(isString(filename), 'saveDataURL requires filename string');
1921
+ ASSERT(isStringLike(url), 'saveDataURL requires url string');
1922
+ ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
1919
1923
 
1920
1924
  // create link for saving screenshots
1921
1925
  const link = document.createElement('a');
@@ -1933,8 +1937,8 @@ function saveDataURL(url, filename='download', revokeTime)
1933
1937
  * @memberof Utilities */
1934
1938
  function shareURL(title, url, callback)
1935
1939
  {
1936
- ASSERT(isString(title), 'shareURL requires title string');
1937
- ASSERT(isString(url), 'shareURL requires url string');
1940
+ ASSERT(isStringLike(title), 'shareURL requires title string');
1941
+ ASSERT(isStringLike(url), 'shareURL requires url string');
1938
1942
  navigator.share?.({title, url}).then(()=>callback?.());
1939
1943
  }
1940
1944
 
@@ -1947,7 +1951,7 @@ function shareURL(title, url, callback)
1947
1951
  * @memberof Utilities */
1948
1952
  function readSaveData(saveName, defaultSaveData)
1949
1953
  {
1950
- ASSERT(isString(saveName), 'loadData requires saveName string');
1954
+ ASSERT(isStringLike(saveName), 'loadData requires saveName string');
1951
1955
 
1952
1956
  // replace undefined values with defaults; tolerate corrupt JSON
1953
1957
  const data = localStorage[saveName];
@@ -1966,8 +1970,50 @@ function readSaveData(saveName, defaultSaveData)
1966
1970
  * @memberof Utilities */
1967
1971
  function writeSaveData(saveName, saveData)
1968
1972
  {
1969
- ASSERT(isString(saveName), 'saveData requires saveName string');
1973
+ ASSERT(isStringLike(saveName), 'saveData requires saveName string');
1970
1974
  localStorage[saveName] = JSON.stringify(saveData);
1975
+ }
1976
+
1977
+ ///////////////////////////////////////////////////////////////////////////////
1978
+
1979
+ // Deterministic well-distributed hash of an integer lattice index to [0, 1).
1980
+ // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
1981
+ function noiseHash(i)
1982
+ {
1983
+ let h = (i | 0) ^ 0x9e3779b9;
1984
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
1985
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
1986
+ h ^= h >>> 16;
1987
+ return (h >>> 0) / 2**32;
1988
+ }
1989
+
1990
+ /** 1D gradient noise — returns a smooth value in [0, 1] for any real x.
1991
+ * Integer inputs land on deterministic lattice values; non-integer inputs
1992
+ * are interpolated with smoothStep for C1 continuity.
1993
+ * @param {number} x
1994
+ * @return {number}
1995
+ * @memberof Utilities */
1996
+ function noise1D(x)
1997
+ {
1998
+ const i = floor(x);
1999
+ return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
2000
+ }
2001
+
2002
+ /** 2D gradient noise — returns a smooth value in [0, 1] for any real (x, y).
2003
+ * @param {number} x
2004
+ * @param {number} y
2005
+ * @return {number}
2006
+ * @memberof Utilities */
2007
+ function noise2D(x, y)
2008
+ {
2009
+ const ix = floor(x), iy = floor(y);
2010
+ const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
2011
+ // large prime decorrelates neighboring rows
2012
+ const h = (a, b) => noiseHash(a + b * 374761393);
2013
+ return lerp(
2014
+ lerp(h(ix, iy ), h(ix + 1, iy ), fx),
2015
+ lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
2016
+ fy);
1971
2017
  }
1972
2018
  /**
1973
2019
  * LittleJS Engine Settings
@@ -3174,6 +3220,12 @@ let textureInfos = [];
3174
3220
  * @memberof Draw */
3175
3221
  let drawCount;
3176
3222
 
3223
+ /** Keeps track of how many primitives were drawn each frame for debugging
3224
+ * A single draw call can render many primitives (e.g. a WebGL sprite batch).
3225
+ * @type {number}
3226
+ * @memberof Draw */
3227
+ let primitiveCount;
3228
+
3177
3229
  // internal predicates for tint short-circuiting in canvas2D draw paths
3178
3230
  // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
3179
3231
  // isBlack includes alpha so additive colors that only contribute alpha are not skipped
@@ -3414,19 +3466,19 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3414
3466
  }
3415
3467
  else
3416
3468
  {
3417
- // if no tile info, force untextured by zeroing rgba (so whatever
3418
- // texture is bound doesn't leak in) and folding color+additive
3419
- // into the additive slot matches the Canvas2D path's
3420
- // color.add(additiveColor) on line ~337.
3469
+ // untextured: glDrawUntextured picks the optimal path (poly
3470
+ // tristrip if already in poly mode, otherwise instanced with
3471
+ // uvs/rgba zeroed). Color+additive are folded together to match
3472
+ // the Canvas2D path's color.add(additiveColor) on line ~337.
3421
3473
  const combined = additiveColor ? color.add(additiveColor) : color;
3422
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
3423
- 0, combined.rgbaInt());
3474
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
3424
3475
  }
3425
3476
  }
3426
3477
  else
3427
3478
  {
3428
3479
  // normal canvas 2D rendering method (slower)
3429
3480
  ++drawCount;
3481
+ ++primitiveCount;
3430
3482
  size = new Vector2(size.x, -size.y); // flip upside down sprites
3431
3483
  drawCanvas2D(pos, size, angle, mirror, (context)=>
3432
3484
  {
@@ -3466,13 +3518,13 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
3466
3518
  * @param {Vector2} pos
3467
3519
  * @param {Vector2} [size=vec2(1)]
3468
3520
  * @param {Color} [colorTop=WHITE]
3469
- * @param {Color} [colorBottom=BLACK]
3521
+ * @param {Color} [colorBottom=CLEAR_WHITE]
3470
3522
  * @param {number} [angle]
3471
3523
  * @param {boolean} [useWebGL=glEnable]
3472
3524
  * @param {boolean} [screenSpace]
3473
3525
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3474
3526
  * @memberof Draw */
3475
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
3527
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
3476
3528
  {
3477
3529
  ASSERT(isVector2(pos), 'pos must be a vec2');
3478
3530
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -3512,6 +3564,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
3512
3564
  {
3513
3565
  // normal canvas 2D rendering method (slower)
3514
3566
  ++drawCount;
3567
+ ++primitiveCount;
3515
3568
  size = new Vector2(size.x, -size.y); // fix upside down sprites
3516
3569
  drawCanvas2D(pos, size, angle, false, (context)=>
3517
3570
  {
@@ -3574,8 +3627,9 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3574
3627
  return;
3575
3628
  }
3576
3629
 
3577
- // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
3630
+ // Canvas2D path — increment counts here (WebGL counts via glFlush)
3578
3631
  ++drawCount;
3632
+ ++primitiveCount;
3579
3633
 
3580
3634
  if (!screenSpace)
3581
3635
  {
@@ -3649,6 +3703,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
3649
3703
  {
3650
3704
  // normal canvas 2D rendering method (slower)
3651
3705
  ++drawCount;
3706
+ ++primitiveCount;
3652
3707
  drawCanvas2D(pos, vec2(1), angle, false, (context)=>
3653
3708
  {
3654
3709
  context.strokeStyle = color.toString();
@@ -3829,6 +3884,77 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
3829
3884
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
3830
3885
  }
3831
3886
 
3887
+ /** Draw a circle filled with a radial gradient from the center to the rim
3888
+ * - Best when batched with other untextured polys
3889
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
3890
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
3891
+ * @param {Vector2} pos
3892
+ * @param {number} [size=1] - Diameter
3893
+ * @param {Color} [colorInner=WHITE]
3894
+ * @param {Color} [colorOuter=CLEAR_WHITE]
3895
+ * @param {boolean} [useWebGL=glEnable]
3896
+ * @param {boolean} [screenSpace]
3897
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3898
+ * @memberof Draw */
3899
+ let drawCircleGradientOffset = 0;
3900
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
3901
+ {
3902
+ ASSERT(isVector2(pos), 'pos must be a vec2');
3903
+ ASSERT(isNumber(size), 'size must be a number');
3904
+ ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
3905
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3906
+
3907
+ if (headlessMode) return;
3908
+
3909
+ if (useWebGL && glEnable)
3910
+ {
3911
+ ASSERT(!!glContext, 'WebGL is not enabled!');
3912
+ if (screenSpace)
3913
+ {
3914
+ // convert to world space
3915
+ pos = screenToWorld(pos);
3916
+ size /= cameraScale;
3917
+ }
3918
+ // fan as tristrip; rotate the boundary vertex by one slice per call
3919
+ // so back-to-back gradients at the same position have their hole
3920
+ // (from gpu edge-rule on the boundary line-degen) at different rim
3921
+ // verts and don't visibly stack
3922
+ const sides = glCircleSides;
3923
+ const radius = size/2;
3924
+ const innerInt = colorInner.rgbaInt();
3925
+ const outerInt = colorOuter.rgbaInt();
3926
+ const offset = drawCircleGradientOffset++;
3927
+ const startA = (offset%sides)/sides*PI*2;
3928
+ const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
3929
+ const colors = [outerInt];
3930
+ for (let i=sides; i--;)
3931
+ {
3932
+ const a = ((i+offset)%sides)/sides*PI*2;
3933
+ points.push(pos);
3934
+ colors.push(innerInt);
3935
+ points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
3936
+ colors.push(outerInt);
3937
+ }
3938
+ glDrawColoredPoints(points, colors);
3939
+ }
3940
+ else
3941
+ {
3942
+ // normal canvas 2D rendering method (slower)
3943
+ ++drawCount;
3944
+ ++primitiveCount;
3945
+ drawCanvas2D(pos, vec2(size), 0, false, (context)=>
3946
+ {
3947
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
3948
+ gradient.addColorStop(0, colorInner.toString());
3949
+ gradient.addColorStop(1, colorOuter.toString());
3950
+ context.fillStyle = gradient;
3951
+ context.beginPath();
3952
+ context.ellipse(0, 0, .5, .5, 0, 0, 9);
3953
+ context.fill();
3954
+ }, screenSpace, context);
3955
+ }
3956
+ }
3957
+
3832
3958
  /**
3833
3959
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
3834
3960
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -3912,15 +4038,15 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
3912
4038
  * @memberof Draw */
3913
4039
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
3914
4040
  {
3915
- ASSERT(isString(text), 'text must be a string');
4041
+ ASSERT(isStringLike(text), 'text must be a string');
3916
4042
  ASSERT(isVector2(pos), 'pos must be a vec2');
3917
4043
  ASSERT(isNumber(size), 'size must be a number');
3918
4044
  ASSERT(isColor(color), 'color must be a color');
3919
4045
  ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
3920
4046
  ASSERT(isColor(lineColor), 'lineColor must be a color');
3921
4047
  ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
3922
- ASSERT(isString(font), 'font must be a string');
3923
- ASSERT(isString(fontStyle), 'fontStyle must be a string');
4048
+ ASSERT(isStringLike(font), 'font must be a string');
4049
+ ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
3924
4050
  ASSERT(isNumber(angle), 'angle must be a number');
3925
4051
 
3926
4052
  context.fillStyle = color.toString();
@@ -3957,7 +4083,7 @@ async function loadTexture(textureIndex, src)
3957
4083
  {
3958
4084
  ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
3959
4085
  ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
3960
- ASSERT(!src || isString(src), 'image src must be a string');
4086
+ ASSERT(!src || isStringLike(src), 'image src must be a string');
3961
4087
 
3962
4088
  const image = new Image;
3963
4089
  if (src)
@@ -4345,7 +4471,7 @@ class FontImage
4345
4471
  */
4346
4472
  drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
4347
4473
  {
4348
- ASSERT(isString(text), 'text must be a string');
4474
+ ASSERT(isStringLike(text), 'text must be a string');
4349
4475
  ASSERT(isVector2(pos), 'pos must be a vec2');
4350
4476
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
4351
4477
  ASSERT(isColor(color), 'color must be a color');
@@ -4506,7 +4632,7 @@ function inputClear()
4506
4632
  * @memberof Input */
4507
4633
  function keyIsDown(key, device=0)
4508
4634
  {
4509
- ASSERT(isString(key), 'key must be a number or string');
4635
+ ASSERT(isStringLike(key), 'key must be a number or string');
4510
4636
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4511
4637
  return !!(inputData[device]?.[key] & 1);
4512
4638
  }
@@ -4518,7 +4644,7 @@ function keyIsDown(key, device=0)
4518
4644
  * @memberof Input */
4519
4645
  function keyWasPressed(key, device=0)
4520
4646
  {
4521
- ASSERT(isString(key), 'key must be a number or string');
4647
+ ASSERT(isStringLike(key), 'key must be a number or string');
4522
4648
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4523
4649
  return !!(inputData[device]?.[key] & 2);
4524
4650
  }
@@ -4530,7 +4656,7 @@ function keyWasPressed(key, device=0)
4530
4656
  * @memberof Input */
4531
4657
  function keyWasReleased(key, device=0)
4532
4658
  {
4533
- ASSERT(isString(key), 'key must be a number or string');
4659
+ ASSERT(isStringLike(key), 'key must be a number or string');
4534
4660
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4535
4661
  return !!(inputData[device]?.[key] & 4);
4536
4662
  }
@@ -4544,10 +4670,10 @@ function keyWasReleased(key, device=0)
4544
4670
  * @memberof Input */
4545
4671
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
4546
4672
  {
4547
- ASSERT(isString(up), 'up key must be a string');
4548
- ASSERT(isString(down), 'down key must be a string');
4549
- ASSERT(isString(left), 'left key must be a string');
4550
- ASSERT(isString(right), 'right key must be a string');
4673
+ ASSERT(isStringLike(up), 'up key must be a string');
4674
+ ASSERT(isStringLike(down), 'down key must be a string');
4675
+ ASSERT(isStringLike(left), 'left key must be a string');
4676
+ ASSERT(isStringLike(right), 'right key must be a string');
4551
4677
  const k = (key)=> keyIsDown(key) ? 1 : 0;
4552
4678
  return vec2(k(right) - k(left), k(up) - k(down));
4553
4679
  }
@@ -5265,12 +5391,10 @@ function touchGamepadButtonCenter()
5265
5391
  * @namespace Audio
5266
5392
  */
5267
5393
 
5268
- /** Audio context used by the engine. Created lazily in audioInit() to avoid
5269
- * browser autoplay warnings about constructing an AudioContext before any
5270
- * user gesture.
5394
+ /** Audio context used by the engine
5271
5395
  * @type {AudioContext}
5272
5396
  * @memberof Audio */
5273
- let audioContext;
5397
+ let audioContext = new AudioContext;
5274
5398
 
5275
5399
  /** Master gain node for all audio to pass through
5276
5400
  * @type {GainNode}
@@ -5286,13 +5410,12 @@ const audioDefaultSampleRate = 44100;
5286
5410
  * @return {boolean} - True if the audio context is running
5287
5411
  * @memberof Audio */
5288
5412
  function audioIsRunning()
5289
- { return audioContext?.state === 'running'; }
5413
+ { return audioContext.state === 'running'; }
5290
5414
 
5291
5415
  function audioInit()
5292
5416
  {
5293
5417
  if (!soundEnable || headlessMode) return;
5294
5418
 
5295
- audioContext = new AudioContext;
5296
5419
  audioMasterGain = audioContext.createGain();
5297
5420
  audioMasterGain.connect(audioContext.destination);
5298
5421
  audioMasterGain.gain.value = soundVolume; // set starting value
@@ -5338,7 +5461,7 @@ class Sound
5338
5461
  {
5339
5462
  if (!soundEnable || headlessMode) return;
5340
5463
 
5341
- ASSERT(!asset || isArray(asset) || isString(asset), 'asset must be a file name or zzfx array');
5464
+ ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
5342
5465
  ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
5343
5466
  ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
5344
5467
  ASSERT(isNumber(range), 'range must be a number');
@@ -5677,14 +5800,15 @@ class SoundInstance
5677
5800
 
5678
5801
  /** Speak text with passed in settings
5679
5802
  * @param {string} text - The text to speak
5680
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
5681
5803
  * @param {number} [volume] - How much to scale volume by
5682
5804
  * @param {number} [rate] - How quickly to speak
5683
5805
  * @param {number} [pitch] - How much to change the pitch by
5806
+ * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
5684
5807
  * @return {SpeechSynthesisUtterance} - The utterance that was spoken
5685
5808
  * @memberof Audio */
5686
- function speak(text, language='', volume=1, rate=1, pitch=1)
5809
+ function speak(text, volume=1, rate=1, pitch=1, language='')
5687
5810
  {
5811
+ ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
5688
5812
  if (!soundEnable || headlessMode) return;
5689
5813
  if (!speechSynthesis) return;
5690
5814
 
@@ -7677,7 +7801,8 @@ function glFlush()
7677
7801
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, glBatchCount);
7678
7802
  else
7679
7803
  glContext.drawArraysInstanced(glContext.TRIANGLE_STRIP, 0, 4, glBatchCount);
7680
- drawCount += glBatchCount;
7804
+ ++drawCount;
7805
+ primitiveCount += glBatchCount;
7681
7806
  glBatchCount = 0;
7682
7807
  }
7683
7808
  glBatchAdditive = glAdditive;
@@ -7738,6 +7863,67 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
7738
7863
  glPositionData[offset++] = angle;
7739
7864
  }
7740
7865
 
7866
+ /** Add an untextured rect to the gl draw list
7867
+ * Picks the optimal path: if already in poly mode, emits a tristrip rect
7868
+ * so it batches with surrounding polys; otherwise uses the instanced path
7869
+ * with uvs and rgba zeroed so the color falls through the additive slot.
7870
+ * @param {number} x
7871
+ * @param {number} y
7872
+ * @param {number} sizeX
7873
+ * @param {number} sizeY
7874
+ * @param {number} angle
7875
+ * @param {number} rgba - color as 32-bit integer
7876
+ * @memberof WebGL */
7877
+ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
7878
+ {
7879
+ if (glPolyMode)
7880
+ {
7881
+ // batch with surrounding polys as a 4-vertex tristrip rect
7882
+ const vertCount = 6; // 4 corners + 2 degenerate verts
7883
+ if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
7884
+ glFlush();
7885
+
7886
+ // compute rotated corners in world space (matches glDrawPointsTransform rotation)
7887
+ const hx = sizeX*.5, hy = sizeY*.5;
7888
+ const c = cos(angle), s = sin(angle);
7889
+ const chx = c*hx, shx = s*hx, chy = c*hy, shy = s*hy;
7890
+ const x0 = x - chx - shy, y0 = y + shx - chy; // (-hx,-hy)
7891
+ const x1 = x + chx - shy, y1 = y - shx - chy; // ( hx,-hy)
7892
+ const x2 = x - chx + shy, y2 = y + shx + chy; // (-hx, hy)
7893
+ const x3 = x + chx + shy, y3 = y - shx + chy; // ( hx, hy)
7894
+
7895
+ // write tristrip with leading/trailing degenerate verts
7896
+ let offset = glBatchCount * gl_INDICES_PER_POLY_VERTEX;
7897
+ glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
7898
+ glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
7899
+ glPositionData[offset++] = x1; glPositionData[offset++] = y1; glColorData[offset++] = rgba;
7900
+ glPositionData[offset++] = x2; glPositionData[offset++] = y2; glColorData[offset++] = rgba;
7901
+ glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
7902
+ glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
7903
+ glBatchCount += vertCount;
7904
+ return;
7905
+ }
7906
+
7907
+ // instanced path: zero uvs and rgba so the texture contribution is killed,
7908
+ // then carry the real color in the additive slot
7909
+ if (glBatchCount >= gl_MAX_INSTANCES || glBatchAdditive !== glAdditive)
7910
+ glFlush();
7911
+ glSetInstancedMode();
7912
+
7913
+ let offset = glBatchCount++ * gl_INDICES_PER_INSTANCE;
7914
+ glPositionData[offset++] = x;
7915
+ glPositionData[offset++] = y;
7916
+ glPositionData[offset++] = sizeX;
7917
+ glPositionData[offset++] = sizeY;
7918
+ glPositionData[offset++] = 0;
7919
+ glPositionData[offset++] = 0;
7920
+ glPositionData[offset++] = 0;
7921
+ glPositionData[offset++] = 0;
7922
+ glColorData[offset++] = 0;
7923
+ glColorData[offset++] = rgba;
7924
+ glPositionData[offset++] = angle;
7925
+ }
7926
+
7741
7927
  /** Transform and add a polygon to the gl draw list
7742
7928
  * @param {Array<Vector2>} points - Array of Vector2 points
7743
7929
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -8374,7 +8560,8 @@ class Medal
8374
8560
  /** @property {boolean} - Is the medal unlocked? */
8375
8561
  this.unlocked = false;
8376
8562
 
8377
- // load the source image if provided
8563
+ /** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any */
8564
+ this.image = undefined;
8378
8565
  if (src)
8379
8566
  (this.image = new Image).src = src;
8380
8567
 
@@ -8537,13 +8724,18 @@ class NewgroundsPlugin
8537
8724
  ASSERT(!cipher || cryptoJS, 'must provide cryptojs if there is a cipher');
8538
8725
 
8539
8726
  newgrounds = this; // set global newgrounds object
8727
+ /** @property {string} - The newgrounds App ID */
8540
8728
  this.app_id = app_id;
8729
+ /** @property {string|undefined} - AES-128/Base64 encryption key, if any */
8541
8730
  this.cipher = cipher;
8731
+ /** @property {Object|undefined} - CryptoJS instance used when cipher is set */
8542
8732
  this.cryptoJS = cryptoJS;
8733
+ /** @property {string} - Hostname used when logging views */
8543
8734
  this.host = location ? location.hostname : '';
8544
8735
 
8545
8736
  // get session id from url search params
8546
8737
  const url = new URL(location.href);
8738
+ /** @property {string|null} - Newgrounds session id from the URL (null when not logged in) */
8547
8739
  this.session_id = url.searchParams.get('ngio_session_id');
8548
8740
 
8549
8741
  if (!this.session_id)
@@ -8551,6 +8743,7 @@ class NewgroundsPlugin
8551
8743
 
8552
8744
  // get medals
8553
8745
  const medalsResult = this.call('Medal.getList');
8746
+ /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
8554
8747
  this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
8555
8748
  debugMedals && LOG(this.medals);
8556
8749
  for (const newgroundsMedal of this.medals)
@@ -8574,6 +8767,7 @@ class NewgroundsPlugin
8574
8767
 
8575
8768
  // get scoreboards
8576
8769
  const scoreboardResult = this.call('ScoreBoard.getBoards');
8770
+ /** @property {Array} - Scoreboards fetched from Newgrounds */
8577
8771
  this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
8578
8772
  debugMedals && LOG(this.scoreboards);
8579
8773
 
@@ -9097,12 +9291,31 @@ class UISystemPlugin
9097
9291
 
9098
9292
  engineAddPlugin(uiUpdate, uiRender);
9099
9293
 
9100
- // set object position in parent space
9294
+ // set object position based on anchor target (parent box, or canvas for roots),
9295
+ // self-pivot, and localPos offset
9101
9296
  function updateTransforms(o)
9102
9297
  {
9103
- if (!o.parent) return;
9104
- o.pos.x = o.localPos.x + o.parent.pos.x;
9105
- o.pos.y = o.localPos.y + o.parent.pos.y;
9298
+ let targetPos, targetSize;
9299
+ if (o.parent)
9300
+ {
9301
+ targetPos = o.parent.pos;
9302
+ targetSize = o.parent.size;
9303
+ }
9304
+ else
9305
+ {
9306
+ // anchor to canvas in native coords (handles nativeHeight if set)
9307
+ targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9308
+ targetSize = uiSystem.nativeHeight
9309
+ ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
9310
+ uiSystem.nativeHeight)
9311
+ : mainCanvasSize;
9312
+ }
9313
+
9314
+ const a = o.anchor;
9315
+ o.pos = targetPos
9316
+ .add(targetSize.multiply(a).scale(.5)) // anchor point on target
9317
+ .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
9318
+ .add(o.localPos); // user offset
9106
9319
  }
9107
9320
 
9108
9321
  // setup recursive update and render
@@ -9569,9 +9782,8 @@ class UISystemPlugin
9569
9782
  // confirm menu
9570
9783
  const confirmMenu = new UIObject(vec2(), size);
9571
9784
  uiSystem.confirmDialog = confirmMenu;
9572
- confirmMenu.onRender = ()=>
9785
+ confirmMenu.onRender = ()=>
9573
9786
  {
9574
- confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9575
9787
  const backgroundColor = hsl(0,0,0,.7);
9576
9788
  uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
9577
9789
  }
@@ -9704,7 +9916,11 @@ class UIObject
9704
9916
  this.navigationIndex = undefined;
9705
9917
  /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
9706
9918
  this.navigationAutoSelect = false;
9707
-
9919
+ /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
9920
+ * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
9921
+ * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
9922
+ this.anchor = vec2();
9923
+
9708
9924
  uiSystem.uiObjects.push(this);
9709
9925
  }
9710
9926
 
@@ -9957,9 +10173,9 @@ class UIText extends UIObject
9957
10173
  {
9958
10174
  super(pos, size);
9959
10175
 
9960
- ASSERT(isString(text), 'ui text must be a string');
10176
+ ASSERT(isStringLike(text), 'ui text must be a string');
9961
10177
  ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
9962
- ASSERT(isString(font), 'ui text font must be a string');
10178
+ ASSERT(isStringLike(font), 'ui text font must be a string');
9963
10179
 
9964
10180
  // set properties
9965
10181
  this.text = text;
@@ -10007,7 +10223,7 @@ class UITextInput extends UIObject
10007
10223
  {
10008
10224
  super(pos, size);
10009
10225
 
10010
- ASSERT(isString(text), 'ui text must be a string');
10226
+ ASSERT(isStringLike(text), 'ui text must be a string');
10011
10227
 
10012
10228
  /** @property {number} - Max length of input (0 = no limit) */
10013
10229
  this.maxLength = 0;
@@ -10144,7 +10360,7 @@ class UIButton extends UIObject
10144
10360
  {
10145
10361
  super(pos, size);
10146
10362
 
10147
- ASSERT(isString(text), 'ui button must be a string');
10363
+ ASSERT(isStringLike(text), 'ui button must be a string');
10148
10364
  ASSERT(isColor(color), 'ui button color must be a color');
10149
10365
 
10150
10366
  /** @property {Vector2} - Text offset for the button */
@@ -10185,7 +10401,7 @@ class UICheckbox extends UIObject
10185
10401
  {
10186
10402
  super(pos, size);
10187
10403
 
10188
- ASSERT(isString(text), 'ui checkbox must be a string');
10404
+ ASSERT(isStringLike(text), 'ui checkbox must be a string');
10189
10405
  ASSERT(isColor(color), 'ui checkbox color must be a color');
10190
10406
 
10191
10407
  /** @property {boolean} - Current percentage value of this slider 0-1 */
@@ -10242,7 +10458,7 @@ class UISlider extends UIObject
10242
10458
  super(pos, size);
10243
10459
 
10244
10460
  ASSERT(isNumber(value), 'ui slider value must be a number');
10245
- ASSERT(isString(text), 'ui slider must be a string');
10461
+ ASSERT(isStringLike(text), 'ui slider must be a string');
10246
10462
  ASSERT(isColor(color), 'ui slider color must be a color');
10247
10463
  ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
10248
10464
 
@@ -10360,7 +10576,7 @@ class UIVideo extends UIObject
10360
10576
  {
10361
10577
  super(pos, size || vec2());
10362
10578
 
10363
- ASSERT(isString(src), 'video src must be a string');
10579
+ ASSERT(isStringLike(src), 'video src must be a string');
10364
10580
  ASSERT(isNumber(volume), 'video volume must be a number');
10365
10581
 
10366
10582
  this.color = BLACK; // default to black background
@@ -12864,13 +13080,21 @@ class Tween
12864
13080
  }
12865
13081
  ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
12866
13082
 
13083
+ /** @property {function(number|Vector2|Color):void} - Called with the interpolated value each frame */
12867
13084
  this.callback = callback;
13085
+ /** @property {number|Vector2|Color} - Starting value */
12868
13086
  this.start = start;
13087
+ /** @property {number|Vector2|Color} - Ending value */
12869
13088
  this.end = end;
13089
+ /** @property {number} - Total duration in seconds */
12870
13090
  this.duration = duration;
13091
+ /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
12871
13092
  this.life = duration;
13093
+ /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
12872
13094
  this.ease = options.ease || Ease.LINEAR;
13095
+ /** @property {boolean} - If true, advance even when the game is paused */
12873
13096
  this.useRealTime = !!options.useRealTime;
13097
+ /** @property {boolean} - If true, stop advancing until cleared */
12874
13098
  this.paused = !!options.paused;
12875
13099
 
12876
13100
  /** @private completion callback set by then(), loop(), pingPong(). */
@@ -13210,7 +13434,7 @@ const Ease =
13210
13434
  function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
13211
13435
  {
13212
13436
  ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
13213
- ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13437
+ ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13214
13438
 
13215
13439
  const parts = propertyPath.split('.');
13216
13440
  const lastKey = parts.pop();
@@ -13403,7 +13627,9 @@ class PathFinder
13403
13627
  // .size + .getCollisionData.
13404
13628
  if (isVector2(source))
13405
13629
  {
13630
+ /** @property {Vector2} - Grid dimensions in tiles */
13406
13631
  this.size = source.floor();
13632
+ /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any */
13407
13633
  this.tileLayer = undefined;
13408
13634
  }
13409
13635
  else
@@ -13415,13 +13641,18 @@ class PathFinder
13415
13641
  }
13416
13642
 
13417
13643
  // Tunables (public, freely re-assignable).
13644
+ /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */
13418
13645
  this.heuristicWeight = 1;
13419
- this.maxLoop = 500;
13646
+ /** @property {number} - Maximum A* expansions before giving up */
13647
+ this.maxLoop = 1e3;
13648
+ /** @property {boolean} - If true, post-process paths with two-pass smoothing */
13420
13649
  this.smoothPath = true;
13650
+ /** @property {boolean} - If true, draw debug visualization during findPath */
13421
13651
  this.debug = false;
13422
- this.debugTime = 2;
13652
+ /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
13653
+ this.debugTime = 1;
13423
13654
 
13424
- // Pre-allocate the node array one node per tile, reused across calls.
13655
+ /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes */
13425
13656
  this.nodes = new Array(this.size.x * this.size.y);
13426
13657
  for (let y = 0; y < this.size.y; ++y)
13427
13658
  for (let x = 0; x < this.size.x; ++x)
@@ -13595,9 +13826,12 @@ class PathFinder
13595
13826
  // Best path so far through neighbor — record it.
13596
13827
  neighbor.parent = current;
13597
13828
  neighbor.g = tentativeG;
13598
- const gdx = endNode.pos.x - neighbor.pos.x;
13599
- const gdy = endNode.pos.y - neighbor.pos.y;
13600
- neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
13829
+ // Octile heuristic tightest admissible distance for an
13830
+ // 8-connected grid with cardinal cost 1 and diagonal cost √2.
13831
+ const adx = abs(endNode.pos.x - neighbor.pos.x);
13832
+ const ady = abs(endNode.pos.y - neighbor.pos.y);
13833
+ const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady);
13834
+ neighbor.f = neighbor.g + h * this.heuristicWeight;
13601
13835
  }
13602
13836
  }
13603
13837
 
@@ -13879,6 +14113,24 @@ class PathFinder
13879
14113
  path.push(original[original.length - 1]);
13880
14114
  }
13881
14115
 
14116
+ /** Drop any middle node that lies exactly on the line through its two
14117
+ * neighbors. Backstop for the smoothing passes — the corners pass
14118
+ * intentionally keeps truly-straight runs, and the string-pulling pass
14119
+ * checks collinearity against the original path, not the in-progress
14120
+ * result, so it can leave 3+ collinear nodes in some edge cases.
14121
+ * @param {PathFinderNode[]} path
14122
+ * @private */
14123
+ dropCollinearNodes(path)
14124
+ {
14125
+ for (let i = path.length - 2; i >= 1; --i)
14126
+ {
14127
+ const a = path[i - 1], b = path[i], c = path[i + 1];
14128
+ if ((b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) ===
14129
+ (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x))
14130
+ path.splice(i, 1);
14131
+ }
14132
+ }
14133
+
13882
14134
  /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
13883
14135
  * and clear (walkable, zero-cost). Used by isLineClear's hot path.
13884
14136
  * @param {number} x
@@ -14046,6 +14298,7 @@ class PathFinder
14046
14298
  {
14047
14299
  this.smoothPathCorners(nodePath);
14048
14300
  this.smoothPathStringPull(nodePath);
14301
+ this.dropCollinearNodes(nodePath);
14049
14302
  }
14050
14303
 
14051
14304
  // Convert to world-space Vector2 path. Return copies, not live node