littlejsengine 1.14.28 → 1.15.3

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.
@@ -33,7 +33,7 @@ const engineName = 'LittleJS';
33
33
  * @type {string}
34
34
  * @default
35
35
  * @memberof Engine */
36
- const engineVersion = '1.15.0';
36
+ const engineVersion = '1.15.3';
37
37
 
38
38
  /** Frames per second to update
39
39
  * @type {number}
@@ -134,7 +134,7 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
134
134
 
135
135
  /**
136
136
  * @callback GameInitCallback - Called after the engine starts, can be async
137
- * @returns {void|Promise<void>}
137
+ * @return {void|Promise<void>}
138
138
  * @memberof Engine
139
139
  */
140
140
  /**
@@ -490,10 +490,10 @@ function engineObjectsDestroy()
490
490
  }
491
491
 
492
492
  /** Collects all object within a given area
493
- * @param {Vector2} [pos] - Center of test area, or undefined for all objects
494
- * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
493
+ * @param {Vector2} [pos] - Center of test area, or undefined for all objects
494
+ * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
495
495
  * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
496
- * @return {Array<EngineObject>} - List of collected objects
496
+ * @return {Array<EngineObject>} - List of collected objects
497
497
  * @memberof Engine */
498
498
  function engineObjectsCollect(pos, size, objects=engineObjects)
499
499
  {
@@ -790,8 +790,9 @@ function LOG(...output) { console.log(...output); }
790
790
  * @param {number} [time]
791
791
  * @param {number} [angle]
792
792
  * @param {boolean} [fill]
793
+ * @param {boolean} [screenSpace]
793
794
  * @memberof Debug */
794
- function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
795
+ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, screenSpace=false)
795
796
  {
796
797
  ASSERT(isVector2(pos), 'pos must be a vec2');
797
798
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -806,7 +807,7 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
806
807
  pos = pos.copy();
807
808
  size = size.copy();
808
809
  const timer = new Timer(time);
809
- debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill});
810
+ debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill, screenSpace});
810
811
  }
811
812
 
812
813
  /** Draw a debug poly in world space
@@ -816,8 +817,9 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
816
817
  * @param {number} [time]
817
818
  * @param {number} [angle]
818
819
  * @param {boolean} [fill]
820
+ * @param {boolean} [screenSpace]
819
821
  * @memberof Debug */
820
- function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
822
+ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screenSpace=false)
821
823
  {
822
824
  ASSERT(isVector2(pos), 'pos must be a vec2');
823
825
  ASSERT(isArray(points), 'points must be an array');
@@ -830,7 +832,7 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
830
832
  pos = pos.copy();
831
833
  points = points.map(p=>p.copy());
832
834
  const timer = new Timer(time);
833
- debugPrimitives.push({pos, points, color, timer, angle, fill});
835
+ debugPrimitives.push({pos, points, color, timer, angle, fill, screenSpace});
834
836
  }
835
837
 
836
838
  /** Draw a debug circle in world space
@@ -839,8 +841,9 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
839
841
  * @param {Color|string} [color]
840
842
  * @param {number} [time]
841
843
  * @param {boolean} [fill]
844
+ * @param {boolean} [screenSpace]
842
845
  * @memberof Debug */
843
- function debugCircle(pos, size=0, color=WHITE, time=0, fill=false)
846
+ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=false)
844
847
  {
845
848
  ASSERT(isVector2(pos), 'pos must be a vec2');
846
849
  ASSERT(isNumber(size), 'size must be a number');
@@ -851,7 +854,7 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false)
851
854
  color = color.toString();
852
855
  pos = pos.copy();
853
856
  const timer = new Timer(time);
854
- debugPrimitives.push({pos, size, color, timer, angle:0, fill});
857
+ debugPrimitives.push({pos, size, color, timer, angle:0, fill, screenSpace});
855
858
  }
856
859
 
857
860
  /** Draw a debug point in world space
@@ -859,9 +862,10 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false)
859
862
  * @param {Color|string} [color]
860
863
  * @param {number} [time]
861
864
  * @param {number} [angle]
865
+ * @param {boolean} [screenSpace]
862
866
  * @memberof Debug */
863
- function debugPoint(pos, color, time, angle)
864
- { debugRect(pos, undefined, color, time, angle); }
867
+ function debugPoint(pos, color, time, angle, screenSpace=false)
868
+ { debugRect(pos, undefined, color, time, angle, false, screenSpace); }
865
869
 
866
870
  /** Draw a debug line in world space
867
871
  * @param {Vector2} posA
@@ -869,8 +873,9 @@ function debugPoint(pos, color, time, angle)
869
873
  * @param {Color|string} [color]
870
874
  * @param {number} [width]
871
875
  * @param {number} [time]
876
+ * @param {boolean} [screenSpace]
872
877
  * @memberof Debug */
873
- function debugLine(posA, posB, color, width=.1, time)
878
+ function debugLine(posA, posB, color, width=.1, time, screenSpace=false)
874
879
  {
875
880
  ASSERT(isVector2(posA), 'posA must be a vec2');
876
881
  ASSERT(isVector2(posB), 'posB must be sa vec2');
@@ -878,7 +883,7 @@ function debugLine(posA, posB, color, width=.1, time)
878
883
 
879
884
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
880
885
  const size = vec2(width, halfDelta.length()*2);
881
- debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), true);
886
+ debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), true, screenSpace);
882
887
  }
883
888
 
884
889
  /** Draw a debug combined axis aligned bounding box in world space
@@ -887,8 +892,10 @@ function debugLine(posA, posB, color, width=.1, time)
887
892
  * @param {Vector2} posB
888
893
  * @param {Vector2} sizeB
889
894
  * @param {Color|string} [color]
895
+ * @param {number} [time]
896
+ * @param {boolean} [screenSpace]
890
897
  * @memberof Debug */
891
- function debugOverlap(posA, sizeA, posB, sizeB, color)
898
+ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
892
899
  {
893
900
  ASSERT(isVector2(posA), 'posA must be a vec2');
894
901
  ASSERT(isVector2(posB), 'posB must be a vec2');
@@ -903,7 +910,7 @@ function debugOverlap(posA, sizeA, posB, sizeB, color)
903
910
  max(posA.x + sizeA.x/2, posB.x + sizeB.x/2),
904
911
  max(posA.y + sizeA.y/2, posB.y + sizeB.y/2)
905
912
  );
906
- debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color);
913
+ debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color, time, 0, false, screenSpace);
907
914
  }
908
915
 
909
916
  /** Draw a debug axis aligned bounding box in world space
@@ -914,8 +921,9 @@ function debugOverlap(posA, sizeA, posB, sizeB, color)
914
921
  * @param {number} [time]
915
922
  * @param {number} [angle]
916
923
  * @param {string} [font]
924
+ * @param {boolean} [screenSpace]
917
925
  * @memberof Debug */
918
- function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace')
926
+ function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=false)
919
927
  {
920
928
  ASSERT(isString(text), 'text must be a string');
921
929
  ASSERT(isVector2(pos), 'pos must be a vec2');
@@ -929,7 +937,7 @@ function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monosp
929
937
  color = color.toString();
930
938
  pos = pos.copy();
931
939
  const timer = new Timer(time);
932
- debugPrimitives.push({text, pos, size, color, timer, angle, font});
940
+ debugPrimitives.push({text, pos, size, color, timer, angle, font, screenSpace});
933
941
  }
934
942
 
935
943
  /** Clear all debug primitives in the list
@@ -1139,21 +1147,26 @@ function debugRender()
1139
1147
  {
1140
1148
  // draw debug primitives
1141
1149
  overlayContext.lineWidth = 2;
1142
- const pointSize = debugPointSize * cameraScale;
1143
1150
  debugPrimitives.forEach(p=>
1144
1151
  {
1145
1152
  overlayContext.save();
1146
1153
 
1147
1154
  // create canvas transform from world space to screen space
1148
- const pos = worldToScreen(p.pos);
1155
+ // without scaling because we want consistent pixel sizes
1156
+ let pos = p.pos, scale = 1, angle = p.angle;
1157
+ if (!p.screenSpace)
1158
+ {
1159
+ pos = worldToScreen(p.pos);
1160
+ scale = cameraScale;
1161
+ angle -= cameraAngle;
1162
+ }
1149
1163
  overlayContext.translate(pos.x|0, pos.y|0);
1150
- overlayContext.rotate(p.angle);
1164
+ overlayContext.rotate(angle);
1151
1165
  overlayContext.scale(1, p.text ? 1 : -1);
1152
1166
  overlayContext.fillStyle = overlayContext.strokeStyle = p.color;
1153
-
1154
1167
  if (p.text !== undefined)
1155
1168
  {
1156
- overlayContext.font = p.size*cameraScale + 'px '+ p.font;
1169
+ overlayContext.font = p.size*scale + 'px '+ p.font;
1157
1170
  overlayContext.textAlign = 'center';
1158
1171
  overlayContext.textBaseline = 'middle';
1159
1172
  overlayContext.fillText(p.text, 0, 0);
@@ -1164,7 +1177,7 @@ function debugRender()
1164
1177
  overlayContext.beginPath();
1165
1178
  for (const point of p.points)
1166
1179
  {
1167
- const p2 = point.scale(cameraScale).floor();
1180
+ const p2 = point.scale(scale).floor();
1168
1181
  overlayContext.lineTo(p2.x, p2.y);
1169
1182
  }
1170
1183
  overlayContext.closePath();
@@ -1174,13 +1187,14 @@ function debugRender()
1174
1187
  else if (p.size === 0 || (p.size.x === 0 && p.size.y === 0))
1175
1188
  {
1176
1189
  // point
1190
+ const pointSize = debugPointSize * scale;
1177
1191
  overlayContext.fillRect(-pointSize/2, -1, pointSize, 3);
1178
1192
  overlayContext.fillRect(-1, -pointSize/2, 3, pointSize);
1179
1193
  }
1180
1194
  else if (p.size.x !== undefined)
1181
1195
  {
1182
1196
  // rect
1183
- const s = p.size.scale(cameraScale).floor();
1197
+ const s = p.size.scale(scale).floor();
1184
1198
  const w = s.x, h = s.y;
1185
1199
  p.fill && overlayContext.fillRect(-w/2|0, -h/2|0, w, h);
1186
1200
  overlayContext.strokeRect(-w/2|0, -h/2|0, w, h);
@@ -1189,7 +1203,7 @@ function debugRender()
1189
1203
  {
1190
1204
  // circle
1191
1205
  overlayContext.beginPath();
1192
- overlayContext.arc(0, 0, p.size*cameraScale/2, 0, 9);
1206
+ overlayContext.arc(0, 0, p.size*scale/2, 0, 9);
1193
1207
  p.fill && overlayContext.fill();
1194
1208
  overlayContext.stroke();
1195
1209
  }
@@ -1555,7 +1569,7 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
1555
1569
  * @param {number} valueA
1556
1570
  * @param {number} valueB
1557
1571
  * @param {number} [wrapSize]
1558
- * @returns {number}
1572
+ * @return {number}
1559
1573
  * @memberof Utilities */
1560
1574
  function distanceWrap(valueA, valueB, wrapSize=1)
1561
1575
  { const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; }
@@ -1565,7 +1579,7 @@ function distanceWrap(valueA, valueB, wrapSize=1)
1565
1579
  * @param {number} valueB
1566
1580
  * @param {number} percent
1567
1581
  * @param {number} [wrapSize]
1568
- * @returns {number}
1582
+ * @return {number}
1569
1583
  * @memberof Utilities */
1570
1584
  function lerpWrap(valueA, valueB, percent, wrapSize=1)
1571
1585
  {
@@ -1577,7 +1591,7 @@ function lerpWrap(valueA, valueB, percent, wrapSize=1)
1577
1591
  /** Returns signed wrapped distance between the two angles passed in
1578
1592
  * @param {number} angleA
1579
1593
  * @param {number} angleB
1580
- * @returns {number}
1594
+ * @return {number}
1581
1595
  * @memberof Utilities */
1582
1596
  function distanceAngle(angleA, angleB) { return distanceWrap(angleA, angleB, 2*PI); }
1583
1597
 
@@ -1585,7 +1599,7 @@ function distanceAngle(angleA, angleB) { return distanceWrap(angleA, angleB, 2*P
1585
1599
  * @param {number} angleA
1586
1600
  * @param {number} angleB
1587
1601
  * @param {number} percent
1588
- * @returns {number}
1602
+ * @return {number}
1589
1603
  * @memberof Utilities */
1590
1604
  function lerpAngle(angleA, angleB, percent) { return lerpWrap(angleA, angleB, percent, 2*PI); }
1591
1605
 
@@ -2554,11 +2568,14 @@ const MAGENTA = debugProtectConstant(rgb(1,0,1));
2554
2568
  class Timer
2555
2569
  {
2556
2570
  /** Create a timer object set time passed in
2557
- * @param {number} [timeLeft] - How much time left before the timer elapses in seconds */
2558
- constructor(timeLeft)
2571
+ * @param {number} [timeLeft] - How much time left before the timer
2572
+ * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
2573
+ constructor(timeLeft, useRealTime=false)
2559
2574
  {
2560
2575
  ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
2561
- this.time = timeLeft === undefined ? undefined : time + timeLeft;
2576
+ this.useRealTime = useRealTime;
2577
+ const globalTime = this.getGlobalTime();
2578
+ this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
2562
2579
  this.setTime = timeLeft;
2563
2580
  }
2564
2581
 
@@ -2567,10 +2584,19 @@ class Timer
2567
2584
  set(timeLeft=0)
2568
2585
  {
2569
2586
  ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
2570
- this.time = time + timeLeft;
2587
+ const globalTime = this.getGlobalTime();
2588
+ this.time = globalTime + timeLeft;
2571
2589
  this.setTime = timeLeft;
2572
2590
  }
2573
2591
 
2592
+ /** Set if the timer should keep running even when the game is paused
2593
+ * @param {boolean} [useRealTime] */
2594
+ setUseRealTime(useRealTime=true)
2595
+ {
2596
+ ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
2597
+ this.useRealTime = useRealTime;
2598
+ }
2599
+
2574
2600
  /** Unset the timer */
2575
2601
  unset() { this.time = undefined; }
2576
2602
 
@@ -2580,24 +2606,28 @@ class Timer
2580
2606
 
2581
2607
  /** Returns true if set and has not elapsed
2582
2608
  * @return {boolean} */
2583
- active() { return time < this.time; }
2609
+ active() { return this.getGlobalTime() < this.time; }
2584
2610
 
2585
2611
  /** Returns true if set and elapsed
2586
2612
  * @return {boolean} */
2587
- elapsed() { return time >= this.time; }
2613
+ elapsed() { return this.getGlobalTime() >= this.time; }
2588
2614
 
2589
2615
  /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
2590
2616
  * @return {number} */
2591
- get() { return this.isSet()? time - this.time : 0; }
2617
+ get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
2592
2618
 
2593
2619
  /** Get percentage elapsed based on time it was set to, returns 0 if not set
2594
2620
  * @return {number} */
2595
- getPercent() { return this.isSet()? 1-percent(this.time - time, 0, this.setTime) : 0; }
2621
+ getPercent() { return this.isSet()? 1-percent(this.time - this.getGlobalTime(), 0, this.setTime) : 0; }
2596
2622
 
2597
2623
  /** Get the time this timer was set to, returns 0 if not set
2598
2624
  * @return {number} */
2599
2625
  getSetTime() { return this.isSet() ? this.setTime : 0; }
2600
2626
 
2627
+ /** Get the current global time this timer is based on
2628
+ * @return {number} */
2629
+ getGlobalTime() { return this.useRealTime ? timeReal : time; }
2630
+
2601
2631
  /** Returns this timer expressed as a string
2602
2632
  * @return {string} */
2603
2633
  toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
@@ -3628,7 +3658,9 @@ class EngineObject
3628
3658
  {
3629
3659
  ASSERT(child.parent === this && this.children.includes(child));
3630
3660
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3631
- this.children.splice(this.children.indexOf(child), 1);
3661
+ const index = this.children.indexOf(child);
3662
+ ASSERT(index >= 0, 'child not found in children array');
3663
+ index >= 0 && this.children.splice(index, 1);
3632
3664
  child.parent = 0;
3633
3665
  }
3634
3666
 
@@ -3947,15 +3979,11 @@ function drawTile(pos, size=new Vector2(1), tileInfo, color=WHITE,
3947
3979
 
3948
3980
  const textureInfo = tileInfo && tileInfo.textureInfo;
3949
3981
  const bleedScale = tileInfo ? tileInfo.bleedScale : 0;
3950
- if (useWebGL)
3982
+ if (useWebGL && glEnable)
3951
3983
  {
3952
3984
  ASSERT(!!glContext, 'WebGL is not enabled!');
3953
3985
  if (screenSpace)
3954
- {
3955
- // convert to world space
3956
- pos = screenToWorld(pos);
3957
- size = size.scale(1/cameraScale);
3958
- }
3986
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
3959
3987
  if (textureInfo)
3960
3988
  {
3961
3989
  // calculate uvs and render
@@ -3991,7 +4019,7 @@ function drawTile(pos, size=new Vector2(1), tileInfo, color=WHITE,
3991
4019
  {
3992
4020
  // normal canvas 2D rendering method (slower)
3993
4021
  ++drawCount;
3994
- size = new Vector2(size.x, -size.y); // fix upside down sprites
4022
+ size = new Vector2(size.x, -size.y); // flip upside down sprites
3995
4023
  drawCanvas2D(pos, size, angle, mirror, (context)=>
3996
4024
  {
3997
4025
  if (textureInfo)
@@ -4043,7 +4071,8 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
4043
4071
  ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
4044
4072
  ASSERT(isNumber(angle), 'angle must be a number');
4045
4073
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4046
- if (useWebGL)
4074
+
4075
+ if (useWebGL && glEnable)
4047
4076
  {
4048
4077
  ASSERT(!!glContext, 'WebGL is not enabled!');
4049
4078
  if (screenSpace)
@@ -4051,6 +4080,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
4051
4080
  // convert to world space
4052
4081
  pos = screenToWorld(pos);
4053
4082
  size = size.scale(1/cameraScale);
4083
+ angle += cameraAngle;
4054
4084
  }
4055
4085
  // build 4 corner points for the rectangle
4056
4086
  const points = [], colors = [];
@@ -4106,17 +4136,14 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
4106
4136
  ASSERT(isVector2(pos), 'pos must be a vec2');
4107
4137
  ASSERT(isNumber(angle), 'angle must be a number');
4108
4138
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4109
- if (useWebGL)
4139
+
4140
+ if (useWebGL && glEnable)
4110
4141
  {
4111
4142
  ASSERT(!!glContext, 'WebGL is not enabled!');
4112
- let scale = 1;
4143
+ let size = vec2(1);
4113
4144
  if (screenSpace)
4114
- {
4115
- // convert to world space
4116
- pos = screenToWorld(pos);
4117
- scale = 1/cameraScale;
4118
- }
4119
- glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, scale, scale, angle, wrap);
4145
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
4146
+ glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
4120
4147
  }
4121
4148
  else
4122
4149
  {
@@ -4212,19 +4239,15 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
4212
4239
  ASSERT(isNumber(angle), 'angle must be a number');
4213
4240
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4214
4241
 
4215
- if (useWebGL)
4242
+ if (useWebGL && glEnable)
4216
4243
  {
4217
4244
  ASSERT(!!glContext, 'WebGL is not enabled!');
4218
- let scale = 1;
4245
+ let size = vec2(1);
4219
4246
  if (screenSpace)
4220
- {
4221
- // convert to world space
4222
- pos = screenToWorld(pos);
4223
- scale = 1/cameraScale;
4224
- }
4225
- glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, scale, scale, angle);
4247
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
4248
+ glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
4226
4249
  if (lineWidth > 0)
4227
- glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, scale, scale, angle);
4250
+ glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
4228
4251
  }
4229
4252
  else
4230
4253
  {
@@ -4267,7 +4290,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
4267
4290
  ASSERT(lineWidth >= 0 && lineWidth < size.x && lineWidth < size.y, 'invalid lineWidth');
4268
4291
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4269
4292
 
4270
- if (useWebGL)
4293
+ if (useWebGL && glEnable)
4271
4294
  {
4272
4295
  // draw as a regular polygon
4273
4296
  const sides = glCircleSides;
@@ -4330,14 +4353,10 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4330
4353
  ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
4331
4354
 
4332
4355
  if (!screenSpace)
4333
- {
4334
- // transform from world space to screen space
4335
- pos = worldToScreen(pos);
4336
- size = size.scale(cameraScale);
4337
- }
4356
+ [pos, size, angle] = worldToScreenTransform(pos, size, angle);
4338
4357
  context.save();
4339
4358
  context.translate(pos.x+.5, pos.y+.5);
4340
- context.rotate(angle-cameraAngle);
4359
+ context.rotate(angle);
4341
4360
  context.scale(mirror ? -size.x : size.x, -size.y);
4342
4361
  drawFunction(context);
4343
4362
  context.restore();
@@ -4363,7 +4382,14 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4363
4382
  * @memberof Draw */
4364
4383
  function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
4365
4384
  {
4366
- drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
4385
+ // convert to screen space
4386
+ pos = worldToScreen(pos);
4387
+ size *= cameraScale;
4388
+ lineWidth *= cameraScale;
4389
+ angle -= cameraAngle;
4390
+ angle *= -1;
4391
+
4392
+ drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
4367
4393
  }
4368
4394
 
4369
4395
  /** Draw text on overlay canvas in world space
@@ -4522,6 +4548,36 @@ function worldToScreenDelta(worldDelta)
4522
4548
  return new Vector2(x * cameraScale, y * -cameraScale);
4523
4549
  }
4524
4550
 
4551
+ /** Convert screen space transform to world space
4552
+ * @param {Vector2} screenPos
4553
+ * @param {Vector2} screenSize
4554
+ * @param {number} [screenAngle]
4555
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
4556
+ * @memberof Draw */
4557
+ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
4558
+ {
4559
+ return [
4560
+ screenToWorld(screenPos),
4561
+ screenSize.scale(1/cameraScale),
4562
+ screenAngle + cameraAngle
4563
+ ];
4564
+ }
4565
+
4566
+ /** Convert world space transform to screen space
4567
+ * @param {Vector2} worldPos
4568
+ * @param {Vector2} worldSize
4569
+ * @param {number} [worldAngle]
4570
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
4571
+ * @memberof Draw */
4572
+ function worldToScreenTransform(worldPos, worldSize, worldAngle=0)
4573
+ {
4574
+ return [
4575
+ worldToScreen(worldPos),
4576
+ worldSize.scale(cameraScale),
4577
+ worldAngle - cameraAngle
4578
+ ];
4579
+ }
4580
+
4525
4581
  /** Get the camera's visible area in world space
4526
4582
  * @return {Vector2}
4527
4583
  * @memberof Draw */
@@ -4929,7 +4985,7 @@ function inputUpdate()
4929
4985
  if (headlessMode) return;
4930
4986
 
4931
4987
  // clear input when lost focus (prevent stuck keys)
4932
- if(!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4988
+ if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4933
4989
  inputClear();
4934
4990
 
4935
4991
  // update mouse world space position and delta
@@ -5487,7 +5543,6 @@ class Sound
5487
5543
  ASSERT(isNumber(pitch), 'pitch must be a number');
5488
5544
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
5489
5545
 
5490
-
5491
5546
  if (!soundEnable || headlessMode) return;
5492
5547
  if (!this.sampleChannels) return;
5493
5548
 
@@ -6620,7 +6675,7 @@ class TileCollisionLayer extends TileLayer
6620
6675
  // remove from collision layers array and destroy
6621
6676
  const index = tileCollisionLayers.indexOf(this);
6622
6677
  ASSERT(index >= 0, 'tile collision layer not found in array');
6623
- tileCollisionLayers.splice(index, 1);
6678
+ index >= 0 && tileCollisionLayers.splice(index, 1);
6624
6679
  super.destroy();
6625
6680
  }
6626
6681
 
@@ -6882,6 +6937,9 @@ class ParticleEmitter extends EngineObject
6882
6937
  this.previousPos = this.pos.copy();
6883
6938
  }
6884
6939
 
6940
+ /** Emitters do not have physics */
6941
+ updatePhysics() {}
6942
+
6885
6943
  /** Update the emitter to spawn particles, called automatically by engine once each frame */
6886
6944
  update()
6887
6945
  {
@@ -8663,6 +8721,7 @@ class UISystemPlugin
8663
8721
  ASSERT(!uiSystem, 'UI system already initialized');
8664
8722
  uiSystem = this;
8665
8723
 
8724
+ // default settings
8666
8725
  /** @property {Color} - Default fill color for UI elements */
8667
8726
  this.defaultColor = WHITE;
8668
8727
  /** @property {Color} - Default outline color for UI elements */
@@ -8691,6 +8750,13 @@ class UISystemPlugin
8691
8750
  this.defaultSoundRelease = undefined;
8692
8751
  /** @property {Sound} - Default sound when interactive UI element is clicked */
8693
8752
  this.defaultSoundClick = undefined;
8753
+ /** @property {Color} - Color for shadow */
8754
+ this.defaultShadowColor = CLEAR_BLACK;
8755
+ /** @property {number} - Size of shadow blur */
8756
+ this.defaultShadowBlur = 5;
8757
+ /** @property {Vector2} - Offset of shadow blur */
8758
+ this.defaultShadowOffset = vec2(5);
8759
+ // system state
8694
8760
  /** @property {Array<UIObject>} - List of all UI elements */
8695
8761
  this.uiObjects = [];
8696
8762
  /** @property {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} - Context to render UI elements to */
@@ -8735,11 +8801,16 @@ class UISystemPlugin
8735
8801
  // reset hover object at start of update
8736
8802
  uiSystem.lastHoverObject = uiSystem.hoverObject;
8737
8803
  uiSystem.hoverObject = undefined;
8804
+
8805
+ // update in reverse order so topmost objects get priority
8738
8806
  for (let i = uiSystem.uiObjects.length; i--;)
8739
8807
  {
8740
8808
  const o = uiSystem.uiObjects[i];
8741
8809
  o.parent || updateObject(o)
8742
8810
  }
8811
+
8812
+ // remove destroyed objects
8813
+ uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);
8743
8814
  }
8744
8815
  function uiRender()
8745
8816
  {
@@ -8776,8 +8847,11 @@ class UISystemPlugin
8776
8847
  * @param {number} [lineWidth]
8777
8848
  * @param {Color} [lineColor]
8778
8849
  * @param {number} [cornerRadius]
8779
- * @param {Color} [gradientColor] */
8780
- drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor)
8850
+ * @param {Color} [gradientColor]
8851
+ * @param {Color} [shadowColor]
8852
+ * @param {number} [shadowBlur]
8853
+ * @param {Color} [shadowOffset] */
8854
+ drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8781
8855
  {
8782
8856
  ASSERT(isVector2(pos), 'pos must be a vec2');
8783
8857
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -8799,12 +8873,22 @@ class UISystemPlugin
8799
8873
  }
8800
8874
  else
8801
8875
  context.fillStyle = color.toString();
8876
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
8877
+ if (shadowColor.a > 0)
8878
+ {
8879
+ // setup shadow
8880
+ context.shadowColor = shadowColor.toString();
8881
+ context.shadowBlur = shadowBlur;
8882
+ context.shadowOffsetX = shadowOffset.x;
8883
+ context.shadowOffsetY = shadowOffset.y;
8884
+ }
8802
8885
  context.beginPath();
8803
8886
  if (cornerRadius && context['roundRect'])
8804
8887
  context['roundRect'](pos.x-size.x/2, pos.y-size.y/2, size.x, size.y, cornerRadius);
8805
8888
  else
8806
8889
  context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
8807
8890
  context.fill();
8891
+ context.shadowColor = '#0000'
8808
8892
  if (lineWidth)
8809
8893
  {
8810
8894
  context.strokeStyle = lineColor.toString();
@@ -8909,6 +8993,15 @@ class UISystemPlugin
8909
8993
  p.x -= sInv*mainCanvasSize.x/2;
8910
8994
  return p;
8911
8995
  }
8996
+
8997
+ /** Destroy and remove all objects
8998
+ * @memberof Engine */
8999
+ destroyObjects()
9000
+ {
9001
+ for (const o of this.uiObjects)
9002
+ o.parent || o.destroy();
9003
+ this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
9004
+ }
8912
9005
  }
8913
9006
 
8914
9007
  ///////////////////////////////////////////////////////////////////////////////
@@ -8984,6 +9077,12 @@ class UIObject
8984
9077
  this.dragActivate = false;
8985
9078
  /** @property {boolean} - True if this can be a hover object */
8986
9079
  this.canBeHover = true;
9080
+ /** @property {Color} - Color for shadow, undefined if no shadow */
9081
+ this.shadowColor = uiSystem.defaultShadowColor?.copy();
9082
+ /** @property {number} - Size of shadow blur */
9083
+ this.shadowBlur = uiSystem.defaultShadowBlur;
9084
+ /** @property {Vector2} - Offset of shadow blur */
9085
+ this.shadowOffset = uiSystem.defaultShadowOffset.copy();
8987
9086
  uiSystem.uiObjects.push(this);
8988
9087
 
8989
9088
  /** @property {Vector2} - How much to offset the text shadow or undefined */
@@ -9010,6 +9109,22 @@ class UIObject
9010
9109
  child.parent = undefined;
9011
9110
  }
9012
9111
 
9112
+
9113
+ /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
9114
+ destroy()
9115
+ {
9116
+ if (this.destroyed)
9117
+ return;
9118
+
9119
+ // disconnect from parent and destroy children
9120
+ this.destroyed = 1;
9121
+ this.parent && this.parent.removeChild(this);
9122
+ for (const child of this.children)
9123
+ {
9124
+ child.parent = 0;
9125
+ child.destroy();
9126
+ }
9127
+ }
9013
9128
  /** Check if the mouse is overlapping a box in screen space
9014
9129
  * @return {boolean} - True if overlapping
9015
9130
  */
@@ -9085,7 +9200,7 @@ class UIObject
9085
9200
 
9086
9201
  const lineColor = this.interactive && this.isActiveObject() && !this.disabled ? this.color : this.lineColor;
9087
9202
  const color = this.disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.color : this.isHoverObject() ? this.hoverColor : this.color : this.color;
9088
- uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor);
9203
+ uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
9089
9204
  }
9090
9205
 
9091
9206
  /** Special update when object is not visible */
@@ -9389,6 +9504,150 @@ class UIScrollbar extends UIObject
9389
9504
  uiSystem.drawText(this.text, this.pos, textSize,
9390
9505
  this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
9391
9506
  }
9507
+ }
9508
+
9509
+ ///////////////////////////////////////////////////////////////////////////////
9510
+ /**
9511
+ * VideoPlayerUIObject - A UI object that plays video
9512
+ * @extends UIObject
9513
+ * @example
9514
+ * // Create a video player UI object
9515
+ * const video = new VideoPlayerUIObject(vec2(400, 300), vec2(320, 240), 'cutscene.mp4', true);
9516
+ * video.play();
9517
+ * @memberof UISystem
9518
+ */
9519
+ class UIVideo extends UIObject
9520
+ {
9521
+ /** Create a video player UI object
9522
+ * @param {Vector2} [pos]
9523
+ * @param {Vector2} [size]
9524
+ * @param {string} src - Video file path or URL
9525
+ * @param {boolean} [autoplay=false] - Start playing immediately?
9526
+ * @param {boolean} [loop=false] - Loop the video?
9527
+ * @param {number} [volume=1] - Volume percent scaled by global volume (0-1)
9528
+ */
9529
+ constructor(pos, size, src, autoplay=false, loop=false, volume=1)
9530
+ {
9531
+ super(pos, size || vec2());
9532
+
9533
+ ASSERT(isString(src), 'video src must be a string');
9534
+ ASSERT(isNumber(volume), 'video volume must be a number');
9535
+
9536
+ this.color = BLACK; // default to black background
9537
+ this.cornerRadius = 0; // default to no corner radius
9538
+
9539
+ /** @property {float} - The video volume */
9540
+ this.volume = volume;
9541
+
9542
+ // create video element
9543
+ /** @property {HTMLVideoElement} - The video player */
9544
+ this.video = document.createElement('video');
9545
+ this.video.loop = loop;
9546
+ this.video.volume = clamp(volume * soundVolume);
9547
+ this.video.muted = !soundEnable;
9548
+ this.video.style.display = 'none';
9549
+ this.video.src = src;
9550
+ document.body.appendChild(this.video);
9551
+ autoplay && this.play();
9552
+ }
9553
+
9554
+ /** Play or resume the video
9555
+ * @return {Promise} Promise that resolves when playback starts */
9556
+ play()
9557
+ {
9558
+ // try to play the video, catch any errors (autoplay may be blocked)
9559
+ const promise = this.video.play();
9560
+ promise?.catch(()=>{});
9561
+ return promise;
9562
+ }
9563
+
9564
+ /** Pause the video */
9565
+ pause() { this.video.pause(); }
9566
+
9567
+ /** Stop and reset the video */
9568
+ stop() { this.video.pause(); this.video.currentTime = 0; }
9569
+
9570
+ /** Check if video is currently loading
9571
+ * @return {boolean} */
9572
+ isLoadng()
9573
+ { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
9574
+
9575
+ /** Check if video is currently paused
9576
+ * @return {boolean} */
9577
+ isPaused() { return this.video.paused; }
9578
+
9579
+ /** Check if video is currently playing
9580
+ * @return {boolean} */
9581
+ isPlaying()
9582
+ { return !this.isPaused() && !this.hasEnded() && !this.isLoadng(); }
9583
+
9584
+ /** Check if video has ended playing
9585
+ * @return {boolean} */
9586
+ hasEnded() { return this.video.ended; }
9587
+
9588
+ /** Set volume (0-1)
9589
+ * @param {number} volume - Volume level (0-1) */
9590
+ setVolume(volume)
9591
+ {
9592
+ this.volume = volume;
9593
+ this.video.volume = clamp(volume * soundVolume);
9594
+ }
9595
+
9596
+ /** Set playback speed
9597
+ * @param {number} rate - Playback rate multiplier */
9598
+ setPlaybackRate(rate) { this.video.playbackRate = rate; }
9599
+
9600
+ /** Get current time in seconds
9601
+ * @return {number} Current playback time */
9602
+ getCurrentTime() { return this.video.currentTime || 0; }
9603
+
9604
+ /** Get duration in seconds
9605
+ * @return {number} Total video duration */
9606
+ getDuration() { return this.video.duration || 0; }
9607
+
9608
+ /** Get the native video dimensions
9609
+ * @return {Vector2} Video dimensions (may be 0,0 if metadata not loaded) */
9610
+ getVideoSize()
9611
+ { return vec2(this.video.videoWidth, this.video.videoHeight); }
9612
+
9613
+ /** Seek to time in seconds
9614
+ * @param {number} time - Time in seconds to seek to */
9615
+ setTime(time)
9616
+ { this.video.currentTime = clamp(time, 0, this.getDuration()); }
9617
+
9618
+ update()
9619
+ {
9620
+ super.update();
9621
+
9622
+ // update volume based on global sound volume
9623
+ this.video.volume = clamp(this.volume * soundVolume);
9624
+ }
9625
+
9626
+ /** Render video to UI canvas */
9627
+ render()
9628
+ {
9629
+ super.render();
9630
+
9631
+ if (this.isLoadng())
9632
+ return;
9633
+ const context = uiSystem.uiContext;
9634
+ const s = this.size;
9635
+ context.save();
9636
+ context.translate(this.pos.x, this.pos.y);
9637
+ context.drawImage(this.video, -s.x/2, -s.y/2, s.x, s.y);
9638
+ context.restore();
9639
+ }
9640
+
9641
+ /** Clean up video on destroy */
9642
+ destroy()
9643
+ {
9644
+ if (this.destroyed)
9645
+ return;
9646
+
9647
+ this.video.pause();
9648
+ this.video.remove();
9649
+ super.destroy();
9650
+ }
9392
9651
  }
9393
9652
  /**
9394
9653
  * LittleJS Box2D Physics Plugin
@@ -11819,6 +12078,7 @@ export
11819
12078
  UIButton,
11820
12079
  UICheckbox,
11821
12080
  UIScrollbar,
12081
+ UIVideo,
11822
12082
 
11823
12083
  // Box2D Physics
11824
12084
  box2d,