littlejsengine 1.18.1 → 1.18.4

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/src/engineDraw.js CHANGED
@@ -72,6 +72,12 @@ let textureInfos = [];
72
72
  * @memberof Draw */
73
73
  let drawCount;
74
74
 
75
+ // internal predicates for tint short-circuiting in canvas2D draw paths
76
+ // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
77
+ // isBlack includes alpha so additive colors that only contribute alpha are not skipped
78
+ /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
79
+ /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
80
+
75
81
  ///////////////////////////////////////////////////////////////////////////////
76
82
 
77
83
  /**
@@ -401,6 +407,96 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
401
407
  }
402
408
  }
403
409
 
410
+ /** Draw a texture tiled (wrapped) across a rectangle in world space.
411
+ * Useful for backgrounds, repeating patterns, and seamless fills.
412
+ * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
413
+ * @param {Vector2} pos - Center of the rect in world space
414
+ * @param {Vector2} size - Size of the rect in world space
415
+ * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
416
+ * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
417
+ * @param {Color} [color=WHITE] - Color to modulate with
418
+ * @param {number} [angle=0] - Angle to rotate by
419
+ * @param {Color} [additiveColor] - Additive color to be applied if any
420
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
421
+ * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
422
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
423
+ * @memberof Draw */
424
+ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
425
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
426
+ {
427
+ ASSERT(isVector2(pos), 'pos must be a vec2');
428
+ ASSERT(isVector2(size), 'size must be a vec2');
429
+ ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
430
+ ASSERT(isColor(color), 'color is invalid');
431
+ ASSERT(isNumber(angle), 'angle must be a number');
432
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
433
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
434
+ ASSERT(!(texture instanceof TileInfo),
435
+ 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
436
+
437
+ // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
438
+ if (headlessMode) return;
439
+
440
+ // resolve texture argument: TextureInfo or index
441
+ const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
442
+ ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
443
+ ASSERT(textureInfo.size.x > 0, 'texture not loaded');
444
+
445
+ if (useWebGL && glEnable)
446
+ {
447
+ ASSERT(!!glContext, 'WebGL is not enabled!');
448
+ if (screenSpace)
449
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
450
+ glSetTexture(textureInfo.glTexture);
451
+ glDraw(pos.x, pos.y, size.x, size.y, angle,
452
+ 0, 0, wrapCount.x, wrapCount.y,
453
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
454
+ return;
455
+ }
456
+
457
+ // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
458
+ ++drawCount;
459
+
460
+ if (!screenSpace)
461
+ {
462
+ pos = worldToScreen(pos);
463
+ size = size.scale(cameraScale);
464
+ angle -= cameraAngle;
465
+ }
466
+
467
+ // pick image source: raw, or tinted bake. Match drawImageColor's
468
+ // "no tint needed" predicate so behavior stays consistent.
469
+ const noTint = !canvasColorTiles ||
470
+ (additiveColor
471
+ ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
472
+ : isWhite(color));
473
+ // alpha is baked into pixels by bakeTintedImage's additive branch;
474
+ // in that case globalAlpha must NOT also apply color.a
475
+ const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
476
+ const source = noTint
477
+ ? textureInfo.image
478
+ : bakeTintedImage(textureInfo.image, color, additiveColor);
479
+
480
+ context = context || drawContext;
481
+ context.save();
482
+ context.translate(pos.x + .5, pos.y + .5);
483
+ context.rotate(angle);
484
+ context.globalAlpha = alphaBaked ? 1 : color.a;
485
+
486
+ const pattern = context.createPattern(source, 'repeat');
487
+ // map pattern-source pixels into user space so the rect contains
488
+ // wrapCount.x × wrapCount.y repeats
489
+ const m = new DOMMatrix()
490
+ .translate(-size.x/2, -size.y/2)
491
+ .scale(size.x / (wrapCount.x * source.width),
492
+ size.y / (wrapCount.y * source.height));
493
+ pattern.setTransform(m);
494
+ context.fillStyle = pattern;
495
+ context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
496
+ context.globalAlpha = 1;
497
+ context.restore();
498
+ }
499
+
404
500
  /** Draw connected lines between a series of points
405
501
  * @param {Array<Vector2>} points
406
502
  * @param {number} [width]
@@ -924,6 +1020,43 @@ function combineCanvases()
924
1020
  mainContext.drawImage(workCanvas, 0, 0);
925
1021
  }
926
1022
 
1023
+ // Internal: bake a color/additive-color tint into workReadCanvas at the
1024
+ // image's native resolution. Returns the work canvas, suitable for
1025
+ // passing to context.createPattern. Used by drawTextureWrapped's
1026
+ // Canvas2D path. Caller is responsible for short-circuiting when no
1027
+ // tint is needed (i.e. color is white and additiveColor is black/none).
1028
+ function bakeTintedImage(image, color, additiveColor)
1029
+ {
1030
+ const w = image.width|0, h = image.height|0;
1031
+ workReadCanvas.width = w;
1032
+ workReadCanvas.height = h;
1033
+ workReadContext.drawImage(image, 0, 0);
1034
+
1035
+ const imageData = workReadContext.getImageData(0, 0, w, h);
1036
+ const data = imageData.data;
1037
+ if (additiveColor && !isBlack(additiveColor))
1038
+ {
1039
+ // multiply + additive (slower)
1040
+ const colorMultiply = [color.r, color.g, color.b, color.a];
1041
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1042
+ additiveColor.b * 255, additiveColor.a * 255];
1043
+ for (let i = 0; i < data.length; ++i)
1044
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1045
+ }
1046
+ else
1047
+ {
1048
+ // RGB only, faster — alpha left intact for the caller
1049
+ for (let i = 0; i < data.length; i+=4)
1050
+ {
1051
+ data[i ] *= color.r;
1052
+ data[i+1] *= color.g;
1053
+ data[i+2] *= color.b;
1054
+ }
1055
+ }
1056
+ workReadContext.putImageData(imageData, 0, 0);
1057
+ return workReadCanvas;
1058
+ }
1059
+
927
1060
  /** Helper function to draw an image with color and additive color applied
928
1061
  * This is slower then normal drawImage when color is applied
929
1062
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -942,8 +1075,6 @@ function combineCanvases()
942
1075
  * @memberof Draw */
943
1076
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
944
1077
  {
945
- function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
946
- function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
947
1078
  const sx2 = bleed;
948
1079
  const sy2 = bleed;
949
1080
  sWidth = max(1,sWidth|0);
@@ -51,6 +51,7 @@ export
51
51
  cameraPos,
52
52
  cameraAngle,
53
53
  cameraScale,
54
+ timeScale,
54
55
  canvasColorTiles,
55
56
  canvasClearColor,
56
57
  canvasMaxSize,
@@ -80,7 +81,7 @@ export
80
81
  gamepadDirectionEmulateStick,
81
82
  inputWASDEmulateDirection,
82
83
  touchGamepadEnable,
83
- touchGamepadCenterButton,
84
+ touchGamepadCenterButtonSize,
84
85
  touchGamepadAnalog,
85
86
  touchGamepadSize,
86
87
  touchGamepadAlpha,
@@ -98,6 +99,7 @@ export
98
99
  setCameraPos,
99
100
  setCameraAngle,
100
101
  setCameraScale,
102
+ setTimeScale,
101
103
  setCanvasColorTiles,
102
104
  setCanvasClearColor,
103
105
  setCanvasMaxSize,
@@ -128,7 +130,7 @@ export
128
130
  setGamepadDirectionEmulateStick,
129
131
  setInputWASDEmulateDirection,
130
132
  setTouchGamepadEnable,
131
- setTouchGamepadCenterButton,
133
+ setTouchGamepadCenterButtonSize,
132
134
  setTouchGamepadButtonCount,
133
135
  setTouchGamepadAnalog,
134
136
  setTouchGamepadSize,
@@ -168,10 +170,12 @@ export
168
170
  distanceAngle,
169
171
  lerpAngle,
170
172
  lerp,
173
+ percentLerp,
171
174
  smoothStep,
172
175
  nearestPowerOfTwo,
173
176
  isOverlapping,
174
177
  isIntersecting,
178
+ lineTest,
175
179
  oscillate,
176
180
 
177
181
  // Utilities
@@ -244,6 +248,7 @@ export
244
248
  drawTile,
245
249
  drawRect,
246
250
  drawRectGradient,
251
+ drawTextureWrapped,
247
252
  drawLineList,
248
253
  drawLine,
249
254
  drawPoly,
@@ -310,6 +315,8 @@ export
310
315
  gamepadStick,
311
316
  gamepadDpad,
312
317
  gamepadConnected,
318
+ gamepadVibrate,
319
+ gamepadVibrateStop,
313
320
  vibrate,
314
321
  vibrateStop,
315
322
  pointerLockRequest,
@@ -260,6 +260,32 @@ function gamepadStickCount(gamepad=gamepadPrimary)
260
260
  return gamepadStickData[gamepad]?.length ?? 0;
261
261
  }
262
262
 
263
+ /** Pulse a gamepad's vibration hardware using the dual-rumble effect if it exists
264
+ * Strong magnitude is usually the left side motor, weak magnitude is usually the right side motor
265
+ * @param {number} [gamepad] - gamepad index
266
+ * @param {number} [duration] - effect duration in ms
267
+ * @param {number} [strongMagnitude] - strong (left) motor intensity, 0 to 1
268
+ * @param {number} [weakMagnitude] - weak (right) motor intensity, 0 to 1
269
+ * @param {number} [startDelay] - delay in ms before the effect starts
270
+ * @memberof Input */
271
+ function gamepadVibrate(gamepad=gamepadPrimary, duration=200, strongMagnitude=1, weakMagnitude=1, startDelay=0)
272
+ {
273
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
274
+ if (!vibrateEnable || headlessMode) return;
275
+ const pad = navigator?.getGamepads?.()[gamepad];
276
+ pad?.vibrationActuator?.playEffect?.('dual-rumble', {duration, strongMagnitude, weakMagnitude, startDelay});
277
+ }
278
+
279
+ /** Stop vibration on a gamepad
280
+ * @memberof Input */
281
+ function gamepadVibrateStop(gamepad=gamepadPrimary)
282
+ {
283
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
284
+ if (!vibrateEnable || headlessMode) return;
285
+ const pad = navigator?.getGamepads?.()[gamepad];
286
+ pad?.vibrationActuator?.reset?.();
287
+ }
288
+
263
289
  ///////////////////////////////////////////////////////////////////////////////
264
290
 
265
291
  /** Pulse the vibration hardware if it exists
@@ -497,7 +523,7 @@ function inputInit()
497
523
  if (touching)
498
524
  {
499
525
  touchGamepadTimer.set();
500
- if (touchGamepadCenterButton && !wasTouching && paused)
526
+ if (touchGamepadCenterButtonSize && !wasTouching && paused)
501
527
  {
502
528
  // touch anywhere to press start when paused
503
529
  touchGamepadButtons[9] = 1;
@@ -522,6 +548,7 @@ function inputInit()
522
548
  // virtual analog stick
523
549
  const delta = touchPos.subtract(stickCenter);
524
550
  touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
551
+ touchGamepadButtons[10] = 1; // also press a button when touching stick
525
552
  }
526
553
  else if (buttonCenter.distance(touchPos) < touchGamepadSize)
527
554
  {
@@ -530,6 +557,7 @@ function inputInit()
530
557
  // virtual right analog stick
531
558
  const delta = touchPos.subtract(buttonCenter);
532
559
  touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
560
+ touchGamepadButtons[11] = 1; // also press a button when touching right stick
533
561
  }
534
562
  // virtual face buttons
535
563
  let button = buttonCenter.subtract(touchPos).direction();
@@ -546,8 +574,7 @@ function inputInit()
546
574
  if (button < touchGamepadButtonCount)
547
575
  touchGamepadButtons[button] = 1;
548
576
  }
549
- else if (touchGamepadCenterButton &&
550
- startCenter.distance(touchPos) < touchGamepadSize)
577
+ else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
551
578
  {
552
579
  // virtual start button in center
553
580
  touchGamepadButtons[9] = 1;
@@ -596,6 +623,18 @@ function inputUpdate()
596
623
  // update touch gamepad if enabled
597
624
  if (touchGamepadEnable && isTouchDevice)
598
625
  {
626
+ if (debugGamepads)
627
+ {
628
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
629
+ const buttonCenter = touchGamepadButtonCenter();
630
+ const startCenter = mainCanvasSize.scale(.5);
631
+
632
+ debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
633
+ debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
634
+ if (touchGamepadCenterButtonSize)
635
+ debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
636
+ }
637
+
599
638
  if (!touchGamepadTimer.isSet()) return;
600
639
 
601
640
  // read virtual analog stick
@@ -623,7 +662,7 @@ function inputUpdate()
623
662
 
624
663
  // read virtual gamepad buttons
625
664
  const data = inputData[1] ?? (inputData[1] = []);
626
- for (let i=10; i--;)
665
+ for (let i=12; i--;)
627
666
  {
628
667
  const wasDown = gamepadIsDown(i,0);
629
668
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
@@ -452,6 +452,7 @@ class EngineObject
452
452
  child.parent = this;
453
453
  child.localPos = localPos.copy();
454
454
  child.localAngle = localAngle;
455
+ child.updateTransforms();
455
456
  return child;
456
457
  }
457
458
 
@@ -27,6 +27,18 @@ let cameraAngle = 0;
27
27
  * @memberof Settings */
28
28
  let cameraScale = 32;
29
29
 
30
+ ///////////////////////////////////////////////////////////////////////////////
31
+ // Time settings
32
+
33
+ /** Scale applied to engine time, can be used for slow motion or fast forward
34
+ * - 1 is normal speed, 2 is double speed, 0.5 is half speed
35
+ * - 0 freezes the simulation without setting the paused flag
36
+ * - Should be >= 0; stacks multiplicatively with the debug +/- shortcut
37
+ * @type {number}
38
+ * @default
39
+ * @memberof Settings */
40
+ let timeScale = 1;
41
+
30
42
  ///////////////////////////////////////////////////////////////////////////////
31
43
  // Display settings
32
44
 
@@ -230,18 +242,22 @@ let touchInputEnable = true;
230
242
 
231
243
  /** True if touch gamepad should appear on mobile devices
232
244
  * - Supports left analog stick, 4 face buttons and start button (button 9)
245
+ * - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
246
+ * - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
247
+
233
248
  * @type {boolean}
234
249
  * @default
235
250
  * @memberof Settings */
236
251
  let touchGamepadEnable = false;
237
252
 
238
253
  /** True if touch gamepad should have start button in the center
254
+ * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
239
255
  * - When the game is paused, any touch will press the button
240
- * - This can function as a way to pause/unpause the game
241
- * @type {boolean}
256
+ * - Set size to enable the center button
257
+ * @type {number}
242
258
  * @default
243
259
  * @memberof Settings */
244
- let touchGamepadCenterButton = true;
260
+ let touchGamepadCenterButtonSize = 300;
245
261
 
246
262
  /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
247
263
  * @type {number}
@@ -259,7 +275,7 @@ let touchGamepadAnalog = true;
259
275
  * @type {number}
260
276
  * @default
261
277
  * @memberof Settings */
262
- let touchGamepadSize = 99;
278
+ let touchGamepadSize = 100;
263
279
 
264
280
  /** Transparency of touch gamepad overlay
265
281
  * @type {number}
@@ -351,6 +367,11 @@ function setCameraAngle(angle) { cameraAngle = angle; }
351
367
  * @memberof Settings */
352
368
  function setCameraScale(scale) { cameraScale = scale; }
353
369
 
370
+ /** Set scale applied to engine time
371
+ * @param {number} scale
372
+ * @memberof Settings */
373
+ function setTimeScale(scale) { timeScale = scale; }
374
+
354
375
  /** Set if tiles should be colorized when using canvas2d
355
376
  * This can be slower but results should look nearly identical to WebGL rendering
356
377
  * It can be enabled/disabled at any time
@@ -527,11 +548,12 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
527
548
  * @memberof Settings */
528
549
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
529
550
 
530
- /** True if touch gamepad should have start button in the center
531
- * - This can function as a way to pause/unpause the game
532
- * @param {boolean} enable
551
+ /** Set if touch gamepad should have start button in the center
552
+ * - Set size to enable the center button
553
+ * - When the game is paused, any touch will press the button
554
+ * @param {number} size
533
555
  * @memberof Settings */
534
- function setTouchGamepadCenterButton(enable) { touchGamepadCenterButton = enable; }
556
+ function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
535
557
 
536
558
  /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
537
559
  * @param {number} count
@@ -304,9 +304,8 @@ function glClearCanvas()
304
304
  /** Set the WebGL texture, called automatically if using multiple textures
305
305
  * - This may also flush the gl buffer resulting in more draw calls and worse performance
306
306
  * @param {WebGLTexture} texture
307
- * @param {boolean} [wrap] - Should the texture wrap or clamp
308
307
  * @memberof WebGL */
309
- function glSetTexture(texture, wrap=false)
308
+ function glSetTexture(texture)
310
309
  {
311
310
  // must flush cache with the old texture to set a new one
312
311
  if (!glContext || texture === glActiveTexture) return;
@@ -314,11 +313,6 @@ function glSetTexture(texture, wrap=false)
314
313
  glFlush();
315
314
  glActiveTexture = texture;
316
315
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
317
-
318
- // set wrap mode
319
- const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
320
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
321
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
322
316
  }
323
317
 
324
318
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
@@ -393,6 +387,8 @@ function glCreateTexture(image)
393
387
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
394
388
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
395
389
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
390
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.REPEAT);
391
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.REPEAT);
396
392
  if (mipMap)
397
393
  glContext.generateMipmap(glContext.TEXTURE_2D);
398
394