minimojs 1.0.0-alpha.2 → 1.0.0-alpha.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/dist/minimo.js CHANGED
@@ -25,10 +25,7 @@
25
25
  * @example
26
26
  * ```ts
27
27
  * // Direct instantiation
28
- * const coin = new Sprite("🪙");
29
- * coin.x = 300;
30
- * coin.y = 200;
31
- * coin.size = 32;
28
+ * const coin = new Sprite("🪙", 300, 200, 32);
32
29
  * game.add(coin);
33
30
  * ```
34
31
  *
@@ -39,10 +36,7 @@
39
36
  * health = 3;
40
37
  *
41
38
  * constructor(x: number, y: number) {
42
- * super("🐢");
43
- * this.x = x;
44
- * this.y = y;
45
- * this.size = 48;
39
+ * super("🐢", x, y, 48);
46
40
  * this.gravityScale = 1;
47
41
  * }
48
42
  * }
@@ -53,7 +47,25 @@
53
47
  */
54
48
  export class Sprite {
55
49
  /**
56
- * Creates a new Sprite with the given emoji and optional position.
50
+ * Base width and height of this sprite in pixels.
51
+ *
52
+ * This value is read-only after construction and defines the glyph-cache
53
+ * render size. The visible and collision size during gameplay is exposed by
54
+ * {@link Sprite.displaySize}.
55
+ */
56
+ get size() {
57
+ return this._size;
58
+ }
59
+ /**
60
+ * Effective rendered/collision size in pixels.
61
+ * Computed as `size * scale` with non-negative clamping.
62
+ */
63
+ get displaySize() {
64
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
65
+ return this._size * Math.max(0, safeScale);
66
+ }
67
+ /**
68
+ * Creates a new Sprite with the given emoji, optional position, and base size.
57
69
  * All other properties use their defaults and can be set after construction.
58
70
  *
59
71
  * @param sprite - The emoji character to render. Must be a single emoji.
@@ -61,15 +73,15 @@ export class Sprite {
61
73
  * @example "🔥", "⭐", "🐢", "💣", "👾"
62
74
  * @param x - Initial X position in world space (center), in pixels. Default: `0`.
63
75
  * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
76
+ * @param size - Base sprite size in pixels. Default: `32`.
64
77
  *
65
78
  * @example
66
79
  * ```ts
67
- * const enemy = new Sprite("👾", 200, 100);
68
- * enemy.size = 40;
80
+ * const enemy = new Sprite("👾", 200, 100, 40);
69
81
  * game.add(enemy);
70
82
  * ```
71
83
  */
72
- constructor(sprite, x = 0, y = 0) {
84
+ constructor(sprite, x = 0, y = 0, size = 32) {
73
85
  /**
74
86
  * X position in world space (horizontal center of the sprite), in pixels.
75
87
  * Positive X points right. Updated each frame by: `x += vx * dt`.
@@ -83,10 +95,34 @@ export class Sprite {
83
95
  */
84
96
  this.y = 0;
85
97
  /**
86
- * Width and height of the sprite's bounding square, in pixels.
87
- * Used for both canvas rendering (font size) and AABB collision detection.
98
+ * Visual scale multiplier applied to {@link Sprite.size}.
99
+ * Default: `1`.
100
+ *
101
+ * Set this at runtime to resize a sprite without changing its base cached
102
+ * glyph size.
103
+ */
104
+ this.scale = 1;
105
+ /**
106
+ * CSS text color used when rendering this sprite.
107
+ *
108
+ * This mainly affects monochrome glyphs and symbol-style sprites.
109
+ * Full-color emoji may ignore this and render with their native colors,
110
+ * depending on browser behavior.
111
+ */
112
+ this.color = "#000000";
113
+ /**
114
+ * Physics body type flag.
115
+ *
116
+ * - `false` (default): the sprite is dynamic and can be moved by velocity,
117
+ * gravity, and explicit collision resolution.
118
+ * - `true`: the sprite is static and is not moved by the engine's built-in
119
+ * velocity/gravity integration. Static sprites act as stable obstacles for
120
+ * simple platform collisions.
121
+ *
122
+ * Static sprites can still be repositioned manually by setting `x` and `y`
123
+ * directly in your own game code.
88
124
  */
89
- this.size = 32;
125
+ this.isStatic = false;
90
126
  /**
91
127
  * Visual rotation of the sprite in degrees.
92
128
  * `0` = upright. Positive values rotate clockwise.
@@ -154,6 +190,8 @@ export class Sprite {
154
190
  this.sprite = sprite;
155
191
  this.x = x;
156
192
  this.y = y;
193
+ const safeSize = Number.isFinite(size) ? size : 32;
194
+ this._size = Math.max(1, safeSize);
157
195
  }
158
196
  }
159
197
  // ---------------------------------------------------------------------------
@@ -168,17 +206,63 @@ export class Sprite {
168
206
  *
169
207
  * ---
170
208
  *
171
- * ## Quick Start
209
+ * ## ES Module Only
210
+ *
211
+ * MinimoJS is an **ES module only** package.
212
+ * You must import it with standard ESM syntax such as:
172
213
  *
173
214
  * ```ts
174
215
  * import { Game, Sprite } from "minimojs";
216
+ * ```
217
+ *
218
+ * In the browser, use it from a module script:
219
+ *
220
+ * ```html
221
+ * <script type="module">
222
+ * import { Game, Sprite } from "./dist/minimo.js";
223
+ * </script>
224
+ * ```
225
+ *
226
+ * Or from a CDN:
227
+ *
228
+ * ```ts
229
+ * import { Game, Sprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
230
+ * ```
231
+ *
232
+ * Do NOT use a classic `<script>` tag without `type="module"`.
233
+ * Do NOT expect a global `window.MinimoJS`.
234
+ * Do NOT use `require("minimojs")` (CommonJS is not supported).
235
+ *
236
+ * ---
237
+ *
238
+ * ## Required Font Setup
239
+ *
240
+ * `drawText()` renders with `"Press Start 2P", monospace`.
241
+ * You MUST load `Press Start 2P` yourself in your `index.html` before starting
242
+ * the game. MinimoJS does NOT download, inject, or manage web fonts for you.
243
+ * If the font is missing, the browser falls back to `monospace`.
244
+ *
245
+ * Example `index.html` `<head>` setup:
246
+ *
247
+ * ```html
248
+ * <link rel="preconnect" href="https://fonts.googleapis.com" />
249
+ * <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
250
+ * <link
251
+ * href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
252
+ * rel="stylesheet"
253
+ * />
254
+ * ```
255
+ *
256
+ * ---
257
+ *
258
+ * ## Quick Start
259
+ *
260
+ * ```ts
261
+ * import { Game, Sprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
175
262
  *
176
263
  * const game = new Game(800, 600);
177
264
  *
178
- * const player = new Sprite("🐢");
179
- * player.x = 400;
180
- * player.y = 500;
181
- * player.size = 48;
265
+ * const player = new Sprite("🐢", 400, 500, 48);
182
266
  * game.add(player);
183
267
  *
184
268
  * game.onUpdate = (dt) => {
@@ -205,7 +289,8 @@ export class Sprite {
205
289
  * ## Flat API
206
290
  *
207
291
  * ALL engine functionality is on the `game` object.
208
- * Do not look for sub-objects like `game.physics`, `game.input`, etc. They do not exist.
292
+ * Do not look for nested subsystem objects like `game.input.keyboard`,
293
+ * `game.physics.world`, etc. They do not exist in MinimoJS v1.
209
294
  *
210
295
  * ---
211
296
  *
@@ -311,8 +396,7 @@ export class Sprite {
311
396
  * ```ts
312
397
  * game.onCreate = () => {
313
398
  * // scene init: add sprites, setup timers
314
- * const skull = new Sprite("💀");
315
- * skull.x = 400; skull.y = 300; skull.size = 96;
399
+ * const skull = new Sprite("💀", 400, 300, 96);
316
400
  * game.add(skull);
317
401
  * };
318
402
  *
@@ -349,7 +433,7 @@ export class Sprite {
349
433
  * - Text input / HTML form elements
350
434
  * - Parallax layers
351
435
  * - Multiple cameras
352
- * - Collision resolution (only detection is supported)
436
+ * - Full physics engine (only basic explicit AABB collision helpers are provided)
353
437
  * - Image sprites (PNG, SVG, canvas, etc.)
354
438
  * - `setTimeout` or `setInterval`
355
439
  */
@@ -372,6 +456,7 @@ export class Game {
372
456
  * @example
373
457
  * ```ts
374
458
  * const game = new Game(800, 600);
459
+ * game.physics = true;
375
460
  * ```
376
461
  */
377
462
  constructor(width = 800, height = 600) {
@@ -428,6 +513,20 @@ export class Game {
428
513
  * ```
429
514
  */
430
515
  this.gravityY = 0;
516
+ /**
517
+ * Enables the basic collision-resolution helpers (`collide` / `collideAny`).
518
+ *
519
+ * When `false` (default), overlap detection still works, but collision
520
+ * resolution helpers are unavailable.
521
+ *
522
+ * Set this to `true` for simple platformer-style collision handling.
523
+ *
524
+ * @example
525
+ * ```ts
526
+ * game.physics = true;
527
+ * ```
528
+ */
529
+ this.physics = false;
431
530
  /**
432
531
  * Horizontal scroll offset of the world camera, in pixels.
433
532
  * The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
@@ -582,8 +681,9 @@ export class Game {
582
681
  // -------------------------------------------------------------------------
583
682
  /**
584
683
  * Registers a {@link Sprite} (or subclass instance) with the engine.
585
- * After calling `add`, the sprite is rendered and receives physics updates
586
- * every frame until {@link Game.destroySprite} or {@link Game.reset} is called.
684
+ * After calling `add`, the sprite is rendered and, if dynamic
685
+ * (`isStatic = false`), receives built-in velocity/gravity integration every
686
+ * frame until {@link Game.destroySprite} or {@link Game.reset} is called.
587
687
  *
588
688
  * **Ownership:** The game instance takes ownership of the sprite from this
589
689
  * point forward. It will appear in {@link Game.getSprites} on the same frame.
@@ -598,16 +698,14 @@ export class Game {
598
698
  * @example
599
699
  * ```ts
600
700
  * // Plain sprite
601
- * const coin = new Sprite("🪙");
602
- * coin.x = 300; coin.y = 200; coin.size = 32;
701
+ * const coin = new Sprite("🪙", 300, 200, 32);
603
702
  * game.add(coin);
604
703
  *
605
704
  * // Custom subclass
606
705
  * class Enemy extends Sprite {
607
706
  * speed = 150;
608
707
  * constructor(x: number, y: number) {
609
- * super("👾");
610
- * this.x = x; this.y = y; this.size = 40;
708
+ * super("👾", x, y, 40);
611
709
  * }
612
710
  * }
613
711
  * const enemy = game.add(new Enemy(600, 100));
@@ -666,10 +764,10 @@ export class Game {
666
764
  * collision detection.
667
765
  *
668
766
  * Each sprite's bounding box is a square centered at `(x, y)` with side
669
- * length `size`. Rotation is **ignored** — the box is always axis-aligned.
767
+ * length `displaySize`. Rotation is **ignored** — the box is always axis-aligned.
670
768
  *
671
769
  * **No collision resolution is performed.** This method is detection-only.
672
- * If you need bounce or push-apart behavior, implement it in `onUpdate`.
770
+ * Use {@link Game.collide} for the engine's basic explicit push-out helper.
673
771
  *
674
772
  * @param a - First sprite.
675
773
  * @param b - Second sprite.
@@ -683,8 +781,8 @@ export class Game {
683
781
  * ```
684
782
  */
685
783
  overlap(a, b) {
686
- const halfA = a.size / 2;
687
- const halfB = b.size / 2;
784
+ const halfA = a.displaySize / 2;
785
+ const halfB = b.displaySize / 2;
688
786
  return (Math.abs(a.x - b.x) < halfA + halfB &&
689
787
  Math.abs(a.y - b.y) < halfA + halfB);
690
788
  }
@@ -720,6 +818,95 @@ export class Game {
720
818
  }
721
819
  return null;
722
820
  }
821
+ /**
822
+ * Tests and resolves a basic AABB collision between two sprites.
823
+ *
824
+ * This helper is intended for simple platformer-style collision response.
825
+ * It uses the same axis-aligned square bounds as {@link Game.overlap}, but
826
+ * additionally pushes one sprite out of the collision and zeroes velocity on
827
+ * the resolved axis.
828
+ *
829
+ * **Resolution rules:**
830
+ * - If the first sprite is dynamic (`isStatic = false`), the first sprite is resolved.
831
+ * - Otherwise, if the second sprite is dynamic, the second sprite is resolved.
832
+ * - If both sprites are static, collision is reported but no movement occurs.
833
+ *
834
+ * The returned flags are always reported relative to the **first** sprite.
835
+ *
836
+ * @param a - First sprite. Usually the moving actor (for example, the player).
837
+ * @param b - Second sprite. Usually a static obstacle or platform.
838
+ * @returns Collision details, or `null` if the sprites do not overlap.
839
+ * @throws Error if the game's physics helpers are not enabled.
840
+ *
841
+ * @example
842
+ * ```ts
843
+ * const hit = game.collide(player, floorTile);
844
+ * if (hit?.grounded) {
845
+ * canJump = true;
846
+ * }
847
+ * ```
848
+ */
849
+ collide(a, b) {
850
+ this._assertPhysicsEnabled();
851
+ if (a === b)
852
+ return null;
853
+ const collision = this._getCollisionResolution(a, b);
854
+ if (!collision)
855
+ return null;
856
+ if (!a.isStatic) {
857
+ a.x += collision.separationX;
858
+ a.y += collision.separationY;
859
+ if (collision.axis === "x") {
860
+ a.vx = 0;
861
+ }
862
+ else {
863
+ a.vy = 0;
864
+ }
865
+ }
866
+ else if (!b.isStatic) {
867
+ b.x -= collision.separationX;
868
+ b.y -= collision.separationY;
869
+ if (collision.axis === "x") {
870
+ b.vx = 0;
871
+ }
872
+ else {
873
+ b.vy = 0;
874
+ }
875
+ }
876
+ return collision.info;
877
+ }
878
+ /**
879
+ * Tests and resolves the first collision found between two groups of sprites.
880
+ *
881
+ * This behaves like {@link Game.overlapAny}, but uses the explicit collision
882
+ * rules from {@link Game.collide} and returns the collision details as the
883
+ * third tuple item.
884
+ *
885
+ * @param listA - First group of sprites.
886
+ * @param listB - Second group of sprites.
887
+ * @returns A `[Sprite, Sprite, CollisionInfo]` tuple for the first collision found,
888
+ * or `null` if no pair overlaps.
889
+ * @throws Error if the game's physics helpers are not enabled.
890
+ *
891
+ * @example
892
+ * ```ts
893
+ * const hit = game.collideAny([player], floorTiles);
894
+ * if (hit?.[2].grounded) {
895
+ * canJump = true;
896
+ * }
897
+ * ```
898
+ */
899
+ collideAny(listA, listB) {
900
+ this._assertPhysicsEnabled();
901
+ for (const a of listA) {
902
+ for (const b of listB) {
903
+ const info = this.collide(a, b);
904
+ if (info)
905
+ return [a, b, info];
906
+ }
907
+ }
908
+ return null;
909
+ }
723
910
  // -------------------------------------------------------------------------
724
911
  // Input — Keyboard
725
912
  // -------------------------------------------------------------------------
@@ -803,14 +990,14 @@ export class Game {
803
990
  * Works with both mouse input and multiple simultaneous touches.
804
991
  *
805
992
  * 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
993
+ * is `sprite.displaySize * radiusScale`. World-space sprites are tested against the
807
994
  * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
808
995
  * screen space.
809
996
  *
810
997
  * Use this for continuous virtual buttons such as touch movement controls.
811
998
  *
812
999
  * @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
813
- * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
1000
+ * @param radiusScale - Multiplier applied to `sprite.displaySize` to define the hit radius.
814
1001
  * Default: `0.5`.
815
1002
  * @returns `true` if any currently held pointer overlaps the sprite hit area.
816
1003
  *
@@ -840,14 +1027,14 @@ export class Game {
840
1027
  * sprite. Works with both mouse input and multiple simultaneous touches.
841
1028
  *
842
1029
  * 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
1030
+ * is `sprite.displaySize * radiusScale`. World-space sprites are tested against the
844
1031
  * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
845
1032
  * screen space.
846
1033
  *
847
1034
  * Use this for one-shot virtual buttons such as menu taps.
848
1035
  *
849
1036
  * @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
850
- * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
1037
+ * @param radiusScale - Multiplier applied to `sprite.displaySize` to define the hit radius.
851
1038
  * Default: `0.5`.
852
1039
  * @returns `true` if any pointer began pressing this frame over the sprite hit area.
853
1040
  *
@@ -890,7 +1077,7 @@ export class Game {
890
1077
  * const pointers = game.getPointers();
891
1078
  * if (pointers.length > 0) {
892
1079
  * const first = pointers[0];
893
- * game.text(`Pointer: ${first.x}, ${first.y}`, 10, 10);
1080
+ * game.drawText(`Pointer: ${first.x}, ${first.y}`, 10, 10, 14);
894
1081
  * }
895
1082
  * ```
896
1083
  */
@@ -902,7 +1089,7 @@ export class Game {
902
1089
  * the target sprite.
903
1090
  *
904
1091
  * 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
1092
+ * is `sprite.displaySize * radiusScale`. World-space sprites are tested against the
906
1093
  * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
907
1094
  * screen space.
908
1095
  *
@@ -910,7 +1097,7 @@ export class Game {
910
1097
  * pointer position over a virtual joystick or draggable control.
911
1098
  *
912
1099
  * @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.
1100
+ * @param radiusScale - Multiplier applied to `sprite.displaySize` to define the hit radius.
914
1101
  * Default: `0.5`.
915
1102
  * @returns A read-only array of active pointer snapshots currently over the sprite.
916
1103
  *
@@ -1142,8 +1329,11 @@ export class Game {
1142
1329
  * The text overlay list is cleared after each render. Call this inside `onUpdate`.
1143
1330
  *
1144
1331
  * **Layer:** Text is always drawn on top of all sprites.
1145
- * **Font:** Text always uses a fixed `monospace` font family.
1146
- * Font family cannot be customized in MinimoJS v1.
1332
+ * **Font:** Text uses `"Press Start 2P", monospace`.
1333
+ * You MUST load `Press Start 2P` yourself in `index.html` (for example via
1334
+ * Google Fonts) before calling {@link Game.start}. MinimoJS does NOT load
1335
+ * external fonts for you. If the font is unavailable, the browser falls back
1336
+ * to `monospace`. Font family cannot be customized in MinimoJS v1.
1147
1337
  *
1148
1338
  * @param text - The string to render. Supports emoji and Unicode.
1149
1339
  * @param x - X position in **screen space** (pixels from canvas left edge).
@@ -1169,6 +1359,24 @@ export class Game {
1169
1359
  // -------------------------------------------------------------------------
1170
1360
  // Misc
1171
1361
  // -------------------------------------------------------------------------
1362
+ /**
1363
+ * Clears the internal sprite glyph cache.
1364
+ *
1365
+ * MinimoJS prerenders sprite glyphs into offscreen canvases for more stable
1366
+ * emoji rendering and better performance. In long-running sessions, you can
1367
+ * call this to release cached glyph variants and force them to be rebuilt on
1368
+ * the next render.
1369
+ *
1370
+ * This does not change any sprite state. It only clears cached render data.
1371
+ *
1372
+ * @example
1373
+ * ```ts
1374
+ * game.clearSpriteCache();
1375
+ * ```
1376
+ */
1377
+ clearSpriteCache() {
1378
+ this._spriteGlyphCache.clear();
1379
+ }
1172
1380
  /**
1173
1381
  * Returns a pseudo-random floating-point number in the range `[0, 1)`.
1174
1382
  * Delegates to `Math.random()`.
@@ -1219,8 +1427,7 @@ export class Game {
1219
1427
  *
1220
1428
  * game.onCreate = () => {
1221
1429
  * // Scene init
1222
- * const skull = new Sprite("💀");
1223
- * skull.x = 400; skull.y = 300; skull.size = 96;
1430
+ * const skull = new Sprite("💀", 400, 300, 96);
1224
1431
  * game.add(skull);
1225
1432
  * game.addTimer(3000, false, () => game.reset()); // auto-restart
1226
1433
  * };
@@ -1387,7 +1594,7 @@ export class Game {
1387
1594
  /** @internal */
1388
1595
  _isScreenPointOverSprite(x, y, sprite, radiusScale) {
1389
1596
  const safeScale = Math.max(0, radiusScale);
1390
- const radius = sprite.size * safeScale;
1597
+ const radius = sprite.displaySize * safeScale;
1391
1598
  const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
1392
1599
  const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
1393
1600
  const dx = x - drawX;
@@ -1448,6 +1655,78 @@ export class Game {
1448
1655
  this._pointerDown = this._mouseDown;
1449
1656
  this._pointerPressed = this._mousePressed;
1450
1657
  }
1658
+ /** @internal */
1659
+ _assertPhysicsEnabled() {
1660
+ if (!this.physics) {
1661
+ throw new Error("MinimoJS: collide() and collideAny() require game.physics = true.");
1662
+ }
1663
+ }
1664
+ /** @internal */
1665
+ _getCollisionResolution(a, b) {
1666
+ const halfA = a.displaySize / 2;
1667
+ const halfB = b.displaySize / 2;
1668
+ const dx = b.x - a.x;
1669
+ const dy = b.y - a.y;
1670
+ const overlapX = halfA + halfB - Math.abs(dx);
1671
+ const overlapY = halfA + halfB - Math.abs(dy);
1672
+ if (overlapX <= 0 || overlapY <= 0) {
1673
+ return null;
1674
+ }
1675
+ let axis;
1676
+ if (overlapX < overlapY) {
1677
+ axis = "x";
1678
+ }
1679
+ else if (overlapY < overlapX) {
1680
+ axis = "y";
1681
+ }
1682
+ else {
1683
+ const relVX = Math.abs(a.vx - b.vx);
1684
+ const relVY = Math.abs(a.vy - b.vy);
1685
+ axis = relVX > relVY ? "x" : "y";
1686
+ }
1687
+ const info = {
1688
+ left: false,
1689
+ right: false,
1690
+ top: false,
1691
+ bottom: false,
1692
+ grounded: false,
1693
+ };
1694
+ if (axis === "x") {
1695
+ if (dx >= 0) {
1696
+ info.right = true;
1697
+ return {
1698
+ info,
1699
+ axis,
1700
+ separationX: -overlapX,
1701
+ separationY: 0,
1702
+ };
1703
+ }
1704
+ info.left = true;
1705
+ return {
1706
+ info,
1707
+ axis,
1708
+ separationX: overlapX,
1709
+ separationY: 0,
1710
+ };
1711
+ }
1712
+ if (dy >= 0) {
1713
+ info.bottom = true;
1714
+ info.grounded = true;
1715
+ return {
1716
+ info,
1717
+ axis,
1718
+ separationX: 0,
1719
+ separationY: -overlapY,
1720
+ };
1721
+ }
1722
+ info.top = true;
1723
+ return {
1724
+ info,
1725
+ axis,
1726
+ separationX: 0,
1727
+ separationY: overlapY,
1728
+ };
1729
+ }
1451
1730
  // -------------------------------------------------------------------------
1452
1731
  // Private — rAF loop
1453
1732
  // -------------------------------------------------------------------------
@@ -1521,6 +1800,9 @@ export class Game {
1521
1800
  /** @internal */
1522
1801
  _updatePhysics(dt) {
1523
1802
  for (const sprite of this._sprites) {
1803
+ if (sprite.isStatic) {
1804
+ continue;
1805
+ }
1524
1806
  if (sprite.gravityScale !== 0) {
1525
1807
  sprite.vx += this.gravityX * sprite.gravityScale * dt;
1526
1808
  sprite.vy += this.gravityY * sprite.gravityScale * dt;
@@ -1535,7 +1817,8 @@ export class Game {
1535
1817
  /** @internal */
1536
1818
  _getSpriteGlyphCanvas(sprite) {
1537
1819
  const size = Math.max(1, Math.round(sprite.size));
1538
- const cacheKey = `${sprite.sprite}::${size}`;
1820
+ const color = sprite.color;
1821
+ const cacheKey = `${sprite.sprite}::${size}::${color}`;
1539
1822
  const cached = this._spriteGlyphCache.get(cacheKey);
1540
1823
  if (cached)
1541
1824
  return cached;
@@ -1552,7 +1835,7 @@ export class Game {
1552
1835
  glyphCtx.shadowBlur = 0;
1553
1836
  glyphCtx.shadowOffsetX = 0;
1554
1837
  glyphCtx.shadowOffsetY = 0;
1555
- glyphCtx.fillStyle = "#ffffff";
1838
+ glyphCtx.fillStyle = color;
1556
1839
  glyphCtx.font = `${size}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
1557
1840
  glyphCtx.textAlign = "center";
1558
1841
  glyphCtx.textBaseline = "middle";
@@ -1590,8 +1873,11 @@ export class Game {
1590
1873
  if (sprite.rotation !== 0) {
1591
1874
  ctx.rotate((sprite.rotation * Math.PI) / 180);
1592
1875
  }
1593
- if (sprite.flipX || sprite.flipY) {
1594
- ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
1876
+ const safeRenderScale = Number.isFinite(sprite.scale)
1877
+ ? Math.max(0, sprite.scale)
1878
+ : 1;
1879
+ if (sprite.flipX || sprite.flipY || safeRenderScale !== 1) {
1880
+ ctx.scale((sprite.flipX ? -1 : 1) * safeRenderScale, (sprite.flipY ? -1 : 1) * safeRenderScale);
1595
1881
  }
1596
1882
  ctx.shadowColor = "transparent";
1597
1883
  ctx.shadowBlur = 0;
@@ -1603,7 +1889,7 @@ export class Game {
1603
1889
  }
1604
1890
  for (const entry of this._textOverlays) {
1605
1891
  ctx.save();
1606
- ctx.font = `${entry.fontSize}px monospace`;
1892
+ ctx.font = `${entry.fontSize}px "Press Start 2P", monospace`;
1607
1893
  ctx.fillStyle = entry.color;
1608
1894
  ctx.textAlign = entry.centered ? "center" : "left";
1609
1895
  ctx.textBaseline = entry.centered ? "middle" : "top";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Emoji-only sprites, rAF loop, TypeScript-first, LLM-friendly.",
5
5
  "type": "module",
6
6
  "main": "dist/minimo.js",