littlejsengine 1.14.19 → 1.14.28

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 (70) hide show
  1. package/dist/littlejs.d.ts +217 -38
  2. package/dist/littlejs.esm.js +879 -324
  3. package/dist/littlejs.esm.min.js +1 -1
  4. package/dist/littlejs.js +861 -324
  5. package/dist/littlejs.min.js +1 -1
  6. package/dist/littlejs.release.js +799 -325
  7. package/examples/box2d/gameObjects.js +6 -10
  8. package/examples/box2d/scenes.js +2 -2
  9. package/examples/breakout/gameObjects.js +1 -1
  10. package/examples/electron/game.js +1 -41
  11. package/examples/electron/index.html +2 -2
  12. package/examples/htmlMenu/game.js +1 -1
  13. package/examples/index.html +19 -10
  14. package/examples/module/game.js +5 -7
  15. package/examples/particles/index.html +6 -9
  16. package/examples/platformer/gameCharacter.js +4 -3
  17. package/examples/platformer/gameEffects.js +7 -2
  18. package/examples/platformer/gameObjects.js +6 -11
  19. package/examples/shorts/animation.js +2 -2
  20. package/examples/shorts/base.html +4 -1
  21. package/examples/shorts/box2d.js +2 -2
  22. package/examples/shorts/box2dCar.js +18 -7
  23. package/examples/shorts/box2dPool.js +108 -0
  24. package/examples/shorts/cameraDrag.js +22 -0
  25. package/examples/shorts/debugDraw.js +37 -0
  26. package/examples/shorts/flappyGame.js +1 -0
  27. package/examples/shorts/fps.js +90 -0
  28. package/examples/shorts/helloWorld.js +1 -1
  29. package/examples/shorts/hillGlideGame.js +1 -3
  30. package/examples/shorts/input.js +59 -0
  31. package/examples/shorts/landerGame.js +1 -3
  32. package/examples/shorts/medals.js +15 -9
  33. package/examples/shorts/music.js +15 -17
  34. package/examples/shorts/musicPlayer.js +24 -24
  35. package/examples/shorts/platformer.js +0 -2
  36. package/examples/shorts/postProcess.js +1 -1
  37. package/examples/shorts/sequencer.js +3 -3
  38. package/examples/shorts/slidingPuzzle.js +1 -1
  39. package/examples/shorts/spaceGame.js +1 -3
  40. package/examples/shorts/spriteAtlas.js +6 -8
  41. package/examples/shorts/starfield.js +1 -1
  42. package/examples/shorts/tileRaycast.js +39 -0
  43. package/examples/shorts/tiles.png +0 -0
  44. package/examples/shorts/tiltedView.js +1 -4
  45. package/examples/shorts/timers.js +1 -2
  46. package/examples/shorts/topDown.js +0 -2
  47. package/examples/starter/game.js +5 -7
  48. package/examples/starter/index.html +2 -2
  49. package/examples/typescript/game.js +3 -2
  50. package/examples/typescript/game.ts +5 -7
  51. package/examples/uiSystem/game.js +2 -2
  52. package/package.json +1 -1
  53. package/plugins/box2d.js +170 -50
  54. package/plugins/pluginExport.js +2 -0
  55. package/plugins/uiSystem.js +55 -27
  56. package/src/engine.js +19 -16
  57. package/src/engineAudio.js +27 -15
  58. package/src/engineDebug.js +64 -0
  59. package/src/engineDraw.js +88 -32
  60. package/src/engineExport.js +16 -0
  61. package/src/engineInput.js +35 -25
  62. package/src/engineMedals.js +2 -2
  63. package/src/engineObject.js +55 -10
  64. package/src/engineParticles.js +36 -20
  65. package/src/engineRelease.js +2 -1
  66. package/src/engineSettings.js +20 -1
  67. package/src/engineTileLayer.js +64 -59
  68. package/src/engineUtilities.js +213 -59
  69. package/src/engineWebGL.js +13 -8
  70. package/examples/shorts/raycasting.js +0 -79
package/src/engine.js CHANGED
@@ -30,7 +30,7 @@ const engineName = 'LittleJS';
30
30
  * @type {string}
31
31
  * @default
32
32
  * @memberof Engine */
33
- const engineVersion = '1.14.19';
33
+ const engineVersion = '1.15.0';
34
34
 
35
35
  /** Frames per second to update
36
36
  * @type {number}
@@ -161,7 +161,7 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
161
161
  async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement=document.body)
162
162
  {
163
163
  ASSERT(!mainContext, 'engine already initialized');
164
- ASSERT(Array.isArray(imageSources), 'pass in images as array');
164
+ ASSERT(isArray(imageSources), 'pass in images as array');
165
165
 
166
166
  // allow passing in empty functions
167
167
  gameInit ||= ()=>{};
@@ -453,21 +453,24 @@ function engineObjectsUpdate()
453
453
  // recursive object update
454
454
  function updateObject(o)
455
455
  {
456
- if (!o.destroyed)
457
- {
458
- o.update();
459
- for (const child of o.children)
460
- updateObject(child);
461
- }
456
+ if (o.destroyed)
457
+ return;
458
+
459
+ o.update();
460
+ for (const child of o.children)
461
+ updateObject(child);
462
462
  }
463
463
  for (const o of engineObjects)
464
464
  {
465
+ if (o.parent)
466
+ continue;
467
+
465
468
  // update top level objects
466
- if (!o.parent)
467
- {
468
- updateObject(o);
469
- o.updateTransforms();
470
- }
469
+ o.update();
470
+ o.updatePhysics();
471
+ for (const child of o.children)
472
+ updateObject(child);
473
+ o.updateTransforms();
471
474
  }
472
475
 
473
476
  // remove destroyed objects
@@ -500,7 +503,7 @@ function engineObjectsCollect(pos, size, objects=engineObjects)
500
503
  else if (size instanceof Vector2) // bounding box test
501
504
  {
502
505
  for (const o of objects)
503
- isOverlapping(pos, size, o.pos, o.size) && collectedObjects.push(o);
506
+ o.isOverlapping(pos, size) && collectedObjects.push(o);
504
507
  }
505
508
  else // circle test
506
509
  {
@@ -513,7 +516,7 @@ function engineObjectsCollect(pos, size, objects=engineObjects)
513
516
 
514
517
  /**
515
518
  * @callback ObjectCallbackFunction - Function that processes an object
516
- * @param {EngineObject} uiObjects
519
+ * @param {EngineObject} object
517
520
  * @memberof Engine
518
521
  */
519
522
 
@@ -561,7 +564,7 @@ function drawEngineSplashScreen(t)
561
564
  // background
562
565
  const p3 = percent(t, 1, .8);
563
566
  const p4 = percent(t, 0, .5);
564
- const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,Math.hypot(w,h)*.7);
567
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.7);
565
568
  g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
566
569
  g.addColorStop(1,hsl(0,0,0,p3).toString());
567
570
  x.save();
@@ -66,6 +66,10 @@ class Sound
66
66
  {
67
67
  if (!soundEnable || headlessMode) return;
68
68
 
69
+ ASSERT(!zzfxSound || isArray(zzfxSound), 'zzfxSound is invalid');
70
+ ASSERT(isNumber(range), 'range must be a number');
71
+ ASSERT(isNumber(taper), 'taper must be a number');
72
+
69
73
  /** @property {number} - World space max range of sound */
70
74
  this.range = range;
71
75
  /** @property {number} - At what percentage of range should it start tapering */
@@ -104,6 +108,12 @@ class Sound
104
108
  */
105
109
  play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
106
110
  {
111
+ ASSERT(!pos || isVector2(pos), 'pos must be a vec2');
112
+ ASSERT(isNumber(volume), 'volume must be a number');
113
+ ASSERT(isNumber(pitch), 'pitch must be a number');
114
+ ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
115
+
116
+
107
117
  if (!soundEnable || headlessMode) return;
108
118
  if (!this.sampleChannels) return;
109
119
 
@@ -149,6 +159,7 @@ class Sound
149
159
  */
150
160
  playNote(semitoneOffset=0, pos, volume)
151
161
  {
162
+ ASSERT(isNumber(semitoneOffset), 'semitoneOffset must be a number');
152
163
  const pitch = getNoteFrequency(semitoneOffset, 1);
153
164
  return this.play(pos, volume, pitch, 0);
154
165
  }
@@ -157,7 +168,7 @@ class Sound
157
168
  * @return {number} - How long the sound is in seconds (undefined if loading)
158
169
  */
159
170
  getDuration()
160
- { return this.sampleChannels && this.sampleChannels[0].length / this.sampleRate; }
171
+ { return this.sampleChannels && this.sampleRate ? this.sampleChannels[0].length / this.sampleRate : 0; }
161
172
 
162
173
  /** Check if sound is loaded, for sounds fetched from a url
163
174
  * @return {boolean} - True if sound is loaded and ready to play
@@ -199,6 +210,7 @@ class SoundWave extends Sound
199
210
  super(undefined, range, taper);
200
211
  if (!soundEnable || headlessMode) return;
201
212
  ASSERT(!filename || isString(filename), 'filename must be a string');
213
+ ASSERT(isNumber(randomness), 'randomness must be a number');
202
214
 
203
215
  /** @property {SoundLoadCallback} - callback function to call when sound is loaded */
204
216
  this.onloadCallback = onloadCallback;
@@ -415,7 +427,7 @@ class SoundInstance
415
427
  /** Get the total duration of this sound
416
428
  * @return {number} - Total duration in seconds
417
429
  */
418
- getDuration() { return this.sound.getDuration() / this.rate; }
430
+ getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
419
431
 
420
432
  /** Get source of this sound instance
421
433
  * @return {AudioBufferSourceNode}
@@ -605,10 +617,10 @@ function zzfxG
605
617
 
606
618
  // biquad LP/HP filter
607
619
  quality = 2, w = PI2 * abs(filter) * 2 / sampleRate,
608
- cos = Math.cos(w), alpha = Math.sin(w) / 2 / quality,
609
- a0 = 1 + alpha, a1 = -2*cos / a0, a2 = (1 - alpha) / a0,
610
- b0 = (1 + sign(filter) * cos) / 2 / a0,
611
- b1 = -(sign(filter) + cos) / a0, b2 = b0,
620
+ cosw = cos(w), alpha = sin(w) / 2 / quality,
621
+ a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
622
+ b0 = (1 + sign(filter) * cosw) / 2 / a0,
623
+ b1 = -(sign(filter) + cosw) / a0, b2 = b0,
612
624
  x2 = 0, x1 = 0, y2 = 0, y1 = 0;
613
625
 
614
626
  // scale by sample rate
@@ -631,15 +643,15 @@ function zzfxG
631
643
  if (!(++crush%(bitCrush*100|0))) // bit crush
632
644
  {
633
645
  s = shape? shape>1? shape>2? shape>3? shape>4? // wave shape
634
- (t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty
635
- Math.sin(t**3) : // 4 noise
636
- Math.max(Math.min(Math.tan(t),1),-1): // 3 tan
637
- 1-(2*t/PI2%2+2)%2: // 2 saw
638
- 1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle
639
- Math.sin(t); // 0 sin
646
+ (t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty
647
+ sin(t**3) : // 4 noise
648
+ max(min(tan(t),1),-1): // 3 tan
649
+ 1-(2*t/PI2%2+2)%2: // 2 saw
650
+ 1-4*abs(round(t/PI2)-t/PI2): // 1 triangle
651
+ sin(t); // 0 sin
640
652
 
641
653
  s = (repeatTime ?
642
- 1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo
654
+ 1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo
643
655
  : 1) *
644
656
  (shape>4?s:sign(s)*abs(s)**shapeCurve) * // shape curve
645
657
  (i < attack ? i/attack : // attack
@@ -661,8 +673,8 @@ function zzfxG
661
673
  }
662
674
 
663
675
  f = (frequency += slide += deltaSlide) *// frequency
664
- Math.cos(modulation*modOffset++); // modulation
665
- t += f + f*noise*Math.sin(i**5); // noise
676
+ cos(modulation*modOffset++); // modulation
677
+ t += f + f*noise*sin(i**5); // noise
666
678
 
667
679
  if (jump && ++jump > pitchJumpTime) // pitch jump
668
680
  {
@@ -73,6 +73,12 @@ function LOG(...output) { console.log(...output); }
73
73
  * @memberof Debug */
74
74
  function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
75
75
  {
76
+ ASSERT(isVector2(pos), 'pos must be a vec2');
77
+ ASSERT(isVector2(size), 'size must be a vec2');
78
+ ASSERT(isString(color) || isColor(color), 'color is invalid');
79
+ ASSERT(isNumber(time), 'time must be a number');
80
+ ASSERT(isNumber(angle), 'angle must be a number');
81
+
76
82
  if (typeof size === 'number')
77
83
  size = vec2(size); // allow passing in floats
78
84
  if (isColor(color))
@@ -93,6 +99,12 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
93
99
  * @memberof Debug */
94
100
  function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
95
101
  {
102
+ ASSERT(isVector2(pos), 'pos must be a vec2');
103
+ ASSERT(isArray(points), 'points must be an array');
104
+ ASSERT(isString(color) || isColor(color), 'color is invalid');
105
+ ASSERT(isNumber(time), 'time must be a number');
106
+ ASSERT(isNumber(angle), 'angle must be a number');
107
+
96
108
  if (isColor(color))
97
109
  color = color.toString();
98
110
  pos = pos.copy();
@@ -110,6 +122,11 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
110
122
  * @memberof Debug */
111
123
  function debugCircle(pos, size=0, color=WHITE, time=0, fill=false)
112
124
  {
125
+ ASSERT(isVector2(pos), 'pos must be a vec2');
126
+ ASSERT(isNumber(size), 'size must be a number');
127
+ ASSERT(isString(color) || isColor(color), 'color is invalid');
128
+ ASSERT(isNumber(time), 'time must be a number');
129
+
113
130
  if (isColor(color))
114
131
  color = color.toString();
115
132
  pos = pos.copy();
@@ -135,6 +152,10 @@ function debugPoint(pos, color, time, angle)
135
152
  * @memberof Debug */
136
153
  function debugLine(posA, posB, color, width=.1, time)
137
154
  {
155
+ ASSERT(isVector2(posA), 'posA must be a vec2');
156
+ ASSERT(isVector2(posB), 'posB must be sa vec2');
157
+ ASSERT(isNumber(width), 'width must be a number');
158
+
138
159
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
139
160
  const size = vec2(width, halfDelta.length()*2);
140
161
  debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), true);
@@ -149,6 +170,11 @@ function debugLine(posA, posB, color, width=.1, time)
149
170
  * @memberof Debug */
150
171
  function debugOverlap(posA, sizeA, posB, sizeB, color)
151
172
  {
173
+ ASSERT(isVector2(posA), 'posA must be a vec2');
174
+ ASSERT(isVector2(posB), 'posB must be a vec2');
175
+ ASSERT(isVector2(sizeA), 'sizeA must be a vec2');
176
+ ASSERT(isVector2(sizeB), 'sizeB must be a vec2');
177
+
152
178
  const minPos = vec2(
153
179
  min(posA.x - sizeA.x/2, posB.x - sizeB.x/2),
154
180
  min(posA.y - sizeA.y/2, posB.y - sizeB.y/2)
@@ -171,6 +197,14 @@ function debugOverlap(posA, sizeA, posB, sizeB, color)
171
197
  * @memberof Debug */
172
198
  function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace')
173
199
  {
200
+ ASSERT(isString(text), 'text must be a string');
201
+ ASSERT(isVector2(pos), 'pos must be a vec2');
202
+ ASSERT(isNumber(size), 'size must be a number');
203
+ ASSERT(isString(color) || isColor(color), 'color is invalid');
204
+ ASSERT(isNumber(time), 'time must be a number');
205
+ ASSERT(isNumber(angle), 'angle must be a number');
206
+ ASSERT(isString(font), 'font must be a string');
207
+
174
208
  if (isColor(color))
175
209
  color = color.toString();
176
210
  pos = pos.copy();
@@ -622,4 +656,34 @@ function debugVideoCaptureUpdate()
622
656
  combineCanvases();
623
657
  debugVideoCaptureTrack.requestFrame();
624
658
  debugVideoCaptureIcon.textContent = '● REC ' + formatTime(debugVideoCaptureTimer);
659
+ }
660
+
661
+ ///////////////////////////////////////////////////////////////////////////////
662
+ // debug utility functions
663
+
664
+ // make color constants immutable with debug assertions
665
+ function debugProtectConstant(obj)
666
+ {
667
+ if (debug)
668
+ {
669
+ // get properties and store original values
670
+ const props = Object.keys(obj), values = {};
671
+ props.forEach(prop => values[prop] = obj[prop]);
672
+
673
+ // replace with getters/setters that assert
674
+ props.forEach(prop =>
675
+ {
676
+ Object.defineProperty(obj, prop, {
677
+ get: () => values[prop],
678
+ set: (value) =>
679
+ {
680
+ ASSERT(false, `Cannot modify engine constant. Attempted to set constant (${obj}) property '${prop}' to '${value}'.`);
681
+ },
682
+ enumerable: true
683
+ });
684
+ });
685
+ }
686
+
687
+ // freeze the object to prevent adding new properties
688
+ return Object.freeze(obj);
625
689
  }
package/src/engineDraw.js CHANGED
@@ -252,6 +252,7 @@ function drawTile(pos, size=new Vector2(1), tileInfo, color=WHITE,
252
252
  const bleedScale = tileInfo ? tileInfo.bleedScale : 0;
253
253
  if (useWebGL)
254
254
  {
255
+ ASSERT(!!glContext, 'WebGL is not enabled!');
255
256
  if (screenSpace)
256
257
  {
257
258
  // convert to world space
@@ -347,6 +348,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
347
348
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
348
349
  if (useWebGL)
349
350
  {
351
+ ASSERT(!!glContext, 'WebGL is not enabled!');
350
352
  if (screenSpace)
351
353
  {
352
354
  // convert to world space
@@ -358,7 +360,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
358
360
  const halfSizeX = size.x/2, halfSizeY = size.y/2;
359
361
  const colorTopInt = colorTop.rgbaInt();
360
362
  const colorBottomInt = colorBottom.rgbaInt();
361
- const c = Math.cos(-angle), s = Math.sin(-angle);
363
+ const c = cos(-angle), s = sin(-angle);
362
364
  for (let i=4; i--;)
363
365
  {
364
366
  const x = i & 1 ? halfSizeX : -halfSizeX;
@@ -401,7 +403,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
401
403
  * @memberof Draw */
402
404
  function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
403
405
  {
404
- ASSERT(Array.isArray(points), 'points must be an array');
406
+ ASSERT(isArray(points), 'points must be an array');
405
407
  ASSERT(isNumber(width), 'width must be a number');
406
408
  ASSERT(isColor(color), 'color is invalid');
407
409
  ASSERT(isVector2(pos), 'pos must be a vec2');
@@ -409,6 +411,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
409
411
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
410
412
  if (useWebGL)
411
413
  {
414
+ ASSERT(!!glContext, 'WebGL is not enabled!');
412
415
  let scale = 1;
413
416
  if (screenSpace)
414
417
  {
@@ -458,6 +461,8 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
458
461
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
459
462
  const size = vec2(width, halfDelta.length()*2);
460
463
  pos = pos.add(posA.add(halfDelta));
464
+ if (screenSpace)
465
+ halfDelta.y *= -1; // flip angle Y if screen space
461
466
  angle += halfDelta.angle();
462
467
  drawRect(pos, size, color, angle, useWebGL, screenSpace, context);
463
468
  }
@@ -485,7 +490,7 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
485
490
  for (let i=sides; i--;)
486
491
  {
487
492
  const a = (i/sides)*PI*2;
488
- points.push(vec2(Math.sin(a)*sizeX, Math.cos(a)*sizeY));
493
+ points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
489
494
  }
490
495
  drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
491
496
  }
@@ -504,7 +509,7 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
504
509
  function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context=undefined)
505
510
  {
506
511
  ASSERT(isVector2(pos), 'pos must be a vec2');
507
- ASSERT(Array.isArray(points), 'points must be an array');
512
+ ASSERT(isArray(points), 'points must be an array');
508
513
  ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
509
514
  ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
510
515
  ASSERT(isNumber(angle), 'angle must be a number');
@@ -512,6 +517,7 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
512
517
 
513
518
  if (useWebGL)
514
519
  {
520
+ ASSERT(!!glContext, 'WebGL is not enabled!');
515
521
  let scale = 1;
516
522
  if (screenSpace)
517
523
  {
@@ -655,11 +661,12 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
655
661
  * @param {string} [font=fontDefault]
656
662
  * @param {string} [fontStyle]
657
663
  * @param {number} [maxWidth]
664
+ * @param {number} [angle]
658
665
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
659
666
  * @memberof Draw */
660
- function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, context=drawContext)
667
+ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
661
668
  {
662
- drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font, fontStyle, maxWidth, context);
669
+ drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
663
670
  }
664
671
 
665
672
  /** Draw text on overlay canvas in world space
@@ -674,10 +681,11 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
674
681
  * @param {string} [font=fontDefault]
675
682
  * @param {string} [fontStyle]
676
683
  * @param {number} [maxWidth]
684
+ * @param {number} [angle]
677
685
  * @memberof Draw */
678
- function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth)
686
+ function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0)
679
687
  {
680
- drawText(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, overlayContext);
688
+ drawText(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, overlayContext);
681
689
  }
682
690
 
683
691
  /** Draw text on overlay canvas in screen space
@@ -692,9 +700,10 @@ function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textA
692
700
  * @param {string} [font=fontDefault]
693
701
  * @param {string} [fontStyle]
694
702
  * @param {number} [maxWidth]
703
+ * @param {number} [angle]
695
704
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=overlayContext]
696
705
  * @memberof Draw */
697
- function drawTextScreen(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, context=overlayContext)
706
+ function drawTextScreen(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=overlayContext)
698
707
  {
699
708
  ASSERT(isString(text), 'text must be a string');
700
709
  ASSERT(isVector2(pos), 'pos must be a vec2');
@@ -705,6 +714,7 @@ function drawTextScreen(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=B
705
714
  ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
706
715
  ASSERT(isString(font), 'font must be a string');
707
716
  ASSERT(isString(fontStyle), 'fontStyle must be a string');
717
+ ASSERT(isNumber(angle), 'angle must be a number');
708
718
 
709
719
  context.fillStyle = color.toString();
710
720
  context.strokeStyle = lineColor.toString();
@@ -714,14 +724,18 @@ function drawTextScreen(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=B
714
724
  context.textBaseline = 'middle';
715
725
 
716
726
  const lines = (text+'').split('\n');
717
- let posY = pos.y;
718
- posY -= (lines.length-1) * size/2; // center text vertically
727
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
728
+ context.save();
729
+ context.translate(pos.x, posY);
730
+ context.rotate(-angle);
731
+ let yOffset = 0;
719
732
  lines.forEach(line=>
720
733
  {
721
- lineWidth && context.strokeText(line, pos.x, posY, maxWidth);
722
- context.fillText(line, pos.x, posY, maxWidth);
723
- posY += size;
734
+ lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
735
+ context.fillText(line, 0, yOffset, maxWidth);
736
+ yOffset += size;
724
737
  });
738
+ context.restore();
725
739
  }
726
740
 
727
741
  ///////////////////////////////////////////////////////////////////////////////
@@ -733,18 +747,18 @@ function drawTextScreen(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=B
733
747
  * @memberof Draw */
734
748
  function screenToWorld(screenPos)
735
749
  {
736
- let cameraPosRelativeX = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale;
737
- let cameraPosRelativeY = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale;
750
+ let x = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale;
751
+ let y = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale;
738
752
  if (cameraAngle)
739
753
  {
740
754
  // apply camera rotation
741
- const cos = Math.cos(-cameraAngle), sin = Math.sin(-cameraAngle);
742
- const rotatedX = cameraPosRelativeX * cos - cameraPosRelativeY * sin;
743
- const rotatedY = cameraPosRelativeX * sin + cameraPosRelativeY * cos;
744
- cameraPosRelativeX = rotatedX;
745
- cameraPosRelativeY = rotatedY;
755
+ const c = cos(-cameraAngle), s = sin(-cameraAngle);
756
+ const rotatedX = x * c - y * s;
757
+ const rotatedY = x * s + y * c;
758
+ x = rotatedX;
759
+ y = rotatedY;
746
760
  }
747
- return new Vector2(cameraPosRelativeX + cameraPos.x, cameraPosRelativeY + cameraPos.y);
761
+ return new Vector2(x + cameraPos.x, y + cameraPos.y);
748
762
  }
749
763
 
750
764
  /** Convert from world to screen space coordinates
@@ -753,24 +767,64 @@ function screenToWorld(screenPos)
753
767
  * @memberof Draw */
754
768
  function worldToScreen(worldPos)
755
769
  {
756
- let cameraPosRelativeX = worldPos.x - cameraPos.x;
757
- let cameraPosRelativeY = worldPos.y - cameraPos.y;
770
+ let x = worldPos.x - cameraPos.x;
771
+ let y = worldPos.y - cameraPos.y;
758
772
  if (cameraAngle)
759
773
  {
760
774
  // apply inverse camera rotation
761
- const cos = Math.cos(cameraAngle), sin = Math.sin(cameraAngle);
762
- const rotatedX = cameraPosRelativeX * cos - cameraPosRelativeY * sin;
763
- const rotatedY = cameraPosRelativeX * sin + cameraPosRelativeY * cos;
764
- cameraPosRelativeX = rotatedX;
765
- cameraPosRelativeY = rotatedY;
775
+ const c = cos(cameraAngle), s = sin(cameraAngle);
776
+ const rotatedX = x * c - y * s;
777
+ const rotatedY = x * s + y * c;
778
+ x = rotatedX;
779
+ y = rotatedY;
766
780
  }
767
781
  return new Vector2
768
782
  (
769
- cameraPosRelativeX * cameraScale + mainCanvasSize.x/2 - .5,
770
- cameraPosRelativeY * -cameraScale + mainCanvasSize.y/2 - .5
783
+ x * cameraScale + mainCanvasSize.x/2 - .5,
784
+ y * -cameraScale + mainCanvasSize.y/2 - .5
771
785
  );
772
786
  }
773
787
 
788
+ /** Convert from screen to world space coordinates for a directional vector (no translation)
789
+ * @param {Vector2} screenDelta
790
+ * @return {Vector2}
791
+ * @memberof Draw */
792
+ function screenToWorldDelta(screenDelta)
793
+ {
794
+ let x = screenDelta.x / cameraScale;
795
+ let y = screenDelta.y / -cameraScale;
796
+ if (cameraAngle)
797
+ {
798
+ // apply camera rotation
799
+ const c = cos(-cameraAngle), s = sin(-cameraAngle);
800
+ const rotatedX = x * c - y * s;
801
+ const rotatedY = x * s + y * c;
802
+ x = rotatedX;
803
+ y = rotatedY;
804
+ }
805
+ return new Vector2(x, y);
806
+ }
807
+
808
+ /** Convert from screen to world space coordinates for a directional vector (no translation)
809
+ * @param {Vector2} worldDelta
810
+ * @return {Vector2}
811
+ * @memberof Draw */
812
+ function worldToScreenDelta(worldDelta)
813
+ {
814
+ let x = worldDelta.x;
815
+ let y = worldDelta.y;
816
+ if (cameraAngle)
817
+ {
818
+ // apply inverse camera rotation
819
+ const c = cos(cameraAngle), s = sin(cameraAngle);
820
+ const rotatedX = x * c - y * s;
821
+ const rotatedY = x * s + y * c;
822
+ x = rotatedX;
823
+ y = rotatedY;
824
+ }
825
+ return new Vector2(x * cameraScale, y * -cameraScale);
826
+ }
827
+
774
828
  /** Get the camera's visible area in world space
775
829
  * @return {Vector2}
776
830
  * @memberof Draw */
@@ -823,6 +877,8 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
823
877
  function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
824
878
  const sx2 = bleedScale;
825
879
  const sy2 = bleedScale;
880
+ sWidth = max(1,sWidth|0);
881
+ sHeight = max(1,sHeight|0);
826
882
  const sWidth2 = sWidth - 2*bleedScale;
827
883
  const sHeight2 = sHeight - 2*bleedScale;
828
884
  if (!canvasColorTiles || (additiveColor ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0 : isWhite(color)))
@@ -837,7 +893,7 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
837
893
  // copy to offscreen canvas
838
894
  workCanvas.width = sWidth;
839
895
  workCanvas.height = sHeight;
840
- workContext.drawImage(image, sx, sy, sWidth, sHeight, 0, 0, sWidth, sHeight);
896
+ workContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
841
897
 
842
898
  // tint image using offscreen work context
843
899
  const imageData = workContext.getImageData(0, 0, sWidth, sHeight);
@@ -79,6 +79,7 @@ export
79
79
  gamepadDirectionEmulateStick,
80
80
  inputWASDEmulateDirection,
81
81
  touchGamepadEnable,
82
+ touchGamepadCenterButton,
82
83
  touchGamepadAnalog,
83
84
  touchGamepadSize,
84
85
  touchGamepadAlpha,
@@ -122,6 +123,7 @@ export
122
123
  setGamepadDirectionEmulateStick,
123
124
  setInputWASDEmulateDirection,
124
125
  setTouchGamepadEnable,
126
+ setTouchGamepadCenterButton,
125
127
  setTouchGamepadAnalog,
126
128
  setTouchGamepadSize,
127
129
  setTouchGamepadAlpha,
@@ -140,9 +142,18 @@ export
140
142
  // Utilities
141
143
  PI,
142
144
  abs,
145
+ floor,
146
+ ceil,
147
+ round,
143
148
  min,
144
149
  max,
145
150
  sign,
151
+ hypot,
152
+ log2,
153
+ sin,
154
+ cos,
155
+ tan,
156
+ atan2,
146
157
  mod,
147
158
  clamp,
148
159
  percent,
@@ -162,6 +173,7 @@ export
162
173
  // Random
163
174
  rand,
164
175
  randInt,
176
+ randBool,
165
177
  randSign,
166
178
  randInCircle,
167
179
  randVec2,
@@ -179,6 +191,7 @@ export
179
191
  isVector2,
180
192
  isNumber,
181
193
  isString,
194
+ isArray,
182
195
 
183
196
  // Default Colors
184
197
  WHITE,
@@ -210,6 +223,8 @@ export
210
223
  drawCount,
211
224
  screenToWorld,
212
225
  worldToScreen,
226
+ screenToWorldDelta,
227
+ worldToScreenDelta,
213
228
  drawTile,
214
229
  drawRect,
215
230
  drawRectGradient,
@@ -277,6 +292,7 @@ export
277
292
  mouseDelta,
278
293
  mouseDeltaScreen,
279
294
  mouseWheel,
295
+ mouseInWindow,
280
296
  isUsingGamepad,
281
297
  inputPreventDefault,
282
298
  setInputPreventDefault,