littlejsengine 1.18.7 → 1.18.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.18.7",
3
+ "version": "1.18.12",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -147,7 +147,8 @@ class Medal
147
147
  /** @property {boolean} - Is the medal unlocked? */
148
148
  this.unlocked = false;
149
149
 
150
- // load the source image if provided
150
+ /** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any */
151
+ this.image = undefined;
151
152
  if (src)
152
153
  (this.image = new Image).src = src;
153
154
 
@@ -63,13 +63,18 @@ class NewgroundsPlugin
63
63
  ASSERT(!cipher || cryptoJS, 'must provide cryptojs if there is a cipher');
64
64
 
65
65
  newgrounds = this; // set global newgrounds object
66
+ /** @property {string} - The newgrounds App ID */
66
67
  this.app_id = app_id;
68
+ /** @property {string|undefined} - AES-128/Base64 encryption key, if any */
67
69
  this.cipher = cipher;
70
+ /** @property {Object|undefined} - CryptoJS instance used when cipher is set */
68
71
  this.cryptoJS = cryptoJS;
72
+ /** @property {string} - Hostname used when logging views */
69
73
  this.host = location ? location.hostname : '';
70
74
 
71
75
  // get session id from url search params
72
76
  const url = new URL(location.href);
77
+ /** @property {string|null} - Newgrounds session id from the URL (null when not logged in) */
73
78
  this.session_id = url.searchParams.get('ngio_session_id');
74
79
 
75
80
  if (!this.session_id)
@@ -77,6 +82,7 @@ class NewgroundsPlugin
77
82
 
78
83
  // get medals
79
84
  const medalsResult = this.call('Medal.getList');
85
+ /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
80
86
  this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
81
87
  debugMedals && LOG(this.medals);
82
88
  for (const newgroundsMedal of this.medals)
@@ -100,6 +106,7 @@ class NewgroundsPlugin
100
106
 
101
107
  // get scoreboards
102
108
  const scoreboardResult = this.call('ScoreBoard.getBoards');
109
+ /** @property {Array} - Scoreboards fetched from Newgrounds */
103
110
  this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
104
111
  debugMedals && LOG(this.scoreboards);
105
112
 
@@ -94,7 +94,9 @@ class PathFinder
94
94
  // .size + .getCollisionData.
95
95
  if (isVector2(source))
96
96
  {
97
+ /** @property {Vector2} - Grid dimensions in tiles */
97
98
  this.size = source.floor();
99
+ /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any */
98
100
  this.tileLayer = undefined;
99
101
  }
100
102
  else
@@ -106,13 +108,18 @@ class PathFinder
106
108
  }
107
109
 
108
110
  // Tunables (public, freely re-assignable).
111
+ /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */
109
112
  this.heuristicWeight = 1;
110
- this.maxLoop = 500;
113
+ /** @property {number} - Maximum A* expansions before giving up */
114
+ this.maxLoop = 1e3;
115
+ /** @property {boolean} - If true, post-process paths with two-pass smoothing */
111
116
  this.smoothPath = true;
117
+ /** @property {boolean} - If true, draw debug visualization during findPath */
112
118
  this.debug = false;
113
- this.debugTime = 2;
119
+ /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
120
+ this.debugTime = 1;
114
121
 
115
- // Pre-allocate the node array one node per tile, reused across calls.
122
+ /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes */
116
123
  this.nodes = new Array(this.size.x * this.size.y);
117
124
  for (let y = 0; y < this.size.y; ++y)
118
125
  for (let x = 0; x < this.size.x; ++x)
@@ -286,9 +293,12 @@ class PathFinder
286
293
  // Best path so far through neighbor — record it.
287
294
  neighbor.parent = current;
288
295
  neighbor.g = tentativeG;
289
- const gdx = endNode.pos.x - neighbor.pos.x;
290
- const gdy = endNode.pos.y - neighbor.pos.y;
291
- neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
296
+ // Octile heuristic tightest admissible distance for an
297
+ // 8-connected grid with cardinal cost 1 and diagonal cost √2.
298
+ const adx = abs(endNode.pos.x - neighbor.pos.x);
299
+ const ady = abs(endNode.pos.y - neighbor.pos.y);
300
+ const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady);
301
+ neighbor.f = neighbor.g + h * this.heuristicWeight;
292
302
  }
293
303
  }
294
304
 
@@ -570,6 +580,24 @@ class PathFinder
570
580
  path.push(original[original.length - 1]);
571
581
  }
572
582
 
583
+ /** Drop any middle node that lies exactly on the line through its two
584
+ * neighbors. Backstop for the smoothing passes — the corners pass
585
+ * intentionally keeps truly-straight runs, and the string-pulling pass
586
+ * checks collinearity against the original path, not the in-progress
587
+ * result, so it can leave 3+ collinear nodes in some edge cases.
588
+ * @param {PathFinderNode[]} path
589
+ * @private */
590
+ dropCollinearNodes(path)
591
+ {
592
+ for (let i = path.length - 2; i >= 1; --i)
593
+ {
594
+ const a = path[i - 1], b = path[i], c = path[i + 1];
595
+ if ((b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) ===
596
+ (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x))
597
+ path.splice(i, 1);
598
+ }
599
+ }
600
+
573
601
  /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
574
602
  * and clear (walkable, zero-cost). Used by isLineClear's hot path.
575
603
  * @param {number} x
@@ -737,6 +765,7 @@ class PathFinder
737
765
  {
738
766
  this.smoothPathCorners(nodePath);
739
767
  this.smoothPathStringPull(nodePath);
768
+ this.dropCollinearNodes(nodePath);
740
769
  }
741
770
 
742
771
  // Convert to world-space Vector2 path. Return copies, not live node
@@ -63,13 +63,21 @@ class Tween
63
63
  }
64
64
  ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
65
65
 
66
+ /** @property {function(number|Vector2|Color):void} - Called with the interpolated value each frame */
66
67
  this.callback = callback;
68
+ /** @property {number|Vector2|Color} - Starting value */
67
69
  this.start = start;
70
+ /** @property {number|Vector2|Color} - Ending value */
68
71
  this.end = end;
72
+ /** @property {number} - Total duration in seconds */
69
73
  this.duration = duration;
74
+ /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
70
75
  this.life = duration;
76
+ /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
71
77
  this.ease = options.ease || Ease.LINEAR;
78
+ /** @property {boolean} - If true, advance even when the game is paused */
72
79
  this.useRealTime = !!options.useRealTime;
80
+ /** @property {boolean} - If true, stop advancing until cleared */
73
81
  this.paused = !!options.paused;
74
82
 
75
83
  /** @private completion callback set by then(), loop(), pingPong(). */
@@ -409,7 +417,7 @@ const Ease =
409
417
  function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
410
418
  {
411
419
  ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
412
- ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
420
+ ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
413
421
 
414
422
  const parts = propertyPath.split('.');
415
423
  const lastKey = parts.pop();
@@ -125,12 +125,31 @@ class UISystemPlugin
125
125
 
126
126
  engineAddPlugin(uiUpdate, uiRender);
127
127
 
128
- // set object position in parent space
128
+ // set object position based on anchor target (parent box, or canvas for roots),
129
+ // self-pivot, and localPos offset
129
130
  function updateTransforms(o)
130
131
  {
131
- if (!o.parent) return;
132
- o.pos.x = o.localPos.x + o.parent.pos.x;
133
- o.pos.y = o.localPos.y + o.parent.pos.y;
132
+ let targetPos, targetSize;
133
+ if (o.parent)
134
+ {
135
+ targetPos = o.parent.pos;
136
+ targetSize = o.parent.size;
137
+ }
138
+ else
139
+ {
140
+ // anchor to canvas in native coords (handles nativeHeight if set)
141
+ targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
142
+ targetSize = uiSystem.nativeHeight
143
+ ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
144
+ uiSystem.nativeHeight)
145
+ : mainCanvasSize;
146
+ }
147
+
148
+ const a = o.anchor;
149
+ o.pos = targetPos
150
+ .add(targetSize.multiply(a).scale(.5)) // anchor point on target
151
+ .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
152
+ .add(o.localPos); // user offset
134
153
  }
135
154
 
136
155
  // setup recursive update and render
@@ -597,9 +616,8 @@ class UISystemPlugin
597
616
  // confirm menu
598
617
  const confirmMenu = new UIObject(vec2(), size);
599
618
  uiSystem.confirmDialog = confirmMenu;
600
- confirmMenu.onRender = ()=>
619
+ confirmMenu.onRender = ()=>
601
620
  {
602
- confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
603
621
  const backgroundColor = hsl(0,0,0,.7);
604
622
  uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
605
623
  }
@@ -732,7 +750,11 @@ class UIObject
732
750
  this.navigationIndex = undefined;
733
751
  /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
734
752
  this.navigationAutoSelect = false;
735
-
753
+ /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
754
+ * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
755
+ * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
756
+ this.anchor = vec2();
757
+
736
758
  uiSystem.uiObjects.push(this);
737
759
  }
738
760
 
@@ -985,9 +1007,9 @@ class UIText extends UIObject
985
1007
  {
986
1008
  super(pos, size);
987
1009
 
988
- ASSERT(isString(text), 'ui text must be a string');
1010
+ ASSERT(isStringLike(text), 'ui text must be a string');
989
1011
  ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
990
- ASSERT(isString(font), 'ui text font must be a string');
1012
+ ASSERT(isStringLike(font), 'ui text font must be a string');
991
1013
 
992
1014
  // set properties
993
1015
  this.text = text;
@@ -1035,7 +1057,7 @@ class UITextInput extends UIObject
1035
1057
  {
1036
1058
  super(pos, size);
1037
1059
 
1038
- ASSERT(isString(text), 'ui text must be a string');
1060
+ ASSERT(isStringLike(text), 'ui text must be a string');
1039
1061
 
1040
1062
  /** @property {number} - Max length of input (0 = no limit) */
1041
1063
  this.maxLength = 0;
@@ -1172,7 +1194,7 @@ class UIButton extends UIObject
1172
1194
  {
1173
1195
  super(pos, size);
1174
1196
 
1175
- ASSERT(isString(text), 'ui button must be a string');
1197
+ ASSERT(isStringLike(text), 'ui button must be a string');
1176
1198
  ASSERT(isColor(color), 'ui button color must be a color');
1177
1199
 
1178
1200
  /** @property {Vector2} - Text offset for the button */
@@ -1213,7 +1235,7 @@ class UICheckbox extends UIObject
1213
1235
  {
1214
1236
  super(pos, size);
1215
1237
 
1216
- ASSERT(isString(text), 'ui checkbox must be a string');
1238
+ ASSERT(isStringLike(text), 'ui checkbox must be a string');
1217
1239
  ASSERT(isColor(color), 'ui checkbox color must be a color');
1218
1240
 
1219
1241
  /** @property {boolean} - Current percentage value of this slider 0-1 */
@@ -1270,7 +1292,7 @@ class UISlider extends UIObject
1270
1292
  super(pos, size);
1271
1293
 
1272
1294
  ASSERT(isNumber(value), 'ui slider value must be a number');
1273
- ASSERT(isString(text), 'ui slider must be a string');
1295
+ ASSERT(isStringLike(text), 'ui slider must be a string');
1274
1296
  ASSERT(isColor(color), 'ui slider color must be a color');
1275
1297
  ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
1276
1298
 
@@ -1388,7 +1410,7 @@ class UIVideo extends UIObject
1388
1410
  {
1389
1411
  super(pos, size || vec2());
1390
1412
 
1391
- ASSERT(isString(src), 'video src must be a string');
1413
+ ASSERT(isStringLike(src), 'video src must be a string');
1392
1414
  ASSERT(isNumber(volume), 'video volume must be a number');
1393
1415
 
1394
1416
  this.color = BLACK; // default to black background
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.18.7';
35
+ const engineVersion = '1.18.12';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -204,7 +204,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
204
204
  const combinedScale = timeScale * debugScale;
205
205
  frameTimeDeltaMS *= combinedScale;
206
206
  frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
207
- if (debugScale <= 1)
207
+ if (combinedScale <= 1)
208
208
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
209
209
 
210
210
  let wasUpdated = false;
@@ -291,6 +291,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
291
291
  glFlush();
292
292
  debugRenderPost();
293
293
  drawCount = 0;
294
+ primitiveCount = 0;
294
295
  }
295
296
  }
296
297
 
@@ -14,12 +14,10 @@
14
14
 
15
15
  'use strict';
16
16
 
17
- /** Audio context used by the engine. Created lazily in audioInit() to avoid
18
- * browser autoplay warnings about constructing an AudioContext before any
19
- * user gesture.
17
+ /** Audio context used by the engine
20
18
  * @type {AudioContext}
21
19
  * @memberof Audio */
22
- let audioContext;
20
+ let audioContext = new AudioContext;
23
21
 
24
22
  /** Master gain node for all audio to pass through
25
23
  * @type {GainNode}
@@ -35,13 +33,12 @@ const audioDefaultSampleRate = 44100;
35
33
  * @return {boolean} - True if the audio context is running
36
34
  * @memberof Audio */
37
35
  function audioIsRunning()
38
- { return audioContext?.state === 'running'; }
36
+ { return audioContext.state === 'running'; }
39
37
 
40
38
  function audioInit()
41
39
  {
42
40
  if (!soundEnable || headlessMode) return;
43
41
 
44
- audioContext = new AudioContext;
45
42
  audioMasterGain = audioContext.createGain();
46
43
  audioMasterGain.connect(audioContext.destination);
47
44
  audioMasterGain.gain.value = soundVolume; // set starting value
@@ -87,7 +84,7 @@ class Sound
87
84
  {
88
85
  if (!soundEnable || headlessMode) return;
89
86
 
90
- ASSERT(!asset || isArray(asset) || isString(asset), 'asset must be a file name or zzfx array');
87
+ ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
91
88
  ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
92
89
  ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
93
90
  ASSERT(isNumber(range), 'range must be a number');
@@ -426,14 +423,15 @@ class SoundInstance
426
423
 
427
424
  /** Speak text with passed in settings
428
425
  * @param {string} text - The text to speak
429
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
430
426
  * @param {number} [volume] - How much to scale volume by
431
427
  * @param {number} [rate] - How quickly to speak
432
428
  * @param {number} [pitch] - How much to change the pitch by
429
+ * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
433
430
  * @return {SpeechSynthesisUtterance} - The utterance that was spoken
434
431
  * @memberof Audio */
435
- function speak(text, language='', volume=1, rate=1, pitch=1)
432
+ function speak(text, volume=1, rate=1, pitch=1, language='')
436
433
  {
434
+ ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
437
435
  if (!soundEnable || headlessMode) return;
438
436
  if (!speechSynthesis) return;
439
437
 
@@ -79,7 +79,7 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, s
79
79
  {
80
80
  ASSERT(isVector2(pos), 'pos must be a vec2');
81
81
  ASSERT(isVector2(size), 'size must be a vec2');
82
- ASSERT(isString(color) || isColor(color), 'color is invalid');
82
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
83
83
  ASSERT(isNumber(time), 'time must be a number');
84
84
  ASSERT(isNumber(angle), 'angle must be a number');
85
85
 
@@ -106,7 +106,7 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screen
106
106
  {
107
107
  ASSERT(isVector2(pos), 'pos must be a vec2');
108
108
  ASSERT(isArray(points), 'points must be an array');
109
- ASSERT(isString(color) || isColor(color), 'color is invalid');
109
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
110
110
  ASSERT(isNumber(time), 'time must be a number');
111
111
  ASSERT(isNumber(angle), 'angle must be a number');
112
112
 
@@ -130,7 +130,7 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=f
130
130
  {
131
131
  ASSERT(isVector2(pos), 'pos must be a vec2');
132
132
  ASSERT(isNumber(size), 'size must be a number');
133
- ASSERT(isString(color) || isColor(color), 'color is invalid');
133
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
134
134
  ASSERT(isNumber(time), 'time must be a number');
135
135
 
136
136
  if (isColor(color))
@@ -208,13 +208,13 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
208
208
  * @memberof Debug */
209
209
  function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=false)
210
210
  {
211
- ASSERT(isString(text), 'text must be a string');
211
+ ASSERT(isStringLike(text), 'text must be a string');
212
212
  ASSERT(isVector2(pos), 'pos must be a vec2');
213
213
  ASSERT(isNumber(size), 'size must be a number');
214
- ASSERT(isString(color) || isColor(color), 'color is invalid');
214
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
215
215
  ASSERT(isNumber(time), 'time must be a number');
216
216
  ASSERT(isNumber(angle), 'angle must be a number');
217
- ASSERT(isString(font), 'font must be a string');
217
+ ASSERT(isStringLike(font), 'font must be a string');
218
218
 
219
219
  if (isColor(color))
220
220
  color = color.toString();
@@ -502,7 +502,8 @@ function debugRender()
502
502
  debugContext.fillText('FPS: ' + averageFPS.toFixed(1) + (glEnable?' WebGL':' Canvas2D'),
503
503
  x, y += h);
504
504
  debugContext.fillText('Objects: ' + engineObjects.length, x, y += h);
505
- debugContext.fillText('Draw Count: ' + drawCount, x, y += h);
505
+ debugContext.fillText('Draw Calls: ' + drawCount, x, y += h);
506
+ debugContext.fillText('Primitives: ' + primitiveCount, x, y += h);
506
507
  debugContext.fillText('---------', x, y += h);
507
508
  debugContext.fillStyle = '#f00';
508
509
  debugContext.fillText('ESC: Debug Overlay', x, y += h);
@@ -573,7 +574,8 @@ function debugRenderPost()
573
574
  mainContext.font = '1em monospace';
574
575
  mainContext.fillStyle = '#000';
575
576
  const text = engineName + ' v' + engineVersion + ' / '
576
- + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1)
577
+ + drawCount + ' / ' + primitiveCount + ' / '
578
+ + engineObjects.length + ' / ' + averageFPS.toFixed(1)
577
579
  + (glEnable ? ' GL' : ' 2D') ;
578
580
  mainContext.fillText(text, mainCanvas.width-3, 3);
579
581
  mainContext.fillStyle = '#fff';
package/src/engineDraw.js CHANGED
@@ -72,6 +72,12 @@ let textureInfos = [];
72
72
  * @memberof Draw */
73
73
  let drawCount;
74
74
 
75
+ /** Keeps track of how many primitives were drawn each frame for debugging
76
+ * A single draw call can render many primitives (e.g. a WebGL sprite batch).
77
+ * @type {number}
78
+ * @memberof Draw */
79
+ let primitiveCount;
80
+
75
81
  // internal predicates for tint short-circuiting in canvas2D draw paths
76
82
  // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
77
83
  // isBlack includes alpha so additive colors that only contribute alpha are not skipped
@@ -312,19 +318,19 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
312
318
  }
313
319
  else
314
320
  {
315
- // if no tile info, force untextured by zeroing rgba (so whatever
316
- // texture is bound doesn't leak in) and folding color+additive
317
- // into the additive slot matches the Canvas2D path's
318
- // color.add(additiveColor) on line ~337.
321
+ // untextured: glDrawUntextured picks the optimal path (poly
322
+ // tristrip if already in poly mode, otherwise instanced with
323
+ // uvs/rgba zeroed). Color+additive are folded together to match
324
+ // the Canvas2D path's color.add(additiveColor) on line ~337.
319
325
  const combined = additiveColor ? color.add(additiveColor) : color;
320
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
321
- 0, combined.rgbaInt());
326
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
322
327
  }
323
328
  }
324
329
  else
325
330
  {
326
331
  // normal canvas 2D rendering method (slower)
327
332
  ++drawCount;
333
+ ++primitiveCount;
328
334
  size = new Vector2(size.x, -size.y); // flip upside down sprites
329
335
  drawCanvas2D(pos, size, angle, mirror, (context)=>
330
336
  {
@@ -364,13 +370,13 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
364
370
  * @param {Vector2} pos
365
371
  * @param {Vector2} [size=vec2(1)]
366
372
  * @param {Color} [colorTop=WHITE]
367
- * @param {Color} [colorBottom=BLACK]
373
+ * @param {Color} [colorBottom=CLEAR_WHITE]
368
374
  * @param {number} [angle]
369
375
  * @param {boolean} [useWebGL=glEnable]
370
376
  * @param {boolean} [screenSpace]
371
377
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
372
378
  * @memberof Draw */
373
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
379
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
374
380
  {
375
381
  ASSERT(isVector2(pos), 'pos must be a vec2');
376
382
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -410,6 +416,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
410
416
  {
411
417
  // normal canvas 2D rendering method (slower)
412
418
  ++drawCount;
419
+ ++primitiveCount;
413
420
  size = new Vector2(size.x, -size.y); // fix upside down sprites
414
421
  drawCanvas2D(pos, size, angle, false, (context)=>
415
422
  {
@@ -472,8 +479,9 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
472
479
  return;
473
480
  }
474
481
 
475
- // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
482
+ // Canvas2D path — increment counts here (WebGL counts via glFlush)
476
483
  ++drawCount;
484
+ ++primitiveCount;
477
485
 
478
486
  if (!screenSpace)
479
487
  {
@@ -547,6 +555,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
547
555
  {
548
556
  // normal canvas 2D rendering method (slower)
549
557
  ++drawCount;
558
+ ++primitiveCount;
550
559
  drawCanvas2D(pos, vec2(1), angle, false, (context)=>
551
560
  {
552
561
  context.strokeStyle = color.toString();
@@ -727,6 +736,77 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
727
736
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
728
737
  }
729
738
 
739
+ /** Draw a circle filled with a radial gradient from the center to the rim
740
+ * - Best when batched with other untextured polys
741
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
742
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
743
+ * @param {Vector2} pos
744
+ * @param {number} [size=1] - Diameter
745
+ * @param {Color} [colorInner=WHITE]
746
+ * @param {Color} [colorOuter=CLEAR_WHITE]
747
+ * @param {boolean} [useWebGL=glEnable]
748
+ * @param {boolean} [screenSpace]
749
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
750
+ * @memberof Draw */
751
+ let drawCircleGradientOffset = 0;
752
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
753
+ {
754
+ ASSERT(isVector2(pos), 'pos must be a vec2');
755
+ ASSERT(isNumber(size), 'size must be a number');
756
+ ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
757
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
758
+
759
+ if (headlessMode) return;
760
+
761
+ if (useWebGL && glEnable)
762
+ {
763
+ ASSERT(!!glContext, 'WebGL is not enabled!');
764
+ if (screenSpace)
765
+ {
766
+ // convert to world space
767
+ pos = screenToWorld(pos);
768
+ size /= cameraScale;
769
+ }
770
+ // fan as tristrip; rotate the boundary vertex by one slice per call
771
+ // so back-to-back gradients at the same position have their hole
772
+ // (from gpu edge-rule on the boundary line-degen) at different rim
773
+ // verts and don't visibly stack
774
+ const sides = glCircleSides;
775
+ const radius = size/2;
776
+ const innerInt = colorInner.rgbaInt();
777
+ const outerInt = colorOuter.rgbaInt();
778
+ const offset = drawCircleGradientOffset++;
779
+ const startA = (offset%sides)/sides*PI*2;
780
+ const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
781
+ const colors = [outerInt];
782
+ for (let i=sides; i--;)
783
+ {
784
+ const a = ((i+offset)%sides)/sides*PI*2;
785
+ points.push(pos);
786
+ colors.push(innerInt);
787
+ points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
788
+ colors.push(outerInt);
789
+ }
790
+ glDrawColoredPoints(points, colors);
791
+ }
792
+ else
793
+ {
794
+ // normal canvas 2D rendering method (slower)
795
+ ++drawCount;
796
+ ++primitiveCount;
797
+ drawCanvas2D(pos, vec2(size), 0, false, (context)=>
798
+ {
799
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
800
+ gradient.addColorStop(0, colorInner.toString());
801
+ gradient.addColorStop(1, colorOuter.toString());
802
+ context.fillStyle = gradient;
803
+ context.beginPath();
804
+ context.ellipse(0, 0, .5, .5, 0, 0, 9);
805
+ context.fill();
806
+ }, screenSpace, context);
807
+ }
808
+ }
809
+
730
810
  /**
731
811
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
732
812
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -810,15 +890,15 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
810
890
  * @memberof Draw */
811
891
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
812
892
  {
813
- ASSERT(isString(text), 'text must be a string');
893
+ ASSERT(isStringLike(text), 'text must be a string');
814
894
  ASSERT(isVector2(pos), 'pos must be a vec2');
815
895
  ASSERT(isNumber(size), 'size must be a number');
816
896
  ASSERT(isColor(color), 'color must be a color');
817
897
  ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
818
898
  ASSERT(isColor(lineColor), 'lineColor must be a color');
819
899
  ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
820
- ASSERT(isString(font), 'font must be a string');
821
- ASSERT(isString(fontStyle), 'fontStyle must be a string');
900
+ ASSERT(isStringLike(font), 'font must be a string');
901
+ ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
822
902
  ASSERT(isNumber(angle), 'angle must be a number');
823
903
 
824
904
  context.fillStyle = color.toString();
@@ -855,7 +935,7 @@ async function loadTexture(textureIndex, src)
855
935
  {
856
936
  ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
857
937
  ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
858
- ASSERT(!src || isString(src), 'image src must be a string');
938
+ ASSERT(!src || isStringLike(src), 'image src must be a string');
859
939
 
860
940
  const image = new Image;
861
941
  if (src)
@@ -1243,7 +1323,7 @@ class FontImage
1243
1323
  */
1244
1324
  drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
1245
1325
  {
1246
- ASSERT(isString(text), 'text must be a string');
1326
+ ASSERT(isStringLike(text), 'text must be a string');
1247
1327
  ASSERT(isVector2(pos), 'pos must be a vec2');
1248
1328
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1249
1329
  ASSERT(isColor(color), 'color must be a color');
@@ -187,6 +187,8 @@ export
187
187
  shareURL,
188
188
  readSaveData,
189
189
  writeSaveData,
190
+ noise1D,
191
+ noise2D,
190
192
 
191
193
  // Random
192
194
  rand,
@@ -208,7 +210,7 @@ export
208
210
  isColor,
209
211
  isVector2,
210
212
  isNumber,
211
- isString,
213
+ isStringLike,
212
214
  isArray,
213
215
 
214
216
  // Default Colors
@@ -240,6 +242,7 @@ export
240
242
  mainCanvasSize,
241
243
  textureInfos,
242
244
  drawCount,
245
+ primitiveCount,
243
246
  screenToWorld,
244
247
  worldToScreen,
245
248
  screenToWorldDelta,
@@ -255,6 +258,7 @@ export
255
258
  drawRegularPoly,
256
259
  drawEllipse,
257
260
  drawCircle,
261
+ drawCircleGradient,
258
262
  drawCanvas2D,
259
263
  drawText,
260
264
  drawTextScreen,
@@ -284,6 +288,7 @@ export
284
288
  glCopyToContext,
285
289
  glSetAntialias,
286
290
  glDraw,
291
+ glDrawUntextured,
287
292
  glDrawPointsTransform,
288
293
  glDrawOutlineTransform,
289
294
  glDrawPoints,