littlejsengine 1.18.8 → 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.
@@ -71,7 +71,7 @@ class EngineObject
71
71
  this.color = color.copy();
72
72
  /** @property {Color} - Additive color to apply when rendered */
73
73
  this.additiveColor = undefined;
74
- /** @property {boolean} - Should it flip along y axis when rendered */
74
+ /** @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. */
75
75
  this.mirror = false;
76
76
  /** @property {boolean} - Has object been destroyed? */
77
77
  this.destroyed = false;
@@ -157,6 +157,9 @@ class EngineObject
157
157
  // child objects do not have physics
158
158
  ASSERT(!this.parent);
159
159
 
160
+ // bail if a collision callback destroyed us mid-frame
161
+ if (this.destroyed) return;
162
+
160
163
  if (this.clampSpeed)
161
164
  {
162
165
  // limit max speed to prevent missing collisions
@@ -212,6 +215,8 @@ class EngineObject
212
215
 
213
216
  // notify objects of collision and check if should be resolved
214
217
  const collide1 = this.collideWithObject(o);
218
+ // callback may have destroyed us; stop resolving against more objects
219
+ if (this.destroyed) return;
215
220
  const collide2 = o.collideWithObject(this);
216
221
  if (!collide1 || !collide2) continue;
217
222
 
@@ -307,13 +312,17 @@ class EngineObject
307
312
  const restitution = max(this.restitution, hitLayer.restitution);
308
313
  if (isBlockedX)
309
314
  {
310
- // try to move up a tiny bit
315
+ // try to step over a 1-tile bump (direction follows gravity sign
316
+ // so inverted gravity steps down off a ceiling bump instead of up;
317
+ // zero gravity defaults to the normal-gravity step-up direction)
311
318
  const epsilon = 1e-3;
312
- const maxMoveUp = .1;
313
- const y = floor(oldPos.y-this.size.y/2+1) +
314
- this.size.y/2 + epsilon;
315
- const delta = y - this.pos.y;
316
- if (delta < maxMoveUp)
319
+ const maxMove = .1;
320
+ const gravitySign = gravity.y > 0 ? -1 : 1;
321
+ const y = gravitySign > 0 ?
322
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
323
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
324
+ const delta = abs(y - this.pos.y);
325
+ if (delta < maxMove)
317
326
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
318
327
  {
319
328
  this.pos.y = y;
@@ -453,6 +462,8 @@ class EngineObject
453
462
  * @return {EngineObject} The child object added */
454
463
  addChild(child, localPos=vec2(), localAngle=0)
455
464
  {
465
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
466
+ if (this.destroyed) return child;
456
467
  ASSERT(!child.parent && !this.children.includes(child));
457
468
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
458
469
  ASSERT(child !== this, 'cannot add self as child');
@@ -469,10 +480,7 @@ class EngineObject
469
480
  removeChild(child)
470
481
  {
471
482
  ASSERT(child.parent === this && this.children.includes(child));
472
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
473
- const index = this.children.indexOf(child);
474
- ASSERT(index >= 0, 'child not found in children array');
475
- index >= 0 && this.children.splice(index, 1);
483
+ this.children.splice(this.children.indexOf(child), 1);
476
484
  child.parent = undefined;
477
485
  }
478
486
 
@@ -68,7 +68,7 @@ class ParticleEmitter extends EngineObject
68
68
  * @param {number} [angleDamping] - How much to dampen particle angular speed
69
69
  * @param {number} [gravityScale] - How much gravity effect particles
70
70
  * @param {number} [particleConeAngle] - Cone for start particle angle
71
- * @param {number} [fadeRate] - How quick to fade particles at start/end in percent of life
71
+ * @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
72
72
  * @param {number} [randomness] - Apply extra randomness percent
73
73
  * @param {boolean} [collideTiles] - Do particles collide against tiles
74
74
  * @param {boolean} [additive] - Should particles use additive blend
@@ -152,7 +152,7 @@ class ParticleEmitter extends EngineObject
152
152
  this.gravityScale = gravityScale;
153
153
  /** @property {number} - Cone for start particle angle */
154
154
  this.particleConeAngle = particleConeAngle;
155
- /** @property {number} - How quick to fade in particles at start/end in percent of life */
155
+ /** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
156
156
  this.fadeRate = fadeRate;
157
157
  /** @property {number} - Apply extra randomness percent */
158
158
  this.randomness = randomness;
@@ -430,33 +430,32 @@ class Particle
430
430
  const hitLayer = tileCollisionTest(this.pos);
431
431
  if (!testCollision(oldPos))
432
432
  {
433
- if (!collideCallback || collideCallback?.(this, hitLayer))
433
+ // testCollision already invoked collideCallback with the
434
+ // correct (this, data, pos) args; no need to re-check here.
435
+ // test which side we bounced off (or both if a corner)
436
+ const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
437
+ const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
438
+ const hitRestitution = max(restitution, hitLayer.restitution);
439
+ const hitFriction = max(friction, hitLayer.friction);
440
+ if (isBlockedX)
434
441
  {
435
- // test which side we bounced off (or both if a corner)
436
- const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
437
- const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
438
- const hitRestitution = max(restitution, hitLayer.restitution);
439
- const hitFriction = max(friction, hitLayer.friction);
440
- if (isBlockedX)
441
- {
442
- // move to previous X position and bounce
443
- this.pos.x = oldPos.x;
444
- this.velocity.x *= -hitRestitution;
445
- this.velocity.y *= hitFriction;
446
- }
447
- if (isBlockedY || !isBlockedX)
448
- {
449
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
450
- if (wasFalling)
451
- this.groundObject = hitLayer;
452
-
453
- // move to previous Y position and bounce
454
- this.pos.y = oldPos.y;
455
- this.velocity.y *= -hitRestitution;
456
- this.velocity.x *= hitFriction;
457
- }
458
- debugPhysics && debugRect(this.pos, this.size, '#f00');
442
+ // move to previous X position and bounce
443
+ this.pos.x = oldPos.x;
444
+ this.velocity.x *= -hitRestitution;
445
+ this.velocity.y *= hitFriction;
459
446
  }
447
+ if (isBlockedY || !isBlockedX)
448
+ {
449
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
450
+ if (wasFalling)
451
+ this.groundObject = hitLayer;
452
+
453
+ // move to previous Y position and bounce
454
+ this.pos.y = oldPos.y;
455
+ this.velocity.y *= -hitRestitution;
456
+ this.velocity.x *= hitFriction;
457
+ }
458
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
460
459
  }
461
460
  }
462
461
  }
@@ -51,7 +51,7 @@ let canvasColorTiles = true;
51
51
 
52
52
  /** Color to clear the canvas to before render, does not clear if alpha is 0
53
53
  * @type {Color}
54
- * @memberof Draw */
54
+ * @memberof Settings */
55
55
  let canvasClearColor = CLEAR_BLACK;
56
56
 
57
57
  /** The max size of the canvas, centered if window is larger
@@ -251,7 +251,8 @@ let touchInputEnable = true;
251
251
  let touchGamepadEnable = false;
252
252
 
253
253
  /** True if touch gamepad should have start button in the center
254
- * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
254
+ * - Prevents activating within 2*touchGamepadSize of the virtual stick or face buttons
255
+ * (one radius for the visible control + one radius of buffer beyond its edge)
255
256
  * - When the game is paused, any touch will press the button
256
257
  * - Set size to enable the center button
257
258
  * @type {number}
@@ -74,14 +74,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
74
74
  * @memberof TileLayers */
75
75
  function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
76
76
  {
77
+ // check every layer and keep the closest hit so a far hit in an
78
+ // earlier-registered layer doesn't shadow a closer hit in a later one
79
+ let closestHit, closestDistSq, closestNormal;
80
+ const scratchNormal = normal && vec2();
77
81
  for (const layer of tileCollisionLayers)
78
82
  {
79
83
  if (!solidOnly || layer.isSolid)
80
84
  {
81
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal)
82
- if (hitPos) return hitPos;
85
+ const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
86
+ if (hitPos)
87
+ {
88
+ const d = posStart.distanceSquared(hitPos);
89
+ if (closestHit === undefined || d < closestDistSq)
90
+ {
91
+ closestHit = hitPos;
92
+ closestDistSq = d;
93
+ if (normal) closestNormal = scratchNormal.copy();
94
+ }
95
+ }
83
96
  }
84
97
  }
98
+ if (closestHit && normal) normal.setFrom(closestNormal);
99
+ return closestHit;
85
100
  }
86
101
 
87
102
  ///////////////////////////////////////////////////////////////////////////////
@@ -255,38 +270,6 @@ class CanvasLayer extends EngineObject
255
270
  drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
256
271
  }
257
272
 
258
- /** Draw a tile onto the layer canvas in world space
259
- * @param {Vector2} pos
260
- * @param {Vector2} [size=vec2(1)]
261
- * @param {TileInfo} [tileInfo]
262
- * @param {Color} [color=WHITE]
263
- * @param {number} [angle]
264
- * @param {boolean} [mirror] */
265
- drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
266
- {
267
- pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
268
- size = size.multiply(this.tileInfo.size);
269
- pos.y = this.canvas.height - pos.y;
270
-
271
- // draw the tile onto the layer canvas
272
- const oldMainCanvasSize = mainCanvasSize;
273
- mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
274
- const useWebGL = this.hasWebGL();
275
- useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
276
- const drawContext = useWebGL ? undefined : this.context;
277
- drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
278
- useWebGL && glSetRenderTarget();
279
- mainCanvasSize = oldMainCanvasSize;
280
- }
281
-
282
- /** Draw a rectangle onto the layer canvas in world space
283
- * @param {Vector2} pos
284
- * @param {Vector2} [size=vec2(1)]
285
- * @param {Color} [color=WHITE]
286
- * @param {number} [angle] */
287
- drawRect(pos, size, color, angle)
288
- { this.drawTile(pos, size, undefined, color, angle); }
289
-
290
273
  /** Create WebGL texture if necessary and copy layer canvas to it */
291
274
  updateWebGL()
292
275
  { this.textureInfo.createWebGLTexture(); }
@@ -342,6 +325,8 @@ class TileLayer extends CanvasLayer
342
325
  this.redrawTileData = ()=> {};
343
326
  this.drawLayerTile = ()=> {};
344
327
  this.drawLayerRect = ()=> {};
328
+ this.drawTile = ()=> {};
329
+ this.drawRect = ()=> {};
345
330
  this.clearLayerRect = ()=> {};
346
331
  return;
347
332
  }
@@ -369,7 +354,7 @@ class TileLayer extends CanvasLayer
369
354
  ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
370
355
 
371
356
  if (!layerPos.arrayCheck(this.size)) return;
372
- this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
357
+ this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
373
358
 
374
359
  if (!redraw) return;
375
360
  const isRedraw = drawContext === this.context;
@@ -384,11 +369,11 @@ class TileLayer extends CanvasLayer
384
369
 
385
370
  /** Get data at a given position in the array
386
371
  * @param {Vector2} layerPos - Local position in array
387
- * @return {TileLayerData} */
372
+ * @return {TileLayerData|undefined} */
388
373
  getData(layerPos)
389
- {
374
+ {
390
375
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
391
- return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0];
376
+ return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
392
377
  }
393
378
 
394
379
  // Update the tile layer, refresh texture if needed
@@ -496,7 +481,7 @@ class TileLayer extends CanvasLayer
496
481
 
497
482
  // draw the tile if it has layer data
498
483
  const d = this.getData(layerPos);
499
- if (!d.tile) return;
484
+ if (!d || !d.tile) return;
500
485
 
501
486
  const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
502
487
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
@@ -542,6 +527,38 @@ class TileLayer extends CanvasLayer
542
527
  drawLayerRect(pos, size, color, angle=0)
543
528
  { this.drawLayerTile(pos, size, undefined, color, angle); }
544
529
 
530
+ /** Draw a tile onto the layer canvas in world space
531
+ * @param {Vector2} pos
532
+ * @param {Vector2} [size=vec2(1)]
533
+ * @param {TileInfo} [tileInfo]
534
+ * @param {Color} [color=WHITE]
535
+ * @param {number} [angle]
536
+ * @param {boolean} [mirror] */
537
+ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
538
+ {
539
+ pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
540
+ size = size.multiply(this.tileInfo.size);
541
+ pos.y = this.canvas.height - pos.y;
542
+
543
+ // draw the tile onto the layer canvas
544
+ const oldMainCanvasSize = mainCanvasSize;
545
+ mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
546
+ const useWebGL = this.hasWebGL();
547
+ useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
548
+ const drawContext = useWebGL ? undefined : this.context;
549
+ drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
550
+ useWebGL && glSetRenderTarget();
551
+ mainCanvasSize = oldMainCanvasSize;
552
+ }
553
+
554
+ /** Draw a rectangle onto the layer canvas in world space
555
+ * @param {Vector2} pos
556
+ * @param {Vector2} [size=vec2(1)]
557
+ * @param {Color} [color=WHITE]
558
+ * @param {number} [angle] */
559
+ drawRect(pos, size, color, angle)
560
+ { this.drawTile(pos, size, undefined, color, angle); }
561
+
545
562
  /** Clear a rectangle in layer space
546
563
  * @param {Vector2} pos - position in pixel coordinates
547
564
  * @param {Vector2} size
@@ -620,7 +637,7 @@ class TileCollisionLayer extends TileLayer
620
637
  setCollisionData(layerPos, data=1)
621
638
  {
622
639
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
623
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
640
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
624
641
  layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
625
642
  }
626
643
 
@@ -635,7 +652,7 @@ class TileCollisionLayer extends TileLayer
635
652
  getCollisionData(layerPos)
636
653
  {
637
654
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
638
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
655
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
639
656
  return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
640
657
  }
641
658
 
@@ -660,8 +677,10 @@ class TileCollisionLayer extends TileLayer
660
677
  const posY = pos.y - this.pos.y;
661
678
  const minX = max(posX - size.x/2|0, 0);
662
679
  const minY = max(posY - size.y/2|0, 0);
663
- const maxX = min(posX + size.x/2, this.size.x);
664
- const maxY = min(posY + size.y/2, this.size.y);
680
+ // ensure at least one cell is visited even when size is 0 and pos
681
+ // lands exactly on an integer boundary (documented point-test mode)
682
+ const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
683
+ const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
665
684
  const hitPos = new Vector2;
666
685
  for (let y = minY; y < maxY; ++y)
667
686
  for (let x = minX; x < maxX; ++x)
@@ -6,6 +6,7 @@
6
6
  * - File saving (text, canvas, data URLs)
7
7
  * - Native share dialog support
8
8
  * - Local storage save data management
9
+ * - Gradient noise (1D and 2D)
9
10
  * @namespace Utilities
10
11
  */
11
12
 
@@ -72,9 +73,15 @@ class Timer
72
73
  * @return {number} */
73
74
  get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
74
75
 
75
- /** Get percentage elapsed based on time it was set to, returns 0 if not set
76
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
77
+ * Zero-duration timers report 1 (already elapsed).
76
78
  * @return {number} */
77
- getPercent() { return this.isSet()? 1-percent(this.time - this.getGlobalTime(), 0, this.setTime) : 0; }
79
+ getPercent()
80
+ {
81
+ if (!this.isSet()) return 0;
82
+ if (!this.setTime) return 1;
83
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
84
+ }
78
85
 
79
86
  /** Get the time this timer was set to, returns 0 if not set
80
87
  * @return {number} */
@@ -101,9 +108,9 @@ class Timer
101
108
  * @memberof Utilities */
102
109
  function formatTime(t)
103
110
  {
104
- const sign = t < 0 ? '-' : '';
111
+ const signStr = t < 0 ? '-' : '';
105
112
  t = abs(t)|0;
106
- return sign + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
113
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
107
114
  }
108
115
 
109
116
  /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
@@ -189,15 +196,20 @@ function shareURL(title, url, callback)
189
196
  function readSaveData(saveName, defaultSaveData)
190
197
  {
191
198
  ASSERT(isStringLike(saveName), 'loadData requires saveName string');
192
-
193
- // replace undefined values with defaults; tolerate corrupt JSON
194
- const data = localStorage[saveName];
199
+
200
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
201
+ // iframes) and corrupt JSON in stored data
195
202
  let loadedData = {};
196
- if (data)
203
+ try
197
204
  {
198
- try { loadedData = JSON.parse(data); }
199
- catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
205
+ const data = localStorage[saveName];
206
+ if (data)
207
+ {
208
+ try { loadedData = JSON.parse(data); }
209
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
210
+ }
200
211
  }
212
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
201
213
  return { ...defaultSaveData, ...loadedData };
202
214
  }
203
215
 
@@ -208,5 +220,49 @@ function readSaveData(saveName, defaultSaveData)
208
220
  function writeSaveData(saveName, saveData)
209
221
  {
210
222
  ASSERT(isStringLike(saveName), 'saveData requires saveName string');
211
- localStorage[saveName] = JSON.stringify(saveData);
223
+ // tolerate localStorage being unavailable or quota exceeded
224
+ try { localStorage[saveName] = JSON.stringify(saveData); }
225
+ catch { LOG('writeSaveData: failed to write', saveName); }
226
+ }
227
+
228
+ ///////////////////////////////////////////////////////////////////////////////
229
+
230
+ // Deterministic well-distributed hash of an integer lattice index to [0, 1).
231
+ // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
232
+ function noiseHash(i)
233
+ {
234
+ let h = (i | 0) ^ 0x9e3779b9;
235
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
236
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
237
+ h ^= h >>> 16;
238
+ return (h >>> 0) / 2**32;
239
+ }
240
+
241
+ /** 1D gradient noise — returns a smooth value in [0, 1] for any real x.
242
+ * Integer inputs land on deterministic lattice values; non-integer inputs
243
+ * are interpolated with smoothStep for C1 continuity.
244
+ * @param {number} x
245
+ * @return {number}
246
+ * @memberof Utilities */
247
+ function noise1D(x)
248
+ {
249
+ const i = floor(x);
250
+ return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
251
+ }
252
+
253
+ /** 2D gradient noise — returns a smooth value in [0, 1] for any real (x, y).
254
+ * @param {number} x
255
+ * @param {number} y
256
+ * @return {number}
257
+ * @memberof Utilities */
258
+ function noise2D(x, y)
259
+ {
260
+ const ix = floor(x), iy = floor(y);
261
+ const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
262
+ // large prime decorrelates neighboring rows
263
+ const h = (a, b) => noiseHash(a + b * 374761393);
264
+ return lerp(
265
+ lerp(h(ix, iy ), h(ix + 1, iy ), fx),
266
+ lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
267
+ fy);
212
268
  }
@@ -88,6 +88,10 @@ function glInit(rootElement)
88
88
  for (const info of glTextureInfos)
89
89
  info.glTexture = undefined;
90
90
  glActiveTexture = undefined;
91
+ // drop any partially-filled batch so the next glFlush doesn't
92
+ // upload stale glBatchCount against fresh empty buffers on restore
93
+ glBatchCount = 0;
94
+ glPolyMode = false;
91
95
  pluginList.forEach(plugin=>plugin.glContextLost?.());
92
96
  });
93
97
  glCanvas.addEventListener('webglcontextrestored', ()=>
@@ -446,6 +450,10 @@ function glSetTextureData(texture, image)
446
450
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
447
451
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
448
452
 
453
+ // keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
454
+ if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
455
+ glContext.generateMipmap(glContext.TEXTURE_2D);
456
+
449
457
  // rebind active texture
450
458
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
451
459
  }
@@ -491,7 +499,7 @@ function glFlush()
491
499
  {
492
500
  if (glEnable && glContext && glBatchCount)
493
501
  {
494
- // set bend mode
502
+ // set blend mode
495
503
  const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
496
504
  glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
497
505
  glContext.enable(glContext.BLEND);
@@ -505,7 +513,8 @@ function glFlush()
505
513
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, glBatchCount);
506
514
  else
507
515
  glContext.drawArraysInstanced(glContext.TRIANGLE_STRIP, 0, 4, glBatchCount);
508
- drawCount += glBatchCount;
516
+ ++drawCount;
517
+ primitiveCount += glBatchCount;
509
518
  glBatchCount = 0;
510
519
  }
511
520
  glBatchAdditive = glAdditive;
@@ -566,6 +575,22 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
566
575
  glPositionData[offset++] = angle;
567
576
  }
568
577
 
578
+ /** Add an untextured rect to the gl draw list
579
+ * Zeroes the uvs and rgba so the texture contribution multiplies to 0,
580
+ * then carries the real color in the additive slot. Works regardless of
581
+ * which texture is currently bound.
582
+ * @param {number} x
583
+ * @param {number} y
584
+ * @param {number} sizeX
585
+ * @param {number} sizeY
586
+ * @param {number} angle
587
+ * @param {number} rgba - color as 32-bit integer
588
+ * @memberof WebGL */
589
+ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
590
+ {
591
+ glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
592
+ }
593
+
569
594
  /** Transform and add a polygon to the gl draw list
570
595
  * @param {Array<Vector2>} points - Array of Vector2 points
571
596
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -579,13 +604,13 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
579
604
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
580
605
  {
581
606
  const pointsOut = [];
607
+ const sa = sin(-angle);
608
+ const ca = cos(-angle);
582
609
  for (const p of points)
583
610
  {
584
611
  // transform the point
585
612
  const px = p.x*sx;
586
613
  const py = p.y*sy;
587
- const sa = sin(-angle);
588
- const ca = cos(-angle);
589
614
  pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
590
615
  }
591
616
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
@@ -617,11 +642,13 @@ function glDrawPoints(points, rgba)
617
642
  {
618
643
  if (!glEnable || points.length < 3)
619
644
  return; // needs at least 3 points to have area
620
-
645
+
621
646
  // flush if there is not enough room or if different blend mode
622
647
  const vertCount = points.length + 2;
623
648
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
624
649
  glFlush();
650
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
651
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
625
652
  glSetPolyMode();
626
653
 
627
654
  // setup triangle strip with degenerate verts at start and end
@@ -645,11 +672,13 @@ function glDrawColoredPoints(points, pointColors)
645
672
  {
646
673
  if (!glEnable || points.length < 3)
647
674
  return; // needs at least 3 points to have area
648
-
675
+
649
676
  // flush if there is not enough room or if different blend mode
650
677
  const vertCount = points.length + 2;
651
678
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
652
679
  glFlush();
680
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
681
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
653
682
  glSetPolyMode();
654
683
 
655
684
  // setup triangle strip with degenerate verts at start and end
@@ -719,7 +748,8 @@ function glMakeOutline(points, width, wrap=true)
719
748
  const strip = [];
720
749
  const n = points.length;
721
750
  const e = 1e-6;
722
- const miterLimit = width*100;
751
+ // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
752
+ const miterLimit = 10;
723
753
  for (let i = 0; i < n; i++)
724
754
  {
725
755
  // for each vertex, calculate normal based on adjacent edges