minimojs 1.0.0-alpha.10 → 1.0.0-alpha.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/README.md CHANGED
@@ -21,10 +21,11 @@ This README is intentionally high-level. It explains what the project is and how
21
21
  - Runtime update delta (`dt`) is seconds
22
22
  - Coordinate system is center-based world space
23
23
  - Positive Y goes downward
24
+ - For new mobile-first games, prefer a portrait canvas of `720x1280`
24
25
 
25
26
  ## What It Intentionally Avoids
26
27
 
27
- - Scene manager/ECS architecture
28
+ - Heavy scene-manager/ECS architecture
28
29
  - Heavy physics engine features
29
30
  - Image spritesheets and asset pipelines
30
31
  - Nested subsystem APIs
@@ -39,26 +40,32 @@ npm install minimojs
39
40
  ## Quick Start
40
41
 
41
42
  ```ts
42
- import { Game, Sprite } from "minimojs";
43
+ import { Game, Sprite, type IScene } from "minimojs";
43
44
 
44
- const game = new Game(800, 600);
45
+ const game = new Game(720, 1280);
45
46
  game.gravityY = 980;
46
47
 
47
- const player = game.add(new Sprite("🐢", 400, 300, 48));
48
- player.x = 400;
49
- player.y = 300;
50
- player.gravityScale = 1;
48
+ class DemoScene implements IScene {
49
+ private player: Sprite | null = null;
51
50
 
52
- game.onUpdate = (dt) => {
53
- if (game.isKeyDown("ArrowLeft")) player.vx = -200;
54
- else if (game.isKeyDown("ArrowRight")) player.vx = 200;
55
- else player.vx = 0;
51
+ onCreate() {
52
+ this.player = game.add(new Sprite("🐢", 360, 640, 48));
53
+ this.player.gravityScale = 1;
54
+ }
56
55
 
57
- if (game.isKeyPressed(" ")) player.vy = -600;
58
- game.drawText("MinimoJS", 10, 10, 16);
59
- };
56
+ onUpdate(dt: number) {
57
+ if (!this.player) return;
60
58
 
61
- game.start();
59
+ if (game.isKeyDown("ArrowLeft")) this.player.vx = -200;
60
+ else if (game.isKeyDown("ArrowRight")) this.player.vx = 200;
61
+ else this.player.vx = 0;
62
+
63
+ if (game.isKeyPressed(" ")) this.player.vy = -600;
64
+ game.drawText("MinimoJS", 10, 10, 16);
65
+ }
66
+ }
67
+
68
+ game.start(new DemoScene());
62
69
  ```
63
70
 
64
71
  Note: `drawText()` uses `"Press Start 2P", monospace`. Load the font in your HTML if you want pixel-font styling.
@@ -71,6 +78,8 @@ Note: `drawText()` uses `"Press Start 2P", monospace`. Load the font in your HTM
71
78
  - `examples/super-minimo-bros/`
72
79
  - `examples/scale-shift/`
73
80
  - `examples/background-desert/`
81
+ - `examples/scene-lab/`
82
+ - `examples/image-sprite-sad-plush/`
74
83
  - `examples/animations/`
75
84
 
76
85
  Run locally from the `minimojs` directory:
@@ -159,7 +159,9 @@ export class RenderSystem {
159
159
  return cached;
160
160
  const surface = this.isTextSprite(sprite)
161
161
  ? this.createTextSurface(sprite)
162
- : this.createEmojiSurface(this.asEmojiSprite(sprite));
162
+ : this.isImageSprite(sprite)
163
+ ? this.createImageSurface(sprite)
164
+ : this.createEmojiSurface(this.asEmojiSprite(sprite));
163
165
  this._surfaceCache.set(cacheKey, surface);
164
166
  return surface;
165
167
  }
@@ -254,9 +256,28 @@ export class RenderSystem {
254
256
  }
255
257
  return canvas;
256
258
  }
259
+ createImageSurface(sprite) {
260
+ const image = sprite.game?.getImage(sprite.imageKey);
261
+ if (!image) {
262
+ throw new Error(`MinimoJS: Image '${sprite.imageKey}' is not loaded.`);
263
+ }
264
+ const canvas = document.createElement("canvas");
265
+ canvas.width = Math.max(1, image.naturalWidth || image.width);
266
+ canvas.height = Math.max(1, image.naturalHeight || image.height);
267
+ const ctx = canvas.getContext("2d");
268
+ if (!ctx) {
269
+ throw new Error("MinimoJS: Could not acquire an image rendering context.");
270
+ }
271
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
272
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
273
+ return canvas;
274
+ }
257
275
  isTextSprite(sprite) {
258
276
  return "text" in sprite && "fontFamily" in sprite && "fontSize" in sprite;
259
277
  }
278
+ isImageSprite(sprite) {
279
+ return "imageKey" in sprite && "setTexture" in sprite;
280
+ }
260
281
  asEmojiSprite(sprite) {
261
282
  if ("sprite" in sprite && "size" in sprite) {
262
283
  return sprite;
@@ -1,10 +1,12 @@
1
1
  /** @internal */
2
2
  export class SpriteSystem {
3
- constructor(animationSystem) {
3
+ constructor(animationSystem, game) {
4
4
  this.animationSystem = animationSystem;
5
+ this.game = game;
5
6
  this._sprites = [];
6
7
  }
7
8
  add(sprite) {
9
+ sprite._setGame(this.game);
8
10
  this._sprites.push(sprite);
9
11
  return sprite;
10
12
  }
package/dist/minimo.d.ts CHANGED
@@ -56,6 +56,25 @@ export interface TrailOptions {
56
56
  * - `"cover"`: preserves aspect ratio and fully covers the destination, cropping if needed.
57
57
  */
58
58
  export type BackgroundFit = "none" | "stretch" | "contain" | "cover";
59
+ /**
60
+ * Minimal scene contract understood by {@link Game.start} and {@link Game.reset}.
61
+ *
62
+ * Scene methods are invoked directly on the scene instance, so class-based
63
+ * scenes keep their expected `this` value.
64
+ */
65
+ export interface IScene {
66
+ /**
67
+ * Called when the scene is created, before the first frame and again after
68
+ * each {@link Game.reset} that targets this scene.
69
+ */
70
+ onCreate?(): void;
71
+ /**
72
+ * Called once per frame after timers, animation, and physics updates.
73
+ *
74
+ * @param dt - Delta time in seconds since the previous frame.
75
+ */
76
+ onUpdate?(dt: number): void;
77
+ }
59
78
  /**
60
79
  * Base class for all renderable MinimoJS actors.
61
80
  *
@@ -64,6 +83,11 @@ export type BackgroundFit = "none" | "stretch" | "contain" | "cover";
64
83
  * engine.
65
84
  */
66
85
  export declare abstract class BaseSprite {
86
+ protected constructor(game?: Game | null);
87
+ /**
88
+ * Game instance associated with this sprite, if any.
89
+ */
90
+ get game(): Game | null;
67
91
  /**
68
92
  * X position in world space (horizontal center of the sprite), in pixels.
69
93
  * Positive X points right. Updated each frame by: `x += vx * dt`.
@@ -233,7 +257,7 @@ export declare class Sprite extends BaseSprite {
233
257
  * All other properties use their defaults and can be set after construction.
234
258
  *
235
259
  * @param sprite - The emoji character to render. Must be a single emoji.
236
- * Image sprites are NOT supported emoji only.
260
+ * Use {@link ImageSprite} for preloaded bitmap textures.
237
261
  * @example "🔥", "⭐", "🐢", "💣", "👾"
238
262
  * @param x - Initial X position in world space (center), in pixels. Default: `0`.
239
263
  * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
@@ -248,9 +272,47 @@ export declare class Sprite extends BaseSprite {
248
272
  constructor(sprite: string, x?: number, y?: number, size?: number);
249
273
  getRenderCacheKey(): string;
250
274
  }
275
+ /**
276
+ * A renderable sprite backed by a preloaded image asset.
277
+ *
278
+ * Width and height are always resolved from the current texture. To resize the
279
+ * sprite visually, use {@link BaseSprite.scale}.
280
+ */
281
+ export declare class ImageSprite extends BaseSprite {
282
+ private _imageKey;
283
+ get imageKey(): string;
284
+ get width(): number;
285
+ get height(): number;
286
+ /**
287
+ * Creates a new image-backed sprite.
288
+ *
289
+ * @param game - Game instance used to resolve the texture key.
290
+ * @param imageKey - Preloaded image key previously registered with {@link Game.loadImage}.
291
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
292
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
293
+ */
294
+ constructor(game: Game, imageKey: string, x?: number, y?: number);
295
+ /**
296
+ * Replaces the current texture with another preloaded image.
297
+ *
298
+ * @param imageKey - New preloaded image key to render.
299
+ */
300
+ setTexture(imageKey: string): void;
301
+ getRenderCacheKey(): string;
302
+ private getResolvedImage;
303
+ private assertTextureAvailable;
304
+ }
251
305
  /**
252
306
  * A renderable text actor that participates in the same animation/effects
253
307
  * pipeline as regular sprites.
308
+ *
309
+ * Use `TextSprite` when the content is primarily text, or when you need text
310
+ * layout features such as wrapping, fixed button sizes, background/border, or
311
+ * text stroke.
312
+ *
313
+ * For controls that are only a single emoji, prefer {@link Sprite}. Emoji-only
314
+ * buttons usually behave better as sprites because they do not need text
315
+ * padding/layout and their bounds match the rendered emoji more directly.
254
316
  */
255
317
  export declare class TextSprite extends BaseSprite {
256
318
  private static _measurementCanvas;
@@ -491,6 +553,11 @@ export interface CollisionInfo {
491
553
  * If you use additional families, register them with {@link Game.requireFont}
492
554
  * before calling {@link Game.start}.
493
555
  *
556
+ * Prefer {@link TextSprite} for persistent UI text, interactive labels,
557
+ * buttons, fixed-size text boxes, and styled text elements. Keep
558
+ * `drawText()` for simple screen-space overlays such as HUD counters, debug
559
+ * text, or other text that is redrawn every frame.
560
+ *
494
561
  * You MUST still declare the fonts yourself in your `index.html`. MinimoJS
495
562
  * does NOT download, inject, or manage web fonts for you. If a font is missing,
496
563
  * the browser falls back to `monospace`.
@@ -509,7 +576,7 @@ export interface CollisionInfo {
509
576
  * Example custom font registration:
510
577
  *
511
578
  * ```ts
512
- * const game = new Game(800, 600);
579
+ * const game = new Game(720, 1280);
513
580
  * game.requireFont('"Bangers"', { weight: "400" });
514
581
  * game.start();
515
582
  * ```
@@ -521,7 +588,7 @@ export interface CollisionInfo {
521
588
  * ```ts
522
589
  * import { Game, Sprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
523
590
  *
524
- * const game = new Game(800, 600);
591
+ * const game = new Game(720, 1280);
525
592
  *
526
593
  * const player = new Sprite("🐢", 400, 500, 48);
527
594
  * game.add(player);
@@ -636,10 +703,10 @@ export interface CollisionInfo {
636
703
  *
637
704
  * ---
638
705
  *
639
- * ## Scene Initialization with `onCreate`
706
+ * ## Scene Initialization with `IScene`
640
707
  *
641
- * MinimoJS has NO scene system. Use {@link Game.onCreate} to build a scene.
642
- * The engine calls `onCreate`:
708
+ * MinimoJS supports simple scene objects via {@link IScene}. The engine calls
709
+ * a scene's `onCreate()`:
643
710
  * - Once before the first frame (when {@link Game.start} is called).
644
711
  * - Again after each {@link Game.reset}.
645
712
  *
@@ -651,25 +718,30 @@ export interface CollisionInfo {
651
718
  * - Scroll position (scrollX and scrollY reset to 0)
652
719
  * - Per-frame input state (pressed keys/pointer)
653
720
  *
654
- * After clearing, `reset()` calls `onCreate()` so your callback can rebuild the
655
- * new scene immediately. Simply re-add sprites and re-register timers.
721
+ * After clearing, `reset()` calls the active scene's `onCreate()` so it can
722
+ * rebuild immediately. Simply re-add sprites and re-register timers.
656
723
  *
657
724
  * ```ts
658
- * game.onCreate = () => {
659
- * // scene init: add sprites, setup timers
660
- * const skull = new Sprite("💀", 400, 300, 96);
661
- * game.add(skull);
662
- * };
663
- *
664
- * game.onUpdate = (dt) => {
665
- * // normal per-frame update
666
- * };
667
- *
668
- * game.start(); // calls onCreate() once before first frame
725
+ * class SkullScene implements IScene {
726
+ * onCreate() {
727
+ * const skull = new Sprite("💀", 400, 300, 96);
728
+ * game.add(skull);
729
+ * }
730
+ *
731
+ * onUpdate(dt: number) {
732
+ * // normal per-frame update
733
+ * }
734
+ * }
735
+ *
736
+ * const scene = new SkullScene();
737
+ * game.start(scene); // calls onCreate() once before first frame
669
738
  * // later:
670
739
  * game.reset(); // clears + calls onCreate() again
671
740
  * ```
672
741
  *
742
+ * Legacy `game.onCreate` / `game.onUpdate` callbacks still work when no active
743
+ * {@link IScene} is set.
744
+ *
673
745
  * ---
674
746
  *
675
747
  * ## Sprite Lifecycle Ownership
@@ -686,7 +758,7 @@ export interface CollisionInfo {
686
758
  * ## Forbidden Features
687
759
  *
688
760
  * The following do NOT exist in MinimoJS v1. Do NOT attempt to use them:
689
- * - Scene system or scene manager
761
+ * - Full scene manager architecture (scene stacks, transitions, loaders, etc.)
690
762
  * - Entity Component System (ECS)
691
763
  * - Physics engine (no Box2D, Matter.js, etc.)
692
764
  * - Camera zoom or scale
@@ -695,7 +767,6 @@ export interface CollisionInfo {
695
767
  * - Parallax layers
696
768
  * - Multiple cameras
697
769
  * - Full physics engine (only basic explicit AABB collision helpers are provided)
698
- * - Image sprites (PNG, SVG, canvas, etc.) as gameplay actors
699
770
  * - `setTimeout` or `setInterval`
700
771
  */
701
772
  export declare class Game {
@@ -819,10 +890,10 @@ export declare class Game {
819
890
  */
820
891
  onPreload: (() => void) | null;
821
892
  /**
822
- * Scene creation callback.
893
+ * Legacy scene creation callback.
823
894
  *
824
895
  * Called once before the first frame on {@link Game.start}, and again after
825
- * each {@link Game.reset}. Use this to create sprites and timers for a scene.
896
+ * each {@link Game.reset} when no active {@link IScene} is set.
826
897
  *
827
898
  * @example
828
899
  * ```ts
@@ -837,7 +908,9 @@ export declare class Game {
837
908
  get onCreate(): (() => void) | null;
838
909
  set onCreate(callback: (() => void) | null);
839
910
  /**
840
- * Callback invoked once per frame after physics and timer updates.
911
+ * Legacy per-frame callback invoked after physics and timer updates.
912
+ *
913
+ * This is used only when no active {@link IScene} is set.
841
914
  *
842
915
  * @param dt - Delta time in **seconds** since the last frame.
843
916
  * Use this for velocity-based movement: `sprite.x += speed * dt`.
@@ -852,21 +925,26 @@ export declare class Game {
852
925
  * ```
853
926
  */
854
927
  onUpdate: ((dt: number) => void) | null;
928
+ /**
929
+ * Currently active scene object, if any.
930
+ */
931
+ get currentScene(): IScene | null;
855
932
  /**
856
933
  * Creates a new MinimoJS game instance.
857
934
  *
858
935
  * The engine creates its own `<canvas>`, sets its dimensions, and appends it
859
936
  * to `document.body`. The canvas is automatically centered and responsively
860
937
  * scaled to use the maximum available viewport space while preserving aspect ratio.
938
+ * For new mobile-first Minimo Games, prefer a portrait canvas such as `720x1280`.
861
939
  *
862
- * @param width - Canvas width in pixels. Default: `800`.
863
- * @param height - Canvas height in pixels. Default: `600`.
940
+ * @param width - Canvas width in pixels. Default: `720`.
941
+ * @param height - Canvas height in pixels. Default: `1280`.
864
942
  *
865
943
  * @throws Error if a 2D context cannot be obtained.
866
944
  *
867
945
  * @example
868
946
  * ```ts
869
- * const game = new Game(800, 600);
947
+ * const game = new Game(720, 1280);
870
948
  * game.physics = true;
871
949
  * ```
872
950
  */
@@ -894,7 +972,7 @@ export declare class Game {
894
972
  */
895
973
  get pointerY(): number;
896
974
  /**
897
- * Registers a {@link Sprite} (or subclass instance) with the engine.
975
+ * Registers a {@link BaseSprite} (or subclass instance) with the engine.
898
976
  *
899
977
  * After calling `add`, the sprite is rendered and, if dynamic
900
978
  * (`isStatic = false`), receives built-in velocity/gravity integration every
@@ -903,11 +981,11 @@ export declare class Game {
903
981
  * **Ownership:** The game instance takes ownership of the sprite from this
904
982
  * point forward. It will appear in {@link Game.getSprites} on the same frame.
905
983
  *
906
- * **Subclasses:** Any class that extends {@link Sprite} can be passed here.
907
- * The engine stores and processes it as a `Sprite`; your custom properties
984
+ * **Subclasses:** Any class that extends {@link BaseSprite} can be passed here.
985
+ * The engine stores and processes it as a live sprite; your custom properties
908
986
  * are preserved on the instance.
909
987
  *
910
- * @param sprite - A {@link Sprite} instance (or subclass) to add.
988
+ * @param sprite - A {@link BaseSprite} instance (or subclass) to add.
911
989
  * @returns The same sprite instance, for chaining or inline assignment.
912
990
  *
913
991
  * @example
@@ -922,7 +1000,7 @@ export declare class Game {
922
1000
  * constructor(x: number, y: number) {
923
1001
  * super("👾", x, y, 40);
924
1002
  * }
925
- * }
1003
+ * }
926
1004
  * const enemy = game.add(new Enemy(600, 100));
927
1005
  * ```
928
1006
  */
@@ -1650,11 +1728,11 @@ export declare class Game {
1650
1728
  */
1651
1729
  clearTimer(id: number): void;
1652
1730
  /**
1653
- * Draws text on screen as a **screen-space overlay** this frame.
1731
+ * Draws text on screen as a **simple screen-space overlay** this frame.
1654
1732
  *
1655
1733
  * **Overlay behavior:** Text is drawn in canvas/screen space — it ignores
1656
1734
  * `scrollX` / `scrollY`. Position `(0, 0)` is always the top-left of the canvas.
1657
- * Use this for HUD elements: score, lives, timer, debug info.
1735
+ * Use this for lightweight HUD elements: score, lives, timer, debug info.
1658
1736
  *
1659
1737
  * **Per-frame:** `drawText` must be called every frame to keep text visible.
1660
1738
  * The text overlay list is cleared after each render. Call this inside `onUpdate`.
@@ -1669,6 +1747,14 @@ export declare class Game {
1669
1747
  * registered with {@link Game.requireFont}. If a font is unavailable, the
1670
1748
  * browser falls back to `monospace`.
1671
1749
  *
1750
+ * Prefer {@link TextSprite} for UI buttons or labels that need persistent
1751
+ * bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
1752
+ * `drawText()` is the lightweight overlay API, not the primary UI API.
1753
+ *
1754
+ * For controls that are only a single emoji, prefer {@link Sprite} over
1755
+ * {@link TextSprite}. Emoji-only buttons do not benefit from text padding and
1756
+ * usually fit more naturally in the sprite pipeline.
1757
+ *
1672
1758
  * @param text - The string to render. Supports emoji and Unicode.
1673
1759
  * @param x - X position in **screen space** (pixels from canvas left edge).
1674
1760
  * @param y - Y position in **screen space** (pixels from canvas top edge).
@@ -1762,25 +1848,26 @@ export declare class Game {
1762
1848
  * - Canvas dimensions
1763
1849
  * - AudioContext
1764
1850
  *
1765
- * After clearing, `reset()` immediately calls `onCreate()` so your callback
1766
- * can rebuild the new scene synchronously.
1851
+ * After clearing, `reset()` immediately calls the active scene's
1852
+ * `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
1853
+ * can rebuild synchronously.
1854
+ *
1855
+ * @param scene - Optional new scene to make active before rebuilding.
1767
1856
  *
1768
1857
  * @example
1769
1858
  * ```ts
1770
- * // Switch from gameplay to game-over screen
1771
- * function gameOver() {
1772
- * game.reset(); // onCreate() is called here, rebuild inside it
1773
- * }
1774
- *
1775
- * game.onCreate = () => {
1776
- * // Scene init
1777
- * const skull = new Sprite("💀", 400, 300, 96);
1778
- * game.add(skull);
1779
- * game.addTimer(3000, false, () => game.reset()); // auto-restart
1780
- * };
1859
+ * class GameOverScene {
1860
+ * onCreate() {
1861
+ * const skull = new Sprite("💀", 400, 300, 96);
1862
+ * game.add(skull);
1863
+ * game.addTimer(3000, false, () => game.reset());
1864
+ * }
1865
+ * }
1866
+ *
1867
+ * game.reset(new GameOverScene());
1781
1868
  * ```
1782
1869
  */
1783
- reset(): void;
1870
+ reset(scene?: IScene): void;
1784
1871
  /**
1785
1872
  * Starts the `requestAnimationFrame` game loop.
1786
1873
  * Safe to call multiple times — does nothing if already running.
@@ -1788,10 +1875,12 @@ export declare class Game {
1788
1875
  * asset registration, loads queued images, waits for all fonts registered via
1789
1876
  * {@link Game.requireFont}, and only then, if images were queued, shows a
1790
1877
  * default loading screen while those image assets are still loading.
1791
- * After preload completes, `onCreate()` is called before the first frame.
1878
+ * After preload completes, the active scene's `onCreate()` is called before
1879
+ * the first frame.
1792
1880
  *
1793
- * The loop calls `onUpdate` once per frame, then renders all sprites and
1794
- * text overlays. Order per frame:
1881
+ * The loop calls the active scene's `onUpdate(dt)` (or legacy
1882
+ * {@link Game.onUpdate}) once per frame, then renders all sprites and text
1883
+ * overlays. Order per frame:
1795
1884
  * 1. Accumulate timer elapsed time; fire ready callbacks.
1796
1885
  * 2. Advance animations (linear interpolation).
1797
1886
  * 3. Advance active explosion effects.
@@ -1805,11 +1894,15 @@ export declare class Game {
1805
1894
  *
1806
1895
  * @example
1807
1896
  * ```ts
1808
- * game.onUpdate = (dt) => { ... };
1809
- * game.start();
1897
+ * class DemoScene {
1898
+ * onCreate() {}
1899
+ * onUpdate(dt: number) {}
1900
+ * }
1901
+ *
1902
+ * game.start(new DemoScene());
1810
1903
  * ```
1811
1904
  */
1812
- start(): void;
1905
+ start(scene?: IScene): void;
1813
1906
  /**
1814
1907
  * Stops the game loop. The canvas retains its last rendered frame.
1815
1908
  * Call {@link Game.start} to resume.
package/dist/minimo.js CHANGED
@@ -32,7 +32,7 @@ import { TimerSystem } from "./internal/TimerSystem.js";
32
32
  * engine.
33
33
  */
34
34
  export class BaseSprite {
35
- constructor() {
35
+ constructor(game = null) {
36
36
  /**
37
37
  * X position in world space (horizontal center of the sprite), in pixels.
38
38
  * Positive X points right. Updated each frame by: `x += vx * dt`.
@@ -137,6 +137,20 @@ export class BaseSprite {
137
137
  * Values > 1 amplify gravity; negative values invert it.
138
138
  */
139
139
  this.gravityScale = 0;
140
+ this._game = game;
141
+ }
142
+ /**
143
+ * Game instance associated with this sprite, if any.
144
+ */
145
+ get game() {
146
+ return this._game;
147
+ }
148
+ /** @internal */
149
+ _setGame(game) {
150
+ if (this._game !== null && this._game !== game) {
151
+ throw new Error("MinimoJS: A sprite cannot be attached to multiple Game instances.");
152
+ }
153
+ this._game = game;
140
154
  }
141
155
  /**
142
156
  * Resolved center X used internally by rendering, hit testing, and collisions.
@@ -207,7 +221,7 @@ export class Sprite extends BaseSprite {
207
221
  * All other properties use their defaults and can be set after construction.
208
222
  *
209
223
  * @param sprite - The emoji character to render. Must be a single emoji.
210
- * Image sprites are NOT supported emoji only.
224
+ * Use {@link ImageSprite} for preloaded bitmap textures.
211
225
  * @example "🔥", "⭐", "🐢", "💣", "👾"
212
226
  * @param x - Initial X position in world space (center), in pixels. Default: `0`.
213
227
  * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
@@ -231,9 +245,86 @@ export class Sprite extends BaseSprite {
231
245
  return `emoji:${this.sprite}|size:${this._size}|color:${this.color}`;
232
246
  }
233
247
  }
248
+ /**
249
+ * A renderable sprite backed by a preloaded image asset.
250
+ *
251
+ * Width and height are always resolved from the current texture. To resize the
252
+ * sprite visually, use {@link BaseSprite.scale}.
253
+ */
254
+ export class ImageSprite extends BaseSprite {
255
+ get imageKey() {
256
+ return this._imageKey;
257
+ }
258
+ get width() {
259
+ const image = this.getResolvedImage();
260
+ return image.naturalWidth || image.width;
261
+ }
262
+ get height() {
263
+ const image = this.getResolvedImage();
264
+ return image.naturalHeight || image.height;
265
+ }
266
+ /**
267
+ * Creates a new image-backed sprite.
268
+ *
269
+ * @param game - Game instance used to resolve the texture key.
270
+ * @param imageKey - Preloaded image key previously registered with {@link Game.loadImage}.
271
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
272
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
273
+ */
274
+ constructor(game, imageKey, x = 0, y = 0) {
275
+ super(game);
276
+ this._imageKey = imageKey;
277
+ this.x = x;
278
+ this.y = y;
279
+ }
280
+ /**
281
+ * Replaces the current texture with another preloaded image.
282
+ *
283
+ * @param imageKey - New preloaded image key to render.
284
+ */
285
+ setTexture(imageKey) {
286
+ if (imageKey === this._imageKey)
287
+ return;
288
+ this.assertTextureAvailable(imageKey);
289
+ this._imageKey = imageKey;
290
+ }
291
+ getRenderCacheKey() {
292
+ const image = this.getResolvedImage();
293
+ return [
294
+ "image",
295
+ this._imageKey,
296
+ image.naturalWidth || image.width,
297
+ image.naturalHeight || image.height,
298
+ ].join("|");
299
+ }
300
+ getResolvedImage() {
301
+ this.assertTextureAvailable(this._imageKey);
302
+ const image = this.game?.getImage(this._imageKey);
303
+ if (!image) {
304
+ throw new Error(`MinimoJS: Image '${this._imageKey}' is not loaded.`);
305
+ }
306
+ return image;
307
+ }
308
+ assertTextureAvailable(imageKey) {
309
+ if (!this.game) {
310
+ throw new Error("MinimoJS: ImageSprite requires an associated Game instance.");
311
+ }
312
+ if (!this.game.hasImage(imageKey)) {
313
+ throw new Error(`MinimoJS: Image '${imageKey}' is not loaded.`);
314
+ }
315
+ }
316
+ }
234
317
  /**
235
318
  * A renderable text actor that participates in the same animation/effects
236
319
  * pipeline as regular sprites.
320
+ *
321
+ * Use `TextSprite` when the content is primarily text, or when you need text
322
+ * layout features such as wrapping, fixed button sizes, background/border, or
323
+ * text stroke.
324
+ *
325
+ * For controls that are only a single emoji, prefer {@link Sprite}. Emoji-only
326
+ * buttons usually behave better as sprites because they do not need text
327
+ * padding/layout and their bounds match the rendered emoji more directly.
237
328
  */
238
329
  export class TextSprite extends BaseSprite {
239
330
  get width() {
@@ -254,8 +345,8 @@ export class TextSprite extends BaseSprite {
254
345
  this.maxWidth = 0;
255
346
  this.fixedWidth = 0;
256
347
  this.fixedHeight = 0;
257
- this.paddingX = 0;
258
- this.paddingY = 0;
348
+ this.paddingX = 2;
349
+ this.paddingY = 2;
259
350
  this.backgroundColor = null;
260
351
  this.borderColor = null;
261
352
  this.borderWidth = 0;
@@ -579,6 +670,11 @@ export class BackgroundLayer {
579
670
  * If you use additional families, register them with {@link Game.requireFont}
580
671
  * before calling {@link Game.start}.
581
672
  *
673
+ * Prefer {@link TextSprite} for persistent UI text, interactive labels,
674
+ * buttons, fixed-size text boxes, and styled text elements. Keep
675
+ * `drawText()` for simple screen-space overlays such as HUD counters, debug
676
+ * text, or other text that is redrawn every frame.
677
+ *
582
678
  * You MUST still declare the fonts yourself in your `index.html`. MinimoJS
583
679
  * does NOT download, inject, or manage web fonts for you. If a font is missing,
584
680
  * the browser falls back to `monospace`.
@@ -597,7 +693,7 @@ export class BackgroundLayer {
597
693
  * Example custom font registration:
598
694
  *
599
695
  * ```ts
600
- * const game = new Game(800, 600);
696
+ * const game = new Game(720, 1280);
601
697
  * game.requireFont('"Bangers"', { weight: "400" });
602
698
  * game.start();
603
699
  * ```
@@ -609,7 +705,7 @@ export class BackgroundLayer {
609
705
  * ```ts
610
706
  * import { Game, Sprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
611
707
  *
612
- * const game = new Game(800, 600);
708
+ * const game = new Game(720, 1280);
613
709
  *
614
710
  * const player = new Sprite("🐢", 400, 500, 48);
615
711
  * game.add(player);
@@ -724,10 +820,10 @@ export class BackgroundLayer {
724
820
  *
725
821
  * ---
726
822
  *
727
- * ## Scene Initialization with `onCreate`
823
+ * ## Scene Initialization with `IScene`
728
824
  *
729
- * MinimoJS has NO scene system. Use {@link Game.onCreate} to build a scene.
730
- * The engine calls `onCreate`:
825
+ * MinimoJS supports simple scene objects via {@link IScene}. The engine calls
826
+ * a scene's `onCreate()`:
731
827
  * - Once before the first frame (when {@link Game.start} is called).
732
828
  * - Again after each {@link Game.reset}.
733
829
  *
@@ -739,25 +835,30 @@ export class BackgroundLayer {
739
835
  * - Scroll position (scrollX and scrollY reset to 0)
740
836
  * - Per-frame input state (pressed keys/pointer)
741
837
  *
742
- * After clearing, `reset()` calls `onCreate()` so your callback can rebuild the
743
- * new scene immediately. Simply re-add sprites and re-register timers.
838
+ * After clearing, `reset()` calls the active scene's `onCreate()` so it can
839
+ * rebuild immediately. Simply re-add sprites and re-register timers.
744
840
  *
745
841
  * ```ts
746
- * game.onCreate = () => {
747
- * // scene init: add sprites, setup timers
748
- * const skull = new Sprite("💀", 400, 300, 96);
749
- * game.add(skull);
750
- * };
751
- *
752
- * game.onUpdate = (dt) => {
753
- * // normal per-frame update
754
- * };
755
- *
756
- * game.start(); // calls onCreate() once before first frame
842
+ * class SkullScene implements IScene {
843
+ * onCreate() {
844
+ * const skull = new Sprite("💀", 400, 300, 96);
845
+ * game.add(skull);
846
+ * }
847
+ *
848
+ * onUpdate(dt: number) {
849
+ * // normal per-frame update
850
+ * }
851
+ * }
852
+ *
853
+ * const scene = new SkullScene();
854
+ * game.start(scene); // calls onCreate() once before first frame
757
855
  * // later:
758
856
  * game.reset(); // clears + calls onCreate() again
759
857
  * ```
760
858
  *
859
+ * Legacy `game.onCreate` / `game.onUpdate` callbacks still work when no active
860
+ * {@link IScene} is set.
861
+ *
761
862
  * ---
762
863
  *
763
864
  * ## Sprite Lifecycle Ownership
@@ -774,7 +875,7 @@ export class BackgroundLayer {
774
875
  * ## Forbidden Features
775
876
  *
776
877
  * The following do NOT exist in MinimoJS v1. Do NOT attempt to use them:
777
- * - Scene system or scene manager
878
+ * - Full scene manager architecture (scene stacks, transitions, loaders, etc.)
778
879
  * - Entity Component System (ECS)
779
880
  * - Physics engine (no Box2D, Matter.js, etc.)
780
881
  * - Camera zoom or scale
@@ -783,7 +884,6 @@ export class BackgroundLayer {
783
884
  * - Parallax layers
784
885
  * - Multiple cameras
785
886
  * - Full physics engine (only basic explicit AABB collision helpers are provided)
786
- * - Image sprites (PNG, SVG, canvas, etc.) as gameplay actors
787
887
  * - `setTimeout` or `setInterval`
788
888
  */
789
889
  export class Game {
@@ -845,10 +945,10 @@ export class Game {
845
945
  this._physicsSystem.enabled = value;
846
946
  }
847
947
  /**
848
- * Scene creation callback.
948
+ * Legacy scene creation callback.
849
949
  *
850
950
  * Called once before the first frame on {@link Game.start}, and again after
851
- * each {@link Game.reset}. Use this to create sprites and timers for a scene.
951
+ * each {@link Game.reset} when no active {@link IScene} is set.
852
952
  *
853
953
  * @example
854
954
  * ```ts
@@ -861,10 +961,16 @@ export class Game {
861
961
  * ```
862
962
  */
863
963
  get onCreate() {
864
- return this._loopSystem.onCreate;
964
+ return this._onCreate;
865
965
  }
866
966
  set onCreate(callback) {
867
- this._loopSystem.onCreate = callback;
967
+ this._onCreate = callback;
968
+ }
969
+ /**
970
+ * Currently active scene object, if any.
971
+ */
972
+ get currentScene() {
973
+ return this._currentScene;
868
974
  }
869
975
  // -------------------------------------------------------------------------
870
976
  // Constructor
@@ -875,24 +981,27 @@ export class Game {
875
981
  * The engine creates its own `<canvas>`, sets its dimensions, and appends it
876
982
  * to `document.body`. The canvas is automatically centered and responsively
877
983
  * scaled to use the maximum available viewport space while preserving aspect ratio.
984
+ * For new mobile-first Minimo Games, prefer a portrait canvas such as `720x1280`.
878
985
  *
879
- * @param width - Canvas width in pixels. Default: `800`.
880
- * @param height - Canvas height in pixels. Default: `600`.
986
+ * @param width - Canvas width in pixels. Default: `720`.
987
+ * @param height - Canvas height in pixels. Default: `1280`.
881
988
  *
882
989
  * @throws Error if a 2D context cannot be obtained.
883
990
  *
884
991
  * @example
885
992
  * ```ts
886
- * const game = new Game(800, 600);
993
+ * const game = new Game(720, 1280);
887
994
  * game.physics = true;
888
995
  * ```
889
996
  */
890
- constructor(width = 800, height = 600) {
997
+ constructor(width = 720, height = 1280) {
891
998
  /** @internal */ this._requiredFonts = new Map();
892
999
  /** @internal */ this._hasAppliedGlobalFontRequirements = false;
893
1000
  /** @internal */ this._isRegisteringPreloadAssets = false;
894
1001
  /** @internal */ this._hasCompletedPreload = false;
895
1002
  /** @internal */ this._preloadPromise = null;
1003
+ /** @internal */ this._onCreate = null;
1004
+ /** @internal */ this._currentScene = null;
896
1005
  /**
897
1006
  * Horizontal scroll offset of the world camera, in pixels.
898
1007
  * The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
@@ -968,7 +1077,9 @@ export class Game {
968
1077
  */
969
1078
  this.onPreload = null;
970
1079
  /**
971
- * Callback invoked once per frame after physics and timer updates.
1080
+ * Legacy per-frame callback invoked after physics and timer updates.
1081
+ *
1082
+ * This is used only when no active {@link IScene} is set.
972
1083
  *
973
1084
  * @param dt - Delta time in **seconds** since the last frame.
974
1085
  * Use this for velocity-based movement: `sprite.x += speed * dt`.
@@ -1000,7 +1111,7 @@ export class Game {
1000
1111
  this._timerSystem = new TimerSystem();
1001
1112
  this._animationSystem = new AnimationSystem();
1002
1113
  this._physicsSystem = new PhysicsSystem();
1003
- this._spriteSystem = new SpriteSystem(this._animationSystem);
1114
+ this._spriteSystem = new SpriteSystem(this._animationSystem, this);
1004
1115
  this._backgroundSystem = new BackgroundSystem();
1005
1116
  this._assetSystem = new AssetSystem();
1006
1117
  this._inputSystem = new InputSystem(this, this._canvas);
@@ -1010,6 +1121,7 @@ export class Game {
1010
1121
  this._explosionSystem = new ExplosionSystem();
1011
1122
  this._trailSystem = new TrailSystem();
1012
1123
  this._loopSystem = new LoopSystem(this._onLoopFrameCallback.bind(this));
1124
+ this._loopSystem.onCreate = this._invokeCreate.bind(this);
1013
1125
  this._inputSystem.bindInputEvents();
1014
1126
  this.requireFont('"Press Start 2P"', {
1015
1127
  sampleText: "Loading... SCORE LIVES GAME OVER YOU WIN",
@@ -1053,7 +1165,7 @@ export class Game {
1053
1165
  // Sprite management
1054
1166
  // -------------------------------------------------------------------------
1055
1167
  /**
1056
- * Registers a {@link Sprite} (or subclass instance) with the engine.
1168
+ * Registers a {@link BaseSprite} (or subclass instance) with the engine.
1057
1169
  *
1058
1170
  * After calling `add`, the sprite is rendered and, if dynamic
1059
1171
  * (`isStatic = false`), receives built-in velocity/gravity integration every
@@ -1062,11 +1174,11 @@ export class Game {
1062
1174
  * **Ownership:** The game instance takes ownership of the sprite from this
1063
1175
  * point forward. It will appear in {@link Game.getSprites} on the same frame.
1064
1176
  *
1065
- * **Subclasses:** Any class that extends {@link Sprite} can be passed here.
1066
- * The engine stores and processes it as a `Sprite`; your custom properties
1177
+ * **Subclasses:** Any class that extends {@link BaseSprite} can be passed here.
1178
+ * The engine stores and processes it as a live sprite; your custom properties
1067
1179
  * are preserved on the instance.
1068
1180
  *
1069
- * @param sprite - A {@link Sprite} instance (or subclass) to add.
1181
+ * @param sprite - A {@link BaseSprite} instance (or subclass) to add.
1070
1182
  * @returns The same sprite instance, for chaining or inline assignment.
1071
1183
  *
1072
1184
  * @example
@@ -1081,7 +1193,7 @@ export class Game {
1081
1193
  * constructor(x: number, y: number) {
1082
1194
  * super("👾", x, y, 40);
1083
1195
  * }
1084
- * }
1196
+ * }
1085
1197
  * const enemy = game.add(new Enemy(600, 100));
1086
1198
  * ```
1087
1199
  */
@@ -1926,11 +2038,11 @@ export class Game {
1926
2038
  // Text
1927
2039
  // -------------------------------------------------------------------------
1928
2040
  /**
1929
- * Draws text on screen as a **screen-space overlay** this frame.
2041
+ * Draws text on screen as a **simple screen-space overlay** this frame.
1930
2042
  *
1931
2043
  * **Overlay behavior:** Text is drawn in canvas/screen space — it ignores
1932
2044
  * `scrollX` / `scrollY`. Position `(0, 0)` is always the top-left of the canvas.
1933
- * Use this for HUD elements: score, lives, timer, debug info.
2045
+ * Use this for lightweight HUD elements: score, lives, timer, debug info.
1934
2046
  *
1935
2047
  * **Per-frame:** `drawText` must be called every frame to keep text visible.
1936
2048
  * The text overlay list is cleared after each render. Call this inside `onUpdate`.
@@ -1945,6 +2057,14 @@ export class Game {
1945
2057
  * registered with {@link Game.requireFont}. If a font is unavailable, the
1946
2058
  * browser falls back to `monospace`.
1947
2059
  *
2060
+ * Prefer {@link TextSprite} for UI buttons or labels that need persistent
2061
+ * bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
2062
+ * `drawText()` is the lightweight overlay API, not the primary UI API.
2063
+ *
2064
+ * For controls that are only a single emoji, prefer {@link Sprite} over
2065
+ * {@link TextSprite}. Emoji-only buttons do not benefit from text padding and
2066
+ * usually fit more naturally in the sprite pipeline.
2067
+ *
1948
2068
  * @param text - The string to render. Supports emoji and Unicode.
1949
2069
  * @param x - X position in **screen space** (pixels from canvas left edge).
1950
2070
  * @param y - Y position in **screen space** (pixels from canvas top edge).
@@ -2067,25 +2187,29 @@ export class Game {
2067
2187
  * - Canvas dimensions
2068
2188
  * - AudioContext
2069
2189
  *
2070
- * After clearing, `reset()` immediately calls `onCreate()` so your callback
2071
- * can rebuild the new scene synchronously.
2190
+ * After clearing, `reset()` immediately calls the active scene's
2191
+ * `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
2192
+ * can rebuild synchronously.
2193
+ *
2194
+ * @param scene - Optional new scene to make active before rebuilding.
2072
2195
  *
2073
2196
  * @example
2074
2197
  * ```ts
2075
- * // Switch from gameplay to game-over screen
2076
- * function gameOver() {
2077
- * game.reset(); // onCreate() is called here, rebuild inside it
2078
- * }
2079
- *
2080
- * game.onCreate = () => {
2081
- * // Scene init
2082
- * const skull = new Sprite("💀", 400, 300, 96);
2083
- * game.add(skull);
2084
- * game.addTimer(3000, false, () => game.reset()); // auto-restart
2085
- * };
2198
+ * class GameOverScene {
2199
+ * onCreate() {
2200
+ * const skull = new Sprite("💀", 400, 300, 96);
2201
+ * game.add(skull);
2202
+ * game.addTimer(3000, false, () => game.reset());
2203
+ * }
2204
+ * }
2205
+ *
2206
+ * game.reset(new GameOverScene());
2086
2207
  * ```
2087
2208
  */
2088
- reset() {
2209
+ reset(scene) {
2210
+ if (scene !== undefined) {
2211
+ this._currentScene = scene;
2212
+ }
2089
2213
  this._backgroundSystem.clearAll();
2090
2214
  this._spriteSystem.clearAll();
2091
2215
  this._timerSystem.clearAll();
@@ -2108,10 +2232,12 @@ export class Game {
2108
2232
  * asset registration, loads queued images, waits for all fonts registered via
2109
2233
  * {@link Game.requireFont}, and only then, if images were queued, shows a
2110
2234
  * default loading screen while those image assets are still loading.
2111
- * After preload completes, `onCreate()` is called before the first frame.
2235
+ * After preload completes, the active scene's `onCreate()` is called before
2236
+ * the first frame.
2112
2237
  *
2113
- * The loop calls `onUpdate` once per frame, then renders all sprites and
2114
- * text overlays. Order per frame:
2238
+ * The loop calls the active scene's `onUpdate(dt)` (or legacy
2239
+ * {@link Game.onUpdate}) once per frame, then renders all sprites and text
2240
+ * overlays. Order per frame:
2115
2241
  * 1. Accumulate timer elapsed time; fire ready callbacks.
2116
2242
  * 2. Advance animations (linear interpolation).
2117
2243
  * 3. Advance active explosion effects.
@@ -2125,11 +2251,18 @@ export class Game {
2125
2251
  *
2126
2252
  * @example
2127
2253
  * ```ts
2128
- * game.onUpdate = (dt) => { ... };
2129
- * game.start();
2254
+ * class DemoScene {
2255
+ * onCreate() {}
2256
+ * onUpdate(dt: number) {}
2257
+ * }
2258
+ *
2259
+ * game.start(new DemoScene());
2130
2260
  * ```
2131
2261
  */
2132
- start() {
2262
+ start(scene) {
2263
+ if (scene !== undefined) {
2264
+ this._currentScene = scene;
2265
+ }
2133
2266
  this.applyGlobalFontRequirements();
2134
2267
  if (this._hasCompletedPreload) {
2135
2268
  this._loopSystem.start();
@@ -2207,7 +2340,9 @@ export class Game {
2207
2340
  this._animationSystem.update(dtMs);
2208
2341
  this._explosionSystem.update(dt, dtMs);
2209
2342
  this._physicsSystem.update(this._spriteSystem.getMutableSprites(), dt);
2210
- if (this.onUpdate)
2343
+ if (this._currentScene?.onUpdate)
2344
+ this._currentScene.onUpdate(dt);
2345
+ else if (this.onUpdate)
2211
2346
  this.onUpdate(dt);
2212
2347
  this._trailSystem.update(dtMs, this._renderSystem.getSpriteRenderSnapshot.bind(this._renderSystem));
2213
2348
  this._render();
@@ -2215,6 +2350,13 @@ export class Game {
2215
2350
  this._textSystem.clear();
2216
2351
  }
2217
2352
  /** @internal */
2353
+ _invokeCreate() {
2354
+ if (this._currentScene?.onCreate)
2355
+ this._currentScene.onCreate();
2356
+ else
2357
+ this._onCreate?.();
2358
+ }
2359
+ /** @internal */
2218
2360
  waitForDocumentFonts() {
2219
2361
  if (typeof document === "undefined") {
2220
2362
  return Promise.resolve();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.12",
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",