littlejsengine 1.18.2 → 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,
@@ -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,
@@ -246,6 +248,7 @@ export
246
248
  drawTile,
247
249
  drawRect,
248
250
  drawRectGradient,
251
+ drawTextureWrapped,
249
252
  drawLineList,
250
253
  drawLine,
251
254
  drawPoly,
@@ -312,6 +315,8 @@ export
312
315
  gamepadStick,
313
316
  gamepadDpad,
314
317
  gamepadConnected,
318
+ gamepadVibrate,
319
+ gamepadVibrateStop,
315
320
  vibrate,
316
321
  vibrateStop,
317
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
@@ -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
 
@@ -355,6 +367,11 @@ function setCameraAngle(angle) { cameraAngle = angle; }
355
367
  * @memberof Settings */
356
368
  function setCameraScale(scale) { cameraScale = scale; }
357
369
 
370
+ /** Set scale applied to engine time
371
+ * @param {number} scale
372
+ * @memberof Settings */
373
+ function setTimeScale(scale) { timeScale = scale; }
374
+
358
375
  /** Set if tiles should be colorized when using canvas2d
359
376
  * This can be slower but results should look nearly identical to WebGL rendering
360
377
  * It can be enabled/disabled at any time
@@ -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
 
@@ -256,3 +256,19 @@ test('TileLayer extends CanvasLayer and stubs render methods in headless', () =>
256
256
  assert.doesNotThrow(() => tl.render());
257
257
  assert.doesNotThrow(() => tl.redraw());
258
258
  });
259
+
260
+ test('drawTextureWrapped is exported', async () =>
261
+ {
262
+ const mod = await import('../dist/littlejs.esm.js');
263
+ assert.equal(typeof mod.drawTextureWrapped, 'function');
264
+ });
265
+
266
+ test('drawTextureWrapped is callable in headless mode', async () =>
267
+ {
268
+ const mod = await import('../dist/littlejs.esm.js');
269
+ const { drawTextureWrapped, vec2 } = mod;
270
+ // headlessMode short-circuits before texture lookup, so this just
271
+ // exercises argument-type ASSERTs (which are stripped in release)
272
+ assert.doesNotThrow(() =>
273
+ drawTextureWrapped(vec2(), vec2(1, 1), vec2(2, 2)));
274
+ });