littlejsengine 1.19.3 → 1.20.0

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.
Files changed (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +58 -17
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. package/plugins/box2d.wasm.wasm +0 -0
package/src/engineDraw.js CHANGED
@@ -81,19 +81,23 @@ let textureInfos = [];
81
81
  /** Keeps track of how many draw calls there were each frame for debugging
82
82
  * @type {number}
83
83
  * @memberof Draw */
84
- let drawCount;
84
+ let drawCount = 0;
85
85
 
86
86
  /** Keeps track of how many primitives were drawn each frame for debugging
87
87
  * A single draw call can render many primitives (e.g. a WebGL sprite batch).
88
88
  * @type {number}
89
89
  * @memberof Draw */
90
- let primitiveCount;
90
+ let primitiveCount = 0;
91
91
 
92
92
  // internal predicates for tint short-circuiting in canvas2D draw paths
93
93
  // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
94
94
  // isBlack includes alpha so additive colors that only contribute alpha are not skipped
95
- /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
96
- /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
95
+ /** @ignore
96
+ * @param {Color} c */
97
+ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
98
+ /** @ignore
99
+ * @param {Color} c */
100
+ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
97
101
 
98
102
  ///////////////////////////////////////////////////////////////////////////////
99
103
 
@@ -120,8 +124,6 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
120
124
  ASSERT(isNumber(texture) || texture instanceof TextureInfo, 'texture must be a number or TextureInfo');
121
125
  ASSERT(isNumber(padding), 'padding must be a number');
122
126
 
123
- if (headlessMode) return new TileInfo;
124
-
125
127
  if (typeof size === 'number')
126
128
  {
127
129
  // if size is a number, make it a vector
@@ -132,6 +134,8 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
132
134
  // create tile info object
133
135
  const textureInfo = typeof texture === 'number' ?
134
136
  textureInfos[texture] : texture;
137
+ if (headlessMode && !textureInfo?.size.x)
138
+ return new TileInfo(new Vector2, size.copy(), textureInfo, padding, bleed); // no image loaded, no place in it
135
139
  ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
136
140
  ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
137
141
 
@@ -203,8 +207,8 @@ class TileInfo
203
207
  const h = this.size.y + this.padding*2;
204
208
  const x = (this.columns ? frame % this.columns : frame) * w;
205
209
  const y = (this.columns ? frame / this.columns | 0 : 0) * h;
206
- ASSERT(this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
207
- ASSERT(this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
210
+ ASSERT(!this.textureInfo || this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
211
+ ASSERT(!this.textureInfo || this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
208
212
  return this.offset(new Vector2(x, y));
209
213
  }
210
214
 
@@ -220,7 +224,8 @@ class TileInfo
220
224
  }
221
225
 
222
226
  /**
223
- * Returns a tile info for an index using this tile as reference
227
+ * Returns a tile info for an index using this tile's size, texture and padding as reference
228
+ * - the index counts from the texture's origin like tile(), for a sprite packed in a texture sheet use frame()
224
229
  * @param {Vector2|number} [index=0]
225
230
  * @return {TileInfo}
226
231
  */
@@ -243,20 +248,21 @@ class TileInfo
243
248
  }
244
249
 
245
250
  /**
246
- * Tile Info - Stores info about each texture
251
+ * Texture Info - Stores info about each texture
247
252
  * @memberof Draw
248
253
  */
249
254
  class TextureInfo
250
255
  {
251
256
  /**
252
257
  * Create a TextureInfo, called automatically by the engine
253
- * @param {HTMLImageElement|OffscreenCanvas} image
258
+ * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
254
259
  * @param {boolean} [useWebGL] - Should use WebGL if available?
255
- * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
260
+ * @param {boolean|Array<number>} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)? Or the WebGL
261
+ * modes across and down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT], as a glTF sampler gives them
256
262
  */
257
263
  constructor(image, useWebGL=true, wrap=false)
258
264
  {
259
- /** @property {HTMLImageElement|OffscreenCanvas} - image source */
265
+ /** @property {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} - image source */
260
266
  this.image = image;
261
267
  /** @property {Vector2} - size of the image */
262
268
  this.size = image ? vec2(image.width, image.height) : vec2();
@@ -265,13 +271,24 @@ class TextureInfo
265
271
  /** @property {WebGLTexture|undefined} - WebGL texture
266
272
  * @type {WebGLTexture|undefined} */
267
273
  this.glTexture = undefined;
268
- /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
274
+ /** @property {boolean|Array<number>} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE, or the WebGL modes
275
+ * across and down
276
+ * @type {boolean|Array<number>} */
269
277
  this.wrap = wrap;
270
278
  useWebGL && this.createWebGLTexture();
271
279
  }
272
280
 
273
281
  /** Creates the WebGL texture, updates if already created */
274
- createWebGLTexture() { glRegisterTextureInfo(this); }
282
+ createWebGLTexture()
283
+ {
284
+ const image = this.image;
285
+ if (image?.width) // the image may have been resized since
286
+ {
287
+ this.size = vec2(image.width, image.height);
288
+ this.sizeInverse = vec2(1/image.width, 1/image.height);
289
+ }
290
+ glRegisterTextureInfo(this);
291
+ }
275
292
 
276
293
  /** Destroys the WebGL texture */
277
294
  destroyWebGLTexture() { glUnregisterTextureInfo(this); }
@@ -281,7 +298,8 @@ class TextureInfo
281
298
  hasWebGL() { return !!this.glTexture; }
282
299
 
283
300
  /** Set the wrap mode for this texture
284
- * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
301
+ * @param {boolean|Array<number>} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across
302
+ * and down */
285
303
  setWrap(wrap=true)
286
304
  {
287
305
  this.wrap = wrap;
@@ -333,7 +351,7 @@ class SpriteAnimation
333
351
  * @return {SpriteAnimation} */
334
352
  loop() { return this.restart('loop'); }
335
353
 
336
- /** Start over from the first frame, run through once and hold the last frame
354
+ /** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed
337
355
  * @return {SpriteAnimation} */
338
356
  play() { return this.restart('once'); }
339
357
 
@@ -367,11 +385,11 @@ class SpriteAnimation
367
385
  if (this.heldFrame !== undefined)
368
386
  return this.heldFrame;
369
387
  const n = this.frameCount, f = floor(this.elapsedFrames);
370
- if (this.mode == 'once')
371
- return min(f, n - 1);
388
+ if (this.mode == 'once') // backward it counts down from the last frame, showing it from the start
389
+ return clamp(this.speed < 0 ? n - 1 + ceil(this.elapsedFrames) : f, 0, n - 1);
372
390
  if (this.mode == 'loop')
373
- return f % n;
374
- const period = max(2 * n - 2, 1), k = f % period; // there and back, the ends once each
391
+ return mod(f, n);
392
+ const period = max(2 * n - 2, 1), k = mod(f, period); // there and back, the ends once each
375
393
  return k < n ? k : period - k;
376
394
  }
377
395
 
@@ -381,7 +399,7 @@ class SpriteAnimation
381
399
 
382
400
  /** True once a play has shown its last frame for its time
383
401
  * @return {boolean} */
384
- get isDone() { return this.mode == 'once' && this.heldFrame === undefined && this.elapsedFrames >= this.frameCount; }
402
+ get isDone() { return this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount; }
385
403
  }
386
404
 
387
405
  ///////////////////////////////////////////////////////////////////////////////
@@ -393,18 +411,26 @@ class SpriteAnimation
393
411
  * - Set it as obj.shader, or use setShader for 2D draws and render3D.shader for 3D draws
394
412
  * - Draws that share a Shader share a batch; with no Shader set nothing changes
395
413
  * - In 2D it shades textured draws, untextured ones like drawRect draw as they are
414
+ * - A render target, like a tile layer drawn in WebGL, holds premultiplied color, and so does every image when
415
+ * tilesPixelated is false; there iChannel0 reads premultiplied texels and the snippet's color is taken as
416
+ * premultiplied too. premultipliedTexture is true there, so a snippet that changes the alpha, or makes a see
417
+ * through color of its own, scales the rgb with it: `if (premultipliedTexture) c.rgb *= k;`
396
418
  * - Compiled once per renderer by the first draw that needs it; a bad snippet throws with the GLSL log in debug
397
419
  * - Make each Shader once, at init, and share it; every one made lives for the session with its programs
398
- * - Names in both renderers: iChannel0 the texture, iTime, iResolution, and localUV, 0 to 1 across the sprite
399
- * or the mesh's own uv
400
- * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, lightCount,
401
- * lights[i], lightColors[i] and shadow()
420
+ * - Names in both renderers: iChannel0 the texture, iTime, iResolution, premultipliedTexture, and localUV, 0 to 1
421
+ * across the sprite or the mesh's own uv
422
+ * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
423
+ * lightCount, lights[i], lightColors[i] and shadow()
424
+ * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
425
+ * parts of a surface still shadows with the whole of it
402
426
  * @example
403
427
  * const fade = new Shader(`
404
428
  * void mainImage(out vec4 c, vec2 uv)
405
429
  * {
406
430
  * c = texture(iChannel0, uv);
407
- * c.a *= .5 + .5*sin(iTime);
431
+ * float fade = .5 + .5*sin(iTime);
432
+ * c.a *= fade;
433
+ * if (premultipliedTexture) c.rgb *= fade; // a tile layer, or any image with tilesPixelated false
408
434
  * }`);
409
435
  * obj.shader = fade;
410
436
  * @memberof Draw
@@ -440,11 +466,11 @@ class Shader
440
466
  * @param {boolean} [mirror] - Is image flipped along the Y axis?
441
467
  * @param {Color} [additiveColor] - Additive color to be applied if any
442
468
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
443
- * @param {boolean} [screenSpace=false] - Are the pos and size are in screen space?
469
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size are in screen space?
444
470
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
445
471
  * @memberof Draw */
446
472
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
447
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
473
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
448
474
  {
449
475
  ASSERT(isVector2(pos), 'pos must be a vec2');
450
476
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -453,15 +479,23 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
453
479
  ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
454
480
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
455
481
 
482
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
483
+
456
484
  const textureInfo = tileInfo?.textureInfo;
485
+ if (textureInfo && !(tileInfo.size.x && tileInfo.size.y))
486
+ return; // a tile with no area draws nothing, like a sprite still loading
457
487
  const bleed = tileInfo?.bleed ?? 0;
458
488
  if (useWebGL && glEnable)
459
489
  {
460
490
  ASSERT(!!glContext, 'WebGL is not enabled!');
461
491
  if (screenSpace)
492
+ {
493
+ if (glSkipScreenSpace) return;
462
494
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
495
+ }
463
496
  if (textureInfo)
464
497
  {
498
+ ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false');
465
499
  // calculate uvs and render
466
500
  const sizeInverse = textureInfo.sizeInverse;
467
501
  const x = tileInfo.pos.x * sizeInverse.x;
@@ -487,8 +521,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
487
521
  }
488
522
  else
489
523
  {
490
- // untextured: fold color+additive to match the Canvas2D path's
491
- // color.add(additiveColor) on line ~337.
524
+ // untextured: color plus additive in one color, as the Canvas2D path below does
492
525
  const combined = additiveColor ? color.add(additiveColor) : color;
493
526
  glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
494
527
  }
@@ -526,7 +559,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
526
559
  * @param {Color} [color=WHITE]
527
560
  * @param {number} [angle]
528
561
  * @param {boolean} [useWebGL=glEnable]
529
- * @param {boolean} [screenSpace]
562
+ * @param {boolean} [screenSpace=drawScreenSpace]
530
563
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
531
564
  * @memberof Draw */
532
565
  function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
@@ -541,10 +574,10 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
541
574
  * @param {Color} [colorBottom=CLEAR_WHITE]
542
575
  * @param {number} [angle]
543
576
  * @param {boolean} [useWebGL=glEnable]
544
- * @param {boolean} [screenSpace]
577
+ * @param {boolean} [screenSpace=drawScreenSpace]
545
578
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
546
579
  * @memberof Draw */
547
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
580
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
548
581
  {
549
582
  ASSERT(isVector2(pos), 'pos must be a vec2');
550
583
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -552,15 +585,15 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
552
585
  ASSERT(isNumber(angle), 'angle must be a number');
553
586
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
554
587
 
588
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
589
+
555
590
  if (useWebGL && glEnable)
556
591
  {
557
592
  ASSERT(!!glContext, 'WebGL is not enabled!');
558
593
  if (screenSpace)
559
594
  {
560
- // convert to world space
561
- pos = screenToWorld(pos);
562
- size = size.scale(1/cameraScale);
563
- angle += cameraAngle;
595
+ if (glSkipScreenSpace) return;
596
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
564
597
  }
565
598
  // build 4 corner points for the rectangle
566
599
  const points = [], colors = [];
@@ -608,11 +641,11 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
608
641
  * @param {number} [angle=0] - Angle to rotate by
609
642
  * @param {Color} [additiveColor] - Additive color to be applied if any
610
643
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
611
- * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
644
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
612
645
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
613
646
  * @memberof Draw */
614
647
  function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
615
- angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
648
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
616
649
  {
617
650
  ASSERT(isVector2(pos), 'pos must be a vec2');
618
651
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -624,8 +657,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
624
657
  ASSERT(!(texture instanceof TileInfo),
625
658
  'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
626
659
 
627
- // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
628
- if (headlessMode) return;
660
+ // only a context passed in is drawn to in headless mode, before the texture lookup, textureInfos is empty there
661
+ if (headlessMode && !context) return;
629
662
 
630
663
  // resolve texture argument: TextureInfo or index
631
664
  const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
@@ -638,7 +671,10 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
638
671
  {
639
672
  ASSERT(!!glContext, 'WebGL is not enabled!');
640
673
  if (screenSpace)
674
+ {
675
+ if (glSkipScreenSpace) return;
641
676
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
677
+ }
642
678
  glSetTexture(textureInfo.glTexture);
643
679
  glDraw(pos.x, pos.y, size.x, size.y, angle,
644
680
  0, 0, wrapCount.x, wrapCount.y,
@@ -660,9 +696,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
660
696
  // pick image source: raw, or tinted bake. Match drawImageColor's
661
697
  // "no tint needed" predicate so behavior stays consistent.
662
698
  const noTint = !canvasColorTiles ||
663
- (additiveColor
664
- ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
665
- : isWhite(color));
699
+ isWhite(color) && (!additiveColor || isBlack(additiveColor));
666
700
  // alpha is baked into pixels by bakeTintedImage's additive branch;
667
701
  // in that case globalAlpha must NOT also apply color.a
668
702
  const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
@@ -698,10 +732,10 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
698
732
  * @param {Vector2} [pos=vec2()] - Offset to apply
699
733
  * @param {number} [angle] - Angle to rotate by
700
734
  * @param {boolean} [useWebGL=glEnable]
701
- * @param {boolean} [screenSpace]
735
+ * @param {boolean} [screenSpace=drawScreenSpace]
702
736
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
703
737
  * @memberof Draw */
704
- function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
738
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
705
739
  {
706
740
  ASSERT(isArray(points), 'points must be an array');
707
741
  ASSERT(isNumber(width), 'width must be a number');
@@ -710,12 +744,17 @@ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), ang
710
744
  ASSERT(isNumber(angle), 'angle must be a number');
711
745
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
712
746
 
747
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
748
+
713
749
  if (useWebGL && glEnable)
714
750
  {
715
751
  ASSERT(!!glContext, 'WebGL is not enabled!');
716
752
  let size = vec2(1);
717
753
  if (screenSpace)
754
+ {
755
+ if (glSkipScreenSpace) return;
718
756
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
757
+ }
719
758
  glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
720
759
  }
721
760
  else
@@ -725,8 +764,11 @@ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), ang
725
764
  ++primitiveCount;
726
765
  drawCanvas2D(pos, vec2(1), angle, false, (context)=>
727
766
  {
767
+ // Canvas2D ignores a width of 0 or below and keeps the last one, WebGL draws none for 0
768
+ // and a negative width as its size, the outline turned inside out
769
+ if (!width) return;
728
770
  context.strokeStyle = color.toString();
729
- context.lineWidth = width;
771
+ context.lineWidth = abs(width);
730
772
  context.beginPath();
731
773
  for (let i=0; i<points.length; ++i)
732
774
  {
@@ -747,14 +789,15 @@ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), ang
747
789
  * @param {Vector2} [pos=vec2()] - Offset to apply
748
790
  * @param {number} [angle] - Angle to rotate by
749
791
  * @param {boolean} [useWebGL=glEnable]
750
- * @param {boolean} [screenSpace]
792
+ * @param {boolean} [screenSpace=drawScreenSpace]
751
793
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
752
794
  * @memberof Draw */
753
- function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
795
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
754
796
  {
755
797
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
756
798
  const size = vec2(width, halfDelta.length()*2);
757
- pos = pos.add(posA.add(halfDelta));
799
+ const middle = posA.add(halfDelta);
800
+ pos = pos.add(angle ? middle.rotate(screenSpace ? -angle : angle) : middle); // screen y is down, so it turns back
758
801
  if (screenSpace)
759
802
  halfDelta.y *= -1; // flip angle Y if screen space
760
803
  angle += halfDelta.angle();
@@ -770,10 +813,10 @@ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWeb
770
813
  * @param {Color} [lineColor=BLACK]
771
814
  * @param {number} [angle]
772
815
  * @param {boolean} [useWebGL=glEnable]
773
- * @param {boolean} [screenSpace]
816
+ * @param {boolean} [screenSpace=drawScreenSpace]
774
817
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
775
818
  * @memberof Draw */
776
- function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
819
+ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
777
820
  {
778
821
  ASSERT(isVector2(size), 'size must be a vec2');
779
822
  ASSERT(isNumber(sides), 'sides must be a number');
@@ -790,6 +833,8 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
790
833
  }
791
834
 
792
835
  /** Draw colored polygon using passed in points
836
+ * - WebGL fills a polygon whose edges do not cross, concave or not; a self crossing one, like a star through its
837
+ * outer points, fills wrong there, so draw it as its simple outline or in parts
793
838
  * @param {Array<Vector2>} points - Array of Vector2 points
794
839
  * @param {Color} [color=WHITE]
795
840
  * @param {number} [lineWidth]
@@ -797,10 +842,10 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
797
842
  * @param {Vector2} [pos=vec2()] - Offset to apply
798
843
  * @param {number} [angle] - Angle to rotate by
799
844
  * @param {boolean} [useWebGL=glEnable]
800
- * @param {boolean} [screenSpace]
845
+ * @param {boolean} [screenSpace=drawScreenSpace]
801
846
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
802
847
  * @memberof Draw */
803
- function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context=undefined)
848
+ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context=undefined)
804
849
  {
805
850
  ASSERT(isVector2(pos), 'pos must be a vec2');
806
851
  ASSERT(isArray(points), 'points must be an array');
@@ -809,18 +854,25 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
809
854
  ASSERT(isNumber(angle), 'angle must be a number');
810
855
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
811
856
 
857
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
858
+
812
859
  if (useWebGL && glEnable)
813
860
  {
814
861
  ASSERT(!!glContext, 'WebGL is not enabled!');
815
862
  let size = vec2(1);
816
863
  if (screenSpace)
864
+ {
865
+ if (glSkipScreenSpace) return;
817
866
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
867
+ }
818
868
  glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
819
869
  if (lineWidth > 0)
820
870
  glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
821
871
  }
822
872
  else
823
873
  {
874
+ ++drawCount;
875
+ ++primitiveCount;
824
876
  drawCanvas2D(pos, vec2(1), angle, false, context=>
825
877
  {
826
878
  context.fillStyle = color.toString();
@@ -829,7 +881,7 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
829
881
  context.lineTo(point.x, point.y);
830
882
  context.closePath();
831
883
  context.fill();
832
- if (lineWidth)
884
+ if (lineWidth > 0) // as in WebGL, Canvas2D would stroke a width below 0 with the last one
833
885
  {
834
886
  context.strokeStyle = lineColor.toString();
835
887
  context.lineWidth = lineWidth;
@@ -839,6 +891,9 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
839
891
  }
840
892
  }
841
893
 
894
+ // the unit rings drawEllipse fills with in WebGL, one for each side count
895
+ const drawEllipseRings = new Map;
896
+
842
897
  /** Draw colored ellipse using passed in point
843
898
  * @param {Vector2} pos
844
899
  * @param {Vector2} [size=vec2(1)] - Width and height diameter
@@ -847,10 +902,10 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
847
902
  * @param {number} [lineWidth]
848
903
  * @param {Color} [lineColor=BLACK]
849
904
  * @param {boolean} [useWebGL=glEnable]
850
- * @param {boolean} [screenSpace]
905
+ * @param {boolean} [screenSpace=drawScreenSpace]
851
906
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
852
907
  * @memberof Draw */
853
- function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
908
+ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
854
909
  {
855
910
  ASSERT(isVector2(pos), 'pos must be a vec2');
856
911
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -860,24 +915,51 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
860
915
  ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0');
861
916
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
862
917
 
863
- // clamp line width to prevent artifacts
864
- lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
918
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
919
+
920
+ // clamp line width to prevent artifacts, a negative size draws mirrored
921
+ lineWidth = clamp(lineWidth, 0, min(abs(size.x), abs(size.y)));
865
922
 
866
923
  if (useWebGL && glEnable)
867
924
  {
868
- // draw as a regular polygon
869
925
  const sides = glCircleSides;
870
- drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
926
+ if (lineWidth > 0)
927
+ {
928
+ // draw as a regular polygon, the outline is made from the points at their size
929
+ drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
930
+ return;
931
+ }
932
+
933
+ // a fill is a unit ring scaled, made once for each side count and already in strip order
934
+ let ring = drawEllipseRings.get(sides);
935
+ if (!ring)
936
+ {
937
+ const points = [];
938
+ for (let i=sides; i--;)
939
+ {
940
+ const a = (i/sides)*PI*2;
941
+ points.push(vec2(sin(a), cos(a)));
942
+ }
943
+ drawEllipseRings.set(sides, ring = glPolyStrip(points));
944
+ }
945
+ if (screenSpace)
946
+ {
947
+ if (glSkipScreenSpace) return;
948
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
949
+ }
950
+ glDrawPointsTransform(ring, color.rgbaInt(), pos.x, pos.y, size.x/2, size.y/2, angle, false);
871
951
  }
872
952
  else
873
953
  {
954
+ ++drawCount;
955
+ ++primitiveCount;
874
956
  drawCanvas2D(pos, vec2(1), angle, false, context=>
875
957
  {
876
958
  context.fillStyle = color.toString();
877
959
  context.beginPath();
878
- context.ellipse(0, 0, size.x/2, size.y/2, 0, 0, 9);
960
+ context.ellipse(0, 0, abs(size.x)/2, abs(size.y)/2, 0, 0, 9); // it throws on a negative radius
879
961
  context.fill();
880
- if (lineWidth)
962
+ if (lineWidth > 0)
881
963
  {
882
964
  context.strokeStyle = lineColor.toString();
883
965
  context.lineWidth = lineWidth;
@@ -894,15 +976,16 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
894
976
  * @param {number} [lineWidth=0]
895
977
  * @param {Color} [lineColor=BLACK]
896
978
  * @param {boolean} [useWebGL=glEnable]
897
- * @param {boolean} [screenSpace]
979
+ * @param {boolean} [screenSpace=drawScreenSpace]
898
980
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
899
981
  * @memberof Draw */
900
- function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
982
+ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
901
983
  {
902
984
  ASSERT(isNumber(size), 'size must be a number');
903
985
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
904
986
  }
905
987
 
988
+ let drawEllipseGradientOffset = 0;
906
989
  /** Draw an ellipse filled with a radial gradient from the center to the rim
907
990
  * - Best when batched with other untextured polys
908
991
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
@@ -913,11 +996,10 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
913
996
  * @param {Color} [colorOuter=CLEAR_WHITE]
914
997
  * @param {number} [angle]
915
998
  * @param {boolean} [useWebGL=glEnable]
916
- * @param {boolean} [screenSpace]
999
+ * @param {boolean} [screenSpace=drawScreenSpace]
917
1000
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
918
1001
  * @memberof Draw */
919
- let drawEllipseGradientOffset = 0;
920
- function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
1002
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
921
1003
  {
922
1004
  ASSERT(isVector2(pos), 'pos must be a vec2');
923
1005
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -925,17 +1007,15 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
925
1007
  ASSERT(isNumber(angle), 'angle must be a number');
926
1008
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
927
1009
 
928
- if (headlessMode) return;
1010
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
929
1011
 
930
1012
  if (useWebGL && glEnable)
931
1013
  {
932
1014
  ASSERT(!!glContext, 'WebGL is not enabled!');
933
1015
  if (screenSpace)
934
1016
  {
935
- // convert to world space
936
- pos = screenToWorld(pos);
937
- size = size.scale(1/cameraScale);
938
- angle += cameraAngle;
1017
+ if (glSkipScreenSpace) return;
1018
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
939
1019
  }
940
1020
  // fan as tristrip; rotate the boundary vertex by one slice per call
941
1021
  // so back-to-back gradients at the same position have their hole
@@ -992,10 +1072,10 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
992
1072
  * @param {Color} [colorInner=WHITE]
993
1073
  * @param {Color} [colorOuter=CLEAR_WHITE]
994
1074
  * @param {boolean} [useWebGL=glEnable]
995
- * @param {boolean} [screenSpace]
1075
+ * @param {boolean} [screenSpace=drawScreenSpace]
996
1076
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
997
1077
  * @memberof Draw */
998
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
1078
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
999
1079
  {
1000
1080
  ASSERT(isNumber(size), 'size must be a number');
1001
1081
  drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
@@ -1013,19 +1093,21 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
1013
1093
  * (e.g. linear gradients) should flip their own Y endpoints accordingly.
1014
1094
  * @param {Vector2} pos
1015
1095
  * @param {Vector2} size
1016
- * @param {number} angle
1096
+ * @param {number} [angle]
1017
1097
  * @param {boolean} [mirror]
1018
- * @param {Canvas2DDrawFunction} [drawFunction]
1019
- * @param {boolean} [screenSpace=false]
1098
+ * @param {Canvas2DDrawFunction} [drawFunction] - Needed, marked optional only because the ones before it are
1099
+ * @param {boolean} [screenSpace=drawScreenSpace]
1020
1100
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1021
1101
  * @memberof Draw */
1022
- function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=false, context=drawContext)
1102
+ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=drawScreenSpace, context=drawContext)
1023
1103
  {
1024
1104
  ASSERT(isVector2(pos), 'pos must be a vec2');
1025
1105
  ASSERT(isVector2(size), 'size must be a vec2');
1026
1106
  ASSERT(isNumber(angle), 'angle must be a number');
1027
1107
  ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
1028
1108
 
1109
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1110
+
1029
1111
  if (!screenSpace)
1030
1112
  {
1031
1113
  pos = worldToScreen(pos);
@@ -1051,7 +1133,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
1051
1133
  * @param {Color} [color=WHITE]
1052
1134
  * @param {number} [lineWidth]
1053
1135
  * @param {Color} [lineColor=BLACK]
1054
- * @param {CanvasTextAlign} [textAlign='center']
1136
+ * @param {'left'|'center'|'right'} [textAlign='center']
1055
1137
  * @param {string} [font=fontDefault]
1056
1138
  * @param {string} [fontStyle]
1057
1139
  * @param {number} [maxWidth]
@@ -1064,8 +1146,9 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1064
1146
  pos = worldToScreen(pos);
1065
1147
  size *= cameraScale;
1066
1148
  lineWidth *= cameraScale;
1149
+ if (maxWidth !== undefined)
1150
+ maxWidth *= cameraScale;
1067
1151
  angle -= cameraAngle;
1068
- angle *= -1;
1069
1152
 
1070
1153
  drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
1071
1154
  }
@@ -1078,11 +1161,11 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1078
1161
  * @param {Color} [color=WHITE]
1079
1162
  * @param {number} [lineWidth]
1080
1163
  * @param {Color} [lineColor=BLACK]
1081
- * @param {CanvasTextAlign} [textAlign]
1164
+ * @param {'left'|'center'|'right'} [textAlign]
1082
1165
  * @param {string} [font=fontDefault]
1083
1166
  * @param {string} [fontStyle]
1084
1167
  * @param {number} [maxWidth]
1085
- * @param {number} [angle]
1168
+ * @param {number} [angle] - Clockwise, like the other screen space draws
1086
1169
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1087
1170
  * @memberof Draw */
1088
1171
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
@@ -1097,9 +1180,10 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
1097
1180
  ASSERT(isStringLike(font), 'font must be a string');
1098
1181
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
1099
1182
  ASSERT(isNumber(angle), 'angle must be a number');
1183
+
1184
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1100
1185
 
1101
1186
  const lines = (text+'').split('\n');
1102
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
1103
1187
  // save before style mutations so caller's context state is preserved
1104
1188
  context.save();
1105
1189
  context.fillStyle = color.toString();
@@ -1108,9 +1192,9 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
1108
1192
  context.textAlign = textAlign;
1109
1193
  context.font = fontStyle + ' ' + size + 'px '+ font;
1110
1194
  context.textBaseline = 'middle';
1111
- context.translate(pos.x, posY);
1112
- context.rotate(-angle);
1113
- let yOffset = 0;
1195
+ context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
1196
+ context.rotate(angle);
1197
+ let yOffset = -(lines.length-1) * size/2; // center vertically
1114
1198
  lines.forEach(line=>
1115
1199
  {
1116
1200
  lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
@@ -1123,15 +1207,18 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
1123
1207
  ///////////////////////////////////////////////////////////////////////////////
1124
1208
  // Drawing utilities
1125
1209
 
1126
- /** Load a texture at a specific index
1127
- * @param {number} textureIndex - Index to store the texture at
1210
+ /** Load a texture at a specific index after engineInit, the images passed to engineInit load this way
1211
+ * @param {number} textureIndex - Index to store the texture at, an unused one
1128
1212
  * @param {string} [src] - Image source path
1129
- * @return {Promise} Promise that resolves when texture is loaded
1213
+ * @return {Promise<TextureInfo>} Resolves to the texture info once the image loads, or fails to with a warning
1130
1214
  * @memberof Draw */
1131
1215
  async function loadTexture(textureIndex, src)
1132
1216
  {
1133
1217
  ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
1134
- ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
1218
+ // engineInit's empty placeholder in slot 0, when it was given no images, or a load that failed can be replaced
1219
+ const old = textureInfos[textureIndex];
1220
+ ASSERT(!old?.size.x, 'textureIndex is already loaded!');
1221
+ old?.destroyWebGLTexture();
1135
1222
  ASSERT(!src || isStringLike(src), 'image src must be a string');
1136
1223
 
1137
1224
  const image = new Image;
@@ -1139,13 +1226,18 @@ async function loadTexture(textureIndex, src)
1139
1226
  {
1140
1227
  await new Promise(resolve =>
1141
1228
  {
1142
- image.onerror = image.onload = resolve;
1229
+ image.onload = resolve;
1230
+ image.onerror = ()=>
1231
+ {
1232
+ console.warn('failed to load image: ' + src); // in release too, like the WebGL warning
1233
+ resolve();
1234
+ };
1143
1235
  image.crossOrigin = 'anonymous';
1144
1236
  image.src = src;
1145
1237
  });
1146
1238
  }
1147
1239
 
1148
- textureInfos[textureIndex] = new TextureInfo(image);
1240
+ return textureInfos[textureIndex] = new TextureInfo(image);
1149
1241
  }
1150
1242
 
1151
1243
  /** Convert from screen to world space coordinates
@@ -1212,7 +1304,7 @@ function screenToWorldDelta(screenDelta)
1212
1304
  return new Vector2(x, y);
1213
1305
  }
1214
1306
 
1215
- /** Convert from screen to world space coordinates for a directional vector (no translation)
1307
+ /** Convert from world to screen space coordinates for a directional vector (no translation)
1216
1308
  * @param {Vector2} worldDelta
1217
1309
  * @return {Vector2}
1218
1310
  * @memberof Draw */
@@ -1256,6 +1348,14 @@ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
1256
1348
  * @memberof Draw */
1257
1349
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1258
1350
 
1351
+ /** Padding for each side of a rectangle, the sides left out are 0
1352
+ * @typedef {Object} CameraFitSides
1353
+ * @property {number} [top]
1354
+ * @property {number} [right]
1355
+ * @property {number} [bottom]
1356
+ * @property {number} [left]
1357
+ * @memberof Draw */
1358
+
1259
1359
  /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
1260
1360
  * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
1261
1361
  * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
@@ -1264,8 +1364,8 @@ function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1264
1364
  * or an object with any of {top, right, bottom, left}
1265
1365
  * @param {Vector2} center - Center of the rectangle in world space
1266
1366
  * @param {Vector2} size - Size of the rectangle in world space
1267
- * @param {number|Vector2|Object} [worldMargin] - World space padding added around the content rectangle
1268
- * @param {number|Vector2|Object} [screenInset] - Screen space padding in pixels reserved on each viewport edge
1367
+ * @param {number|Vector2|CameraFitSides} [worldMargin] - World space padding added around the content rectangle
1368
+ * @param {number|Vector2|CameraFitSides} [screenInset] - Screen space padding in pixels reserved on each viewport edge
1269
1369
  * @return {number} - The new camera scale
1270
1370
  * @memberof Draw */
1271
1371
  function cameraFit(center, size, worldMargin, screenInset)
@@ -1285,12 +1385,15 @@ function cameraFit(center, size, worldMargin, screenInset)
1285
1385
  if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
1286
1386
  return cameraScale;
1287
1387
 
1288
- // scale to fit the padded content
1289
- cameraScale = min(viewW / worldW, viewH / worldH);
1388
+ // scale to fit the padded content, measured along the camera's axes when it is turned
1389
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1390
+ const fitW = abs(c) * worldW + abs(s) * worldH, fitH = abs(s) * worldW + abs(c) * worldH;
1391
+ cameraScale = min(viewW / fitW, viewH / fitH);
1290
1392
 
1291
- // calculate offset vectors
1393
+ // calculate offset vectors, the inset is on the screen, so it turns back into the world with the camera
1292
1394
  const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
1293
- const insetVector = vec2(inset.right - inset.left, inset.top - inset.bottom).scale(.5 / cameraScale);
1395
+ const ix = (inset.right - inset.left) * .5 / cameraScale, iy = (inset.top - inset.bottom) * .5 / cameraScale;
1396
+ const insetVector = vec2(ix * c + iy * s, iy * c - ix * s);
1294
1397
 
1295
1398
  // apply the offsets and return camera scale
1296
1399
  cameraPos = center.add(marginVector).add(insetVector);
@@ -1316,7 +1419,7 @@ function cameraFit(center, size, worldMargin, screenInset)
1316
1419
  * If size is a Vector2, uses the length as diameter
1317
1420
  * This can be used to cull offscreen objects from render or update
1318
1421
  * @param {Vector2} pos - world space position
1319
- * @param {Vector2|number} size - world space size or diameter
1422
+ * @param {Vector2|number} [size] - world space size or diameter
1320
1423
  * @return {boolean}
1321
1424
  * @memberof Draw */
1322
1425
  function isOnScreen(pos, size=0)
@@ -1327,8 +1430,7 @@ function isOnScreen(pos, size=0)
1327
1430
  // cameraScale of 0 collapses world coords; nothing is visible
1328
1431
  if (!cameraScale) return false;
1329
1432
 
1330
- // optimized circle on screen test
1331
- // pos = worldToScreen(pos);
1433
+ // circle on screen test, worldToScreen inlined in doubled screen units so it compares against the full canvas size
1332
1434
  let x = pos.x - cameraPos.x;
1333
1435
  let y = pos.y - cameraPos.y;
1334
1436
  if (cameraAngle)
@@ -1402,6 +1504,31 @@ function combineCanvases()
1402
1504
  mainContext.restore();
1403
1505
  }
1404
1506
 
1507
+ // tint straight color pixels in place, rgb times color, plus additiveColor with alpha when it adds anything;
1508
+ // returns true when color.a went into the alpha, so it is not applied again as globalAlpha
1509
+ function tintImageData(data, color, additiveColor)
1510
+ {
1511
+ if (additiveColor && !isBlack(additiveColor))
1512
+ {
1513
+ // multiply + additive (slower), color.a is baked into the alpha channel here
1514
+ const colorMultiply = [color.r, color.g, color.b, color.a];
1515
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1516
+ additiveColor.b * 255, additiveColor.a * 255];
1517
+ for (let i = 0; i < data.length; ++i)
1518
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1519
+ return true;
1520
+ }
1521
+
1522
+ // RGB only, faster — alpha left intact for the caller
1523
+ for (let i = 0; i < data.length; i+=4)
1524
+ {
1525
+ data[i ] *= color.r;
1526
+ data[i+1] *= color.g;
1527
+ data[i+2] *= color.b;
1528
+ }
1529
+ return false;
1530
+ }
1531
+
1405
1532
  // Internal: bake a color/additive-color tint into workReadCanvas at the
1406
1533
  // image's native resolution. Returns the work canvas, suitable for
1407
1534
  // passing to context.createPattern. Used by drawTextureWrapped's
@@ -1415,45 +1542,27 @@ function bakeTintedImage(image, color, additiveColor)
1415
1542
  workReadContext.drawImage(image, 0, 0);
1416
1543
 
1417
1544
  const imageData = workReadContext.getImageData(0, 0, w, h);
1418
- const data = imageData.data;
1419
- if (additiveColor && !isBlack(additiveColor))
1420
- {
1421
- // multiply + additive (slower)
1422
- const colorMultiply = [color.r, color.g, color.b, color.a];
1423
- const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1424
- additiveColor.b * 255, additiveColor.a * 255];
1425
- for (let i = 0; i < data.length; ++i)
1426
- data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1427
- }
1428
- else
1429
- {
1430
- // RGB only, faster — alpha left intact for the caller
1431
- for (let i = 0; i < data.length; i+=4)
1432
- {
1433
- data[i ] *= color.r;
1434
- data[i+1] *= color.g;
1435
- data[i+2] *= color.b;
1436
- }
1437
- }
1545
+ tintImageData(imageData.data, color, additiveColor);
1438
1546
  workReadContext.putImageData(imageData, 0, 0);
1439
1547
  return workReadCanvas;
1440
1548
  }
1441
1549
 
1442
- /** Helper function to draw an image with color and additive color applied
1443
- * This is slower then normal drawImage when color is applied
1444
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1445
- * @param {HTMLImageElement|OffscreenCanvas} image
1446
- * @param {number} sx
1447
- * @param {number} sy
1448
- * @param {number} sWidth
1449
- * @param {number} sHeight
1450
- * @param {number} dx
1451
- * @param {number} dy
1452
- * @param {number} dWidth
1453
- * @param {number} dHeight
1454
- * @param {Color} color
1455
- * @param {Color} [additiveColor]
1456
- * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1550
+ /** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
1551
+ * This is slower than normal drawImage when color is applied
1552
+ * @ignore
1553
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1554
+ * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
1555
+ * @param {number} sx
1556
+ * @param {number} sy
1557
+ * @param {number} sWidth
1558
+ * @param {number} sHeight
1559
+ * @param {number} dx
1560
+ * @param {number} dy
1561
+ * @param {number} dWidth
1562
+ * @param {number} dHeight
1563
+ * @param {Color} color
1564
+ * @param {Color} [additiveColor]
1565
+ * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1457
1566
  * @memberof Draw */
1458
1567
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1459
1568
  {
@@ -1463,9 +1572,9 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
1463
1572
  sHeight = max(1,sHeight|0);
1464
1573
  const sWidth2 = sWidth - 2*bleed;
1465
1574
  const sHeight2 = sHeight - 2*bleed;
1466
- if (!canvasColorTiles || (additiveColor ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0 : isWhite(color)))
1575
+ if (!canvasColorTiles || isWhite(color) && (!additiveColor || isBlack(additiveColor)))
1467
1576
  {
1468
- // white texture with no additive alpha, no need to tint
1577
+ // white tint and nothing added, the texels are drawn as they are
1469
1578
  context.globalAlpha = color.a;
1470
1579
  context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1471
1580
  context.globalAlpha = 1;
@@ -1479,31 +1588,11 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
1479
1588
 
1480
1589
  // tint image using offscreen work context
1481
1590
  const imageData = workReadContext.getImageData(0, 0, sWidth, sHeight);
1482
- const data = imageData.data;
1483
- if (additiveColor && !isBlack(additiveColor))
1484
- {
1485
- // slower path with additive color
1486
- const colorMultiply = [color.r, color.g, color.b, color.a];
1487
- const colorAdd = [additiveColor.r * 255, additiveColor.g * 255, additiveColor.b * 255, additiveColor.a * 255];
1488
- for (let i = 0; i < data.length; ++i)
1489
- data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1490
- workReadContext.putImageData(imageData, 0, 0);
1491
- context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1492
- }
1493
- else
1494
- {
1495
- // faster path with no additive color
1496
- for (let i = 0; i < data.length; i+=4)
1497
- {
1498
- data[i ] *= color.r;
1499
- data[i+1] *= color.g;
1500
- data[i+2] *= color.b;
1501
- }
1502
- workReadContext.putImageData(imageData, 0, 0);
1503
- context.globalAlpha = color.a;
1504
- context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1505
- context.globalAlpha = 1;
1506
- }
1591
+ const alphaBaked = tintImageData(imageData.data, color, additiveColor);
1592
+ workReadContext.putImageData(imageData, 0, 0);
1593
+ if (!alphaBaked) context.globalAlpha = color.a;
1594
+ context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1595
+ if (!alphaBaked) context.globalAlpha = 1;
1507
1596
  }
1508
1597
  }
1509
1598
 
@@ -1556,7 +1645,7 @@ let engineImageFont;
1556
1645
  * const font = engineImageFont;
1557
1646
  *
1558
1647
  * // draw text
1559
- * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
1648
+ * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50), 16);
1560
1649
  */
1561
1650
  class ImageFont
1562
1651
  {
@@ -1568,14 +1657,15 @@ class ImageFont
1568
1657
  ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
1569
1658
 
1570
1659
  /** @property {TileInfo} - Tile info for the font */
1571
- this.tileInfo = tileInfo.frame(0);
1660
+ this.tileInfo = tileInfo; // kept, not copied, so a loadSprite tile filled in once it loads is seen
1572
1661
  }
1573
1662
 
1574
1663
  /** Draw text in world space using the image font
1664
+ * - The text stays upright and ignores cameraAngle, each glyph is snapped to whole screen pixels to keep it crisp
1575
1665
  * @param {string|number} text
1576
1666
  * @param {Vector2} pos
1577
1667
  * @param {Vector2|number} [size]
1578
- * @param {boolean} [center=true]
1668
+ * @param {boolean} [center=true] - center each line on pos, and the lines of multi-line text around it
1579
1669
  * @param {Color} [color=WHITE]
1580
1670
  * @param {boolean} [useWebGL=glEnable]
1581
1671
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -1593,14 +1683,18 @@ class ImageFont
1593
1683
  }
1594
1684
  else
1595
1685
  size = size.scale(cameraScale);
1686
+ // world text, only drawn through screen space, so it still casts in a pass that skips screen space draws
1687
+ const skip = glSkipScreenSpace;
1688
+ glSkipScreenSpace = false;
1596
1689
  this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context);
1690
+ glSkipScreenSpace = skip;
1597
1691
  }
1598
1692
 
1599
1693
  /** Draw text in screen space using the image font
1600
1694
  * @param {string|number} text
1601
1695
  * @param {Vector2} pos
1602
1696
  * @param {Vector2|number} size
1603
- * @param {boolean} [center]
1697
+ * @param {boolean} [center] - center each line on pos, and the lines of multi-line text around it
1604
1698
  * @param {Color} [color=WHITE]
1605
1699
  * @param {boolean} [useWebGL=glEnable]
1606
1700
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -1610,35 +1704,27 @@ class ImageFont
1610
1704
  ASSERT(isStringLike(text), 'text must be a string');
1611
1705
  ASSERT(isVector2(pos), 'pos must be a vec2');
1612
1706
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1707
+ ASSERT(typeof center === 'boolean', 'center must be a boolean, the color comes after it, unlike drawText');
1613
1708
  ASSERT(isColor(color), 'color must be a color');
1614
1709
 
1615
1710
  // if size is a number, make it a vector
1616
- size = typeof size === 'number' ? new Vector2(size, size) : size;
1711
+ const glyphSize = typeof size === 'number' ? new Vector2(size, size) : size;
1617
1712
 
1618
- // precache objects for drawing
1713
+ // precache objects for drawing, a copy of the tile info each glyph moves, the font's own stays put
1619
1714
  const drawPos = new Vector2;
1620
- const tileInfo = this.tileInfo;
1621
- const padding = tileInfo.padding;
1622
- const sizePaddedX = tileInfo.size.x + padding*2;
1623
- const sizePaddedY = tileInfo.size.y + padding*2;
1624
- const cols = tileInfo.textureInfo.size.x / sizePaddedX |0;
1625
-
1626
- // draw each line of text
1627
- (text+'').split('\n').forEach((line, j)=>
1715
+ const tileInfo = this.tileInfo.frame(0);
1716
+
1717
+ // draw each line of text, centered vertically like drawTextScreen when center is set
1718
+ const lines = (text+'').split('\n');
1719
+ const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
1720
+ lines.forEach((line, j)=>
1628
1721
  {
1629
- const centerOffset = center ? (line.length-1) * size.x / 2 : 0;
1722
+ const centerOffset = center ? (line.length-1) * glyphSize.x / 2 : 0;
1630
1723
  for (let i=line.length; i--;)
1631
1724
  {
1632
- // get the character index
1725
+ // get the glyph, out of range characters use the last one
1633
1726
  const charCode = line.charCodeAt(i);
1634
- const index = charCode < 32 || charCode > 127 ?
1635
- 95 : charCode - 32; // handle out of range characters
1636
-
1637
- // get the position of the tile
1638
- const x = index % cols;
1639
- const y = index / cols |0;
1640
- tileInfo.pos.x = x*sizePaddedX + padding;
1641
- tileInfo.pos.y = y*sizePaddedY + padding;
1727
+ this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
1642
1728
 
1643
1729
  // snap the glyph edges to whole pixels
1644
1730
  // tiles are drawn from their center, so snapping the center
@@ -1647,12 +1733,28 @@ class ImageFont
1647
1733
  // no pixel center inside it and is not rasterized at all
1648
1734
  // ceil picks the nearest aligned position, breaking ties
1649
1735
  // downward to match how this used to truncate
1650
- drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
1651
- drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
1652
- drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
1736
+ drawPos.x = ceil(pos.x + i * glyphSize.x - centerOffset - glyphSize.x/2) + glyphSize.x/2 - .5;
1737
+ drawPos.y = ceil(pos.y + j * glyphSize.y - centerOffsetY - glyphSize.y/2) + glyphSize.y/2 - .5;
1738
+ drawTile(drawPos, glyphSize, tileInfo, color, 0, false, undefined, useWebGL, true, context);
1653
1739
  }
1654
1740
  });
1655
1741
  }
1742
+
1743
+ /** Get where a glyph sits in the texture: counted in the font's own columns when its tile has them, like a font
1744
+ * packed by loadSprite, otherwise along the texture's grid from the font's first tile, the way tile() lays it out
1745
+ * @param {number} index - Glyph number, 0 is the space and the characters follow in ASCII order
1746
+ * @param {Vector2} [pos] - Written into and returned, for a loop that places many
1747
+ * @return {Vector2} */
1748
+ getGlyphPos(index, pos=new Vector2)
1749
+ {
1750
+ const t = this.tileInfo, padding = t.padding;
1751
+ const w = t.size.x + padding*2, h = t.size.y + padding*2;
1752
+ if (t.columns)
1753
+ return pos.set(t.pos.x + index % t.columns * w, t.pos.y + (index / t.columns |0) * h);
1754
+ const columns = t.textureInfo.size.x / w |0;
1755
+ const glyph = ((t.pos.y - padding) / h |0) * columns + ((t.pos.x - padding) / w |0) + index;
1756
+ return pos.set(glyph % columns * w + padding, (glyph / columns |0) * h + padding);
1757
+ }
1656
1758
  }
1657
1759
 
1658
1760
  // load engine font, called automatically on startup