littlejsengine 1.19.3 → 1.21.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 (54) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2857 -955
  4. package/dist/littlejs.esm.js +33428 -20995
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +33386 -20996
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +27841 -20422
  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 +786 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +148 -27
  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 +25 -0
  21. package/plugins/postProcess.js +96 -24
  22. package/plugins/render3d.js +2067 -1632
  23. package/plugins/render3dDebug.js +110 -0
  24. package/plugins/render3dEditor.js +1812 -0
  25. package/plugins/render3dExtras.js +1948 -0
  26. package/plugins/render3dLevel.js +189 -0
  27. package/plugins/render3dVoxels.js +514 -0
  28. package/plugins/sceneSystem.js +58 -0
  29. package/plugins/textureSheet.js +496 -458
  30. package/plugins/threejs.js +183 -163
  31. package/plugins/tweakables.js +411 -0
  32. package/plugins/tweenSystem.js +265 -145
  33. package/plugins/uiSystem.js +2024 -1687
  34. package/plugins/zzfxm.js +159 -159
  35. package/src/engine.js +378 -97
  36. package/src/engineAudio.js +231 -127
  37. package/src/engineBuild.mjs +292 -256
  38. package/src/engineDebug.js +967 -743
  39. package/src/engineDraw.js +391 -198
  40. package/src/engineEditor.js +2452 -0
  41. package/src/engineExport.js +18 -0
  42. package/src/engineInput.js +1466 -1293
  43. package/src/engineLogo.js +19 -0
  44. package/src/engineMath.js +1274 -1252
  45. package/src/engineObject.js +197 -82
  46. package/src/engineParticles.js +583 -532
  47. package/src/engineRelease.js +79 -45
  48. package/src/engineSettings.js +115 -29
  49. package/src/engineTileLayer.js +995 -741
  50. package/src/engineUtilities.js +19 -13
  51. package/src/engineWebGL.js +239 -82
  52. package/src/jsconfig.json +17 -13
  53. package/plugins/box2d.wasm.js +0 -630
  54. 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;
@@ -290,12 +308,20 @@ class TextureInfo
290
308
  }
291
309
 
292
310
  ///////////////////////////////////////////////////////////////////////////////
311
+ /**
312
+ * @callback SpriteAnimationEndCallback - Called once when a SpriteAnimation's play ends
313
+ * @return {void}
314
+ * @memberof Draw
315
+ */
316
+
293
317
  /**
294
318
  * SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back
295
319
  * - Driven by the engine time like a Timer, so it pauses with the game and needs no update call
296
320
  * - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile
297
321
  * - loop, play and pingPong each start over from the first frame; stop holds the current one
322
+ * - play(onEnd) calls onEnd once the play ends, on the first read after it, since there is no update to call it
298
323
  * - Frames follow each other along the row, as tileInfo.frame counts them
324
+ * - SpriteAnimator switches between a character's animations by name
299
325
  * @example
300
326
  * const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each
301
327
  * const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame
@@ -327,15 +353,24 @@ class SpriteAnimation
327
353
  /** @property {number|undefined} - The frame held by stop, undefined while running
328
354
  * @type {number|undefined} */
329
355
  this.heldFrame = undefined;
356
+ /** @property {SpriteAnimationEndCallback|undefined} - Called once when a play ends, on the first read after it
357
+ * @type {SpriteAnimationEndCallback|undefined} */
358
+ this.onEnd = undefined;
330
359
  }
331
360
 
332
361
  /** Start over from the first frame and repeat forever
333
362
  * @return {SpriteAnimation} */
334
363
  loop() { return this.restart('loop'); }
335
364
 
336
- /** Start over from the first frame, run through once and hold the last frame
365
+ /** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed
366
+ * @param {function():void} [onEnd] - Called once when it ends, on the first read of it after that
337
367
  * @return {SpriteAnimation} */
338
- play() { return this.restart('once'); }
368
+ play(onEnd)
369
+ {
370
+ this.restart('once');
371
+ this.onEnd = onEnd;
372
+ return this;
373
+ }
339
374
 
340
375
  /** Start over from the first frame and run there and back forever
341
376
  * @return {SpriteAnimation} */
@@ -345,7 +380,7 @@ class SpriteAnimation
345
380
  * @return {SpriteAnimation} */
346
381
  stop() { this.heldFrame = this.frame; return this; }
347
382
 
348
- /** Start over from the first frame in a mode
383
+ /** Start over from the first frame in a mode, with no end callback
349
384
  * @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out
350
385
  * @return {SpriteAnimation} */
351
386
  restart(mode=this.mode)
@@ -353,6 +388,7 @@ class SpriteAnimation
353
388
  this.mode = mode;
354
389
  this.startTime = time;
355
390
  this.heldFrame = undefined;
391
+ this.onEnd = undefined;
356
392
  return this;
357
393
  }
358
394
 
@@ -364,14 +400,15 @@ class SpriteAnimation
364
400
  * @return {number} */
365
401
  get frame()
366
402
  {
403
+ this.isDone; // a read after a play ends calls onEnd, which may start it over
367
404
  if (this.heldFrame !== undefined)
368
405
  return this.heldFrame;
369
406
  const n = this.frameCount, f = floor(this.elapsedFrames);
370
- if (this.mode == 'once')
371
- return min(f, n - 1);
407
+ if (this.mode == 'once') // backward it counts down from the last frame, showing it from the start
408
+ return clamp(this.speed < 0 ? n - 1 + ceil(this.elapsedFrames) : f, 0, n - 1);
372
409
  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
410
+ return mod(f, n);
411
+ const period = max(2 * n - 2, 1), k = mod(f, period); // there and back, the ends once each
375
412
  return k < n ? k : period - k;
376
413
  }
377
414
 
@@ -379,9 +416,81 @@ class SpriteAnimation
379
416
  * @return {TileInfo} */
380
417
  get tileInfo() { return this.firstTile.frame(this.frame); }
381
418
 
382
- /** True once a play has shown its last frame for its time
419
+ /** True once a play has shown its last frame for its time, the first read after that calls onEnd
383
420
  * @return {boolean} */
384
- get isDone() { return this.mode == 'once' && this.heldFrame === undefined && this.elapsedFrames >= this.frameCount; }
421
+ get isDone()
422
+ {
423
+ const done = this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount;
424
+ if (done && this.onEnd)
425
+ {
426
+ const onEnd = this.onEnd;
427
+ this.onEnd = undefined; // once, and it can start a new play with a callback of its own
428
+ onEnd();
429
+ }
430
+ return done;
431
+ }
432
+ }
433
+
434
+ ///////////////////////////////////////////////////////////////////////////////
435
+ /**
436
+ * SpriteAnimator - A character's animations by name, like idle, walk and attack, and the one showing now
437
+ * - Each clip is a SpriteAnimation and keeps its own mode, loop, play or pingPong
438
+ * - set starts a clip over only when it changes or a play of it has ended, so it can be called every update
439
+ * - Read tileInfo each frame for the frame to draw, as with a SpriteAnimation
440
+ * @example
441
+ * const hero = new SpriteAnimator({
442
+ * idle: new SpriteAnimation(tile(0, 16), 2, .4),
443
+ * walk: new SpriteAnimation(tile(2, 16), 4, .1),
444
+ * attack: new SpriteAnimation(tile(6, 16), 3, .05).play(),
445
+ * });
446
+ * hero.set('attack', ()=> hero.set('idle')); // back to idle when the attack ends
447
+ * // in update: this.tileInfo = hero.tileInfo;
448
+ * @memberof Draw
449
+ */
450
+ class SpriteAnimator
451
+ {
452
+ /** Create an animator from its clips, showing the first one
453
+ * @param {Object<string, SpriteAnimation>} clips - The clips by name */
454
+ constructor(clips)
455
+ {
456
+ const names = Object.keys(clips);
457
+ ASSERT(names.length > 0, 'an animator needs at least one clip');
458
+ /** @property {Object<string, SpriteAnimation>} - The clips by name */
459
+ this.clips = clips;
460
+ /** @property {string} - The name of the clip showing now */
461
+ this.name = names[0];
462
+ }
463
+
464
+ /** Show a clip, starting it over in its own mode unless it is showing and has not ended
465
+ * @param {string} name - The clip's name
466
+ * @param {function():void} [onEnd] - Called once when a play clip ends, as with SpriteAnimation.play
467
+ * @return {SpriteAnimator} */
468
+ set(name, onEnd)
469
+ {
470
+ ASSERT(this.clips[name], 'no clip named ' + name);
471
+ if (name == this.name && !this.clip.isDone)
472
+ return this;
473
+ this.name = name;
474
+ this.clip.restart();
475
+ this.clip.onEnd = onEnd;
476
+ return this;
477
+ }
478
+
479
+ /** The clip showing now
480
+ * @return {SpriteAnimation} */
481
+ get clip() { return this.clips[this.name]; }
482
+
483
+ /** The tile of the frame showing now
484
+ * @return {TileInfo} */
485
+ get tileInfo() { return this.clip.tileInfo; }
486
+
487
+ /** The frame of the clip showing now
488
+ * @return {number} */
489
+ get frame() { return this.clip.frame; }
490
+
491
+ /** True once the clip showing now is a play that has ended
492
+ * @return {boolean} */
493
+ get isDone() { return this.clip.isDone; }
385
494
  }
386
495
 
387
496
  ///////////////////////////////////////////////////////////////////////////////
@@ -393,18 +502,26 @@ class SpriteAnimation
393
502
  * - Set it as obj.shader, or use setShader for 2D draws and render3D.shader for 3D draws
394
503
  * - Draws that share a Shader share a batch; with no Shader set nothing changes
395
504
  * - In 2D it shades textured draws, untextured ones like drawRect draw as they are
505
+ * - A render target, like a tile layer drawn in WebGL, holds premultiplied color, and so does every image when
506
+ * tilesPixelated is false; there iChannel0 reads premultiplied texels and the snippet's color is taken as
507
+ * premultiplied too. premultipliedTexture is true there, so a snippet that changes the alpha, or makes a see
508
+ * through color of its own, scales the rgb with it: `if (premultipliedTexture) c.rgb *= k;`
396
509
  * - Compiled once per renderer by the first draw that needs it; a bad snippet throws with the GLSL log in debug
397
510
  * - 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()
511
+ * - Names in both renderers: iChannel0 the texture, iTime, iResolution, premultipliedTexture, and localUV, 0 to 1
512
+ * across the sprite or the mesh's own uv
513
+ * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
514
+ * lightCount, lights[i], lightColors[i] and shadow()
515
+ * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
516
+ * parts of a surface still shadows with the whole of it
402
517
  * @example
403
518
  * const fade = new Shader(`
404
519
  * void mainImage(out vec4 c, vec2 uv)
405
520
  * {
406
521
  * c = texture(iChannel0, uv);
407
- * c.a *= .5 + .5*sin(iTime);
522
+ * float fade = .5 + .5*sin(iTime);
523
+ * c.a *= fade;
524
+ * if (premultipliedTexture) c.rgb *= fade; // a tile layer, or any image with tilesPixelated false
408
525
  * }`);
409
526
  * obj.shader = fade;
410
527
  * @memberof Draw
@@ -440,11 +557,11 @@ class Shader
440
557
  * @param {boolean} [mirror] - Is image flipped along the Y axis?
441
558
  * @param {Color} [additiveColor] - Additive color to be applied if any
442
559
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
443
- * @param {boolean} [screenSpace=false] - Are the pos and size are in screen space?
560
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size are in screen space?
444
561
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
445
562
  * @memberof Draw */
446
563
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
447
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
564
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
448
565
  {
449
566
  ASSERT(isVector2(pos), 'pos must be a vec2');
450
567
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -453,15 +570,23 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
453
570
  ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
454
571
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
455
572
 
573
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
574
+
456
575
  const textureInfo = tileInfo?.textureInfo;
576
+ if (textureInfo && !(tileInfo.size.x && tileInfo.size.y))
577
+ return; // a tile with no area draws nothing, like a sprite still loading
457
578
  const bleed = tileInfo?.bleed ?? 0;
458
579
  if (useWebGL && glEnable)
459
580
  {
460
581
  ASSERT(!!glContext, 'WebGL is not enabled!');
461
582
  if (screenSpace)
583
+ {
584
+ if (glSkipScreenSpace) return;
462
585
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
586
+ }
463
587
  if (textureInfo)
464
588
  {
589
+ ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false');
465
590
  // calculate uvs and render
466
591
  const sizeInverse = textureInfo.sizeInverse;
467
592
  const x = tileInfo.pos.x * sizeInverse.x;
@@ -487,8 +612,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
487
612
  }
488
613
  else
489
614
  {
490
- // untextured: fold color+additive to match the Canvas2D path's
491
- // color.add(additiveColor) on line ~337.
615
+ // untextured: color plus additive in one color, as the Canvas2D path below does
492
616
  const combined = additiveColor ? color.add(additiveColor) : color;
493
617
  glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
494
618
  }
@@ -526,7 +650,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
526
650
  * @param {Color} [color=WHITE]
527
651
  * @param {number} [angle]
528
652
  * @param {boolean} [useWebGL=glEnable]
529
- * @param {boolean} [screenSpace]
653
+ * @param {boolean} [screenSpace=drawScreenSpace]
530
654
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
531
655
  * @memberof Draw */
532
656
  function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
@@ -541,10 +665,10 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
541
665
  * @param {Color} [colorBottom=CLEAR_WHITE]
542
666
  * @param {number} [angle]
543
667
  * @param {boolean} [useWebGL=glEnable]
544
- * @param {boolean} [screenSpace]
668
+ * @param {boolean} [screenSpace=drawScreenSpace]
545
669
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
546
670
  * @memberof Draw */
547
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
671
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
548
672
  {
549
673
  ASSERT(isVector2(pos), 'pos must be a vec2');
550
674
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -552,15 +676,15 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
552
676
  ASSERT(isNumber(angle), 'angle must be a number');
553
677
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
554
678
 
679
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
680
+
555
681
  if (useWebGL && glEnable)
556
682
  {
557
683
  ASSERT(!!glContext, 'WebGL is not enabled!');
558
684
  if (screenSpace)
559
685
  {
560
- // convert to world space
561
- pos = screenToWorld(pos);
562
- size = size.scale(1/cameraScale);
563
- angle += cameraAngle;
686
+ if (glSkipScreenSpace) return;
687
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
564
688
  }
565
689
  // build 4 corner points for the rectangle
566
690
  const points = [], colors = [];
@@ -608,11 +732,11 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
608
732
  * @param {number} [angle=0] - Angle to rotate by
609
733
  * @param {Color} [additiveColor] - Additive color to be applied if any
610
734
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
611
- * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
735
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
612
736
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
613
737
  * @memberof Draw */
614
738
  function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
615
- angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
739
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
616
740
  {
617
741
  ASSERT(isVector2(pos), 'pos must be a vec2');
618
742
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -624,8 +748,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
624
748
  ASSERT(!(texture instanceof TileInfo),
625
749
  'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
626
750
 
627
- // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
628
- if (headlessMode) return;
751
+ // only a context passed in is drawn to in headless mode, before the texture lookup, textureInfos is empty there
752
+ if (headlessMode && !context) return;
629
753
 
630
754
  // resolve texture argument: TextureInfo or index
631
755
  const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
@@ -638,7 +762,10 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
638
762
  {
639
763
  ASSERT(!!glContext, 'WebGL is not enabled!');
640
764
  if (screenSpace)
765
+ {
766
+ if (glSkipScreenSpace) return;
641
767
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
768
+ }
642
769
  glSetTexture(textureInfo.glTexture);
643
770
  glDraw(pos.x, pos.y, size.x, size.y, angle,
644
771
  0, 0, wrapCount.x, wrapCount.y,
@@ -660,9 +787,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
660
787
  // pick image source: raw, or tinted bake. Match drawImageColor's
661
788
  // "no tint needed" predicate so behavior stays consistent.
662
789
  const noTint = !canvasColorTiles ||
663
- (additiveColor
664
- ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
665
- : isWhite(color));
790
+ isWhite(color) && (!additiveColor || isBlack(additiveColor));
666
791
  // alpha is baked into pixels by bakeTintedImage's additive branch;
667
792
  // in that case globalAlpha must NOT also apply color.a
668
793
  const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
@@ -698,10 +823,10 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
698
823
  * @param {Vector2} [pos=vec2()] - Offset to apply
699
824
  * @param {number} [angle] - Angle to rotate by
700
825
  * @param {boolean} [useWebGL=glEnable]
701
- * @param {boolean} [screenSpace]
826
+ * @param {boolean} [screenSpace=drawScreenSpace]
702
827
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
703
828
  * @memberof Draw */
704
- function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
829
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
705
830
  {
706
831
  ASSERT(isArray(points), 'points must be an array');
707
832
  ASSERT(isNumber(width), 'width must be a number');
@@ -710,12 +835,17 @@ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), ang
710
835
  ASSERT(isNumber(angle), 'angle must be a number');
711
836
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
712
837
 
838
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
839
+
713
840
  if (useWebGL && glEnable)
714
841
  {
715
842
  ASSERT(!!glContext, 'WebGL is not enabled!');
716
843
  let size = vec2(1);
717
844
  if (screenSpace)
845
+ {
846
+ if (glSkipScreenSpace) return;
718
847
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
848
+ }
719
849
  glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
720
850
  }
721
851
  else
@@ -725,8 +855,11 @@ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), ang
725
855
  ++primitiveCount;
726
856
  drawCanvas2D(pos, vec2(1), angle, false, (context)=>
727
857
  {
858
+ // Canvas2D ignores a width of 0 or below and keeps the last one, WebGL draws none for 0
859
+ // and a negative width as its size, the outline turned inside out
860
+ if (!width) return;
728
861
  context.strokeStyle = color.toString();
729
- context.lineWidth = width;
862
+ context.lineWidth = abs(width);
730
863
  context.beginPath();
731
864
  for (let i=0; i<points.length; ++i)
732
865
  {
@@ -747,14 +880,15 @@ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), ang
747
880
  * @param {Vector2} [pos=vec2()] - Offset to apply
748
881
  * @param {number} [angle] - Angle to rotate by
749
882
  * @param {boolean} [useWebGL=glEnable]
750
- * @param {boolean} [screenSpace]
883
+ * @param {boolean} [screenSpace=drawScreenSpace]
751
884
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
752
885
  * @memberof Draw */
753
- function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
886
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
754
887
  {
755
888
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
756
889
  const size = vec2(width, halfDelta.length()*2);
757
- pos = pos.add(posA.add(halfDelta));
890
+ const middle = posA.add(halfDelta);
891
+ pos = pos.add(angle ? middle.rotate(screenSpace ? -angle : angle) : middle); // screen y is down, so it turns back
758
892
  if (screenSpace)
759
893
  halfDelta.y *= -1; // flip angle Y if screen space
760
894
  angle += halfDelta.angle();
@@ -770,10 +904,10 @@ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWeb
770
904
  * @param {Color} [lineColor=BLACK]
771
905
  * @param {number} [angle]
772
906
  * @param {boolean} [useWebGL=glEnable]
773
- * @param {boolean} [screenSpace]
907
+ * @param {boolean} [screenSpace=drawScreenSpace]
774
908
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
775
909
  * @memberof Draw */
776
- function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
910
+ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
777
911
  {
778
912
  ASSERT(isVector2(size), 'size must be a vec2');
779
913
  ASSERT(isNumber(sides), 'sides must be a number');
@@ -790,6 +924,8 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
790
924
  }
791
925
 
792
926
  /** Draw colored polygon using passed in points
927
+ * - WebGL fills a polygon whose edges do not cross, concave or not; a self crossing one, like a star through its
928
+ * outer points, fills wrong there, so draw it as its simple outline or in parts
793
929
  * @param {Array<Vector2>} points - Array of Vector2 points
794
930
  * @param {Color} [color=WHITE]
795
931
  * @param {number} [lineWidth]
@@ -797,10 +933,10 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
797
933
  * @param {Vector2} [pos=vec2()] - Offset to apply
798
934
  * @param {number} [angle] - Angle to rotate by
799
935
  * @param {boolean} [useWebGL=glEnable]
800
- * @param {boolean} [screenSpace]
936
+ * @param {boolean} [screenSpace=drawScreenSpace]
801
937
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
802
938
  * @memberof Draw */
803
- function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context=undefined)
939
+ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context=undefined)
804
940
  {
805
941
  ASSERT(isVector2(pos), 'pos must be a vec2');
806
942
  ASSERT(isArray(points), 'points must be an array');
@@ -809,18 +945,25 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
809
945
  ASSERT(isNumber(angle), 'angle must be a number');
810
946
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
811
947
 
948
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
949
+
812
950
  if (useWebGL && glEnable)
813
951
  {
814
952
  ASSERT(!!glContext, 'WebGL is not enabled!');
815
953
  let size = vec2(1);
816
954
  if (screenSpace)
955
+ {
956
+ if (glSkipScreenSpace) return;
817
957
  [pos, size, angle] = screenToWorldTransform(pos, size, angle);
958
+ }
818
959
  glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
819
960
  if (lineWidth > 0)
820
961
  glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
821
962
  }
822
963
  else
823
964
  {
965
+ ++drawCount;
966
+ ++primitiveCount;
824
967
  drawCanvas2D(pos, vec2(1), angle, false, context=>
825
968
  {
826
969
  context.fillStyle = color.toString();
@@ -829,7 +972,7 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
829
972
  context.lineTo(point.x, point.y);
830
973
  context.closePath();
831
974
  context.fill();
832
- if (lineWidth)
975
+ if (lineWidth > 0) // as in WebGL, Canvas2D would stroke a width below 0 with the last one
833
976
  {
834
977
  context.strokeStyle = lineColor.toString();
835
978
  context.lineWidth = lineWidth;
@@ -839,6 +982,9 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
839
982
  }
840
983
  }
841
984
 
985
+ // the unit rings drawEllipse fills with in WebGL, one for each side count
986
+ const drawEllipseRings = new Map;
987
+
842
988
  /** Draw colored ellipse using passed in point
843
989
  * @param {Vector2} pos
844
990
  * @param {Vector2} [size=vec2(1)] - Width and height diameter
@@ -847,10 +993,10 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
847
993
  * @param {number} [lineWidth]
848
994
  * @param {Color} [lineColor=BLACK]
849
995
  * @param {boolean} [useWebGL=glEnable]
850
- * @param {boolean} [screenSpace]
996
+ * @param {boolean} [screenSpace=drawScreenSpace]
851
997
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
852
998
  * @memberof Draw */
853
- function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
999
+ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
854
1000
  {
855
1001
  ASSERT(isVector2(pos), 'pos must be a vec2');
856
1002
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -860,24 +1006,51 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
860
1006
  ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0');
861
1007
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
862
1008
 
863
- // clamp line width to prevent artifacts
864
- lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
1009
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1010
+
1011
+ // clamp line width to prevent artifacts, a negative size draws mirrored
1012
+ lineWidth = clamp(lineWidth, 0, min(abs(size.x), abs(size.y)));
865
1013
 
866
1014
  if (useWebGL && glEnable)
867
1015
  {
868
- // draw as a regular polygon
869
1016
  const sides = glCircleSides;
870
- drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
1017
+ if (lineWidth > 0)
1018
+ {
1019
+ // draw as a regular polygon, the outline is made from the points at their size
1020
+ drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
1021
+ return;
1022
+ }
1023
+
1024
+ // a fill is a unit ring scaled, made once for each side count and already in strip order
1025
+ let ring = drawEllipseRings.get(sides);
1026
+ if (!ring)
1027
+ {
1028
+ const points = [];
1029
+ for (let i=sides; i--;)
1030
+ {
1031
+ const a = (i/sides)*PI*2;
1032
+ points.push(vec2(sin(a), cos(a)));
1033
+ }
1034
+ drawEllipseRings.set(sides, ring = glPolyStrip(points));
1035
+ }
1036
+ if (screenSpace)
1037
+ {
1038
+ if (glSkipScreenSpace) return;
1039
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
1040
+ }
1041
+ glDrawPointsTransform(ring, color.rgbaInt(), pos.x, pos.y, size.x/2, size.y/2, angle, false);
871
1042
  }
872
1043
  else
873
1044
  {
1045
+ ++drawCount;
1046
+ ++primitiveCount;
874
1047
  drawCanvas2D(pos, vec2(1), angle, false, context=>
875
1048
  {
876
1049
  context.fillStyle = color.toString();
877
1050
  context.beginPath();
878
- context.ellipse(0, 0, size.x/2, size.y/2, 0, 0, 9);
1051
+ context.ellipse(0, 0, abs(size.x)/2, abs(size.y)/2, 0, 0, 9); // it throws on a negative radius
879
1052
  context.fill();
880
- if (lineWidth)
1053
+ if (lineWidth > 0)
881
1054
  {
882
1055
  context.strokeStyle = lineColor.toString();
883
1056
  context.lineWidth = lineWidth;
@@ -894,15 +1067,16 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
894
1067
  * @param {number} [lineWidth=0]
895
1068
  * @param {Color} [lineColor=BLACK]
896
1069
  * @param {boolean} [useWebGL=glEnable]
897
- * @param {boolean} [screenSpace]
1070
+ * @param {boolean} [screenSpace=drawScreenSpace]
898
1071
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
899
1072
  * @memberof Draw */
900
- function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
1073
+ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
901
1074
  {
902
1075
  ASSERT(isNumber(size), 'size must be a number');
903
1076
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
904
1077
  }
905
1078
 
1079
+ let drawEllipseGradientOffset = 0;
906
1080
  /** Draw an ellipse filled with a radial gradient from the center to the rim
907
1081
  * - Best when batched with other untextured polys
908
1082
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
@@ -913,11 +1087,10 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
913
1087
  * @param {Color} [colorOuter=CLEAR_WHITE]
914
1088
  * @param {number} [angle]
915
1089
  * @param {boolean} [useWebGL=glEnable]
916
- * @param {boolean} [screenSpace]
1090
+ * @param {boolean} [screenSpace=drawScreenSpace]
917
1091
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
918
1092
  * @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)
1093
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
921
1094
  {
922
1095
  ASSERT(isVector2(pos), 'pos must be a vec2');
923
1096
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -925,17 +1098,15 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
925
1098
  ASSERT(isNumber(angle), 'angle must be a number');
926
1099
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
927
1100
 
928
- if (headlessMode) return;
1101
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
929
1102
 
930
1103
  if (useWebGL && glEnable)
931
1104
  {
932
1105
  ASSERT(!!glContext, 'WebGL is not enabled!');
933
1106
  if (screenSpace)
934
1107
  {
935
- // convert to world space
936
- pos = screenToWorld(pos);
937
- size = size.scale(1/cameraScale);
938
- angle += cameraAngle;
1108
+ if (glSkipScreenSpace) return;
1109
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
939
1110
  }
940
1111
  // fan as tristrip; rotate the boundary vertex by one slice per call
941
1112
  // so back-to-back gradients at the same position have their hole
@@ -992,10 +1163,10 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
992
1163
  * @param {Color} [colorInner=WHITE]
993
1164
  * @param {Color} [colorOuter=CLEAR_WHITE]
994
1165
  * @param {boolean} [useWebGL=glEnable]
995
- * @param {boolean} [screenSpace]
1166
+ * @param {boolean} [screenSpace=drawScreenSpace]
996
1167
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
997
1168
  * @memberof Draw */
998
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
1169
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
999
1170
  {
1000
1171
  ASSERT(isNumber(size), 'size must be a number');
1001
1172
  drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
@@ -1013,19 +1184,21 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
1013
1184
  * (e.g. linear gradients) should flip their own Y endpoints accordingly.
1014
1185
  * @param {Vector2} pos
1015
1186
  * @param {Vector2} size
1016
- * @param {number} angle
1187
+ * @param {number} [angle]
1017
1188
  * @param {boolean} [mirror]
1018
- * @param {Canvas2DDrawFunction} [drawFunction]
1019
- * @param {boolean} [screenSpace=false]
1189
+ * @param {Canvas2DDrawFunction} [drawFunction] - Needed, marked optional only because the ones before it are
1190
+ * @param {boolean} [screenSpace=drawScreenSpace]
1020
1191
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1021
1192
  * @memberof Draw */
1022
- function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=false, context=drawContext)
1193
+ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=drawScreenSpace, context=drawContext)
1023
1194
  {
1024
1195
  ASSERT(isVector2(pos), 'pos must be a vec2');
1025
1196
  ASSERT(isVector2(size), 'size must be a vec2');
1026
1197
  ASSERT(isNumber(angle), 'angle must be a number');
1027
1198
  ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
1028
1199
 
1200
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1201
+
1029
1202
  if (!screenSpace)
1030
1203
  {
1031
1204
  pos = worldToScreen(pos);
@@ -1051,7 +1224,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
1051
1224
  * @param {Color} [color=WHITE]
1052
1225
  * @param {number} [lineWidth]
1053
1226
  * @param {Color} [lineColor=BLACK]
1054
- * @param {CanvasTextAlign} [textAlign='center']
1227
+ * @param {'left'|'center'|'right'} [textAlign='center']
1055
1228
  * @param {string} [font=fontDefault]
1056
1229
  * @param {string} [fontStyle]
1057
1230
  * @param {number} [maxWidth]
@@ -1064,8 +1237,9 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1064
1237
  pos = worldToScreen(pos);
1065
1238
  size *= cameraScale;
1066
1239
  lineWidth *= cameraScale;
1240
+ if (maxWidth !== undefined)
1241
+ maxWidth *= cameraScale;
1067
1242
  angle -= cameraAngle;
1068
- angle *= -1;
1069
1243
 
1070
1244
  drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
1071
1245
  }
@@ -1078,11 +1252,11 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1078
1252
  * @param {Color} [color=WHITE]
1079
1253
  * @param {number} [lineWidth]
1080
1254
  * @param {Color} [lineColor=BLACK]
1081
- * @param {CanvasTextAlign} [textAlign]
1255
+ * @param {'left'|'center'|'right'} [textAlign]
1082
1256
  * @param {string} [font=fontDefault]
1083
1257
  * @param {string} [fontStyle]
1084
1258
  * @param {number} [maxWidth]
1085
- * @param {number} [angle]
1259
+ * @param {number} [angle] - Clockwise, like the other screen space draws
1086
1260
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1087
1261
  * @memberof Draw */
1088
1262
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
@@ -1097,9 +1271,10 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
1097
1271
  ASSERT(isStringLike(font), 'font must be a string');
1098
1272
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
1099
1273
  ASSERT(isNumber(angle), 'angle must be a number');
1274
+
1275
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1100
1276
 
1101
1277
  const lines = (text+'').split('\n');
1102
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
1103
1278
  // save before style mutations so caller's context state is preserved
1104
1279
  context.save();
1105
1280
  context.fillStyle = color.toString();
@@ -1108,9 +1283,9 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
1108
1283
  context.textAlign = textAlign;
1109
1284
  context.font = fontStyle + ' ' + size + 'px '+ font;
1110
1285
  context.textBaseline = 'middle';
1111
- context.translate(pos.x, posY);
1112
- context.rotate(-angle);
1113
- let yOffset = 0;
1286
+ context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
1287
+ context.rotate(angle);
1288
+ let yOffset = -(lines.length-1) * size/2; // center vertically
1114
1289
  lines.forEach(line=>
1115
1290
  {
1116
1291
  lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
@@ -1123,29 +1298,37 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
1123
1298
  ///////////////////////////////////////////////////////////////////////////////
1124
1299
  // Drawing utilities
1125
1300
 
1126
- /** Load a texture at a specific index
1127
- * @param {number} textureIndex - Index to store the texture at
1301
+ /** Load a texture at a specific index after engineInit, the images passed to engineInit load this way
1302
+ * @param {number} textureIndex - Index to store the texture at, an unused one
1128
1303
  * @param {string} [src] - Image source path
1129
- * @return {Promise} Promise that resolves when texture is loaded
1304
+ * @return {Promise<TextureInfo>} Resolves to the texture info once the image loads, or fails to with a warning
1130
1305
  * @memberof Draw */
1131
1306
  async function loadTexture(textureIndex, src)
1132
1307
  {
1133
1308
  ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
1134
- ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
1309
+ // engineInit's empty placeholder in slot 0, when it was given no images, or a load that failed can be replaced
1310
+ const old = textureInfos[textureIndex];
1311
+ ASSERT(!old?.size.x, 'textureIndex is already loaded!');
1312
+ old?.destroyWebGLTexture();
1135
1313
  ASSERT(!src || isStringLike(src), 'image src must be a string');
1136
1314
 
1137
1315
  const image = new Image;
1138
1316
  if (src)
1139
1317
  {
1140
- await new Promise(resolve =>
1318
+ await engineAddLoad(new Promise(resolve => // startup waits for it
1141
1319
  {
1142
- image.onerror = image.onload = resolve;
1320
+ image.onload = resolve;
1321
+ image.onerror = ()=>
1322
+ {
1323
+ console.warn('failed to load image: ' + src); // in release too, like the WebGL warning
1324
+ resolve();
1325
+ };
1143
1326
  image.crossOrigin = 'anonymous';
1144
1327
  image.src = src;
1145
- });
1328
+ }));
1146
1329
  }
1147
1330
 
1148
- textureInfos[textureIndex] = new TextureInfo(image);
1331
+ return textureInfos[textureIndex] = new TextureInfo(image);
1149
1332
  }
1150
1333
 
1151
1334
  /** Convert from screen to world space coordinates
@@ -1212,7 +1395,7 @@ function screenToWorldDelta(screenDelta)
1212
1395
  return new Vector2(x, y);
1213
1396
  }
1214
1397
 
1215
- /** Convert from screen to world space coordinates for a directional vector (no translation)
1398
+ /** Convert from world to screen space coordinates for a directional vector (no translation)
1216
1399
  * @param {Vector2} worldDelta
1217
1400
  * @return {Vector2}
1218
1401
  * @memberof Draw */
@@ -1256,6 +1439,14 @@ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
1256
1439
  * @memberof Draw */
1257
1440
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1258
1441
 
1442
+ /** Padding for each side of a rectangle, the sides left out are 0
1443
+ * @typedef {Object} CameraFitSides
1444
+ * @property {number} [top]
1445
+ * @property {number} [right]
1446
+ * @property {number} [bottom]
1447
+ * @property {number} [left]
1448
+ * @memberof Draw */
1449
+
1259
1450
  /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
1260
1451
  * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
1261
1452
  * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
@@ -1264,8 +1455,8 @@ function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1264
1455
  * or an object with any of {top, right, bottom, left}
1265
1456
  * @param {Vector2} center - Center of the rectangle in world space
1266
1457
  * @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
1458
+ * @param {number|Vector2|CameraFitSides} [worldMargin] - World space padding added around the content rectangle
1459
+ * @param {number|Vector2|CameraFitSides} [screenInset] - Screen space padding in pixels reserved on each viewport edge
1269
1460
  * @return {number} - The new camera scale
1270
1461
  * @memberof Draw */
1271
1462
  function cameraFit(center, size, worldMargin, screenInset)
@@ -1285,12 +1476,15 @@ function cameraFit(center, size, worldMargin, screenInset)
1285
1476
  if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
1286
1477
  return cameraScale;
1287
1478
 
1288
- // scale to fit the padded content
1289
- cameraScale = min(viewW / worldW, viewH / worldH);
1479
+ // scale to fit the padded content, measured along the camera's axes when it is turned
1480
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1481
+ const fitW = abs(c) * worldW + abs(s) * worldH, fitH = abs(s) * worldW + abs(c) * worldH;
1482
+ cameraScale = min(viewW / fitW, viewH / fitH);
1290
1483
 
1291
- // calculate offset vectors
1484
+ // calculate offset vectors, the inset is on the screen, so it turns back into the world with the camera
1292
1485
  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);
1486
+ const ix = (inset.right - inset.left) * .5 / cameraScale, iy = (inset.top - inset.bottom) * .5 / cameraScale;
1487
+ const insetVector = vec2(ix * c + iy * s, iy * c - ix * s);
1294
1488
 
1295
1489
  // apply the offsets and return camera scale
1296
1490
  cameraPos = center.add(marginVector).add(insetVector);
@@ -1316,7 +1510,7 @@ function cameraFit(center, size, worldMargin, screenInset)
1316
1510
  * If size is a Vector2, uses the length as diameter
1317
1511
  * This can be used to cull offscreen objects from render or update
1318
1512
  * @param {Vector2} pos - world space position
1319
- * @param {Vector2|number} size - world space size or diameter
1513
+ * @param {Vector2|number} [size] - world space size or diameter
1320
1514
  * @return {boolean}
1321
1515
  * @memberof Draw */
1322
1516
  function isOnScreen(pos, size=0)
@@ -1327,8 +1521,7 @@ function isOnScreen(pos, size=0)
1327
1521
  // cameraScale of 0 collapses world coords; nothing is visible
1328
1522
  if (!cameraScale) return false;
1329
1523
 
1330
- // optimized circle on screen test
1331
- // pos = worldToScreen(pos);
1524
+ // circle on screen test, worldToScreen inlined in doubled screen units so it compares against the full canvas size
1332
1525
  let x = pos.x - cameraPos.x;
1333
1526
  let y = pos.y - cameraPos.y;
1334
1527
  if (cameraAngle)
@@ -1402,6 +1595,31 @@ function combineCanvases()
1402
1595
  mainContext.restore();
1403
1596
  }
1404
1597
 
1598
+ // tint straight color pixels in place, rgb times color, plus additiveColor with alpha when it adds anything;
1599
+ // returns true when color.a went into the alpha, so it is not applied again as globalAlpha
1600
+ function tintImageData(data, color, additiveColor)
1601
+ {
1602
+ if (additiveColor && !isBlack(additiveColor))
1603
+ {
1604
+ // multiply + additive (slower), color.a is baked into the alpha channel here
1605
+ const colorMultiply = [color.r, color.g, color.b, color.a];
1606
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1607
+ additiveColor.b * 255, additiveColor.a * 255];
1608
+ for (let i = 0; i < data.length; ++i)
1609
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1610
+ return true;
1611
+ }
1612
+
1613
+ // RGB only, faster — alpha left intact for the caller
1614
+ for (let i = 0; i < data.length; i+=4)
1615
+ {
1616
+ data[i ] *= color.r;
1617
+ data[i+1] *= color.g;
1618
+ data[i+2] *= color.b;
1619
+ }
1620
+ return false;
1621
+ }
1622
+
1405
1623
  // Internal: bake a color/additive-color tint into workReadCanvas at the
1406
1624
  // image's native resolution. Returns the work canvas, suitable for
1407
1625
  // passing to context.createPattern. Used by drawTextureWrapped's
@@ -1415,45 +1633,27 @@ function bakeTintedImage(image, color, additiveColor)
1415
1633
  workReadContext.drawImage(image, 0, 0);
1416
1634
 
1417
1635
  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
- }
1636
+ tintImageData(imageData.data, color, additiveColor);
1438
1637
  workReadContext.putImageData(imageData, 0, 0);
1439
1638
  return workReadCanvas;
1440
1639
  }
1441
1640
 
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
1641
+ /** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
1642
+ * This is slower than normal drawImage when color is applied
1643
+ * @ignore
1644
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1645
+ * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
1646
+ * @param {number} sx
1647
+ * @param {number} sy
1648
+ * @param {number} sWidth
1649
+ * @param {number} sHeight
1650
+ * @param {number} dx
1651
+ * @param {number} dy
1652
+ * @param {number} dWidth
1653
+ * @param {number} dHeight
1654
+ * @param {Color} color
1655
+ * @param {Color} [additiveColor]
1656
+ * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1457
1657
  * @memberof Draw */
1458
1658
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1459
1659
  {
@@ -1463,9 +1663,9 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
1463
1663
  sHeight = max(1,sHeight|0);
1464
1664
  const sWidth2 = sWidth - 2*bleed;
1465
1665
  const sHeight2 = sHeight - 2*bleed;
1466
- if (!canvasColorTiles || (additiveColor ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0 : isWhite(color)))
1666
+ if (!canvasColorTiles || isWhite(color) && (!additiveColor || isBlack(additiveColor)))
1467
1667
  {
1468
- // white texture with no additive alpha, no need to tint
1668
+ // white tint and nothing added, the texels are drawn as they are
1469
1669
  context.globalAlpha = color.a;
1470
1670
  context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1471
1671
  context.globalAlpha = 1;
@@ -1479,31 +1679,11 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
1479
1679
 
1480
1680
  // tint image using offscreen work context
1481
1681
  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
- }
1682
+ const alphaBaked = tintImageData(imageData.data, color, additiveColor);
1683
+ workReadContext.putImageData(imageData, 0, 0);
1684
+ if (!alphaBaked) context.globalAlpha = color.a;
1685
+ context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1686
+ if (!alphaBaked) context.globalAlpha = 1;
1507
1687
  }
1508
1688
  }
1509
1689
 
@@ -1556,7 +1736,7 @@ let engineImageFont;
1556
1736
  * const font = engineImageFont;
1557
1737
  *
1558
1738
  * // draw text
1559
- * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
1739
+ * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50), 16);
1560
1740
  */
1561
1741
  class ImageFont
1562
1742
  {
@@ -1568,14 +1748,15 @@ class ImageFont
1568
1748
  ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
1569
1749
 
1570
1750
  /** @property {TileInfo} - Tile info for the font */
1571
- this.tileInfo = tileInfo.frame(0);
1751
+ this.tileInfo = tileInfo; // kept, not copied, so a loadSprite tile filled in once it loads is seen
1572
1752
  }
1573
1753
 
1574
1754
  /** Draw text in world space using the image font
1755
+ * - The text stays upright and ignores cameraAngle, each glyph is snapped to whole screen pixels to keep it crisp
1575
1756
  * @param {string|number} text
1576
1757
  * @param {Vector2} pos
1577
1758
  * @param {Vector2|number} [size]
1578
- * @param {boolean} [center=true]
1759
+ * @param {boolean} [center=true] - center each line on pos, and the lines of multi-line text around it
1579
1760
  * @param {Color} [color=WHITE]
1580
1761
  * @param {boolean} [useWebGL=glEnable]
1581
1762
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -1593,14 +1774,18 @@ class ImageFont
1593
1774
  }
1594
1775
  else
1595
1776
  size = size.scale(cameraScale);
1777
+ // world text, only drawn through screen space, so it still casts in a pass that skips screen space draws
1778
+ const skip = glSkipScreenSpace;
1779
+ glSkipScreenSpace = false;
1596
1780
  this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context);
1781
+ glSkipScreenSpace = skip;
1597
1782
  }
1598
1783
 
1599
1784
  /** Draw text in screen space using the image font
1600
1785
  * @param {string|number} text
1601
1786
  * @param {Vector2} pos
1602
1787
  * @param {Vector2|number} size
1603
- * @param {boolean} [center]
1788
+ * @param {boolean} [center] - center each line on pos, and the lines of multi-line text around it
1604
1789
  * @param {Color} [color=WHITE]
1605
1790
  * @param {boolean} [useWebGL=glEnable]
1606
1791
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -1610,35 +1795,27 @@ class ImageFont
1610
1795
  ASSERT(isStringLike(text), 'text must be a string');
1611
1796
  ASSERT(isVector2(pos), 'pos must be a vec2');
1612
1797
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1798
+ ASSERT(typeof center === 'boolean', 'center must be a boolean, the color comes after it, unlike drawText');
1613
1799
  ASSERT(isColor(color), 'color must be a color');
1614
1800
 
1615
1801
  // if size is a number, make it a vector
1616
- size = typeof size === 'number' ? new Vector2(size, size) : size;
1802
+ const glyphSize = typeof size === 'number' ? new Vector2(size, size) : size;
1617
1803
 
1618
- // precache objects for drawing
1804
+ // precache objects for drawing, a copy of the tile info each glyph moves, the font's own stays put
1619
1805
  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)=>
1806
+ const tileInfo = this.tileInfo.frame(0);
1807
+
1808
+ // draw each line of text, centered vertically like drawTextScreen when center is set
1809
+ const lines = (text+'').split('\n');
1810
+ const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
1811
+ lines.forEach((line, j)=>
1628
1812
  {
1629
- const centerOffset = center ? (line.length-1) * size.x / 2 : 0;
1813
+ const centerOffset = center ? (line.length-1) * glyphSize.x / 2 : 0;
1630
1814
  for (let i=line.length; i--;)
1631
1815
  {
1632
- // get the character index
1816
+ // get the glyph, out of range characters use the last one
1633
1817
  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;
1818
+ this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
1642
1819
 
1643
1820
  // snap the glyph edges to whole pixels
1644
1821
  // tiles are drawn from their center, so snapping the center
@@ -1647,12 +1824,28 @@ class ImageFont
1647
1824
  // no pixel center inside it and is not rasterized at all
1648
1825
  // ceil picks the nearest aligned position, breaking ties
1649
1826
  // 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);
1827
+ drawPos.x = ceil(pos.x + i * glyphSize.x - centerOffset - glyphSize.x/2) + glyphSize.x/2 - .5;
1828
+ drawPos.y = ceil(pos.y + j * glyphSize.y - centerOffsetY - glyphSize.y/2) + glyphSize.y/2 - .5;
1829
+ drawTile(drawPos, glyphSize, tileInfo, color, 0, false, undefined, useWebGL, true, context);
1653
1830
  }
1654
1831
  });
1655
1832
  }
1833
+
1834
+ /** Get where a glyph sits in the texture: counted in the font's own columns when its tile has them, like a font
1835
+ * packed by loadSprite, otherwise along the texture's grid from the font's first tile, the way tile() lays it out
1836
+ * @param {number} index - Glyph number, 0 is the space and the characters follow in ASCII order
1837
+ * @param {Vector2} [pos] - Written into and returned, for a loop that places many
1838
+ * @return {Vector2} */
1839
+ getGlyphPos(index, pos=new Vector2)
1840
+ {
1841
+ const t = this.tileInfo, padding = t.padding;
1842
+ const w = t.size.x + padding*2, h = t.size.y + padding*2;
1843
+ if (t.columns)
1844
+ return pos.set(t.pos.x + index % t.columns * w, t.pos.y + (index / t.columns |0) * h);
1845
+ const columns = t.textureInfo.size.x / w |0;
1846
+ const glyph = ((t.pos.y - padding) / h |0) * columns + ((t.pos.x - padding) / w |0) + index;
1847
+ return pos.set(glyph % columns * w + padding, (glyph / columns |0) * h + padding);
1848
+ }
1656
1849
  }
1657
1850
 
1658
1851
  // load engine font, called automatically on startup