littlejsengine 1.18.12 → 1.18.15

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.12';
38
+ const engineVersion = '1.18.15';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -164,12 +164,20 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
164
164
  * ['tiles.png', 'tilesLevel.png'] // images to load
165
165
  * );
166
166
  * @memberof Engine */
167
- async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement=document.body)
167
+ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
168
168
  {
169
169
  showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
170
170
  ASSERT(!mainContext, 'engine already initialized');
171
+ // runtime guard so release builds (where the assert is stripped) don't
172
+ // double-register listeners / double-add canvases on a second call
173
+ if (mainContext) return;
171
174
  ASSERT(isArray(imageSources), 'pass in images as array');
172
175
 
176
+ // ensure body exists for minimal HTML where the script runs before <body> is parsed
177
+ if (!document.body)
178
+ document.documentElement.appendChild(document.createElement('body'));
179
+ rootElement ||= document.body;
180
+
173
181
  // allow passing in empty functions
174
182
  gameInit ||= ()=>{};
175
183
  gameUpdate ||= ()=>{};
@@ -195,6 +203,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
195
203
  {
196
204
  // update time keeping
197
205
  let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
206
+ // skip delta on the very first frame so timeReal doesn't jump
207
+ // by ~page-load-time when RAF starts handing real timestamps
208
+ if (!frameTimeLastMS) frameTimeDeltaMS = 0;
198
209
  frameTimeLastMS = frameTimeMS;
199
210
  if (debug || debugWatermark)
200
211
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
@@ -425,7 +436,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
425
436
  promises.push(loadTexture(0));
426
437
 
427
438
  // load engine font image
428
- promises.push(fontImageInit());
439
+ promises.push(imageFontInit());
429
440
 
430
441
  if (showSplashScreen)
431
442
  {
@@ -671,19 +682,19 @@ const max = Math.max;
671
682
  * @param {number} x
672
683
  * @return {number}
673
684
  * @memberof Math */
674
- const sign = Math.sign;
685
+ const sign = (x) => Math.sign(x);
675
686
 
676
687
  /** Returns hypotenuse of values passed in
677
688
  * @param {...number} values
678
689
  * @return {number}
679
690
  * @memberof Math */
680
- const hypot = Math.hypot;
691
+ const hypot = (...values) => Math.hypot(...values);
681
692
 
682
693
  /** Returns log2 of value passed in
683
694
  * @param {number} x
684
695
  * @return {number}
685
696
  * @memberof Math */
686
- const log2 = Math.log2;
697
+ const log2 = (x) => Math.log2(x);
687
698
 
688
699
  /** Returns sin of value passed in
689
700
  * @param {number} x
@@ -825,7 +836,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
825
836
  const dy = (posA.y - posB.y)*2;
826
837
  const sx = sizeA.x + sizeB.x;
827
838
  const sy = sizeA.y + sizeB.y;
828
- return dx >= -sx && dx < sx && dy >= -sy && dy < sy;
839
+ // symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
840
+ return abs(dx) < sx && abs(dy) < sy;
829
841
  }
830
842
 
831
843
  /** Returns true if a line segment is intersecting an axis aligned box
@@ -914,7 +926,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
914
926
  /**
915
927
  * Check if object is an array
916
928
  * @param {any} a
917
- * @return {boolean}
929
+ * @return {a is Array<any>}
918
930
  * @memberof Math */
919
931
  function isArray(a) { return Array.isArray(a); }
920
932
 
@@ -1052,7 +1064,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
1052
1064
  * @return {Vector2}
1053
1065
  * @memberof Random */
1054
1066
  function randInCircle(radius=1, minRadius=0)
1055
- { return radius > 0 ? randVec2(radius * rand(minRadius / radius, 1)**.5) : new Vector2; }
1067
+ {
1068
+ // r is uniform in area ⇒ r² uniform in [minRadius², radius²]
1069
+ // (the squared inner bound is what makes minRadius the actual exclusion edge)
1070
+ if (radius <= 0) return new Vector2;
1071
+ const ratio = clamp(minRadius / radius);
1072
+ return randVec2(radius * rand(ratio*ratio, 1)**.5);
1073
+ }
1056
1074
 
1057
1075
  /** Returns a random color between the two passed in colors, combine components if linear
1058
1076
  * @param {Color} [colorA=WHITE]
@@ -1122,7 +1140,14 @@ class RandomGenerator
1122
1140
  * @param {number} [valueA]
1123
1141
  * @param {number} [valueB]
1124
1142
  * @return {number} */
1125
- floatSign(valueA=1, valueB=0) { return this.float(valueA, valueB) * this.sign(); }
1143
+ floatSign(valueA=1, valueB=0)
1144
+ {
1145
+ const lo = min(valueA, valueB);
1146
+ const hi = max(valueA, valueB);
1147
+ const d = hi - lo;
1148
+ const e = this.float(d*2);
1149
+ return e < d ? lo + e : d - lo - e;
1150
+ }
1126
1151
 
1127
1152
  /** Returns a random angle between -PI and PI
1128
1153
  * @return {number} */
@@ -1835,9 +1860,15 @@ class Timer
1835
1860
  * @return {number} */
1836
1861
  get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
1837
1862
 
1838
- /** Get percentage elapsed based on time it was set to, returns 0 if not set
1863
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
1864
+ * Zero-duration timers report 1 (already elapsed).
1839
1865
  * @return {number} */
1840
- getPercent() { return this.isSet()? 1-percent(this.time - this.getGlobalTime(), 0, this.setTime) : 0; }
1866
+ getPercent()
1867
+ {
1868
+ if (!this.isSet()) return 0;
1869
+ if (!this.setTime) return 1;
1870
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
1871
+ }
1841
1872
 
1842
1873
  /** Get the time this timer was set to, returns 0 if not set
1843
1874
  * @return {number} */
@@ -1864,9 +1895,9 @@ class Timer
1864
1895
  * @memberof Utilities */
1865
1896
  function formatTime(t)
1866
1897
  {
1867
- const sign = t < 0 ? '-' : '';
1898
+ const signStr = t < 0 ? '-' : '';
1868
1899
  t = abs(t)|0;
1869
- return sign + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
1900
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
1870
1901
  }
1871
1902
 
1872
1903
  /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
@@ -1952,15 +1983,20 @@ function shareURL(title, url, callback)
1952
1983
  function readSaveData(saveName, defaultSaveData)
1953
1984
  {
1954
1985
  ASSERT(isStringLike(saveName), 'loadData requires saveName string');
1955
-
1956
- // replace undefined values with defaults; tolerate corrupt JSON
1957
- const data = localStorage[saveName];
1986
+
1987
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
1988
+ // iframes) and corrupt JSON in stored data
1958
1989
  let loadedData = {};
1959
- if (data)
1990
+ try
1960
1991
  {
1961
- try { loadedData = JSON.parse(data); }
1962
- catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1992
+ const data = localStorage[saveName];
1993
+ if (data)
1994
+ {
1995
+ try { loadedData = JSON.parse(data); }
1996
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1997
+ }
1963
1998
  }
1999
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
1964
2000
  return { ...defaultSaveData, ...loadedData };
1965
2001
  }
1966
2002
 
@@ -1971,7 +2007,9 @@ function readSaveData(saveName, defaultSaveData)
1971
2007
  function writeSaveData(saveName, saveData)
1972
2008
  {
1973
2009
  ASSERT(isStringLike(saveName), 'saveData requires saveName string');
1974
- localStorage[saveName] = JSON.stringify(saveData);
2010
+ // tolerate localStorage being unavailable or quota exceeded
2011
+ try { localStorage[saveName] = JSON.stringify(saveData); }
2012
+ catch { LOG('writeSaveData: failed to write', saveName); }
1975
2013
  }
1976
2014
 
1977
2015
  ///////////////////////////////////////////////////////////////////////////////
@@ -2066,7 +2104,7 @@ let canvasColorTiles = true;
2066
2104
 
2067
2105
  /** Color to clear the canvas to before render, does not clear if alpha is 0
2068
2106
  * @type {Color}
2069
- * @memberof Draw */
2107
+ * @memberof Settings */
2070
2108
  let canvasClearColor = CLEAR_BLACK;
2071
2109
 
2072
2110
  /** The max size of the canvas, centered if window is larger
@@ -2266,7 +2304,8 @@ let touchInputEnable = true;
2266
2304
  let touchGamepadEnable = false;
2267
2305
 
2268
2306
  /** True if touch gamepad should have start button in the center
2269
- * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
2307
+ * - Prevents activating within 2*touchGamepadSize of the virtual stick or face buttons
2308
+ * (one radius for the visible control + one radius of buffer beyond its edge)
2270
2309
  * - When the game is paused, any touch will press the button
2271
2310
  * - Set size to enable the center button
2272
2311
  * @type {number}
@@ -2678,7 +2717,7 @@ class EngineObject
2678
2717
  this.color = color.copy();
2679
2718
  /** @property {Color} - Additive color to apply when rendered */
2680
2719
  this.additiveColor = undefined;
2681
- /** @property {boolean} - Should it flip along y axis when rendered */
2720
+ /** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
2682
2721
  this.mirror = false;
2683
2722
  /** @property {boolean} - Has object been destroyed? */
2684
2723
  this.destroyed = false;
@@ -2764,6 +2803,9 @@ class EngineObject
2764
2803
  // child objects do not have physics
2765
2804
  ASSERT(!this.parent);
2766
2805
 
2806
+ // bail if a collision callback destroyed us mid-frame
2807
+ if (this.destroyed) return;
2808
+
2767
2809
  if (this.clampSpeed)
2768
2810
  {
2769
2811
  // limit max speed to prevent missing collisions
@@ -2819,6 +2861,8 @@ class EngineObject
2819
2861
 
2820
2862
  // notify objects of collision and check if should be resolved
2821
2863
  const collide1 = this.collideWithObject(o);
2864
+ // callback may have destroyed us; stop resolving against more objects
2865
+ if (this.destroyed) return;
2822
2866
  const collide2 = o.collideWithObject(this);
2823
2867
  if (!collide1 || !collide2) continue;
2824
2868
 
@@ -2914,13 +2958,17 @@ class EngineObject
2914
2958
  const restitution = max(this.restitution, hitLayer.restitution);
2915
2959
  if (isBlockedX)
2916
2960
  {
2917
- // try to move up a tiny bit
2961
+ // try to step over a 1-tile bump (direction follows gravity sign
2962
+ // so inverted gravity steps down off a ceiling bump instead of up;
2963
+ // zero gravity defaults to the normal-gravity step-up direction)
2918
2964
  const epsilon = 1e-3;
2919
- const maxMoveUp = .1;
2920
- const y = floor(oldPos.y-this.size.y/2+1) +
2921
- this.size.y/2 + epsilon;
2922
- const delta = y - this.pos.y;
2923
- if (delta < maxMoveUp)
2965
+ const maxMove = .1;
2966
+ const gravitySign = gravity.y > 0 ? -1 : 1;
2967
+ const y = gravitySign > 0 ?
2968
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
2969
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
2970
+ const delta = abs(y - this.pos.y);
2971
+ if (delta < maxMove)
2924
2972
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
2925
2973
  {
2926
2974
  this.pos.y = y;
@@ -3060,6 +3108,8 @@ class EngineObject
3060
3108
  * @return {EngineObject} The child object added */
3061
3109
  addChild(child, localPos=vec2(), localAngle=0)
3062
3110
  {
3111
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
3112
+ if (this.destroyed) return child;
3063
3113
  ASSERT(!child.parent && !this.children.includes(child));
3064
3114
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3065
3115
  ASSERT(child !== this, 'cannot add self as child');
@@ -3076,10 +3126,7 @@ class EngineObject
3076
3126
  removeChild(child)
3077
3127
  {
3078
3128
  ASSERT(child.parent === this && this.children.includes(child));
3079
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3080
- const index = this.children.indexOf(child);
3081
- ASSERT(index >= 0, 'child not found in children array');
3082
- index >= 0 && this.children.splice(index, 1);
3129
+ this.children.splice(this.children.indexOf(child), 1);
3083
3130
  child.parent = undefined;
3084
3131
  }
3085
3132
 
@@ -3154,7 +3201,7 @@ class EngineObject
3154
3201
  * - Optimized tile sheet sprite rendering using WebGL batching
3155
3202
  * - Primitive drawing for polygons, ellipses, and lines
3156
3203
  * - Tile-based rendering with TileInfo and TextureInfo classes
3157
- * - Text rendering with custom fonts and FontImage support
3204
+ * - Text rendering with custom fonts and ImageFont support
3158
3205
  * - Color and additive color blending for effects
3159
3206
  * - Rotation, mirroring, and scaling transformations
3160
3207
  * - Camera system with position, scale, and rotation
@@ -3250,7 +3297,7 @@ let primitiveCount;
3250
3297
  * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
3251
3298
  * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
3252
3299
  * @memberof Draw */
3253
- function tile(index=new Vector2, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3300
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3254
3301
  {
3255
3302
  ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
3256
3303
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
@@ -3301,8 +3348,8 @@ class TileInfo
3301
3348
  * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
3302
3349
  * @param {Vector2} [size] - Size of tile in pixels
3303
3350
  * @param {TextureInfo} [textureInfo] - Texture info to use
3304
- * @param {number} [padding] - How many pixels padding around tiles
3305
- * @param {number} [bleed] - How many pixels smaller to draw tiles
3351
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
3352
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
3306
3353
  */
3307
3354
  constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
3308
3355
  {
@@ -3334,7 +3381,7 @@ class TileInfo
3334
3381
  ASSERT(typeof frame === 'number');
3335
3382
  const w = this.size.x + this.padding*2;
3336
3383
  const x = frame*w;
3337
- ASSERT(x < this.textureInfo.size.x, 'frame extends beyond texture width!');
3384
+ ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
3338
3385
  return this.offset(new Vector2(x));
3339
3386
  }
3340
3387
 
@@ -3423,7 +3470,7 @@ class TextureInfo
3423
3470
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
3424
3471
  * @memberof Draw */
3425
3472
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3426
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
3473
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
3427
3474
  {
3428
3475
  ASSERT(isVector2(pos), 'pos must be a vec2');
3429
3476
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -3466,10 +3513,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3466
3513
  }
3467
3514
  else
3468
3515
  {
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.
3516
+ // untextured: fold color+additive to match the Canvas2D path's
3517
+ // color.add(additiveColor) on line ~337.
3473
3518
  const combined = additiveColor ? color.add(additiveColor) : color;
3474
3519
  glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
3475
3520
  }
@@ -3682,7 +3727,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3682
3727
  * @param {boolean} [screenSpace]
3683
3728
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3684
3729
  * @memberof Draw */
3685
- function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
3730
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
3686
3731
  {
3687
3732
  ASSERT(isArray(points), 'points must be an array');
3688
3733
  ASSERT(isNumber(width), 'width must be a number');
@@ -3731,7 +3776,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
3731
3776
  * @param {boolean} [screenSpace]
3732
3777
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3733
3778
  * @memberof Draw */
3734
- function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
3779
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
3735
3780
  {
3736
3781
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
3737
3782
  const size = vec2(width, halfDelta.length()*2);
@@ -3747,9 +3792,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
3747
3792
  * @param {Vector2} [size=vec2(1)]
3748
3793
  * @param {number} [sides]
3749
3794
  * @param {Color} [color=WHITE]
3750
- * @param {number} [angle]
3751
3795
  * @param {number} [lineWidth]
3752
3796
  * @param {Color} [lineColor=BLACK]
3797
+ * @param {number} [angle]
3753
3798
  * @param {boolean} [useWebGL=glEnable]
3754
3799
  * @param {boolean} [screenSpace]
3755
3800
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -3842,7 +3887,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
3842
3887
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3843
3888
 
3844
3889
  // clamp line width to prevent artifacts
3845
- lineWidth = clamp(lineWidth, 0, Math.min(size.x, size.y));
3890
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
3846
3891
 
3847
3892
  if (useWebGL && glEnable)
3848
3893
  {
@@ -3884,24 +3929,26 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
3884
3929
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
3885
3930
  }
3886
3931
 
3887
- /** Draw a circle filled with a radial gradient from the center to the rim
3932
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
3888
3933
  * - Best when batched with other untextured polys
3889
3934
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
3890
3935
  * - Stacking gradients at the exact same position may show a faint vertical artifact
3891
3936
  * @param {Vector2} pos
3892
- * @param {number} [size=1] - Diameter
3937
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
3893
3938
  * @param {Color} [colorInner=WHITE]
3894
3939
  * @param {Color} [colorOuter=CLEAR_WHITE]
3940
+ * @param {number} [angle]
3895
3941
  * @param {boolean} [useWebGL=glEnable]
3896
3942
  * @param {boolean} [screenSpace]
3897
3943
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3898
3944
  * @memberof Draw */
3899
- let drawCircleGradientOffset = 0;
3900
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
3945
+ let drawEllipseGradientOffset = 0;
3946
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
3901
3947
  {
3902
3948
  ASSERT(isVector2(pos), 'pos must be a vec2');
3903
- ASSERT(isNumber(size), 'size must be a number');
3949
+ ASSERT(isVector2(size), 'size must be a vec2');
3904
3950
  ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
3951
+ ASSERT(isNumber(angle), 'angle must be a number');
3905
3952
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3906
3953
 
3907
3954
  if (headlessMode) return;
@@ -3913,26 +3960,33 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
3913
3960
  {
3914
3961
  // convert to world space
3915
3962
  pos = screenToWorld(pos);
3916
- size /= cameraScale;
3963
+ size = size.scale(1/cameraScale);
3964
+ angle += cameraAngle;
3917
3965
  }
3918
3966
  // fan as tristrip; rotate the boundary vertex by one slice per call
3919
3967
  // so back-to-back gradients at the same position have their hole
3920
3968
  // (from gpu edge-rule on the boundary line-degen) at different rim
3921
3969
  // verts and don't visibly stack
3922
3970
  const sides = glCircleSides;
3923
- const radius = size/2;
3971
+ const radiusX = size.x/2, radiusY = size.y/2;
3924
3972
  const innerInt = colorInner.rgbaInt();
3925
3973
  const outerInt = colorOuter.rgbaInt();
3926
- const offset = drawCircleGradientOffset++;
3974
+ const offset = drawEllipseGradientOffset++;
3975
+ const c = cos(-angle), s = sin(-angle);
3976
+ const rim = (a) =>
3977
+ {
3978
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
3979
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
3980
+ };
3927
3981
  const startA = (offset%sides)/sides*PI*2;
3928
- const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
3982
+ const points = [rim(startA)];
3929
3983
  const colors = [outerInt];
3930
3984
  for (let i=sides; i--;)
3931
3985
  {
3932
3986
  const a = ((i+offset)%sides)/sides*PI*2;
3933
3987
  points.push(pos);
3934
3988
  colors.push(innerInt);
3935
- points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
3989
+ points.push(rim(a));
3936
3990
  colors.push(outerInt);
3937
3991
  }
3938
3992
  glDrawColoredPoints(points, colors);
@@ -3942,7 +3996,7 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
3942
3996
  // normal canvas 2D rendering method (slower)
3943
3997
  ++drawCount;
3944
3998
  ++primitiveCount;
3945
- drawCanvas2D(pos, vec2(size), 0, false, (context)=>
3999
+ drawCanvas2D(pos, size, angle, false, (context)=>
3946
4000
  {
3947
4001
  const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
3948
4002
  gradient.addColorStop(0, colorInner.toString());
@@ -3955,6 +4009,24 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
3955
4009
  }
3956
4010
  }
3957
4011
 
4012
+ /** Draw a circle filled with a radial gradient from the center to the rim
4013
+ * - Best when batched with other untextured polys
4014
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4015
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
4016
+ * @param {Vector2} pos
4017
+ * @param {number} [size=1] - Diameter
4018
+ * @param {Color} [colorInner=WHITE]
4019
+ * @param {Color} [colorOuter=CLEAR_WHITE]
4020
+ * @param {boolean} [useWebGL=glEnable]
4021
+ * @param {boolean} [screenSpace]
4022
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4023
+ * @memberof Draw */
4024
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4025
+ {
4026
+ ASSERT(isNumber(size), 'size must be a number');
4027
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
4028
+ }
4029
+
3958
4030
  /**
3959
4031
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
3960
4032
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -4009,7 +4081,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4009
4081
  * @param {number} [angle]
4010
4082
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
4011
4083
  * @memberof Draw */
4012
- function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
4084
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
4013
4085
  {
4014
4086
  // convert to screen space
4015
4087
  pos = worldToScreen(pos);
@@ -4049,16 +4121,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
4049
4121
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4050
4122
  ASSERT(isNumber(angle), 'angle must be a number');
4051
4123
 
4124
+ const lines = (text+'').split('\n');
4125
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
4126
+ // save before style mutations so caller's context state is preserved
4127
+ context.save();
4052
4128
  context.fillStyle = color.toString();
4053
4129
  context.strokeStyle = lineColor.toString();
4054
4130
  context.lineWidth = lineWidth;
4055
4131
  context.textAlign = textAlign;
4056
4132
  context.font = fontStyle + ' ' + size + 'px '+ font;
4057
4133
  context.textBaseline = 'middle';
4058
-
4059
- const lines = (text+'').split('\n');
4060
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
4061
- context.save();
4062
4134
  context.translate(pos.x, posY);
4063
4135
  context.rotate(-angle);
4064
4136
  let yOffset = 0;
@@ -4219,6 +4291,9 @@ function isOnScreen(pos, size=0)
4219
4291
  ASSERT(isVector2(pos), 'pos must be a vec2');
4220
4292
  ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
4221
4293
 
4294
+ // cameraScale of 0 collapses world coords; nothing is visible
4295
+ if (!cameraScale) return false;
4296
+
4222
4297
  // optimized circle on screen test
4223
4298
  // pos = worldToScreen(pos);
4224
4299
  let x = pos.x - cameraPos.x;
@@ -4260,7 +4335,10 @@ function combineCanvases()
4260
4335
  const w = mainCanvasSize.x, h = mainCanvasSize.y;
4261
4336
  workCanvas.width = w;
4262
4337
  workCanvas.height = h;
4263
- workContext.fillRect(0,0,w,h); // remove background alpha
4338
+ // remove background alpha — explicit fillStyle so a previous caller
4339
+ // leaving workContext.fillStyle transparent can't silently no-op this
4340
+ workContext.fillStyle = '#000';
4341
+ workContext.fillRect(0,0,w,h);
4264
4342
  glCopyToContext(workContext);
4265
4343
  workContext.drawImage(mainCanvas, 0, 0);
4266
4344
  mainContext.drawImage(workCanvas, 0, 0);
@@ -4403,33 +4481,33 @@ function setCursor(cursorStyle = 'auto')
4403
4481
  ///////////////////////////////////////////////////////////////////////////////
4404
4482
 
4405
4483
  /** Engine font image, 8x8 font provided by the engine
4406
- * @type {FontImage}
4484
+ * @type {ImageFont}
4407
4485
  * @memberof Draw */
4408
- let engineFontImage;
4486
+ let engineImageFont;
4409
4487
 
4410
4488
  /**
4411
- * Font Image Object - Draw text by using tiles in an image
4489
+ * Image Font Object - Draw text by using tiles in an image
4412
4490
  * - 96 characters (from space to tilde) are stored in an image
4413
4491
  * - A 8x8 default engine font is supplied for general use
4414
4492
  * - This system is WebGL enabled for fast text rendering
4415
4493
  * - Fonts can also be colored and scaled along each axis
4416
- *
4494
+ *
4417
4495
  * @memberof Draw
4418
4496
  * @example
4419
4497
  * // use built in font
4420
- * const font = engineFontImage;
4498
+ * const font = engineImageFont;
4421
4499
  *
4422
4500
  * // draw text
4423
4501
  * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
4424
4502
  */
4425
- class FontImage
4503
+ class ImageFont
4426
4504
  {
4427
4505
  /** Create an image font
4428
4506
  * @param {TileInfo} tileInfo - Tile info of first character in font
4429
4507
  */
4430
4508
  constructor(tileInfo)
4431
4509
  {
4432
- ASSERT(!!tileInfo, 'tileInfo is required for FontImage');
4510
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
4433
4511
 
4434
4512
  /** @property {TileInfo} - Tile info for the font */
4435
4513
  this.tileInfo = tileInfo.frame(0);
@@ -4514,7 +4592,7 @@ class FontImage
4514
4592
  }
4515
4593
 
4516
4594
  // load engine font, called automatically on startup
4517
- async function fontImageInit()
4595
+ async function imageFontInit()
4518
4596
  {
4519
4597
  const image = new Image;
4520
4598
  await new Promise(resolve =>
@@ -4527,7 +4605,7 @@ async function fontImageInit()
4527
4605
  const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
4528
4606
  const textureInfo = new TextureInfo(image);
4529
4607
  const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
4530
- engineFontImage = new FontImage(tileInfo);
4608
+ engineImageFont = new ImageFont(tileInfo);
4531
4609
  }
4532
4610
  /**
4533
4611
  * LittleJS Input System
@@ -4976,9 +5054,11 @@ function inputInit()
4976
5054
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
4977
5055
  }
4978
5056
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
4979
- function onMouseWheel(e)
4980
- {
4981
- mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY);
5057
+ function onMouseWheel(e)
5058
+ {
5059
+ // accumulate so multiple wheel events in one frame are not lost
5060
+ if (!e.ctrlKey)
5061
+ mouseWheel += sign(e.deltaY);
4982
5062
  if (inputPreventDefault && e.cancelable && document.hasFocus())
4983
5063
  e.preventDefault(); // prevent page scrolling
4984
5064
  }
@@ -5103,9 +5183,14 @@ function inputInit()
5103
5183
  if (button < touchGamepadButtonCount)
5104
5184
  touchGamepadButtons[button] = 1;
5105
5185
  }
5106
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
5186
+ else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
5187
+ stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
5188
+ buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
5107
5189
  {
5108
5190
  // virtual start button in center
5191
+ // require a fat-finger buffer of touchGamepadSize beyond the
5192
+ // edge of the stick/buttons so drift off those controls can't
5193
+ // accidentally fire start
5109
5194
  touchGamepadButtons[9] = 1;
5110
5195
  }
5111
5196
  }
@@ -5147,7 +5232,7 @@ function inputUpdate()
5147
5232
  v > min ? percent(v, min, max) :
5148
5233
  v < -min ? -percent(-v, min, max) : 0;
5149
5234
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5150
- }
5235
+ };
5151
5236
 
5152
5237
  // update touch gamepad if enabled
5153
5238
  if (touchGamepadEnable && isTouchDevice)
@@ -5161,7 +5246,12 @@ function inputUpdate()
5161
5246
  debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5162
5247
  debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5163
5248
  if (touchGamepadCenterButtonSize)
5249
+ {
5164
5250
  debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
5251
+ // exclusion bubbles around controls (where start is blocked)
5252
+ debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5253
+ debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5254
+ }
5165
5255
  }
5166
5256
 
5167
5257
  if (!touchGamepadTimer.isSet()) return;
@@ -5268,13 +5358,6 @@ function inputUpdate()
5268
5358
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5269
5359
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5270
5360
  }
5271
- else if (gamepad.axes && gamepad.axes.length >= 2)
5272
- {
5273
- // digital style dpad from axes
5274
- const x = clamp(round(gamepad.axes[0]), -1, 1);
5275
- const y = clamp(round(gamepad.axes[1]), -1, 1);
5276
- dpad.set(x, -y);
5277
- }
5278
5361
 
5279
5362
  // copy dpad to left analog stick when pressed
5280
5363
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
@@ -5480,7 +5563,7 @@ class Sound
5480
5563
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
5481
5564
  this.onloadCallback = onloadCallback;
5482
5565
 
5483
- if (Array.isArray(asset))
5566
+ if (isArray(asset))
5484
5567
  {
5485
5568
  // generate zzfx sound — copy so we don't mutate the caller's array
5486
5569
  const zzfxSound = asset.slice();
@@ -5572,7 +5655,7 @@ class Sound
5572
5655
  }
5573
5656
 
5574
5657
  /** Get how long this sound is in seconds
5575
- * @return {number} - How long the sound is in seconds (undefined if loading)
5658
+ * @return {number} - How long the sound is in seconds (0 if loading)
5576
5659
  */
5577
5660
  getDuration()
5578
5661
  { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
@@ -5729,10 +5812,14 @@ class SoundInstance
5729
5812
  {
5730
5813
  if (fadeTime)
5731
5814
  {
5732
- // ramp off gain
5815
+ // ramp off gain from current volume (not 1, or low-volume
5816
+ // instances would jump back up before fading);
5817
+ // cancel any prior scheduling so stacked stop calls don't
5818
+ // re-anchor partway through a previous fade
5733
5819
  const startFade = audioContext.currentTime;
5734
5820
  const endFade = startFade + fadeTime;
5735
- this.gainNode.gain.linearRampToValueAtTime(1, startFade);
5821
+ this.gainNode.gain.cancelScheduledValues(startFade);
5822
+ this.gainNode.gain.setValueAtTime(this.volume, startFade);
5736
5823
  this.gainNode.gain.linearRampToValueAtTime(0, endFade);
5737
5824
  this.source.stop(endFade);
5738
5825
  }
@@ -5780,13 +5867,14 @@ class SoundInstance
5780
5867
  */
5781
5868
  getCurrentTime()
5782
5869
  {
5783
- const deltaTime = mod(audioContext.currentTime - this.startTime,
5784
- this.getDuration());
5785
- return this.isPlaying() ? deltaTime : this.pausedTime;
5870
+ if (!this.isPlaying()) return this.pausedTime;
5871
+ const duration = this.getDuration();
5872
+ // guard mod against 0 duration (rate=0 or sound not loaded)
5873
+ return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
5786
5874
  }
5787
5875
 
5788
5876
  /** Get the total duration of this sound
5789
- * @return {number} - Total duration in seconds
5877
+ * @return {number} - Total duration in seconds (0 if loading)
5790
5878
  */
5791
5879
  getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
5792
5880
 
@@ -5810,7 +5898,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
5810
5898
  {
5811
5899
  ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
5812
5900
  if (!soundEnable || headlessMode) return;
5813
- if (!speechSynthesis) return;
5901
+ if (typeof speechSynthesis === 'undefined') return;
5814
5902
 
5815
5903
  // common languages (not supported by all browsers)
5816
5904
  // en - english, it - italian, fr - french, de - german, es - spanish
@@ -5828,7 +5916,11 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
5828
5916
 
5829
5917
  /** Stop all queued speech
5830
5918
  * @memberof Audio */
5831
- function speakStop() {speechSynthesis?.cancel();}
5919
+ function speakStop()
5920
+ {
5921
+ if (typeof speechSynthesis !== 'undefined')
5922
+ speechSynthesis.cancel();
5923
+ }
5832
5924
 
5833
5925
  /** Get frequency of a note on a musical scale
5834
5926
  * @param {number} semitoneOffset - How many semitones away from the root note
@@ -5890,9 +5982,14 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
5890
5982
  const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
5891
5983
  source.connect(pannerNode).connect(gainNode);
5892
5984
 
5893
- // callback when the sound ends
5894
- if (onended)
5895
- source.addEventListener('ended', ()=> onended(source));
5985
+ // disconnect nodes when the sound ends so the audio graph doesn't grow
5986
+ // unbounded across many play() calls (source.stop() also fires 'ended')
5987
+ source.addEventListener('ended', ()=>
5988
+ {
5989
+ gainNode.disconnect();
5990
+ pannerNode.disconnect();
5991
+ if (onended) onended(source);
5992
+ });
5896
5993
 
5897
5994
  // play and return sound
5898
5995
  const startOffset = offset * rate;
@@ -6129,14 +6226,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
6129
6226
  * @memberof TileLayers */
6130
6227
  function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
6131
6228
  {
6229
+ // check every layer and keep the closest hit so a far hit in an
6230
+ // earlier-registered layer doesn't shadow a closer hit in a later one
6231
+ let closestHit, closestDistSq, closestNormal;
6232
+ const scratchNormal = normal && vec2();
6132
6233
  for (const layer of tileCollisionLayers)
6133
6234
  {
6134
6235
  if (!solidOnly || layer.isSolid)
6135
6236
  {
6136
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal)
6137
- if (hitPos) return hitPos;
6237
+ const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
6238
+ if (hitPos)
6239
+ {
6240
+ const d = posStart.distanceSquared(hitPos);
6241
+ if (closestHit === undefined || d < closestDistSq)
6242
+ {
6243
+ closestHit = hitPos;
6244
+ closestDistSq = d;
6245
+ if (normal) closestNormal = scratchNormal.copy();
6246
+ }
6247
+ }
6138
6248
  }
6139
6249
  }
6250
+ if (closestHit && normal) normal.setFrom(closestNormal);
6251
+ return closestHit;
6140
6252
  }
6141
6253
 
6142
6254
  ///////////////////////////////////////////////////////////////////////////////
@@ -6310,38 +6422,6 @@ class CanvasLayer extends EngineObject
6310
6422
  drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
6311
6423
  }
6312
6424
 
6313
- /** Draw a tile onto the layer canvas in world space
6314
- * @param {Vector2} pos
6315
- * @param {Vector2} [size=vec2(1)]
6316
- * @param {TileInfo} [tileInfo]
6317
- * @param {Color} [color=WHITE]
6318
- * @param {number} [angle]
6319
- * @param {boolean} [mirror] */
6320
- drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
6321
- {
6322
- pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
6323
- size = size.multiply(this.tileInfo.size);
6324
- pos.y = this.canvas.height - pos.y;
6325
-
6326
- // draw the tile onto the layer canvas
6327
- const oldMainCanvasSize = mainCanvasSize;
6328
- mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
6329
- const useWebGL = this.hasWebGL();
6330
- useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
6331
- const drawContext = useWebGL ? undefined : this.context;
6332
- drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
6333
- useWebGL && glSetRenderTarget();
6334
- mainCanvasSize = oldMainCanvasSize;
6335
- }
6336
-
6337
- /** Draw a rectangle onto the layer canvas in world space
6338
- * @param {Vector2} pos
6339
- * @param {Vector2} [size=vec2(1)]
6340
- * @param {Color} [color=WHITE]
6341
- * @param {number} [angle] */
6342
- drawRect(pos, size, color, angle)
6343
- { this.drawTile(pos, size, undefined, color, angle); }
6344
-
6345
6425
  /** Create WebGL texture if necessary and copy layer canvas to it */
6346
6426
  updateWebGL()
6347
6427
  { this.textureInfo.createWebGLTexture(); }
@@ -6397,6 +6477,8 @@ class TileLayer extends CanvasLayer
6397
6477
  this.redrawTileData = ()=> {};
6398
6478
  this.drawLayerTile = ()=> {};
6399
6479
  this.drawLayerRect = ()=> {};
6480
+ this.drawTile = ()=> {};
6481
+ this.drawRect = ()=> {};
6400
6482
  this.clearLayerRect = ()=> {};
6401
6483
  return;
6402
6484
  }
@@ -6424,7 +6506,7 @@ class TileLayer extends CanvasLayer
6424
6506
  ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
6425
6507
 
6426
6508
  if (!layerPos.arrayCheck(this.size)) return;
6427
- this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
6509
+ this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
6428
6510
 
6429
6511
  if (!redraw) return;
6430
6512
  const isRedraw = drawContext === this.context;
@@ -6439,11 +6521,11 @@ class TileLayer extends CanvasLayer
6439
6521
 
6440
6522
  /** Get data at a given position in the array
6441
6523
  * @param {Vector2} layerPos - Local position in array
6442
- * @return {TileLayerData} */
6524
+ * @return {TileLayerData|undefined} */
6443
6525
  getData(layerPos)
6444
- {
6526
+ {
6445
6527
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6446
- return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0];
6528
+ return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
6447
6529
  }
6448
6530
 
6449
6531
  // Update the tile layer, refresh texture if needed
@@ -6551,7 +6633,7 @@ class TileLayer extends CanvasLayer
6551
6633
 
6552
6634
  // draw the tile if it has layer data
6553
6635
  const d = this.getData(layerPos);
6554
- if (!d.tile) return;
6636
+ if (!d || !d.tile) return;
6555
6637
 
6556
6638
  const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
6557
6639
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
@@ -6597,6 +6679,38 @@ class TileLayer extends CanvasLayer
6597
6679
  drawLayerRect(pos, size, color, angle=0)
6598
6680
  { this.drawLayerTile(pos, size, undefined, color, angle); }
6599
6681
 
6682
+ /** Draw a tile onto the layer canvas in world space
6683
+ * @param {Vector2} pos
6684
+ * @param {Vector2} [size=vec2(1)]
6685
+ * @param {TileInfo} [tileInfo]
6686
+ * @param {Color} [color=WHITE]
6687
+ * @param {number} [angle]
6688
+ * @param {boolean} [mirror] */
6689
+ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
6690
+ {
6691
+ pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
6692
+ size = size.multiply(this.tileInfo.size);
6693
+ pos.y = this.canvas.height - pos.y;
6694
+
6695
+ // draw the tile onto the layer canvas
6696
+ const oldMainCanvasSize = mainCanvasSize;
6697
+ mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
6698
+ const useWebGL = this.hasWebGL();
6699
+ useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
6700
+ const drawContext = useWebGL ? undefined : this.context;
6701
+ drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
6702
+ useWebGL && glSetRenderTarget();
6703
+ mainCanvasSize = oldMainCanvasSize;
6704
+ }
6705
+
6706
+ /** Draw a rectangle onto the layer canvas in world space
6707
+ * @param {Vector2} pos
6708
+ * @param {Vector2} [size=vec2(1)]
6709
+ * @param {Color} [color=WHITE]
6710
+ * @param {number} [angle] */
6711
+ drawRect(pos, size, color, angle)
6712
+ { this.drawTile(pos, size, undefined, color, angle); }
6713
+
6600
6714
  /** Clear a rectangle in layer space
6601
6715
  * @param {Vector2} pos - position in pixel coordinates
6602
6716
  * @param {Vector2} size
@@ -6675,7 +6789,7 @@ class TileCollisionLayer extends TileLayer
6675
6789
  setCollisionData(layerPos, data=1)
6676
6790
  {
6677
6791
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6678
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
6792
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
6679
6793
  layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
6680
6794
  }
6681
6795
 
@@ -6690,7 +6804,7 @@ class TileCollisionLayer extends TileLayer
6690
6804
  getCollisionData(layerPos)
6691
6805
  {
6692
6806
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6693
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
6807
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
6694
6808
  return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
6695
6809
  }
6696
6810
 
@@ -6715,8 +6829,10 @@ class TileCollisionLayer extends TileLayer
6715
6829
  const posY = pos.y - this.pos.y;
6716
6830
  const minX = max(posX - size.x/2|0, 0);
6717
6831
  const minY = max(posY - size.y/2|0, 0);
6718
- const maxX = min(posX + size.x/2, this.size.x);
6719
- const maxY = min(posY + size.y/2, this.size.y);
6832
+ // ensure at least one cell is visited even when size is 0 and pos
6833
+ // lands exactly on an integer boundary (documented point-test mode)
6834
+ const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
6835
+ const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
6720
6836
  const hitPos = new Vector2;
6721
6837
  for (let y = minY; y < maxY; ++y)
6722
6838
  for (let x = minX; x < maxX; ++x)
@@ -6835,7 +6951,7 @@ class ParticleEmitter extends EngineObject
6835
6951
  * @param {number} [angleDamping] - How much to dampen particle angular speed
6836
6952
  * @param {number} [gravityScale] - How much gravity effect particles
6837
6953
  * @param {number} [particleConeAngle] - Cone for start particle angle
6838
- * @param {number} [fadeRate] - How quick to fade particles at start/end in percent of life
6954
+ * @param {number} [fadeRate] - Fraction of life spent fading: half at fade-in (start), half at fade-out (end). e.g. .2 = 10% fade-in, 80% full opacity, 10% fade-out
6839
6955
  * @param {number} [randomness] - Apply extra randomness percent
6840
6956
  * @param {boolean} [collideTiles] - Do particles collide against tiles
6841
6957
  * @param {boolean} [additive] - Should particles use additive blend
@@ -6919,7 +7035,7 @@ class ParticleEmitter extends EngineObject
6919
7035
  this.gravityScale = gravityScale;
6920
7036
  /** @property {number} - Cone for start particle angle */
6921
7037
  this.particleConeAngle = particleConeAngle;
6922
- /** @property {number} - How quick to fade in particles at start/end in percent of life */
7038
+ /** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
6923
7039
  this.fadeRate = fadeRate;
6924
7040
  /** @property {number} - Apply extra randomness percent */
6925
7041
  this.randomness = randomness;
@@ -7197,33 +7313,32 @@ class Particle
7197
7313
  const hitLayer = tileCollisionTest(this.pos);
7198
7314
  if (!testCollision(oldPos))
7199
7315
  {
7200
- if (!collideCallback || collideCallback?.(this, hitLayer))
7316
+ // testCollision already invoked collideCallback with the
7317
+ // correct (this, data, pos) args; no need to re-check here.
7318
+ // test which side we bounced off (or both if a corner)
7319
+ const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7320
+ const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7321
+ const hitRestitution = max(restitution, hitLayer.restitution);
7322
+ const hitFriction = max(friction, hitLayer.friction);
7323
+ if (isBlockedX)
7201
7324
  {
7202
- // test which side we bounced off (or both if a corner)
7203
- const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7204
- const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7205
- const hitRestitution = max(restitution, hitLayer.restitution);
7206
- const hitFriction = max(friction, hitLayer.friction);
7207
- if (isBlockedX)
7208
- {
7209
- // move to previous X position and bounce
7210
- this.pos.x = oldPos.x;
7211
- this.velocity.x *= -hitRestitution;
7212
- this.velocity.y *= hitFriction;
7213
- }
7214
- if (isBlockedY || !isBlockedX)
7215
- {
7216
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7217
- if (wasFalling)
7218
- this.groundObject = hitLayer;
7219
-
7220
- // move to previous Y position and bounce
7221
- this.pos.y = oldPos.y;
7222
- this.velocity.y *= -hitRestitution;
7223
- this.velocity.x *= hitFriction;
7224
- }
7225
- debugPhysics && debugRect(this.pos, this.size, '#f00');
7325
+ // move to previous X position and bounce
7326
+ this.pos.x = oldPos.x;
7327
+ this.velocity.x *= -hitRestitution;
7328
+ this.velocity.y *= hitFriction;
7226
7329
  }
7330
+ if (isBlockedY || !isBlockedX)
7331
+ {
7332
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7333
+ if (wasFalling)
7334
+ this.groundObject = hitLayer;
7335
+
7336
+ // move to previous Y position and bounce
7337
+ this.pos.y = oldPos.y;
7338
+ this.velocity.y *= -hitRestitution;
7339
+ this.velocity.x *= hitFriction;
7340
+ }
7341
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
7227
7342
  }
7228
7343
  }
7229
7344
  }
@@ -7384,6 +7499,10 @@ function glInit(rootElement)
7384
7499
  for (const info of glTextureInfos)
7385
7500
  info.glTexture = undefined;
7386
7501
  glActiveTexture = undefined;
7502
+ // drop any partially-filled batch so the next glFlush doesn't
7503
+ // upload stale glBatchCount against fresh empty buffers on restore
7504
+ glBatchCount = 0;
7505
+ glPolyMode = false;
7387
7506
  pluginList.forEach(plugin=>plugin.glContextLost?.());
7388
7507
  });
7389
7508
  glCanvas.addEventListener('webglcontextrestored', ()=>
@@ -7742,6 +7861,10 @@ function glSetTextureData(texture, image)
7742
7861
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
7743
7862
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
7744
7863
 
7864
+ // keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
7865
+ if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
7866
+ glContext.generateMipmap(glContext.TEXTURE_2D);
7867
+
7745
7868
  // rebind active texture
7746
7869
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7747
7870
  }
@@ -7787,7 +7910,7 @@ function glFlush()
7787
7910
  {
7788
7911
  if (glEnable && glContext && glBatchCount)
7789
7912
  {
7790
- // set bend mode
7913
+ // set blend mode
7791
7914
  const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
7792
7915
  glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
7793
7916
  glContext.enable(glContext.BLEND);
@@ -7864,9 +7987,9 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
7864
7987
  }
7865
7988
 
7866
7989
  /** 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.
7990
+ * Zeroes the uvs and rgba so the texture contribution multiplies to 0,
7991
+ * then carries the real color in the additive slot. Works regardless of
7992
+ * which texture is currently bound.
7870
7993
  * @param {number} x
7871
7994
  * @param {number} y
7872
7995
  * @param {number} sizeX
@@ -7876,52 +7999,7 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
7876
7999
  * @memberof WebGL */
7877
8000
  function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
7878
8001
  {
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;
8002
+ glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
7925
8003
  }
7926
8004
 
7927
8005
  /** Transform and add a polygon to the gl draw list
@@ -7937,13 +8015,13 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
7937
8015
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
7938
8016
  {
7939
8017
  const pointsOut = [];
8018
+ const sa = sin(-angle);
8019
+ const ca = cos(-angle);
7940
8020
  for (const p of points)
7941
8021
  {
7942
8022
  // transform the point
7943
8023
  const px = p.x*sx;
7944
8024
  const py = p.y*sy;
7945
- const sa = sin(-angle);
7946
- const ca = cos(-angle);
7947
8025
  pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
7948
8026
  }
7949
8027
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
@@ -7975,11 +8053,13 @@ function glDrawPoints(points, rgba)
7975
8053
  {
7976
8054
  if (!glEnable || points.length < 3)
7977
8055
  return; // needs at least 3 points to have area
7978
-
8056
+
7979
8057
  // flush if there is not enough room or if different blend mode
7980
8058
  const vertCount = points.length + 2;
7981
8059
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
7982
8060
  glFlush();
8061
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8062
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
7983
8063
  glSetPolyMode();
7984
8064
 
7985
8065
  // setup triangle strip with degenerate verts at start and end
@@ -8003,11 +8083,13 @@ function glDrawColoredPoints(points, pointColors)
8003
8083
  {
8004
8084
  if (!glEnable || points.length < 3)
8005
8085
  return; // needs at least 3 points to have area
8006
-
8086
+
8007
8087
  // flush if there is not enough room or if different blend mode
8008
8088
  const vertCount = points.length + 2;
8009
8089
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8010
8090
  glFlush();
8091
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8092
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
8011
8093
  glSetPolyMode();
8012
8094
 
8013
8095
  // setup triangle strip with degenerate verts at start and end
@@ -8077,7 +8159,8 @@ function glMakeOutline(points, width, wrap=true)
8077
8159
  const strip = [];
8078
8160
  const n = points.length;
8079
8161
  const e = 1e-6;
8080
- const miterLimit = width*100;
8162
+ // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
8163
+ const miterLimit = 10;
8081
8164
  for (let i = 0; i < n; i++)
8082
8165
  {
8083
8166
  // for each vertex, calculate normal based on adjacent edges
@@ -8333,7 +8416,7 @@ function drawEngineLogo(t)
8333
8416
  x.closePath();
8334
8417
  gradient(0, Y, 0, Y+H,C);
8335
8418
  }
8336
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8419
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
8337
8420
 
8338
8421
  // center and fit to screen
8339
8422
  const alpha = oscillate(1,1,t);
@@ -8743,8 +8826,20 @@ class NewgroundsPlugin
8743
8826
 
8744
8827
  // get medals
8745
8828
  const medalsResult = this.call('Medal.getList');
8829
+
8830
+ // bail early if the first call failed (offline / bad session /
8831
+ // server error) so we don't block the main thread on more sync
8832
+ // XHRs that are guaranteed to also fail
8833
+ if (!medalsResult || !medalsResult.result || medalsResult.result.error)
8834
+ {
8835
+ debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
8836
+ this.medals = [];
8837
+ this.scoreboards = [];
8838
+ return;
8839
+ }
8840
+
8746
8841
  /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
8747
- this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
8842
+ this.medals = medalsResult.result.data?.['medals'] || [];
8748
8843
  debugMedals && LOG(this.medals);
8749
8844
  for (const newgroundsMedal of this.medals)
8750
8845
  {
@@ -8764,11 +8859,11 @@ class NewgroundsPlugin
8764
8859
  medal.description = medal.description + ` (${ medal.value })`;
8765
8860
  }
8766
8861
  }
8767
-
8862
+
8768
8863
  // get scoreboards
8769
8864
  const scoreboardResult = this.call('ScoreBoard.getBoards');
8770
8865
  /** @property {Array} - Scoreboards fetched from Newgrounds */
8771
- this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
8866
+ this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
8772
8867
  debugMedals && LOG(this.scoreboards);
8773
8868
 
8774
8869
  // keep the session alive with a ping every minute
@@ -8952,10 +9047,14 @@ class PostProcessPlugin
8952
9047
  function postProcessRender()
8953
9048
  {
8954
9049
  if (headlessMode || !glEnable) return;
8955
-
9050
+
8956
9051
  // clear out the buffer
8957
9052
  glFlush();
8958
9053
 
9054
+ // ensure we render to the default framebuffer (in case any earlier
9055
+ // caller this frame left a render target bound)
9056
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
9057
+
8959
9058
  // setup shader program to draw a quad
8960
9059
  glContext.useProgram(postProcess.shader);
8961
9060
  glContext.bindVertexArray(postProcess.vao);
@@ -8996,6 +9095,9 @@ class PostProcessPlugin
8996
9095
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
8997
9096
  }
8998
9097
 
9098
+ // restore default so subsequent dynamic texture uploads aren't flipped
9099
+ glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
9100
+
8999
9101
  // force it to set instanced mode
9000
9102
  glSetInstancedMode(true);
9001
9103
  }
@@ -9622,11 +9724,17 @@ class UISystemPlugin
9622
9724
  * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
9623
9725
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
9624
9726
  {
9625
- function setCallback(callback, listenerType)
9727
+ // remove any prior listeners so repeated setup calls don't stack
9728
+ if (this._dragListeners)
9729
+ for (const [type, listener] of this._dragListeners)
9730
+ document.removeEventListener(type, listener);
9731
+ this._dragListeners = [];
9732
+ const setCallback = (callback, listenerType)=>
9626
9733
  {
9627
- function listener(e) { e.preventDefault(); callback && callback(e); }
9734
+ const listener = (e)=> { e.preventDefault(); callback && callback(e); };
9628
9735
  document.addEventListener(listenerType, listener);
9629
- }
9736
+ this._dragListeners.push([listenerType, listener]);
9737
+ };
9630
9738
  setCallback(onDrop, 'drop');
9631
9739
  setCallback(onDragEnter, 'dragenter');
9632
9740
  setCallback(onDragLeave, 'dragleave');
@@ -9950,6 +10058,15 @@ class UIObject
9950
10058
  if (this.destroyed)
9951
10059
  return;
9952
10060
 
10061
+ // clear ui-system references that point at this object so events
10062
+ // don't keep firing against a destroyed target (especially the
10063
+ // keydown listener attached for keyInputObject)
10064
+ if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
10065
+ if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
10066
+ if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
10067
+ if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
10068
+ if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
10069
+
9953
10070
  // disconnect from parent and destroy children
9954
10071
  this.destroyed = 1;
9955
10072
  this.parent?.removeChild(this);
@@ -9958,6 +10075,8 @@ class UIObject
9958
10075
  child.parent = undefined;
9959
10076
  child.destroy();
9960
10077
  }
10078
+ // clear references so destroyed children can be GC'd
10079
+ this.children.length = 0;
9961
10080
  }
9962
10081
 
9963
10082
  /** Check if the mouse is overlapping this ui object
@@ -10547,6 +10666,7 @@ class UISlider extends UIObject
10547
10666
  {
10548
10667
  // toggle value between 0 and 1
10549
10668
  this.value = this.value ? 0 : 1;
10669
+ this.onChange();
10550
10670
  this.onRelease();
10551
10671
  super.navigatePressed();
10552
10672
  }
@@ -10906,6 +11026,11 @@ class Box2dObject extends EngineObject
10906
11026
  // destroy physics body, fixtures, and joints
10907
11027
  ASSERT(this.body, 'Box2dObject has no body to destroy');
10908
11028
  box2d.world.DestroyBody(this.body);
11029
+
11030
+ // remove from tracked list so paused / headless sessions don't leak
11031
+ const i = box2d.objects.indexOf(this);
11032
+ if (i >= 0)
11033
+ box2d.objects.splice(i, 1);
10909
11034
  super.destroy();
10910
11035
  }
10911
11036
 
@@ -10994,7 +11119,9 @@ class Box2dObject extends EngineObject
10994
11119
  /** Add a box shape to the body
10995
11120
  * @param {Vector2} [size]
10996
11121
  * @param {Vector2} [offset]
10997
- * @param {number} [angle]
11122
+ * @param {number} [angle] - LittleJS convention (clockwise positive).
11123
+ * Negated internally to match Box2D's CCW-positive convention so the
11124
+ * fixture aligns with the same angle passed to drawRect/drawTile.
10998
11125
  * @param {number} [density]
10999
11126
  * @param {number} [friction]
11000
11127
  * @param {number} [restitution]
@@ -11007,7 +11134,7 @@ class Box2dObject extends EngineObject
11007
11134
  ASSERT(isNumber(angle), 'angle must be a number');
11008
11135
 
11009
11136
  const shape = new box2d.instance.b2PolygonShape();
11010
- shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
11137
+ shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
11011
11138
  return this.addShape(shape, density, friction, restitution, isSensor);
11012
11139
  }
11013
11140
 
@@ -11309,9 +11436,10 @@ class Box2dObject extends EngineObject
11309
11436
  {
11310
11437
  const data = new box2d.instance.b2MassData();
11311
11438
  this.body.GetMassData(data);
11312
- localCenter && data.set_center(box2d.vec2dTo(localCenter));
11313
- mass && data.set_mass(mass);
11314
- momentOfInertia && data.set_I(momentOfInertia);
11439
+ // use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
11440
+ if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
11441
+ if (mass !== undefined) data.set_mass(mass);
11442
+ if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
11315
11443
  this.body.SetMassData(data);
11316
11444
  }
11317
11445
 
@@ -12483,6 +12611,8 @@ class Box2dPlugin
12483
12611
  const fixtureB = contact.GetFixtureB();
12484
12612
  const objectA = fixtureA.GetBody().object;
12485
12613
  const objectB = fixtureB.GetBody().object;
12614
+ // raw user-created b2Bodies may have no .object — skip those
12615
+ if (!objectA || !objectB) return;
12486
12616
  objectA.beginContact(objectB);
12487
12617
  objectB.beginContact(objectA);
12488
12618
  }
@@ -12493,6 +12623,7 @@ class Box2dPlugin
12493
12623
  const fixtureB = contact.GetFixtureB();
12494
12624
  const objectA = fixtureA.GetBody().object;
12495
12625
  const objectB = fixtureB.GetBody().object;
12626
+ if (!objectA || !objectB) return;
12496
12627
  objectA.endContact(objectB);
12497
12628
  objectB.endContact(objectA);
12498
12629
  };
@@ -12871,7 +13002,7 @@ async function box2dInit()
12871
13002
  debugDraw.DrawTransform = function(transform)
12872
13003
  {
12873
13004
  transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
12874
- const pos = vec2(transform.get_p());
13005
+ const pos = box2d.vec2From(transform.get_p());
12875
13006
  const angle = -transform.get_q().GetAngle();
12876
13007
  const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
12877
13008
  const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
@@ -13275,7 +13406,7 @@ const Ease =
13275
13406
  * @param {number} x
13276
13407
  * @returns {number}
13277
13408
  * @memberof TweenSystem */
13278
- EXPO: (x) => 2 ** (10 * x - 10),
13409
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
13279
13410
 
13280
13411
  /** Back ease-in: overshoots backward at the start before snapping forward.
13281
13412
  * @param {number} x
@@ -13288,6 +13419,8 @@ const Ease =
13288
13419
  * @returns {number}
13289
13420
  * @memberof TweenSystem */
13290
13421
  ELASTIC: (x) =>
13422
+ x === 0 ? 0 :
13423
+ x === 1 ? 1 :
13291
13424
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13292
13425
 
13293
13426
  /** Spring-like ease-out: oscillates outward after passing the target.
@@ -13448,29 +13581,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
13448
13581
  }
13449
13582
 
13450
13583
  // Continuation that schedules the next loop iteration when one finishes.
13451
- // Called from the completed tween's `then` slot. Decrements the counter and
13452
- // only spawns a new tween if more iterations remain.
13453
- function loopContinuation(prev)
13454
- {
13455
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13456
- const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
13457
- { ease: prev.ease, useRealTime: prev.useRealTime });
13458
- next.loopRemaining = prev.loopRemaining === Infinity
13459
- ? Infinity
13460
- : prev.loopRemaining - 1;
13461
- next.thenCallback = () => loopContinuation(next);
13462
- }
13463
-
13464
- // Continuation for pingPong: spawns a new tween with start and end swapped.
13465
- function pingPongContinuation(prev)
13466
- {
13467
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13468
- const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
13469
- { ease: prev.ease, useRealTime: prev.useRealTime });
13470
- next.loopRemaining = prev.loopRemaining === Infinity
13471
- ? Infinity
13472
- : prev.loopRemaining - 1;
13473
- next.thenCallback = () => pingPongContinuation(next);
13584
+ // Reuses the same Tween object across iterations so the user's handle
13585
+ // from `.loop()` keeps working — calling `.stop()` mid-loop now cancels
13586
+ // the entire chain instead of just the current iteration.
13587
+ function loopContinuation(tween)
13588
+ {
13589
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
13590
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
13591
+ tween.life = tween.duration;
13592
+ tween.thenCallback = () => loopContinuation(tween);
13593
+ tweenActive.push(tween);
13594
+ // snap to start for the new iteration (matches Tween constructor behavior)
13595
+ tween.callback(tween.interp(tween.duration));
13596
+ }
13597
+
13598
+ // Continuation for pingPong: swaps start and end on the same tween each iteration.
13599
+ function pingPongContinuation(tween)
13600
+ {
13601
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
13602
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
13603
+ const tmp = tween.start;
13604
+ tween.start = tween.end;
13605
+ tween.end = tmp;
13606
+ tween.life = tween.duration;
13607
+ tween.thenCallback = () => pingPongContinuation(tween);
13608
+ tweenActive.push(tween);
13609
+ tween.callback(tween.interp(tween.duration));
13474
13610
  }
13475
13611
 
13476
13612
  /** Engine plugin hook: advance every active tween by the appropriate delta.
@@ -13804,11 +13940,13 @@ class PathFinder
13804
13940
  if (dx !== 0 && dy !== 0)
13805
13941
  {
13806
13942
  // Diagonal step: refuse if either cardinal neighbor is
13807
- // blocked or has cost. Prevents cutting through corners.
13943
+ // blocked. Prevents cutting through walls at corners.
13944
+ // (Costed-but-walkable cardinals do not block — diagonal
13945
+ // movement around expensive terrain is standard A*.)
13808
13946
  const card1 = this.getNode(current.pos.x + dx, current.pos.y);
13809
- if (!card1 || card1.cost > 0 || !card1.walkable) continue;
13947
+ if (!card1 || !card1.walkable) continue;
13810
13948
  const card2 = this.getNode(current.pos.x, current.pos.y + dy);
13811
- if (!card2 || card2.cost > 0 || !card2.walkable) continue;
13949
+ if (!card2 || !card2.walkable) continue;
13812
13950
  stepCost = PATHFINDER_DIAGONAL_COST;
13813
13951
  }
13814
13952