minimojs 1.0.0-alpha.1 → 1.0.0-alpha.2

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/dist/minimo.js CHANGED
@@ -386,12 +386,19 @@ export class Game {
386
386
  /** @internal */ this._pointerPressed = false;
387
387
  /** @internal */ this._pointerX = 0;
388
388
  /** @internal */ this._pointerY = 0;
389
+ /** @internal */ this._mouseDown = false;
390
+ /** @internal */ this._mousePressed = false;
391
+ /** @internal */ this._mouseX = 0;
392
+ /** @internal */ this._mouseY = 0;
393
+ /** @internal */ this._touchPointers = new Map();
394
+ /** @internal */ this._primaryTouchId = null;
389
395
  /** @internal */ this._rafId = null;
390
396
  /** @internal */ this._lastTimestamp = null;
391
397
  /** @internal */ this._running = false;
392
398
  /** @internal */ this._hasCreated = false;
393
399
  /** @internal */ this._onResize = () => this._applyResponsiveCanvasLayout();
394
400
  /** @internal */ this._audioCtx = null;
401
+ /** @internal */ this._spriteGlyphCache = new Map();
395
402
  /** @internal */ this._lastAppliedPageBackground = undefined;
396
403
  // -------------------------------------------------------------------------
397
404
  // Public state
@@ -791,6 +798,136 @@ export class Game {
791
798
  isPointerPressed() {
792
799
  return this._pointerPressed;
793
800
  }
801
+ /**
802
+ * Returns `true` while any active pointer is held down over the target sprite.
803
+ * Works with both mouse input and multiple simultaneous touches.
804
+ *
805
+ * Pointer hit testing uses a circular area centered on the sprite. The radius
806
+ * is `sprite.size * radiusScale`. World-space sprites are tested against the
807
+ * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
808
+ * screen space.
809
+ *
810
+ * Use this for continuous virtual buttons such as touch movement controls.
811
+ *
812
+ * @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
813
+ * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
814
+ * Default: `0.5`.
815
+ * @returns `true` if any currently held pointer overlaps the sprite hit area.
816
+ *
817
+ * @example
818
+ * ```ts
819
+ * if (game.isPointerDownOverSprite(leftButton, 0.72)) {
820
+ * player.x -= 200 * dt;
821
+ * }
822
+ * ```
823
+ */
824
+ isPointerDownOverSprite(sprite, radiusScale = 0.5) {
825
+ if (!sprite)
826
+ return false;
827
+ if (this._mouseDown &&
828
+ this._isScreenPointOverSprite(this._mouseX, this._mouseY, sprite, radiusScale)) {
829
+ return true;
830
+ }
831
+ for (const pointer of this._touchPointers.values()) {
832
+ if (this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale)) {
833
+ return true;
834
+ }
835
+ }
836
+ return false;
837
+ }
838
+ /**
839
+ * Returns `true` only on the frame any pointer first pressed over the target
840
+ * sprite. Works with both mouse input and multiple simultaneous touches.
841
+ *
842
+ * Pointer hit testing uses a circular area centered on the sprite. The radius
843
+ * is `sprite.size * radiusScale`. World-space sprites are tested against the
844
+ * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
845
+ * screen space.
846
+ *
847
+ * Use this for one-shot virtual buttons such as menu taps.
848
+ *
849
+ * @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
850
+ * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
851
+ * Default: `0.5`.
852
+ * @returns `true` if any pointer began pressing this frame over the sprite hit area.
853
+ *
854
+ * @example
855
+ * ```ts
856
+ * if (game.isPointerPressedOverSprite(startButton, 0.8)) {
857
+ * startGame();
858
+ * }
859
+ * ```
860
+ */
861
+ isPointerPressedOverSprite(sprite, radiusScale = 0.5) {
862
+ if (!sprite)
863
+ return false;
864
+ if (this._mousePressed &&
865
+ this._isScreenPointOverSprite(this._mouseX, this._mouseY, sprite, radiusScale)) {
866
+ return true;
867
+ }
868
+ for (const pointer of this._touchPointers.values()) {
869
+ if (pointer.pressed &&
870
+ this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale)) {
871
+ return true;
872
+ }
873
+ }
874
+ return false;
875
+ }
876
+ /**
877
+ * Returns a read-only snapshot of all currently active pointers.
878
+ *
879
+ * Mouse appears in the list only while the mouse button is held down. Touches
880
+ * appear while they remain active on the canvas. Coordinates are returned in
881
+ * canvas/screen space.
882
+ *
883
+ * Use this for advanced multitouch controls such as joysticks, drag handles,
884
+ * or gesture-like gameplay logic.
885
+ *
886
+ * @returns A read-only array of active pointer snapshots.
887
+ *
888
+ * @example
889
+ * ```ts
890
+ * const pointers = game.getPointers();
891
+ * if (pointers.length > 0) {
892
+ * const first = pointers[0];
893
+ * game.text(`Pointer: ${first.x}, ${first.y}`, 10, 10);
894
+ * }
895
+ * ```
896
+ */
897
+ getPointers() {
898
+ return this._collectPointers();
899
+ }
900
+ /**
901
+ * Returns a read-only snapshot of all active pointers currently overlapping
902
+ * the target sprite.
903
+ *
904
+ * Pointer hit testing uses a circular area centered on the sprite. The radius
905
+ * is `sprite.size * radiusScale`. World-space sprites are tested against the
906
+ * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
907
+ * screen space.
908
+ *
909
+ * Use this when you need more than a boolean result, such as reading the exact
910
+ * pointer position over a virtual joystick or draggable control.
911
+ *
912
+ * @param sprite - Target sprite to test. If `null` / `undefined`, returns an empty array.
913
+ * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
914
+ * Default: `0.5`.
915
+ * @returns A read-only array of active pointer snapshots currently over the sprite.
916
+ *
917
+ * @example
918
+ * ```ts
919
+ * const touches = game.getPointersOverSprite(joystickBase, 1.0);
920
+ * if (touches.length > 0) {
921
+ * const p = touches[0];
922
+ * const dx = p.x - joystickBase.x;
923
+ * }
924
+ * ```
925
+ */
926
+ getPointersOverSprite(sprite, radiusScale = 0.5) {
927
+ if (!sprite)
928
+ return [];
929
+ return this._collectPointers().filter((pointer) => this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale));
930
+ }
794
931
  /**
795
932
  * Returns `true` when the current device appears to be mobile/touch-first.
796
933
  *
@@ -1095,7 +1232,11 @@ export class Game {
1095
1232
  this._animations = [];
1096
1233
  this._textOverlays = [];
1097
1234
  this._keysPressed.clear();
1098
- this._pointerPressed = false;
1235
+ this._mousePressed = false;
1236
+ for (const pointer of this._touchPointers.values()) {
1237
+ pointer.pressed = false;
1238
+ }
1239
+ this._syncPrimaryPointer();
1099
1240
  this.scrollX = 0;
1100
1241
  this.scrollY = 0;
1101
1242
  this._invokeCreate();
@@ -1161,58 +1302,152 @@ export class Game {
1161
1302
  window.addEventListener("keyup", (e) => {
1162
1303
  this._keysDown.delete(e.key);
1163
1304
  });
1164
- const getCanvasPoint = (e) => {
1305
+ const getCanvasPoint = (clientX, clientY) => {
1165
1306
  const rect = this._canvas.getBoundingClientRect();
1166
1307
  const scaleX = this._canvas.width / rect.width;
1167
1308
  const scaleY = this._canvas.height / rect.height;
1168
- let clientX;
1169
- let clientY;
1170
- if (e instanceof MouseEvent) {
1171
- clientX = e.clientX;
1172
- clientY = e.clientY;
1173
- }
1174
- else {
1175
- clientX = e.touches[0]?.clientX ?? this._pointerX;
1176
- clientY = e.touches[0]?.clientY ?? this._pointerY;
1177
- }
1178
1309
  return {
1179
1310
  x: (clientX - rect.left) * scaleX,
1180
1311
  y: (clientY - rect.top) * scaleY,
1181
1312
  };
1182
1313
  };
1183
1314
  this._canvas.addEventListener("mousedown", (e) => {
1184
- const p = getCanvasPoint(e);
1185
- this._pointerX = p.x;
1186
- this._pointerY = p.y;
1187
- this._pointerDown = true;
1188
- this._pointerPressed = true;
1315
+ const p = getCanvasPoint(e.clientX, e.clientY);
1316
+ this._mouseX = p.x;
1317
+ this._mouseY = p.y;
1318
+ this._mouseDown = true;
1319
+ this._mousePressed = true;
1320
+ this._syncPrimaryPointer();
1189
1321
  });
1190
1322
  this._canvas.addEventListener("mouseup", () => {
1191
- this._pointerDown = false;
1323
+ this._mouseDown = false;
1324
+ this._syncPrimaryPointer();
1192
1325
  });
1193
1326
  this._canvas.addEventListener("mousemove", (e) => {
1194
- const p = getCanvasPoint(e);
1195
- this._pointerX = p.x;
1196
- this._pointerY = p.y;
1327
+ const p = getCanvasPoint(e.clientX, e.clientY);
1328
+ this._mouseX = p.x;
1329
+ this._mouseY = p.y;
1330
+ this._syncPrimaryPointer();
1197
1331
  });
1198
1332
  this._canvas.addEventListener("touchstart", (e) => {
1199
- const p = getCanvasPoint(e);
1200
- this._pointerX = p.x;
1201
- this._pointerY = p.y;
1202
- this._pointerDown = true;
1203
- this._pointerPressed = true;
1333
+ for (const touch of Array.from(e.changedTouches)) {
1334
+ const p = getCanvasPoint(touch.clientX, touch.clientY);
1335
+ this._touchPointers.set(touch.identifier, {
1336
+ x: p.x,
1337
+ y: p.y,
1338
+ pressed: true,
1339
+ });
1340
+ if (this._primaryTouchId === null) {
1341
+ this._primaryTouchId = touch.identifier;
1342
+ }
1343
+ }
1344
+ this._syncPrimaryPointer();
1345
+ e.preventDefault();
1346
+ }, { passive: false });
1347
+ this._canvas.addEventListener("touchend", (e) => {
1348
+ for (const touch of Array.from(e.changedTouches)) {
1349
+ this._touchPointers.delete(touch.identifier);
1350
+ if (this._primaryTouchId === touch.identifier) {
1351
+ this._primaryTouchId = null;
1352
+ }
1353
+ }
1354
+ this._syncPrimaryPointer();
1355
+ e.preventDefault();
1356
+ }, { passive: false });
1357
+ this._canvas.addEventListener("touchcancel", (e) => {
1358
+ for (const touch of Array.from(e.changedTouches)) {
1359
+ this._touchPointers.delete(touch.identifier);
1360
+ if (this._primaryTouchId === touch.identifier) {
1361
+ this._primaryTouchId = null;
1362
+ }
1363
+ }
1364
+ this._syncPrimaryPointer();
1204
1365
  e.preventDefault();
1205
1366
  }, { passive: false });
1206
- this._canvas.addEventListener("touchend", () => {
1207
- this._pointerDown = false;
1208
- });
1209
1367
  this._canvas.addEventListener("touchmove", (e) => {
1210
- const p = getCanvasPoint(e);
1211
- this._pointerX = p.x;
1212
- this._pointerY = p.y;
1368
+ for (const touch of Array.from(e.changedTouches)) {
1369
+ const p = getCanvasPoint(touch.clientX, touch.clientY);
1370
+ const existing = this._touchPointers.get(touch.identifier);
1371
+ if (existing) {
1372
+ existing.x = p.x;
1373
+ existing.y = p.y;
1374
+ }
1375
+ else {
1376
+ this._touchPointers.set(touch.identifier, {
1377
+ x: p.x,
1378
+ y: p.y,
1379
+ pressed: false,
1380
+ });
1381
+ }
1382
+ }
1383
+ this._syncPrimaryPointer();
1213
1384
  e.preventDefault();
1214
1385
  }, { passive: false });
1215
1386
  }
1387
+ /** @internal */
1388
+ _isScreenPointOverSprite(x, y, sprite, radiusScale) {
1389
+ const safeScale = Math.max(0, radiusScale);
1390
+ const radius = sprite.size * safeScale;
1391
+ const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
1392
+ const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
1393
+ const dx = x - drawX;
1394
+ const dy = y - drawY;
1395
+ return dx * dx + dy * dy <= radius * radius;
1396
+ }
1397
+ /** @internal */
1398
+ _collectPointers() {
1399
+ const pointers = [];
1400
+ if (this._mouseDown) {
1401
+ pointers.push({
1402
+ id: "mouse",
1403
+ kind: "mouse",
1404
+ x: this._mouseX,
1405
+ y: this._mouseY,
1406
+ pressed: this._mousePressed,
1407
+ });
1408
+ }
1409
+ for (const [id, pointer] of this._touchPointers.entries()) {
1410
+ pointers.push({
1411
+ id,
1412
+ kind: "touch",
1413
+ x: pointer.x,
1414
+ y: pointer.y,
1415
+ pressed: pointer.pressed,
1416
+ });
1417
+ }
1418
+ return pointers;
1419
+ }
1420
+ /** @internal */
1421
+ _syncPrimaryPointer() {
1422
+ if (this._primaryTouchId !== null) {
1423
+ const primaryTouch = this._touchPointers.get(this._primaryTouchId);
1424
+ if (primaryTouch) {
1425
+ this._pointerX = primaryTouch.x;
1426
+ this._pointerY = primaryTouch.y;
1427
+ this._pointerDown = true;
1428
+ this._pointerPressed = primaryTouch.pressed;
1429
+ return;
1430
+ }
1431
+ this._primaryTouchId = null;
1432
+ }
1433
+ if (this._touchPointers.size > 0) {
1434
+ const firstTouchEntry = this._touchPointers.entries().next().value;
1435
+ if (firstTouchEntry) {
1436
+ const [touchId, touch] = firstTouchEntry;
1437
+ this._primaryTouchId = touchId;
1438
+ this._pointerX = touch.x;
1439
+ this._pointerY = touch.y;
1440
+ this._pointerDown = true;
1441
+ this._pointerPressed = touch.pressed;
1442
+ return;
1443
+ }
1444
+ }
1445
+ this._primaryTouchId = null;
1446
+ this._pointerX = this._mouseX;
1447
+ this._pointerY = this._mouseY;
1448
+ this._pointerDown = this._mouseDown;
1449
+ this._pointerPressed = this._mousePressed;
1450
+ }
1216
1451
  // -------------------------------------------------------------------------
1217
1452
  // Private — rAF loop
1218
1453
  // -------------------------------------------------------------------------
@@ -1235,7 +1470,11 @@ export class Game {
1235
1470
  this.onUpdate(dt);
1236
1471
  this._render();
1237
1472
  this._keysPressed.clear();
1238
- this._pointerPressed = false;
1473
+ this._mousePressed = false;
1474
+ for (const pointer of this._touchPointers.values()) {
1475
+ pointer.pressed = false;
1476
+ }
1477
+ this._syncPrimaryPointer();
1239
1478
  this._textOverlays = [];
1240
1479
  this._rafId = requestAnimationFrame(this._loop.bind(this));
1241
1480
  }
@@ -1294,6 +1533,34 @@ export class Game {
1294
1533
  // Private — rendering
1295
1534
  // -------------------------------------------------------------------------
1296
1535
  /** @internal */
1536
+ _getSpriteGlyphCanvas(sprite) {
1537
+ const size = Math.max(1, Math.round(sprite.size));
1538
+ const cacheKey = `${sprite.sprite}::${size}`;
1539
+ const cached = this._spriteGlyphCache.get(cacheKey);
1540
+ if (cached)
1541
+ return cached;
1542
+ const glyphCanvas = document.createElement("canvas");
1543
+ const boxSize = Math.max(2, Math.ceil(size * 2));
1544
+ glyphCanvas.width = boxSize;
1545
+ glyphCanvas.height = boxSize;
1546
+ const glyphCtx = glyphCanvas.getContext("2d");
1547
+ if (!glyphCtx) {
1548
+ throw new Error("MinimoJS: Could not acquire a glyph rendering context.");
1549
+ }
1550
+ glyphCtx.clearRect(0, 0, boxSize, boxSize);
1551
+ glyphCtx.shadowColor = "transparent";
1552
+ glyphCtx.shadowBlur = 0;
1553
+ glyphCtx.shadowOffsetX = 0;
1554
+ glyphCtx.shadowOffsetY = 0;
1555
+ glyphCtx.fillStyle = "#ffffff";
1556
+ glyphCtx.font = `${size}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
1557
+ glyphCtx.textAlign = "center";
1558
+ glyphCtx.textBaseline = "middle";
1559
+ glyphCtx.fillText(sprite.sprite, boxSize / 2, boxSize / 2);
1560
+ this._spriteGlyphCache.set(cacheKey, glyphCanvas);
1561
+ return glyphCanvas;
1562
+ }
1563
+ /** @internal */
1297
1564
  _render() {
1298
1565
  const ctx = this._ctx;
1299
1566
  const W = this._canvas.width;
@@ -1317,8 +1584,8 @@ export class Game {
1317
1584
  continue;
1318
1585
  ctx.save();
1319
1586
  ctx.globalAlpha = Math.max(0, Math.min(1, sprite.alpha));
1320
- const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
1321
- const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
1587
+ const drawX = Math.round(sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX);
1588
+ const drawY = Math.round(sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY);
1322
1589
  ctx.translate(drawX, drawY);
1323
1590
  if (sprite.rotation !== 0) {
1324
1591
  ctx.rotate((sprite.rotation * Math.PI) / 180);
@@ -1326,10 +1593,12 @@ export class Game {
1326
1593
  if (sprite.flipX || sprite.flipY) {
1327
1594
  ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
1328
1595
  }
1329
- ctx.font = `${sprite.size}px serif`;
1330
- ctx.textAlign = "center";
1331
- ctx.textBaseline = "middle";
1332
- ctx.fillText(sprite.sprite, 0, 0);
1596
+ ctx.shadowColor = "transparent";
1597
+ ctx.shadowBlur = 0;
1598
+ ctx.shadowOffsetX = 0;
1599
+ ctx.shadowOffsetY = 0;
1600
+ const glyphCanvas = this._getSpriteGlyphCanvas(sprite);
1601
+ ctx.drawImage(glyphCanvas, -glyphCanvas.width / 2, -glyphCanvas.height / 2);
1333
1602
  ctx.restore();
1334
1603
  }
1335
1604
  for (const entry of this._textOverlays) {
@@ -0,0 +1,10 @@
1
+ export function updatePhysics(sprites, gravityX, gravityY, dt) {
2
+ for (const sprite of sprites) {
3
+ if (sprite.gravityScale !== 0) {
4
+ sprite.vx += gravityX * sprite.gravityScale * dt;
5
+ sprite.vy += gravityY * sprite.gravityScale * dt;
6
+ }
7
+ sprite.x += sprite.vx * dt;
8
+ sprite.y += sprite.vy * dt;
9
+ }
10
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/render.js ADDED
@@ -0,0 +1,75 @@
1
+ export function renderFrame(options) {
2
+ const { ctx, canvas, sprites, textOverlays, background, backgroundGradient, scrollX, scrollY, } = options;
3
+ const width = canvas.width;
4
+ const height = canvas.height;
5
+ ctx.clearRect(0, 0, width, height);
6
+ if (backgroundGradient !== null) {
7
+ const gradient = ctx.createLinearGradient(0, 0, 0, height);
8
+ gradient.addColorStop(0, backgroundGradient.from);
9
+ gradient.addColorStop(1, backgroundGradient.to);
10
+ ctx.fillStyle = gradient;
11
+ ctx.fillRect(0, 0, width, height);
12
+ }
13
+ else if (background !== null) {
14
+ ctx.fillStyle = background;
15
+ ctx.fillRect(0, 0, width, height);
16
+ }
17
+ const sortedSprites = [...sprites].sort((a, b) => a.layer - b.layer);
18
+ for (const sprite of sortedSprites) {
19
+ if (!sprite.visible)
20
+ continue;
21
+ ctx.save();
22
+ ctx.globalAlpha = Math.max(0, Math.min(1, sprite.alpha));
23
+ const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - scrollX;
24
+ const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - scrollY;
25
+ ctx.translate(drawX, drawY);
26
+ if (sprite.rotation !== 0) {
27
+ ctx.rotate((sprite.rotation * Math.PI) / 180);
28
+ }
29
+ if (sprite.flipX || sprite.flipY) {
30
+ ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
31
+ }
32
+ ctx.font = `${sprite.size}px serif`;
33
+ ctx.textAlign = "center";
34
+ ctx.textBaseline = "middle";
35
+ ctx.fillText(sprite.sprite, 0, 0);
36
+ ctx.restore();
37
+ }
38
+ for (const entry of textOverlays) {
39
+ ctx.save();
40
+ ctx.font = `${entry.fontSize}px monospace`;
41
+ ctx.fillStyle = entry.color;
42
+ ctx.textAlign = entry.centered ? "center" : "left";
43
+ ctx.textBaseline = entry.centered ? "middle" : "top";
44
+ ctx.fillText(entry.text, entry.x, entry.y);
45
+ ctx.restore();
46
+ }
47
+ }
48
+ export function applyPageBackground(pageBackground, lastAppliedPageBackground) {
49
+ if (!document.body)
50
+ return lastAppliedPageBackground;
51
+ if (lastAppliedPageBackground === pageBackground)
52
+ return lastAppliedPageBackground;
53
+ if (pageBackground === null) {
54
+ document.body.style.removeProperty("background");
55
+ }
56
+ else {
57
+ document.body.style.background = pageBackground;
58
+ }
59
+ return pageBackground;
60
+ }
61
+ export function applyResponsiveCanvasLayout(canvas) {
62
+ if (!document.body)
63
+ return;
64
+ document.body.style.margin = "0";
65
+ document.body.style.minHeight = "100vh";
66
+ document.body.style.display = "grid";
67
+ document.body.style.placeItems = "center";
68
+ document.body.style.touchAction = "manipulation";
69
+ const viewportWidth = Math.max(1, window.innerWidth);
70
+ const viewportHeight = Math.max(1, window.innerHeight);
71
+ const scale = Math.min(viewportWidth / canvas.width, viewportHeight / canvas.height);
72
+ const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
73
+ canvas.style.width = `${Math.floor(canvas.width * safeScale)}px`;
74
+ canvas.style.height = `${Math.floor(canvas.height * safeScale)}px`;
75
+ }
package/dist/sprite.js ADDED
@@ -0,0 +1,149 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Sprite
3
+ // ---------------------------------------------------------------------------
4
+ /**
5
+ * A 2D game object rendered as an emoji on the canvas.
6
+ *
7
+ * Instantiate directly or extend to create custom sprite types.
8
+ * Register with the engine by passing the instance to {@link Game.add}.
9
+ *
10
+ * **Coordinate system:** center-based world space. `(x, y)` is the center of
11
+ * the sprite. Positive X = right, positive Y = down.
12
+ *
13
+ * **Lifecycle:** A sprite exists until {@link Game.destroySprite} is called or
14
+ * {@link Game.reset} is invoked. After destruction, do not read or write its fields.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // Direct instantiation
19
+ * const coin = new Sprite("🪙");
20
+ * coin.x = 300;
21
+ * coin.y = 200;
22
+ * coin.size = 32;
23
+ * game.add(coin);
24
+ * ```
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * // Subclassing for custom game objects
29
+ * class Player extends Sprite {
30
+ * health = 3;
31
+ *
32
+ * constructor(x: number, y: number) {
33
+ * super("🐢");
34
+ * this.x = x;
35
+ * this.y = y;
36
+ * this.size = 48;
37
+ * this.gravityScale = 1;
38
+ * }
39
+ * }
40
+ *
41
+ * const player = new Player(400, 300);
42
+ * game.add(player);
43
+ * ```
44
+ */
45
+ export class Sprite {
46
+ /**
47
+ * Creates a new Sprite with the given emoji and optional position.
48
+ * All other properties use their defaults and can be set after construction.
49
+ *
50
+ * @param sprite - The emoji character to render. Must be a single emoji.
51
+ * Image sprites are NOT supported — emoji only.
52
+ * @example "🔥", "⭐", "🐢", "💣", "👾"
53
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
54
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const enemy = new Sprite("👾", 200, 100);
59
+ * enemy.size = 40;
60
+ * game.add(enemy);
61
+ * ```
62
+ */
63
+ constructor(sprite, x = 0, y = 0) {
64
+ /**
65
+ * X position in world space (horizontal center of the sprite), in pixels.
66
+ * Positive X points right. Updated each frame by: `x += vx * dt`.
67
+ * May be set directly to teleport the sprite.
68
+ */
69
+ this.x = 0;
70
+ /**
71
+ * Y position in world space (vertical center of the sprite), in pixels.
72
+ * Positive Y points down. Updated each frame by: `y += vy * dt`.
73
+ * May be set directly to teleport the sprite.
74
+ */
75
+ this.y = 0;
76
+ /**
77
+ * Width and height of the sprite's bounding square, in pixels.
78
+ * Used for both canvas rendering (font size) and AABB collision detection.
79
+ */
80
+ this.size = 32;
81
+ /**
82
+ * Visual rotation of the sprite in degrees.
83
+ * `0` = upright. Positive values rotate clockwise.
84
+ * **Note:** rotation does NOT affect the AABB collision box — it remains axis-aligned.
85
+ */
86
+ this.rotation = 0;
87
+ /**
88
+ * Horizontal visual flip.
89
+ * When `true`, the sprite is mirrored left-right during rendering.
90
+ * This is visual-only and does NOT affect collision detection.
91
+ */
92
+ this.flipX = false;
93
+ /**
94
+ * Vertical visual flip.
95
+ * When `true`, the sprite is mirrored top-bottom during rendering.
96
+ * This is visual-only and does NOT affect collision detection.
97
+ */
98
+ this.flipY = false;
99
+ /**
100
+ * Camera scroll toggle for this sprite.
101
+ * When `false` (default), the sprite is rendered in world space and is affected
102
+ * by {@link Game.scrollX} and {@link Game.scrollY}.
103
+ * When `true`, the sprite is rendered in canvas/screen space and ignores camera
104
+ * scrolling. Useful for HUD-like sprites that should stay fixed on screen.
105
+ */
106
+ this.ignoreScroll = false;
107
+ /**
108
+ * Opacity of the sprite. Must be in range `[0, 1]`.
109
+ * `0` = fully transparent, `1` = fully opaque.
110
+ * Clamped to `[0, 1]` at render time.
111
+ */
112
+ this.alpha = 1;
113
+ /**
114
+ * Controls whether this sprite is drawn each frame.
115
+ * When `false`, the sprite still receives physics updates (gravity, velocity)
116
+ * but is not rendered. Use {@link Game.destroySprite} to fully remove a sprite.
117
+ */
118
+ this.visible = true;
119
+ /**
120
+ * Render layer order. Sprites with higher `layer` values are drawn on top.
121
+ * Sprites on the same layer render in creation order (first created = bottom).
122
+ * Changing this at runtime takes effect on the next frame.
123
+ */
124
+ this.layer = 0;
125
+ /**
126
+ * Horizontal velocity in pixels per second.
127
+ * Applied each frame: `x += vx * dt`.
128
+ * Gravity also modifies this: `vx += gravityX * gravityScale * dt`.
129
+ */
130
+ this.vx = 0;
131
+ /**
132
+ * Vertical velocity in pixels per second.
133
+ * Applied each frame: `y += vy * dt`.
134
+ * Gravity also modifies this: `vy += gravityY * gravityScale * dt`.
135
+ */
136
+ this.vy = 0;
137
+ /**
138
+ * Gravity multiplier for this sprite.
139
+ * Each frame: `vx += game.gravityX * gravityScale * dt`
140
+ * `vy += game.gravityY * gravityScale * dt`.
141
+ * `0` = immune to gravity (default). `1` = full gravity.
142
+ * Values > 1 amplify gravity; negative values invert it.
143
+ */
144
+ this.gravityScale = 0;
145
+ this.sprite = sprite;
146
+ this.x = x;
147
+ this.y = y;
148
+ }
149
+ }
package/dist/timers.js ADDED
@@ -0,0 +1,23 @@
1
+ export function updateTimers(timers, dtMs) {
2
+ const toRemove = new Set();
3
+ const toFire = [];
4
+ for (const timer of timers) {
5
+ timer.elapsed += dtMs;
6
+ if (timer.elapsed >= timer.delayMs) {
7
+ toFire.push(timer);
8
+ if (timer.repeat) {
9
+ timer.elapsed -= timer.delayMs;
10
+ }
11
+ else {
12
+ toRemove.add(timer.id);
13
+ }
14
+ }
15
+ }
16
+ const nextTimers = toRemove.size > 0
17
+ ? timers.filter((timer) => !toRemove.has(timer.id))
18
+ : timers;
19
+ for (const timer of toFire) {
20
+ timer.callback();
21
+ }
22
+ return nextTimers;
23
+ }