minimojs 1.0.0-alpha.10 → 1.0.0-alpha.11

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
@@ -24,7 +24,7 @@ This README is intentionally high-level. It explains what the project is and how
24
24
 
25
25
  ## What It Intentionally Avoids
26
26
 
27
- - Scene manager/ECS architecture
27
+ - Heavy scene-manager/ECS architecture
28
28
  - Heavy physics engine features
29
29
  - Image spritesheets and asset pipelines
30
30
  - Nested subsystem APIs
@@ -39,26 +39,32 @@ npm install minimojs
39
39
  ## Quick Start
40
40
 
41
41
  ```ts
42
- import { Game, Sprite } from "minimojs";
42
+ import { Game, Sprite, type IScene } from "minimojs";
43
43
 
44
44
  const game = new Game(800, 600);
45
45
  game.gravityY = 980;
46
46
 
47
- const player = game.add(new Sprite("🐢", 400, 300, 48));
48
- player.x = 400;
49
- player.y = 300;
50
- player.gravityScale = 1;
47
+ class DemoScene implements IScene {
48
+ private player: Sprite | null = null;
51
49
 
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;
50
+ onCreate() {
51
+ this.player = game.add(new Sprite("🐢", 400, 300, 48));
52
+ this.player.gravityScale = 1;
53
+ }
56
54
 
57
- if (game.isKeyPressed(" ")) player.vy = -600;
58
- game.drawText("MinimoJS", 10, 10, 16);
59
- };
55
+ onUpdate(dt: number) {
56
+ if (!this.player) return;
60
57
 
61
- game.start();
58
+ if (game.isKeyDown("ArrowLeft")) this.player.vx = -200;
59
+ else if (game.isKeyDown("ArrowRight")) this.player.vx = 200;
60
+ else this.player.vx = 0;
61
+
62
+ if (game.isKeyPressed(" ")) this.player.vy = -600;
63
+ game.drawText("MinimoJS", 10, 10, 16);
64
+ }
65
+ }
66
+
67
+ game.start(new DemoScene());
62
68
  ```
63
69
 
64
70
  Note: `drawText()` uses `"Press Start 2P", monospace`. Load the font in your HTML if you want pixel-font styling.
@@ -71,6 +77,8 @@ Note: `drawText()` uses `"Press Start 2P", monospace`. Load the font in your HTM
71
77
  - `examples/super-minimo-bros/`
72
78
  - `examples/scale-shift/`
73
79
  - `examples/background-desert/`
80
+ - `examples/scene-lab/`
81
+ - `examples/image-sprite-sad-plush/`
74
82
  - `examples/animations/`
75
83
 
76
84
  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`.
@@ -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,6 +925,10 @@ 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
  *
@@ -894,7 +971,7 @@ export declare class Game {
894
971
  */
895
972
  get pointerY(): number;
896
973
  /**
897
- * Registers a {@link Sprite} (or subclass instance) with the engine.
974
+ * Registers a {@link BaseSprite} (or subclass instance) with the engine.
898
975
  *
899
976
  * After calling `add`, the sprite is rendered and, if dynamic
900
977
  * (`isStatic = false`), receives built-in velocity/gravity integration every
@@ -903,11 +980,11 @@ export declare class Game {
903
980
  * **Ownership:** The game instance takes ownership of the sprite from this
904
981
  * point forward. It will appear in {@link Game.getSprites} on the same frame.
905
982
  *
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
983
+ * **Subclasses:** Any class that extends {@link BaseSprite} can be passed here.
984
+ * The engine stores and processes it as a live sprite; your custom properties
908
985
  * are preserved on the instance.
909
986
  *
910
- * @param sprite - A {@link Sprite} instance (or subclass) to add.
987
+ * @param sprite - A {@link BaseSprite} instance (or subclass) to add.
911
988
  * @returns The same sprite instance, for chaining or inline assignment.
912
989
  *
913
990
  * @example
@@ -922,7 +999,7 @@ export declare class Game {
922
999
  * constructor(x: number, y: number) {
923
1000
  * super("👾", x, y, 40);
924
1001
  * }
925
- * }
1002
+ * }
926
1003
  * const enemy = game.add(new Enemy(600, 100));
927
1004
  * ```
928
1005
  */
@@ -1650,11 +1727,11 @@ export declare class Game {
1650
1727
  */
1651
1728
  clearTimer(id: number): void;
1652
1729
  /**
1653
- * Draws text on screen as a **screen-space overlay** this frame.
1730
+ * Draws text on screen as a **simple screen-space overlay** this frame.
1654
1731
  *
1655
1732
  * **Overlay behavior:** Text is drawn in canvas/screen space — it ignores
1656
1733
  * `scrollX` / `scrollY`. Position `(0, 0)` is always the top-left of the canvas.
1657
- * Use this for HUD elements: score, lives, timer, debug info.
1734
+ * Use this for lightweight HUD elements: score, lives, timer, debug info.
1658
1735
  *
1659
1736
  * **Per-frame:** `drawText` must be called every frame to keep text visible.
1660
1737
  * The text overlay list is cleared after each render. Call this inside `onUpdate`.
@@ -1669,6 +1746,14 @@ export declare class Game {
1669
1746
  * registered with {@link Game.requireFont}. If a font is unavailable, the
1670
1747
  * browser falls back to `monospace`.
1671
1748
  *
1749
+ * Prefer {@link TextSprite} for UI buttons or labels that need persistent
1750
+ * bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
1751
+ * `drawText()` is the lightweight overlay API, not the primary UI API.
1752
+ *
1753
+ * For controls that are only a single emoji, prefer {@link Sprite} over
1754
+ * {@link TextSprite}. Emoji-only buttons do not benefit from text padding and
1755
+ * usually fit more naturally in the sprite pipeline.
1756
+ *
1672
1757
  * @param text - The string to render. Supports emoji and Unicode.
1673
1758
  * @param x - X position in **screen space** (pixels from canvas left edge).
1674
1759
  * @param y - Y position in **screen space** (pixels from canvas top edge).
@@ -1762,25 +1847,26 @@ export declare class Game {
1762
1847
  * - Canvas dimensions
1763
1848
  * - AudioContext
1764
1849
  *
1765
- * After clearing, `reset()` immediately calls `onCreate()` so your callback
1766
- * can rebuild the new scene synchronously.
1850
+ * After clearing, `reset()` immediately calls the active scene's
1851
+ * `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
1852
+ * can rebuild synchronously.
1853
+ *
1854
+ * @param scene - Optional new scene to make active before rebuilding.
1767
1855
  *
1768
1856
  * @example
1769
1857
  * ```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
- * };
1858
+ * class GameOverScene {
1859
+ * onCreate() {
1860
+ * const skull = new Sprite("💀", 400, 300, 96);
1861
+ * game.add(skull);
1862
+ * game.addTimer(3000, false, () => game.reset());
1863
+ * }
1864
+ * }
1865
+ *
1866
+ * game.reset(new GameOverScene());
1781
1867
  * ```
1782
1868
  */
1783
- reset(): void;
1869
+ reset(scene?: IScene): void;
1784
1870
  /**
1785
1871
  * Starts the `requestAnimationFrame` game loop.
1786
1872
  * Safe to call multiple times — does nothing if already running.
@@ -1788,10 +1874,12 @@ export declare class Game {
1788
1874
  * asset registration, loads queued images, waits for all fonts registered via
1789
1875
  * {@link Game.requireFont}, and only then, if images were queued, shows a
1790
1876
  * default loading screen while those image assets are still loading.
1791
- * After preload completes, `onCreate()` is called before the first frame.
1877
+ * After preload completes, the active scene's `onCreate()` is called before
1878
+ * the first frame.
1792
1879
  *
1793
- * The loop calls `onUpdate` once per frame, then renders all sprites and
1794
- * text overlays. Order per frame:
1880
+ * The loop calls the active scene's `onUpdate(dt)` (or legacy
1881
+ * {@link Game.onUpdate}) once per frame, then renders all sprites and text
1882
+ * overlays. Order per frame:
1795
1883
  * 1. Accumulate timer elapsed time; fire ready callbacks.
1796
1884
  * 2. Advance animations (linear interpolation).
1797
1885
  * 3. Advance active explosion effects.
@@ -1805,11 +1893,15 @@ export declare class Game {
1805
1893
  *
1806
1894
  * @example
1807
1895
  * ```ts
1808
- * game.onUpdate = (dt) => { ... };
1809
- * game.start();
1896
+ * class DemoScene {
1897
+ * onCreate() {}
1898
+ * onUpdate(dt: number) {}
1899
+ * }
1900
+ *
1901
+ * game.start(new DemoScene());
1810
1902
  * ```
1811
1903
  */
1812
- start(): void;
1904
+ start(scene?: IScene): void;
1813
1905
  /**
1814
1906
  * Stops the game loop. The canvas retains its last rendered frame.
1815
1907
  * 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`.
@@ -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
@@ -893,6 +999,8 @@ export class Game {
893
999
  /** @internal */ this._isRegisteringPreloadAssets = false;
894
1000
  /** @internal */ this._hasCompletedPreload = false;
895
1001
  /** @internal */ this._preloadPromise = null;
1002
+ /** @internal */ this._onCreate = null;
1003
+ /** @internal */ this._currentScene = null;
896
1004
  /**
897
1005
  * Horizontal scroll offset of the world camera, in pixels.
898
1006
  * The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
@@ -968,7 +1076,9 @@ export class Game {
968
1076
  */
969
1077
  this.onPreload = null;
970
1078
  /**
971
- * Callback invoked once per frame after physics and timer updates.
1079
+ * Legacy per-frame callback invoked after physics and timer updates.
1080
+ *
1081
+ * This is used only when no active {@link IScene} is set.
972
1082
  *
973
1083
  * @param dt - Delta time in **seconds** since the last frame.
974
1084
  * Use this for velocity-based movement: `sprite.x += speed * dt`.
@@ -1000,7 +1110,7 @@ export class Game {
1000
1110
  this._timerSystem = new TimerSystem();
1001
1111
  this._animationSystem = new AnimationSystem();
1002
1112
  this._physicsSystem = new PhysicsSystem();
1003
- this._spriteSystem = new SpriteSystem(this._animationSystem);
1113
+ this._spriteSystem = new SpriteSystem(this._animationSystem, this);
1004
1114
  this._backgroundSystem = new BackgroundSystem();
1005
1115
  this._assetSystem = new AssetSystem();
1006
1116
  this._inputSystem = new InputSystem(this, this._canvas);
@@ -1010,6 +1120,7 @@ export class Game {
1010
1120
  this._explosionSystem = new ExplosionSystem();
1011
1121
  this._trailSystem = new TrailSystem();
1012
1122
  this._loopSystem = new LoopSystem(this._onLoopFrameCallback.bind(this));
1123
+ this._loopSystem.onCreate = this._invokeCreate.bind(this);
1013
1124
  this._inputSystem.bindInputEvents();
1014
1125
  this.requireFont('"Press Start 2P"', {
1015
1126
  sampleText: "Loading... SCORE LIVES GAME OVER YOU WIN",
@@ -1053,7 +1164,7 @@ export class Game {
1053
1164
  // Sprite management
1054
1165
  // -------------------------------------------------------------------------
1055
1166
  /**
1056
- * Registers a {@link Sprite} (or subclass instance) with the engine.
1167
+ * Registers a {@link BaseSprite} (or subclass instance) with the engine.
1057
1168
  *
1058
1169
  * After calling `add`, the sprite is rendered and, if dynamic
1059
1170
  * (`isStatic = false`), receives built-in velocity/gravity integration every
@@ -1062,11 +1173,11 @@ export class Game {
1062
1173
  * **Ownership:** The game instance takes ownership of the sprite from this
1063
1174
  * point forward. It will appear in {@link Game.getSprites} on the same frame.
1064
1175
  *
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
1176
+ * **Subclasses:** Any class that extends {@link BaseSprite} can be passed here.
1177
+ * The engine stores and processes it as a live sprite; your custom properties
1067
1178
  * are preserved on the instance.
1068
1179
  *
1069
- * @param sprite - A {@link Sprite} instance (or subclass) to add.
1180
+ * @param sprite - A {@link BaseSprite} instance (or subclass) to add.
1070
1181
  * @returns The same sprite instance, for chaining or inline assignment.
1071
1182
  *
1072
1183
  * @example
@@ -1081,7 +1192,7 @@ export class Game {
1081
1192
  * constructor(x: number, y: number) {
1082
1193
  * super("👾", x, y, 40);
1083
1194
  * }
1084
- * }
1195
+ * }
1085
1196
  * const enemy = game.add(new Enemy(600, 100));
1086
1197
  * ```
1087
1198
  */
@@ -1926,11 +2037,11 @@ export class Game {
1926
2037
  // Text
1927
2038
  // -------------------------------------------------------------------------
1928
2039
  /**
1929
- * Draws text on screen as a **screen-space overlay** this frame.
2040
+ * Draws text on screen as a **simple screen-space overlay** this frame.
1930
2041
  *
1931
2042
  * **Overlay behavior:** Text is drawn in canvas/screen space — it ignores
1932
2043
  * `scrollX` / `scrollY`. Position `(0, 0)` is always the top-left of the canvas.
1933
- * Use this for HUD elements: score, lives, timer, debug info.
2044
+ * Use this for lightweight HUD elements: score, lives, timer, debug info.
1934
2045
  *
1935
2046
  * **Per-frame:** `drawText` must be called every frame to keep text visible.
1936
2047
  * The text overlay list is cleared after each render. Call this inside `onUpdate`.
@@ -1945,6 +2056,14 @@ export class Game {
1945
2056
  * registered with {@link Game.requireFont}. If a font is unavailable, the
1946
2057
  * browser falls back to `monospace`.
1947
2058
  *
2059
+ * Prefer {@link TextSprite} for UI buttons or labels that need persistent
2060
+ * bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
2061
+ * `drawText()` is the lightweight overlay API, not the primary UI API.
2062
+ *
2063
+ * For controls that are only a single emoji, prefer {@link Sprite} over
2064
+ * {@link TextSprite}. Emoji-only buttons do not benefit from text padding and
2065
+ * usually fit more naturally in the sprite pipeline.
2066
+ *
1948
2067
  * @param text - The string to render. Supports emoji and Unicode.
1949
2068
  * @param x - X position in **screen space** (pixels from canvas left edge).
1950
2069
  * @param y - Y position in **screen space** (pixels from canvas top edge).
@@ -2067,25 +2186,29 @@ export class Game {
2067
2186
  * - Canvas dimensions
2068
2187
  * - AudioContext
2069
2188
  *
2070
- * After clearing, `reset()` immediately calls `onCreate()` so your callback
2071
- * can rebuild the new scene synchronously.
2189
+ * After clearing, `reset()` immediately calls the active scene's
2190
+ * `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
2191
+ * can rebuild synchronously.
2192
+ *
2193
+ * @param scene - Optional new scene to make active before rebuilding.
2072
2194
  *
2073
2195
  * @example
2074
2196
  * ```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
- * };
2197
+ * class GameOverScene {
2198
+ * onCreate() {
2199
+ * const skull = new Sprite("💀", 400, 300, 96);
2200
+ * game.add(skull);
2201
+ * game.addTimer(3000, false, () => game.reset());
2202
+ * }
2203
+ * }
2204
+ *
2205
+ * game.reset(new GameOverScene());
2086
2206
  * ```
2087
2207
  */
2088
- reset() {
2208
+ reset(scene) {
2209
+ if (scene !== undefined) {
2210
+ this._currentScene = scene;
2211
+ }
2089
2212
  this._backgroundSystem.clearAll();
2090
2213
  this._spriteSystem.clearAll();
2091
2214
  this._timerSystem.clearAll();
@@ -2108,10 +2231,12 @@ export class Game {
2108
2231
  * asset registration, loads queued images, waits for all fonts registered via
2109
2232
  * {@link Game.requireFont}, and only then, if images were queued, shows a
2110
2233
  * default loading screen while those image assets are still loading.
2111
- * After preload completes, `onCreate()` is called before the first frame.
2234
+ * After preload completes, the active scene's `onCreate()` is called before
2235
+ * the first frame.
2112
2236
  *
2113
- * The loop calls `onUpdate` once per frame, then renders all sprites and
2114
- * text overlays. Order per frame:
2237
+ * The loop calls the active scene's `onUpdate(dt)` (or legacy
2238
+ * {@link Game.onUpdate}) once per frame, then renders all sprites and text
2239
+ * overlays. Order per frame:
2115
2240
  * 1. Accumulate timer elapsed time; fire ready callbacks.
2116
2241
  * 2. Advance animations (linear interpolation).
2117
2242
  * 3. Advance active explosion effects.
@@ -2125,11 +2250,18 @@ export class Game {
2125
2250
  *
2126
2251
  * @example
2127
2252
  * ```ts
2128
- * game.onUpdate = (dt) => { ... };
2129
- * game.start();
2253
+ * class DemoScene {
2254
+ * onCreate() {}
2255
+ * onUpdate(dt: number) {}
2256
+ * }
2257
+ *
2258
+ * game.start(new DemoScene());
2130
2259
  * ```
2131
2260
  */
2132
- start() {
2261
+ start(scene) {
2262
+ if (scene !== undefined) {
2263
+ this._currentScene = scene;
2264
+ }
2133
2265
  this.applyGlobalFontRequirements();
2134
2266
  if (this._hasCompletedPreload) {
2135
2267
  this._loopSystem.start();
@@ -2207,7 +2339,9 @@ export class Game {
2207
2339
  this._animationSystem.update(dtMs);
2208
2340
  this._explosionSystem.update(dt, dtMs);
2209
2341
  this._physicsSystem.update(this._spriteSystem.getMutableSprites(), dt);
2210
- if (this.onUpdate)
2342
+ if (this._currentScene?.onUpdate)
2343
+ this._currentScene.onUpdate(dt);
2344
+ else if (this.onUpdate)
2211
2345
  this.onUpdate(dt);
2212
2346
  this._trailSystem.update(dtMs, this._renderSystem.getSpriteRenderSnapshot.bind(this._renderSystem));
2213
2347
  this._render();
@@ -2215,6 +2349,13 @@ export class Game {
2215
2349
  this._textSystem.clear();
2216
2350
  }
2217
2351
  /** @internal */
2352
+ _invokeCreate() {
2353
+ if (this._currentScene?.onCreate)
2354
+ this._currentScene.onCreate();
2355
+ else
2356
+ this._onCreate?.();
2357
+ }
2358
+ /** @internal */
2218
2359
  waitForDocumentFonts() {
2219
2360
  if (typeof document === "undefined") {
2220
2361
  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.11",
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",