minimojs 1.0.0-alpha.3 → 1.0.0-alpha.31

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.
Files changed (63) hide show
  1. package/README.md +87 -284
  2. package/dist/internal/AnimationSystem.js +345 -0
  3. package/dist/internal/AssetSystem.d.ts +6 -0
  4. package/dist/internal/AssetSystem.js +273 -0
  5. package/dist/internal/BackgroundSystem.d.ts +1 -0
  6. package/dist/internal/BackgroundSystem.js +26 -0
  7. package/dist/internal/CanvasSystem.d.ts +1 -0
  8. package/dist/internal/CanvasSystem.js +51 -0
  9. package/dist/internal/DesignNamingCheck.d.ts +1 -0
  10. package/dist/internal/DesignNamingCheck.js +7 -0
  11. package/dist/internal/ExplosionSystem.d.ts +1 -0
  12. package/dist/internal/ExplosionSystem.js +540 -0
  13. package/dist/internal/InputSystem.d.ts +1 -0
  14. package/dist/internal/InputSystem.js +292 -0
  15. package/dist/internal/LoopSystem.d.ts +1 -0
  16. package/dist/internal/LoopSystem.js +61 -0
  17. package/dist/internal/PhysicsSystem.d.ts +1 -0
  18. package/dist/internal/PhysicsSystem.js +174 -0
  19. package/dist/internal/RenderSystem.d.ts +1 -0
  20. package/dist/internal/RenderSystem.js +1021 -0
  21. package/dist/internal/SoundSystem.d.ts +39 -0
  22. package/dist/internal/SoundSystem.js +292 -0
  23. package/dist/internal/SpriteSystem.d.ts +1 -0
  24. package/dist/internal/SpriteSystem.js +32 -0
  25. package/dist/internal/TextSystem.d.ts +1 -0
  26. package/dist/internal/TextSystem.js +16 -0
  27. package/dist/internal/TimerSystem.d.ts +1 -0
  28. package/dist/internal/TimerSystem.js +43 -0
  29. package/dist/internal/TrailSystem.d.ts +1 -0
  30. package/dist/internal/TrailSystem.js +116 -0
  31. package/dist/internal/TransitionSystem.d.ts +1 -0
  32. package/dist/internal/TransitionSystem.js +74 -0
  33. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
  34. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
  35. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
  36. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
  37. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
  38. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
  39. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
  40. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
  41. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
  42. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
  43. package/dist/minimo-ambient.d.ts +299 -0
  44. package/dist/minimo-ambient.js +1445 -0
  45. package/dist/minimo-arcaderacer.d.ts +1431 -0
  46. package/dist/minimo-arcaderacer.js +2060 -0
  47. package/dist/minimo-cards.d.ts +359 -0
  48. package/dist/minimo-cards.js +819 -0
  49. package/dist/minimo-format.d.ts +429 -0
  50. package/dist/minimo-format.js +447 -0
  51. package/dist/minimo.d.ts +1815 -209
  52. package/dist/minimo.js +2660 -783
  53. package/package.json +10 -3
  54. package/dist/animations.js +0 -30
  55. package/dist/audio.js +0 -17
  56. package/dist/game.js +0 -1105
  57. package/dist/input.js +0 -185
  58. package/dist/internal-types.js +0 -4
  59. package/dist/physics.js +0 -10
  60. package/dist/render.js +0 -75
  61. package/dist/sprite.js +0 -149
  62. package/dist/timers.js +0 -23
  63. /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
package/dist/minimo.js CHANGED
@@ -7,69 +7,40 @@
7
7
  * ALL ROTATIONS ARE IN DEGREES.
8
8
  * THE ENGINE LOOP USES requestAnimationFrame ONLY.
9
9
  */
10
+ import { AnimationSystem } from "./internal/AnimationSystem.js";
11
+ import { AssetSystem } from "./internal/AssetSystem.js";
12
+ import { BackgroundSystem } from "./internal/BackgroundSystem.js";
13
+ import { CanvasSystem } from "./internal/CanvasSystem.js";
14
+ import { ExplosionSystem } from "./internal/ExplosionSystem.js";
15
+ import { InputSystem } from "./internal/InputSystem.js";
16
+ import { LoopSystem } from "./internal/LoopSystem.js";
17
+ import { PhysicsSystem } from "./internal/PhysicsSystem.js";
18
+ import { RenderSystem } from "./internal/RenderSystem.js";
19
+ import { SoundSystem } from "./internal/SoundSystem.js";
20
+ import { SpriteSystem } from "./internal/SpriteSystem.js";
21
+ import { TextSystem } from "./internal/TextSystem.js";
22
+ import { TrailSystem } from "./internal/TrailSystem.js";
23
+ import { TransitionSystem } from "./internal/TransitionSystem.js";
24
+ import { TimerSystem } from "./internal/TimerSystem.js";
25
+ import { Design, findFirst, parseAssetFile, parseDesignObject } from "./minimo-format.js";
26
+ export { Design, parseAssetFile } from "./minimo-format.js";
27
+ export { SoundPlayback } from "./internal/SoundSystem.js";
28
+ /** How long a scene may load before it dims and its progress ring appears, so a fast load shows neither. */
29
+ const SCENE_LOADING_INDICATOR_DELAY_MS = 250;
30
+ /** How long the dimming and the ring take to fade in once they appear. */
31
+ const SCENE_LOADING_FADE_MS = 200;
10
32
  // ---------------------------------------------------------------------------
11
- // Sprite
33
+ // EmojiSprite
12
34
  // ---------------------------------------------------------------------------
13
35
  /**
14
- * A 2D game object rendered as an emoji on the canvas.
15
- *
16
- * Instantiate directly or extend to create custom sprite types.
17
- * Register with the engine by passing the instance to {@link Game.add}.
18
- *
19
- * **Coordinate system:** center-based world space. `(x, y)` is the center of
20
- * the sprite. Positive X = right, positive Y = down.
21
- *
22
- * **Lifecycle:** A sprite exists until {@link Game.destroySprite} is called or
23
- * {@link Game.reset} is invoked. After destruction, do not read or write its fields.
24
- *
25
- * @example
26
- * ```ts
27
- * // Direct instantiation
28
- * const coin = new Sprite("🪙");
29
- * coin.x = 300;
30
- * coin.y = 200;
31
- * coin.size = 32;
32
- * game.add(coin);
33
- * ```
34
- *
35
- * @example
36
- * ```ts
37
- * // Subclassing for custom game objects
38
- * class Player extends Sprite {
39
- * health = 3;
40
- *
41
- * constructor(x: number, y: number) {
42
- * super("🐢");
43
- * this.x = x;
44
- * this.y = y;
45
- * this.size = 48;
46
- * this.gravityScale = 1;
47
- * }
48
- * }
36
+ * Base class for all renderable MinimoJS actors.
49
37
  *
50
- * const player = new Player(400, 300);
51
- * game.add(player);
52
- * ```
38
+ * Concrete subclasses provide their own visual content while inheriting the
39
+ * shared transform, visibility, physics, and animation state required by the
40
+ * engine.
53
41
  */
54
42
  export class Sprite {
55
- /**
56
- * Creates a new Sprite with the given emoji and optional position.
57
- * All other properties use their defaults and can be set after construction.
58
- *
59
- * @param sprite - The emoji character to render. Must be a single emoji.
60
- * Image sprites are NOT supported — emoji only.
61
- * @example "🔥", "⭐", "🐢", "💣", "👾"
62
- * @param x - Initial X position in world space (center), in pixels. Default: `0`.
63
- * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
64
- *
65
- * @example
66
- * ```ts
67
- * const enemy = new Sprite("👾", 200, 100);
68
- * enemy.size = 40;
69
- * game.add(enemy);
70
- * ```
71
- */
72
- constructor(sprite, x = 0, y = 0) {
43
+ constructor(game = null) {
73
44
  /**
74
45
  * X position in world space (horizontal center of the sprite), in pixels.
75
46
  * Positive X points right. Updated each frame by: `x += vx * dt`.
@@ -83,10 +54,42 @@ export class Sprite {
83
54
  */
84
55
  this.y = 0;
85
56
  /**
86
- * Width and height of the sprite's bounding square, in pixels.
87
- * Used for both canvas rendering (font size) and AABB collision detection.
57
+ * Visual scale multiplier applied to this sprite's logical width/height.
58
+ * Default: `1`.
88
59
  */
89
- this.size = 32;
60
+ this.scale = 1;
61
+ /** @internal */
62
+ this._renderData = null;
63
+ /**
64
+ * Optional logical body width used by physics helpers and collision checks.
65
+ *
66
+ * When `null` (default), MinimoJS uses the sprite's visual {@link Sprite.width}.
67
+ * When set, this value is scaled by {@link Sprite.scale} the same way as the
68
+ * visual sprite size.
69
+ */
70
+ this.bodyWidth = null;
71
+ /**
72
+ * Optional logical body height used by physics helpers and collision checks.
73
+ *
74
+ * When `null` (default), MinimoJS uses the sprite's visual {@link Sprite.height}.
75
+ * When set, this value is scaled by {@link Sprite.scale} the same way as the
76
+ * visual sprite size.
77
+ */
78
+ this.bodyHeight = null;
79
+ /**
80
+ * Horizontal body offset, in local sprite pixels before scale is applied.
81
+ *
82
+ * Positive values move the collision body to the right of the sprite's rendered center.
83
+ * Negative values move it to the left.
84
+ */
85
+ this.bodyOffsetX = 0;
86
+ /**
87
+ * Vertical body offset, in local sprite pixels before scale is applied.
88
+ *
89
+ * Positive values move the collision body downward relative to the sprite's
90
+ * rendered center. Negative values move it upward.
91
+ */
92
+ this.bodyOffsetY = 0;
90
93
  /**
91
94
  * CSS text color used when rendering this sprite.
92
95
  *
@@ -95,6 +98,19 @@ export class Sprite {
95
98
  * depending on browser behavior.
96
99
  */
97
100
  this.color = "#000000";
101
+ /**
102
+ * Physics body type flag.
103
+ *
104
+ * - `false` (default): the sprite is dynamic and can be moved by velocity,
105
+ * gravity, and explicit collision resolution.
106
+ * - `true`: the sprite is static and is not moved by the engine's built-in
107
+ * velocity/gravity integration. Static sprites act as stable obstacles for
108
+ * simple platform collisions.
109
+ *
110
+ * Static sprites can still be repositioned manually by setting `x` and `y`
111
+ * directly in your own game code.
112
+ */
113
+ this.isStatic = false;
98
114
  /**
99
115
  * Visual rotation of the sprite in degrees.
100
116
  * `0` = upright. Positive values rotate clockwise.
@@ -159,11 +175,772 @@ export class Sprite {
159
175
  * Values > 1 amplify gravity; negative values invert it.
160
176
  */
161
177
  this.gravityScale = 0;
178
+ /**
179
+ * Optional name, so code can ask an object what it is. Names may repeat,
180
+ * like a `name` attribute in the DOM. The engine never reads it.
181
+ * A sprite created from a design file takes the object's `name`.
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * if (other.name === "door") openDoor();
186
+ * ```
187
+ */
188
+ this.name = undefined;
189
+ /**
190
+ * Tags for grouping, like CSS classes in the DOM. Empty by default. The
191
+ * engine never reads them. A sprite created from a design file takes the
192
+ * object's `tags`, which {@link Game.registerBuilderByTag} also matches.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * if (other.tags.includes("coin")) collect(other);
197
+ * ```
198
+ */
199
+ this.tags = [];
200
+ /**
201
+ * The game's own values for this object, such as a button's action or an
202
+ * enemy's speed. An empty object by default, so `sprite.data.hp = 3` needs
203
+ * no check. The engine never reads it. A sprite created from a design file
204
+ * takes a copy of the object's `data`.
205
+ */
206
+ this.data = {};
207
+ // `abstract` and `protected` are erased by the TypeScript compiler, and games
208
+ // are written in JavaScript, so nothing but this check stops old code from
209
+ // calling `new Sprite(glyph, x, y, size)`. Without it the glyph would be
210
+ // stored as this sprite's game reference, the position and size would be
211
+ // discarded, and the failure would surface one call later inside `Game.add`
212
+ // as an unrelated "attached to multiple Game instances" error.
213
+ if (new.target === Sprite) {
214
+ throw new Error("MinimoJS: Sprite is an abstract type and cannot be constructed directly. " +
215
+ "Use EmojiSprite for an emoji, ImageSprite for a preloaded image, " +
216
+ "TextSprite for text, or DrawSprite for procedural drawing.");
217
+ }
218
+ this._game = game;
219
+ }
220
+ /**
221
+ * Game instance associated with this sprite, if any.
222
+ */
223
+ get game() {
224
+ return this._game;
225
+ }
226
+ /** @internal */
227
+ _setGame(game) {
228
+ if (this._game !== null && this._game !== game) {
229
+ throw new Error("MinimoJS: A sprite cannot be attached to multiple Game instances.");
230
+ }
231
+ this._game = game;
232
+ }
233
+ /**
234
+ * Resolved center X used internally by rendering, hit testing, and collisions.
235
+ */
236
+ get renderX() {
237
+ return this.x + this.getAnchorOffsetX();
238
+ }
239
+ /**
240
+ * Resolved center Y used internally by rendering, hit testing, and collisions.
241
+ */
242
+ get renderY() {
243
+ return this.y + this.getAnchorOffsetY();
244
+ }
245
+ /**
246
+ * Effective rendered/collision width in pixels.
247
+ */
248
+ get displayWidth() {
249
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
250
+ return this.width * Math.max(0, safeScale);
251
+ }
252
+ /**
253
+ * Effective rendered/collision height in pixels.
254
+ */
255
+ get displayHeight() {
256
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
257
+ return this.height * Math.max(0, safeScale);
258
+ }
259
+ /**
260
+ * Effective collision-body width in pixels after applying {@link Sprite.scale}.
261
+ */
262
+ get bodyDisplayWidth() {
263
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
264
+ const baseWidth = typeof this.bodyWidth === "number" &&
265
+ Number.isFinite(this.bodyWidth) &&
266
+ this.bodyWidth > 0
267
+ ? this.bodyWidth
268
+ : this.width;
269
+ return baseWidth * Math.max(0, safeScale);
270
+ }
271
+ /**
272
+ * Effective collision-body height in pixels after applying {@link Sprite.scale}.
273
+ */
274
+ get bodyDisplayHeight() {
275
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
276
+ const baseHeight = typeof this.bodyHeight === "number" &&
277
+ Number.isFinite(this.bodyHeight) &&
278
+ this.bodyHeight > 0
279
+ ? this.bodyHeight
280
+ : this.height;
281
+ return baseHeight * Math.max(0, safeScale);
282
+ }
283
+ /**
284
+ * Resolved body center X used internally by physics helpers and collision checks.
285
+ */
286
+ get bodyCenterX() {
287
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
288
+ return this.renderX + this.bodyOffsetX * Math.max(0, safeScale);
289
+ }
290
+ /**
291
+ * Resolved body center Y used internally by physics helpers and collision checks.
292
+ */
293
+ get bodyCenterY() {
294
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
295
+ return this.renderY + this.bodyOffsetY * Math.max(0, safeScale);
296
+ }
297
+ getAnchorOffsetX() {
298
+ return 0;
299
+ }
300
+ getAnchorOffsetY() {
301
+ return 0;
302
+ }
303
+ }
304
+ /**
305
+ * A 2D game object rendered as an emoji on the canvas.
306
+ *
307
+ * Instantiate directly or extend to create custom sprite types.
308
+ * Register with the engine by passing the instance to {@link Game.add}.
309
+ *
310
+ * **Coordinate system:** center-based world space. `(x, y)` is the center of
311
+ * the sprite. Positive X = right, positive Y = down.
312
+ *
313
+ * **Lifecycle:** A sprite exists until {@link Game.destroySprite} is called or
314
+ * {@link Game.reset} is invoked. After destruction, do not read or write its fields.
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * const coin = new EmojiSprite("🪙", 300, 200, 32);
319
+ * game.add(coin);
320
+ * ```
321
+ */
322
+ export class EmojiSprite extends Sprite {
323
+ get size() {
324
+ return this._size;
325
+ }
326
+ get width() {
327
+ return this._size;
328
+ }
329
+ get height() {
330
+ return this._size;
331
+ }
332
+ get displaySize() {
333
+ return this.displayWidth;
334
+ }
335
+ /**
336
+ * Creates a new EmojiSprite with the given emoji, optional position, and base size.
337
+ * All other properties use their defaults and can be set after construction.
338
+ *
339
+ * @param sprite - The emoji character to render. Must be a single emoji.
340
+ * Use {@link ImageSprite} for preloaded bitmap textures.
341
+ * @example "🔥", "⭐", "🐢", "💣", "👾"
342
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
343
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
344
+ * @param size - Base sprite size in pixels. Default: `32`.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * const enemy = new EmojiSprite("👾", 200, 100, 40);
349
+ * game.add(enemy);
350
+ * ```
351
+ */
352
+ constructor(sprite, x = 0, y = 0, size = 32) {
353
+ super();
162
354
  this.sprite = sprite;
163
355
  this.x = x;
164
356
  this.y = y;
357
+ const safeSize = Number.isFinite(size) ? size : 32;
358
+ this._size = Math.max(1, safeSize);
359
+ }
360
+ getRenderCacheKey() {
361
+ return `emoji:${this.sprite}|size:${this._size}|color:${this.color}`;
165
362
  }
166
363
  }
364
+ /**
365
+ * A renderable sprite backed by a preloaded image asset.
366
+ *
367
+ * Width and height are always resolved from the current texture. To resize the
368
+ * sprite visually, use {@link Sprite.scale}.
369
+ */
370
+ export class ImageSprite extends Sprite {
371
+ get imageKey() {
372
+ return this._imageKey;
373
+ }
374
+ get width() {
375
+ const image = this.getResolvedImage();
376
+ return image instanceof HTMLImageElement
377
+ ? image.naturalWidth || image.width
378
+ : image.width;
379
+ }
380
+ get height() {
381
+ const image = this.getResolvedImage();
382
+ return image instanceof HTMLImageElement
383
+ ? image.naturalHeight || image.height
384
+ : image.height;
385
+ }
386
+ /**
387
+ * Creates a new image-backed sprite.
388
+ *
389
+ * @param game - Game instance used to resolve the texture key.
390
+ * @param imageKey - Texture key previously registered with {@link Game.loadImage} or {@link Game.createTexture}.
391
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
392
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
393
+ */
394
+ constructor(game, imageKey, x = 0, y = 0) {
395
+ super(game);
396
+ this._imageKey = imageKey;
397
+ this.x = x;
398
+ this.y = y;
399
+ }
400
+ /**
401
+ * Replaces the current texture with another preloaded image.
402
+ *
403
+ * @param imageKey - New texture key to render.
404
+ */
405
+ setTexture(imageKey) {
406
+ if (imageKey === this._imageKey)
407
+ return;
408
+ this.assertTextureAvailable(imageKey);
409
+ this._imageKey = imageKey;
410
+ }
411
+ getRenderCacheKey() {
412
+ const image = this.getResolvedImage();
413
+ const width = image instanceof HTMLImageElement ? image.naturalWidth || image.width : image.width;
414
+ const height = image instanceof HTMLImageElement ? image.naturalHeight || image.height : image.height;
415
+ return [
416
+ "image",
417
+ this._imageKey,
418
+ this.game?.getImageVersion(this._imageKey) ?? 0,
419
+ width,
420
+ height,
421
+ ].join("|");
422
+ }
423
+ /** @internal */
424
+ getResolvedImage() {
425
+ this.assertTextureAvailable(this._imageKey);
426
+ const image = this.game?.getImage(this._imageKey);
427
+ if (!image) {
428
+ throw new Error(`MinimoJS: Image '${this._imageKey}' is not loaded.`);
429
+ }
430
+ return image;
431
+ }
432
+ /** @internal */
433
+ assertTextureAvailable(imageKey) {
434
+ if (!this.game) {
435
+ throw new Error("MinimoJS: ImageSprite requires an associated Game instance.");
436
+ }
437
+ if (!this.game.hasImage(imageKey)) {
438
+ throw new Error(`MinimoJS: Image '${imageKey}' is not loaded.`);
439
+ }
440
+ }
441
+ }
442
+ /**
443
+ * A renderable sprite backed by a per-instance canvas that is repainted on demand.
444
+ *
445
+ * `DrawSprite` is useful for procedural shapes, gauges, charts, minimaps, and
446
+ * HUD widgets whose appearance changes often and is easier to express with
447
+ * Canvas 2D drawing commands than with emoji, text, or image swaps.
448
+ *
449
+ * Treat `DrawSprite` as a specialized tool, not the default sprite type.
450
+ * Because it may execute custom Canvas 2D drawing code repeatedly, overusing it
451
+ * can affect game performance much more than regular {@link EmojiSprite},
452
+ * {@link ImageSprite}, or {@link TextSprite} instances.
453
+ *
454
+ * Prefer the other sprite types whenever they can express the same result more
455
+ * simply. Reach for `DrawSprite` only when you truly need procedural drawing or
456
+ * custom per-sprite canvas rendering that the built-in sprite types cannot
457
+ * provide cleanly.
458
+ *
459
+ * MinimoJS creates and owns an internal canvas for each `DrawSprite` instance.
460
+ * Before every engine render that needs this sprite's surface, the engine:
461
+ *
462
+ * 1. Resolves the sprite's internal canvas size
463
+ * 2. Optionally clears and repaints that internal canvas
464
+ * 3. Draws the resulting canvas like any other sprite surface
465
+ *
466
+ * By default, `DrawSprite` is live-rendered: the engine clears the internal
467
+ * canvas and calls {@link DrawSprite.redraw} every frame.
468
+ *
469
+ * Set {@link DrawSprite.frozen} to `true` if you want to keep and reuse the
470
+ * last rendered canvas contents without repainting on each frame. This is
471
+ * useful for shapes or procedural art that you only want to draw once.
472
+ *
473
+ * While `frozen` is `true`, MinimoJS reuses the existing surface exactly as-is:
474
+ * it does not clear the canvas and does not call {@link DrawSprite.redraw}
475
+ * again, unless the surface does not exist yet or its size had to be rebuilt.
476
+ *
477
+ * This means freezing is a rendering optimization and content-preservation
478
+ * flag, not a separate caching system. You can switch `frozen` on or off at
479
+ * runtime whenever it makes sense for your sprite.
480
+ *
481
+ * The local drawing coordinate system uses the sprite surface itself:
482
+ * - `(0, 0)` is the top-left corner of the internal canvas
483
+ * - `width` / `height` match the sprite's logical size before `scale`
484
+ * - draw centered content yourself if you want the visual origin in the middle
485
+ *
486
+ * Override {@link DrawSprite.redraw} in a subclass, or assign your own method
487
+ * on an instance if you prefer an inline style in JavaScript.
488
+ */
489
+ export class DrawSprite extends Sprite {
490
+ get width() {
491
+ return this._width;
492
+ }
493
+ get height() {
494
+ return this._height;
495
+ }
496
+ /**
497
+ * Creates a new dynamic canvas-backed sprite.
498
+ *
499
+ * @param width - Internal canvas width in pixels. Minimum `1`.
500
+ * @param height - Internal canvas height in pixels. Minimum `1`.
501
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
502
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
503
+ */
504
+ constructor(width, height, x = 0, y = 0) {
505
+ super();
506
+ /** @internal */
507
+ this._surface = null;
508
+ /** @internal */
509
+ this._surfaceCtx = null;
510
+ /** @internal */
511
+ this._lastRedrawPassId = -1;
512
+ /**
513
+ * When `false` (default), MinimoJS clears this sprite's internal canvas and
514
+ * calls {@link DrawSprite.redraw} on every render pass.
515
+ *
516
+ * When `true`, MinimoJS keeps and reuses the existing canvas contents without
517
+ * clearing or repainting them again, unless the internal surface does not yet
518
+ * exist or had to be resized.
519
+ *
520
+ * Use this when your procedural drawing becomes static after its first paint,
521
+ * or when you want explicit manual control over when the sprite is redrawn by
522
+ * toggling `frozen` at runtime.
523
+ */
524
+ this.frozen = false;
525
+ this._surfaceId = DrawSprite._nextSurfaceId++;
526
+ this._width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
527
+ this._height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
528
+ this.x = x;
529
+ this.y = y;
530
+ }
531
+ /**
532
+ * Called by the engine whenever the sprite's internal surface must be repainted.
533
+ *
534
+ * The provided context is already cleared and reset to the default 2D canvas
535
+ * state for the current surface size. MinimoJS repaints each `DrawSprite` at
536
+ * most once per render pass while it is not {@link DrawSprite.frozen}, so
537
+ * repeated snapshot reads during the same frame reuse the already-redrawn
538
+ * surface.
539
+ *
540
+ * @param ctx - The sprite's internal 2D drawing context.
541
+ */
542
+ redraw(_ctx) { }
543
+ getRenderCacheKey() {
544
+ return `draw:${this._surfaceId}`;
545
+ }
546
+ }
547
+ /** @internal */
548
+ DrawSprite._nextSurfaceId = 1;
549
+ /**
550
+ * A renderable text actor that participates in the same animation/effects
551
+ * pipeline as regular sprites.
552
+ *
553
+ * Use `TextSprite` when the content is primarily text, or when you need text
554
+ * layout features such as wrapping, fixed button sizes, background/border, or
555
+ * text stroke.
556
+ *
557
+ * For controls that are only a single emoji, prefer {@link EmojiSprite}. Emoji-only
558
+ * buttons usually behave better as sprites because they do not need text
559
+ * padding/layout and their bounds match the rendered emoji more directly.
560
+ */
561
+ export class TextSprite extends Sprite {
562
+ get width() {
563
+ this.ensureMeasured();
564
+ return this._measuredWidth;
565
+ }
566
+ get height() {
567
+ this.ensureMeasured();
568
+ return this._measuredHeight;
569
+ }
570
+ constructor(text, x = 0, y = 0, fontSizeOrConfig = 16, config = {}) {
571
+ super();
572
+ this.anchorX = "center";
573
+ this.anchorY = "middle";
574
+ /**
575
+ * CSS font family. Default: `monospace`, which needs no download. Name a web
576
+ * font here, and register it with {@link Game.requireFont}, to use one.
577
+ */
578
+ this.fontFamily = "monospace";
579
+ this.fontWeight = "400";
580
+ this.lineHeight = 1.2;
581
+ this.maxWidth = 0;
582
+ this.fixedWidth = 0;
583
+ this.fixedHeight = 0;
584
+ this.paddingX = 2;
585
+ this.paddingY = 2;
586
+ this.backgroundColor = null;
587
+ this.borderColor = null;
588
+ this.borderWidth = 0;
589
+ this.cornerRadius = 0;
590
+ this.strokeColor = null;
591
+ this.strokeWidth = 0;
592
+ this.textAlign = "center";
593
+ /** @internal */
594
+ this._measuredWidth = 1;
595
+ /** @internal */
596
+ this._measuredHeight = 1;
597
+ /** @internal */
598
+ this._measurementCacheKey = "";
599
+ this.text = text;
600
+ this.x = x;
601
+ this.y = y;
602
+ this.fontSize =
603
+ typeof fontSizeOrConfig === "number" ? fontSizeOrConfig : 16;
604
+ this.color = "#ffffff";
605
+ if (typeof fontSizeOrConfig === "number") {
606
+ this.applyConfig(config);
607
+ }
608
+ else {
609
+ this.applyConfig(fontSizeOrConfig);
610
+ }
611
+ }
612
+ getRenderCacheKey() {
613
+ return [
614
+ "text",
615
+ this.text,
616
+ this.anchorX,
617
+ this.anchorY,
618
+ this.fontFamily,
619
+ this.fontSize,
620
+ this.fontWeight,
621
+ this.lineHeight,
622
+ this.maxWidth,
623
+ this.fixedWidth,
624
+ this.fixedHeight,
625
+ this.color,
626
+ this.textAlign,
627
+ this.paddingX,
628
+ this.paddingY,
629
+ this.backgroundColor ?? "",
630
+ this.borderColor ?? "",
631
+ this.borderWidth,
632
+ this.cornerRadius,
633
+ this.strokeColor ?? "",
634
+ this.strokeWidth,
635
+ ].join("|");
636
+ }
637
+ get align() {
638
+ return this.textAlign;
639
+ }
640
+ set align(value) {
641
+ this.textAlign = value;
642
+ }
643
+ getAnchorOffsetX() {
644
+ if (this.anchorX === "left") {
645
+ return this.displayWidth / 2;
646
+ }
647
+ if (this.anchorX === "right") {
648
+ return -this.displayWidth / 2;
649
+ }
650
+ return 0;
651
+ }
652
+ getAnchorOffsetY() {
653
+ if (this.anchorY === "top") {
654
+ return this.displayHeight / 2;
655
+ }
656
+ if (this.anchorY === "bottom") {
657
+ return -this.displayHeight / 2;
658
+ }
659
+ return 0;
660
+ }
661
+ /** @internal */
662
+ applyConfig(config) {
663
+ if (config.anchorX !== undefined)
664
+ this.anchorX = config.anchorX;
665
+ if (config.anchorY !== undefined)
666
+ this.anchorY = config.anchorY;
667
+ if (config.fontFamily !== undefined)
668
+ this.fontFamily = config.fontFamily;
669
+ if (config.fontSize !== undefined)
670
+ this.fontSize = config.fontSize;
671
+ if (config.fontWeight !== undefined)
672
+ this.fontWeight = config.fontWeight;
673
+ if (config.lineHeight !== undefined)
674
+ this.lineHeight = config.lineHeight;
675
+ if (config.maxWidth !== undefined)
676
+ this.maxWidth = config.maxWidth;
677
+ if (config.fixedWidth !== undefined)
678
+ this.fixedWidth = config.fixedWidth;
679
+ if (config.fixedHeight !== undefined)
680
+ this.fixedHeight = config.fixedHeight;
681
+ if (config.paddingX !== undefined)
682
+ this.paddingX = config.paddingX;
683
+ if (config.paddingY !== undefined)
684
+ this.paddingY = config.paddingY;
685
+ if (config.backgroundColor !== undefined)
686
+ this.backgroundColor = config.backgroundColor;
687
+ if (config.borderColor !== undefined)
688
+ this.borderColor = config.borderColor;
689
+ if (config.borderWidth !== undefined)
690
+ this.borderWidth = config.borderWidth;
691
+ if (config.cornerRadius !== undefined)
692
+ this.cornerRadius = config.cornerRadius;
693
+ if (config.strokeColor !== undefined)
694
+ this.strokeColor = config.strokeColor;
695
+ if (config.strokeWidth !== undefined)
696
+ this.strokeWidth = config.strokeWidth;
697
+ if (config.textAlign !== undefined)
698
+ this.textAlign = config.textAlign;
699
+ if (config.align !== undefined)
700
+ this.textAlign = config.align;
701
+ }
702
+ /** @internal */
703
+ ensureMeasured() {
704
+ const nextKey = [
705
+ this.text,
706
+ this.fontFamily,
707
+ this.fontSize,
708
+ this.fontWeight,
709
+ this.lineHeight,
710
+ this.maxWidth,
711
+ this.fixedWidth,
712
+ this.fixedHeight,
713
+ this.paddingX,
714
+ this.paddingY,
715
+ this.borderWidth,
716
+ this.strokeWidth,
717
+ ].join("|");
718
+ if (this._measurementCacheKey === nextKey) {
719
+ return;
720
+ }
721
+ if (typeof document === "undefined") {
722
+ this._measuredWidth = 1;
723
+ this._measuredHeight = 1;
724
+ this._measurementCacheKey = nextKey;
725
+ return;
726
+ }
727
+ if (TextSprite._measurementCanvas === null) {
728
+ TextSprite._measurementCanvas = document.createElement("canvas");
729
+ TextSprite._measurementContext =
730
+ TextSprite._measurementCanvas.getContext("2d");
731
+ }
732
+ const ctx = TextSprite._measurementContext;
733
+ if (!ctx) {
734
+ this._measuredWidth = 1;
735
+ this._measuredHeight = 1;
736
+ this._measurementCacheKey = nextKey;
737
+ return;
738
+ }
739
+ ctx.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`;
740
+ const lines = this.layoutMeasuredLines(ctx);
741
+ const measuredWidth = Math.max(1, Math.ceil(lines.reduce((maxWidth, line) => Math.max(maxWidth, ctx.measureText(line).width), 0)));
742
+ const lineHeightPx = Math.max(1, Math.ceil(this.fontSize * this.lineHeight));
743
+ const contentHeight = Math.max(1, lineHeightPx * Math.max(lines.length, 1));
744
+ const borderPad = Math.max(0, this.borderWidth) * 2;
745
+ const strokePad = Math.max(0, this.strokeWidth) * 2;
746
+ this._measuredWidth = Math.max(1, Math.ceil(this.fixedWidth > 0
747
+ ? this.fixedWidth
748
+ : measuredWidth + this.paddingX * 2 + borderPad + strokePad));
749
+ this._measuredHeight = Math.max(1, Math.ceil(this.fixedHeight > 0
750
+ ? this.fixedHeight
751
+ : contentHeight + this.paddingY * 2 + borderPad + strokePad));
752
+ this._measurementCacheKey = nextKey;
753
+ }
754
+ /** @internal */
755
+ layoutMeasuredLines(ctx) {
756
+ const rawLines = this.text.split("\n");
757
+ const availableTextWidth = this.getAvailableTextWidth();
758
+ if (availableTextWidth <= 0) {
759
+ return rawLines.length > 0 ? rawLines : [""];
760
+ }
761
+ const wrappedLines = [];
762
+ for (const rawLine of rawLines) {
763
+ const words = rawLine.split(/\s+/).filter((word) => word.length > 0);
764
+ if (words.length === 0) {
765
+ wrappedLines.push("");
766
+ continue;
767
+ }
768
+ let currentLine = words[0];
769
+ for (let i = 1; i < words.length; i++) {
770
+ const candidate = `${currentLine} ${words[i]}`;
771
+ if (ctx.measureText(candidate).width <= availableTextWidth) {
772
+ currentLine = candidate;
773
+ }
774
+ else {
775
+ wrappedLines.push(currentLine);
776
+ currentLine = words[i];
777
+ }
778
+ }
779
+ wrappedLines.push(currentLine);
780
+ }
781
+ return wrappedLines.length > 0 ? wrappedLines : [""];
782
+ }
783
+ /** @internal */
784
+ getAvailableTextWidth() {
785
+ const borderPad = Math.max(0, this.borderWidth) * 2;
786
+ const strokePad = Math.max(0, this.strokeWidth) * 2;
787
+ const limits = [];
788
+ if (this.maxWidth > 0) {
789
+ limits.push(this.maxWidth - this.paddingX * 2 - borderPad - strokePad);
790
+ }
791
+ if (this.fixedWidth > 0) {
792
+ limits.push(this.fixedWidth - this.paddingX * 2 - borderPad - strokePad);
793
+ }
794
+ if (limits.length === 0) {
795
+ return 0;
796
+ }
797
+ return Math.max(1, Math.floor(Math.min(...limits)));
798
+ }
799
+ }
800
+ /** @internal */
801
+ TextSprite._measurementCanvas = null;
802
+ /** @internal */
803
+ TextSprite._measurementContext = null;
804
+ /**
805
+ * A static image-based background layer rendered behind all sprites.
806
+ *
807
+ * Background layers are visual-only: they do not participate in physics,
808
+ * collisions, input hit-testing, or sprite queries.
809
+ *
810
+ * `x` / `y` use **top-left screen-space coordinates**, unlike {@link Sprite},
811
+ * which uses center-based world space.
812
+ *
813
+ * The texture referenced by {@link BackgroundLayer.imageKey} must be registered
814
+ * with {@link Game.loadImage} during {@link Game.onPreload}, or created
815
+ * dynamically with {@link Game.createTexture}.
816
+ *
817
+ * @example
818
+ * ```ts
819
+ * game.onPreload = () => {
820
+ * game.loadImage("sky", "assets/sky.png");
821
+ * };
822
+ *
823
+ * game.onCreate = () => {
824
+ * const bg = game.addBackground(new BackgroundLayer("sky"));
825
+ * bg.fit = "cover";
826
+ * };
827
+ * ```
828
+ */
829
+ export class BackgroundLayer {
830
+ /**
831
+ * Creates a new background layer.
832
+ *
833
+ * @param imageKey - Preloaded image key previously registered with {@link Game.loadImage}.
834
+ * @param x - Destination rectangle left edge in screen space. Default: `0`.
835
+ * @param y - Destination rectangle top edge in screen space. Default: `0`.
836
+ * @param width - Destination rectangle width. `0` means canvas width. Default: `0`.
837
+ * @param height - Destination rectangle height. `0` means canvas height. Default: `0`.
838
+ */
839
+ constructor(imageKey, x = 0, y = 0, width = 0, height = 0) {
840
+ /**
841
+ * Opacity of the layer in range `[0, 1]`.
842
+ */
843
+ this.alpha = 1;
844
+ /**
845
+ * Controls whether the layer is drawn.
846
+ */
847
+ this.visible = true;
848
+ /**
849
+ * Orders background layers relative to each other.
850
+ * Higher values render on top of lower background layers, but still behind sprites.
851
+ */
852
+ this.layer = 0;
853
+ /**
854
+ * How the image is fit inside the destination rectangle.
855
+ * Default: `"cover"`.
856
+ */
857
+ this.fit = "cover";
858
+ /**
859
+ * Optional name, so code can ask a layer what it is. Names may repeat. The
860
+ * engine never reads it. See {@link Sprite.name}.
861
+ */
862
+ this.name = undefined;
863
+ /**
864
+ * Tags for grouping. Empty by default. The engine never reads them. See
865
+ * {@link Sprite.tags}.
866
+ */
867
+ this.tags = [];
868
+ /**
869
+ * The game's own values for this layer. An empty object by default. The
870
+ * engine never reads it. See {@link Sprite.data}.
871
+ */
872
+ this.data = {};
873
+ this.imageKey = imageKey;
874
+ this.x = x;
875
+ this.y = y;
876
+ this.width = width;
877
+ this.height = height;
878
+ }
879
+ }
880
+ /**
881
+ * The objects {@link Game.createObjectList} created, in the order of their
882
+ * data, and searches on them.
883
+ *
884
+ * An `ObjectList` only holds and finds its objects; it never changes them.
885
+ * Remove them through the game, as any other object:
886
+ *
887
+ * ```ts
888
+ * for (const obj of ui.objects) {
889
+ * if (obj instanceof BackgroundLayer) game.destroyBackgroundLayer(obj);
890
+ * else game.destroySprite(obj);
891
+ * }
892
+ * ```
893
+ *
894
+ * Searches read each object's current {@link Sprite.name} and
895
+ * {@link Sprite.tags}. In TypeScript, name the type you expect:
896
+ * `ui.findByName<ImageSprite>("play")`.
897
+ */
898
+ export class ObjectList {
899
+ /** @internal */
900
+ constructor(objects) {
901
+ this.objects = Object.freeze([...objects]);
902
+ }
903
+ /**
904
+ * Returns the first object whose `name` is `name`, in order.
905
+ * Throws an error naming `name` when there is none.
906
+ *
907
+ * @example
908
+ * ```ts
909
+ * const play = ui.findByName("play");
910
+ * ```
911
+ */
912
+ findByName(name) {
913
+ return findFirst(this.objects, (obj) => obj.name === name, `named "${name}"`, "the object list");
914
+ }
915
+ /** Returns every object whose `name` is `name`, in order; an empty list when there is none. */
916
+ findAllByName(name) {
917
+ return this.objects.filter((obj) => obj.name === name);
918
+ }
919
+ /**
920
+ * Returns the first object whose `tags` include `tag`, in order.
921
+ * Throws an error naming `tag` when there is none.
922
+ */
923
+ findByTag(tag) {
924
+ return findFirst(this.objects, (obj) => obj.tags.includes(tag), `tagged "${tag}"`, "the object list");
925
+ }
926
+ /** Returns every object whose `tags` include `tag`, in order; an empty list when there is none. */
927
+ findAllByTag(tag) {
928
+ return this.objects.filter((obj) => obj.tags.includes(tag));
929
+ }
930
+ }
931
+ /** The runtime class of each type of design object. */
932
+ const DESIGN_OBJECT_CLASSES = {
933
+ image: ImageSprite,
934
+ emoji: EmojiSprite,
935
+ text: TextSprite,
936
+ background: BackgroundLayer,
937
+ };
938
+ function describeDesignObject(obj) {
939
+ return obj.name === undefined ? `the object ${obj.id}` : `the object "${obj.name}" (${obj.id})`;
940
+ }
941
+ // ---------------------------------------------------------------------------
942
+ // Internal types (not part of the public API declarations)
943
+ // ---------------------------------------------------------------------------
167
944
  // ---------------------------------------------------------------------------
168
945
  // Game
169
946
  // ---------------------------------------------------------------------------
@@ -176,18 +953,88 @@ export class Sprite {
176
953
  *
177
954
  * ---
178
955
  *
956
+ * ## ES Module Only
957
+ *
958
+ * MinimoJS is an **ES module only** package.
959
+ * You must import it with standard ESM syntax such as:
960
+ *
961
+ * ```ts
962
+ * import { Game, EmojiSprite } from "minimojs";
963
+ * ```
964
+ *
965
+ * In the browser, use it from a module script:
966
+ *
967
+ * ```html
968
+ * <script type="module">
969
+ * import { Game, EmojiSprite } from "./dist/minimo.js";
970
+ * </script>
971
+ * ```
972
+ *
973
+ * Or from a CDN:
974
+ *
975
+ * ```ts
976
+ * import { Game, EmojiSprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
977
+ * ```
978
+ *
979
+ * Do NOT use a classic `<script>` tag without `type="module"`.
980
+ * Do NOT expect a global `window.MinimoJS`.
981
+ * Do NOT use `require("minimojs")` (CommonJS is not supported).
982
+ *
983
+ * ---
984
+ *
985
+ * ## Font Setup
986
+ *
987
+ * `drawText()` and `TextSprite` default to `monospace`, which every device
988
+ * has, so a game that names no font downloads none and waits for none. To use
989
+ * a web font, declare it in your page, register it with {@link Game.requireFont}
990
+ * before calling {@link Game.start}, and name it in {@link TextSprite.fontFamily}.
991
+ *
992
+ * Prefer {@link TextSprite} for persistent UI text, interactive labels,
993
+ * buttons, fixed-size text boxes, and styled text elements. Keep
994
+ * `drawText()` for simple screen-space overlays such as HUD counters, debug
995
+ * text, or other text that is redrawn every frame.
996
+ *
997
+ * You MUST still declare the fonts yourself in your `index.html`. MinimoJS
998
+ * does NOT download, inject, or manage web fonts for you. If a font is missing,
999
+ * the browser falls back to `monospace`.
1000
+ *
1001
+ * Example `index.html` `<head>` setup:
1002
+ *
1003
+ * ```html
1004
+ * <link rel="preconnect" href="https://fonts.googleapis.com" />
1005
+ * <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1006
+ * <link
1007
+ * href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
1008
+ * rel="stylesheet"
1009
+ * />
1010
+ * ```
1011
+ *
1012
+ * Example custom font registration:
1013
+ *
1014
+ * ```ts
1015
+ * const game = new Game(720, 1280);
1016
+ * game.requireFont('"Bangers"', { weight: "400" });
1017
+ * game.start();
1018
+ * ```
1019
+ *
1020
+ * ---
1021
+ *
179
1022
  * ## Quick Start
180
1023
  *
181
1024
  * ```ts
182
- * import { Game, Sprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
1025
+ * import { Game, ImageSprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
1026
+ *
1027
+ * const game = new Game(720, 1280);
1028
+ *
1029
+ * game.onPreload = () => {
1030
+ * game.loadImage("player", "assets/player.png");
1031
+ * };
183
1032
  *
184
- * const game = new Game(800, 600);
1033
+ * let player: ImageSprite;
185
1034
  *
186
- * const player = new Sprite("🐢");
187
- * player.x = 400;
188
- * player.y = 500;
189
- * player.size = 48;
190
- * game.add(player);
1035
+ * game.onCreate = () => {
1036
+ * player = game.add(new ImageSprite(game, "player", 400, 500));
1037
+ * };
191
1038
  *
192
1039
  * game.onUpdate = (dt) => {
193
1040
  * if (game.isKeyDown("ArrowLeft")) player.vx = -200;
@@ -200,6 +1047,13 @@ export class Sprite {
200
1047
  * game.start();
201
1048
  * ```
202
1049
  *
1050
+ * Prototyping, and have no art yet? Swap the texture for an emoji and drop the
1051
+ * preload entirely — everything else stays the same:
1052
+ *
1053
+ * ```ts
1054
+ * const player = game.add(new EmojiSprite("🐢", 400, 500, 48));
1055
+ * ```
1056
+ *
203
1057
  * ---
204
1058
  *
205
1059
  * ## Engine Philosophy
@@ -213,7 +1067,8 @@ export class Sprite {
213
1067
  * ## Flat API
214
1068
  *
215
1069
  * ALL engine functionality is on the `game` object.
216
- * Do not look for sub-objects like `game.physics`, `game.input`, etc. They do not exist.
1070
+ * Do not look for nested subsystem objects like `game.input.keyboard`,
1071
+ * `game.physics.world`, etc. They do not exist in MinimoJS v1.
217
1072
  *
218
1073
  * ---
219
1074
  *
@@ -224,13 +1079,28 @@ export class Sprite {
224
1079
  *
225
1080
  * ---
226
1081
  *
227
- * ## Emoji-Only Sprites
228
- *
229
- * Every sprite MUST use a single emoji character as its visual representation.
230
- * PNG, SVG, spritesheet, and image sprites are NOT supported.
231
- * Use Unicode emoji: `"🔥"`, `"⭐"`, `"💣"`, `"🐢"`, `"👾"`, `"🧱"`, etc.
232
- * AI agents can also use text-like emojis (regional indicators, symbols, letters)
233
- * to build fun title art, HUD labels, and expressive in-game text.
1082
+ * ## Choosing a Visual Source
1083
+ *
1084
+ * A sprite's look comes from one of four concrete types, all of which extend the
1085
+ * shared abstract {@link Sprite} type. Use `Sprite` as the type in your own
1086
+ * signatures; never construct it directly.
1087
+ *
1088
+ * 1. {@link ImageSprite} — the game's own images, and the first option to reach
1089
+ * for. Register a texture with {@link Game.loadImage} inside
1090
+ * {@link Game.onPreload}, or build one at runtime with
1091
+ * {@link Game.createTexture}, then render it with
1092
+ * `new ImageSprite(game, key, x, y)`. An image looks identical on every
1093
+ * device and belongs to the game.
1094
+ * 2. {@link EmojiSprite} — a single Unicode emoji, and the shortest path to a
1095
+ * first prototype: `new EmojiSprite("🐢", x, y, size)`. Note that an
1096
+ * emoji is drawn with the player's own system emoji font, so the same game
1097
+ * looks different across iOS, Android, and Windows, and a glyph the platform
1098
+ * lacks renders as an empty box. Prefer an image whenever the look matters.
1099
+ * 3. {@link TextSprite} — text, labels, and buttons.
1100
+ * 4. {@link DrawSprite} — procedural Canvas 2D drawing, for shapes and gauges
1101
+ * the other three cannot express cleanly.
1102
+ *
1103
+ * Emoji and images mix freely in one game.
234
1104
  *
235
1105
  * ---
236
1106
  *
@@ -246,7 +1116,7 @@ export class Sprite {
246
1116
  *
247
1117
  * ALL time parameters to engine methods are in **milliseconds (ms)**.
248
1118
  * This includes: {@link Game.addTimer}, {@link Game.animateAlpha},
249
- * {@link Game.animateRotation}, and {@link Game.sound}.
1119
+ * {@link Game.animateRotation}, {@link Game.animateDeform}, and {@link Game.sound}.
250
1120
  *
251
1121
  * The `dt` parameter in {@link Game.onUpdate} is an exception — it is in
252
1122
  * **seconds** for convenient velocity math (`position += velocity * dt`).
@@ -298,10 +1168,10 @@ export class Sprite {
298
1168
  *
299
1169
  * ---
300
1170
  *
301
- * ## Scene Initialization with `onCreate`
1171
+ * ## Scene Initialization with `IScene`
302
1172
  *
303
- * MinimoJS has NO scene system. Use {@link Game.onCreate} to build a scene.
304
- * The engine calls `onCreate`:
1173
+ * MinimoJS supports simple scene objects via {@link IScene}. The engine calls
1174
+ * a scene's `onCreate()`:
305
1175
  * - Once before the first frame (when {@link Game.start} is called).
306
1176
  * - Again after each {@link Game.reset}.
307
1177
  *
@@ -313,29 +1183,42 @@ export class Sprite {
313
1183
  * - Scroll position (scrollX and scrollY reset to 0)
314
1184
  * - Per-frame input state (pressed keys/pointer)
315
1185
  *
316
- * After clearing, `reset()` calls `onCreate()` so your callback can rebuild the
317
- * new scene immediately. Simply re-add sprites and re-register timers.
1186
+ * After clearing, `reset()` calls the active scene's `onCreate()` so it can
1187
+ * rebuild immediately. Simply re-add sprites and re-register timers.
318
1188
  *
319
1189
  * ```ts
320
- * game.onCreate = () => {
321
- * // scene init: add sprites, setup timers
322
- * const skull = new Sprite("💀");
323
- * skull.x = 400; skull.y = 300; skull.size = 96;
324
- * game.add(skull);
325
- * };
1190
+ * class SkullScene implements IScene {
1191
+ * onCreate() {
1192
+ * const skull = new EmojiSprite("💀", 400, 300, 96);
1193
+ * game.add(skull);
1194
+ * }
326
1195
  *
327
- * game.onUpdate = (dt) => {
328
- * // normal per-frame update
329
- * };
1196
+ * onUpdate(dt: number) {
1197
+ * // normal per-frame update
1198
+ * }
1199
+ * }
330
1200
  *
331
- * game.start(); // calls onCreate() once before first frame
1201
+ * const scene = new SkullScene();
1202
+ * game.start(scene); // calls onCreate() once before first frame
332
1203
  * // later:
333
1204
  * game.reset(); // clears + calls onCreate() again
334
1205
  * ```
335
1206
  *
1207
+ * Legacy `game.onCreate` / `game.onUpdate` callbacks still work when no active
1208
+ * {@link IScene} is set.
1209
+ *
1210
+ * ### Loading a Scene's Own Images
1211
+ *
1212
+ * Load images in {@link Game.onPreload}: once, at boot. Only a game with many
1213
+ * scenes that each carry many assets of their own — typically a game with many
1214
+ * levels, whose player does not reach every level in a session — should give
1215
+ * scenes an {@link IScene.onPreload}. Entering such a scene loads its images
1216
+ * first, with the scene being left frozen and dimmed under a progress ring, so `reset()`
1217
+ * and `transitionTo()` may return before the new scene's `onCreate()` runs.
1218
+ *
336
1219
  * ---
337
1220
  *
338
- * ## Sprite Lifecycle Ownership
1221
+ * ## EmojiSprite Lifecycle Ownership
339
1222
  *
340
1223
  * The `Game` instance owns all sprites.
341
1224
  * - Create sprites with {@link Game.add}.
@@ -349,7 +1232,7 @@ export class Sprite {
349
1232
  * ## Forbidden Features
350
1233
  *
351
1234
  * The following do NOT exist in MinimoJS v1. Do NOT attempt to use them:
352
- * - Scene system or scene manager
1235
+ * - Full scene manager architecture (scene stacks, transitions, loaders, etc.)
353
1236
  * - Entity Component System (ECS)
354
1237
  * - Physics engine (no Box2D, Matter.js, etc.)
355
1238
  * - Camera zoom or scale
@@ -357,11 +1240,110 @@ export class Sprite {
357
1240
  * - Text input / HTML form elements
358
1241
  * - Parallax layers
359
1242
  * - Multiple cameras
360
- * - Collision resolution (only detection is supported)
361
- * - Image sprites (PNG, SVG, canvas, etc.)
1243
+ * - Full physics engine (only basic explicit AABB collision helpers are provided)
362
1244
  * - `setTimeout` or `setInterval`
363
1245
  */
364
1246
  export class Game {
1247
+ // -------------------------------------------------------------------------
1248
+ // Public state
1249
+ // -------------------------------------------------------------------------
1250
+ /**
1251
+ * Horizontal gravity acceleration in pixels per second².
1252
+ * Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
1253
+ * Positive = accelerates right. Typical value for sideways wind: `200`.
1254
+ * Default: `0`.
1255
+ *
1256
+ * @example
1257
+ * ```ts
1258
+ * game.gravityX = 0; // no horizontal gravity
1259
+ * ```
1260
+ */
1261
+ get gravityX() {
1262
+ return this._physicsSystem.gravityX;
1263
+ }
1264
+ set gravityX(value) {
1265
+ this._physicsSystem.gravityX = value;
1266
+ }
1267
+ /**
1268
+ * Vertical gravity acceleration in pixels per second².
1269
+ * Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
1270
+ * Positive = accelerates downward (canvas Y-axis points down).
1271
+ * Typical value for platformers: `980` (approximately Earth gravity in px/s²).
1272
+ * Default: `0`.
1273
+ *
1274
+ * @example
1275
+ * ```ts
1276
+ * game.gravityY = 980; // standard downward gravity
1277
+ * ```
1278
+ */
1279
+ get gravityY() {
1280
+ return this._physicsSystem.gravityY;
1281
+ }
1282
+ set gravityY(value) {
1283
+ this._physicsSystem.gravityY = value;
1284
+ }
1285
+ /**
1286
+ * Enables the basic collision-resolution helpers (`collide` / `collideAny`).
1287
+ *
1288
+ * When `false` (default), overlap detection still works, but collision
1289
+ * resolution helpers are unavailable.
1290
+ *
1291
+ * Set this to `true` for simple platformer-style collision handling.
1292
+ *
1293
+ * @example
1294
+ * ```ts
1295
+ * game.physics = true;
1296
+ * ```
1297
+ */
1298
+ get physics() {
1299
+ return this._physicsSystem.enabled;
1300
+ }
1301
+ set physics(value) {
1302
+ this._physicsSystem.enabled = value;
1303
+ }
1304
+ /**
1305
+ * Legacy scene creation callback.
1306
+ *
1307
+ * Called once before the first frame on {@link Game.start}, and again after
1308
+ * each {@link Game.reset} when no active {@link IScene} is set.
1309
+ *
1310
+ * @example
1311
+ * ```ts
1312
+ * game.onCreate = () => {
1313
+ * const player = new EmojiSprite("🐢");
1314
+ * player.x = 200;
1315
+ * player.y = 300;
1316
+ * game.add(player);
1317
+ * };
1318
+ * ```
1319
+ */
1320
+ get onCreate() {
1321
+ return this._onCreate;
1322
+ }
1323
+ set onCreate(callback) {
1324
+ this._onCreate = callback;
1325
+ }
1326
+ /**
1327
+ * Currently active scene object, if any.
1328
+ */
1329
+ get currentScene() {
1330
+ return this._currentScene;
1331
+ }
1332
+ /**
1333
+ * Returns `true` while a full-screen scene transition is playing.
1334
+ */
1335
+ get isTransitioning() {
1336
+ return this._transitionSystem.isActive;
1337
+ }
1338
+ /**
1339
+ * Returns `true` while images are loading: at boot, or while a scene with
1340
+ * pending images in its {@link IScene.onPreload} is being entered. While a
1341
+ * scene loads, the scene being left is frozen and `reset()` and
1342
+ * `transitionTo()` are ignored.
1343
+ */
1344
+ get isLoading() {
1345
+ return this._preloadPromise !== null || this._sceneLoad !== null;
1346
+ }
365
1347
  // -------------------------------------------------------------------------
366
1348
  // Constructor
367
1349
  // -------------------------------------------------------------------------
@@ -371,71 +1353,29 @@ export class Game {
371
1353
  * The engine creates its own `<canvas>`, sets its dimensions, and appends it
372
1354
  * to `document.body`. The canvas is automatically centered and responsively
373
1355
  * scaled to use the maximum available viewport space while preserving aspect ratio.
1356
+ * For new mobile-first Minimo Games, prefer a portrait canvas such as `720x1280`.
374
1357
  *
375
- * @param width - Canvas width in pixels. Default: `800`.
376
- * @param height - Canvas height in pixels. Default: `600`.
1358
+ * @param width - Canvas width in pixels. Default: `720`.
1359
+ * @param height - Canvas height in pixels. Default: `1280`.
377
1360
  *
378
1361
  * @throws Error if a 2D context cannot be obtained.
379
1362
  *
380
1363
  * @example
381
1364
  * ```ts
382
- * const game = new Game(800, 600);
1365
+ * const game = new Game(720, 1280);
1366
+ * game.physics = true;
383
1367
  * ```
384
1368
  */
385
- constructor(width = 800, height = 600) {
386
- /** @internal */ this._sprites = [];
387
- /** @internal */ this._timers = [];
388
- /** @internal */ this._animations = [];
389
- /** @internal */ this._textOverlays = [];
390
- /** @internal */ this._timerIdCounter = 0;
391
- /** @internal */ this._keysDown = new Set();
392
- /** @internal */ this._keysPressed = new Set();
393
- /** @internal */ this._pointerDown = false;
394
- /** @internal */ this._pointerPressed = false;
395
- /** @internal */ this._pointerX = 0;
396
- /** @internal */ this._pointerY = 0;
397
- /** @internal */ this._mouseDown = false;
398
- /** @internal */ this._mousePressed = false;
399
- /** @internal */ this._mouseX = 0;
400
- /** @internal */ this._mouseY = 0;
401
- /** @internal */ this._touchPointers = new Map();
402
- /** @internal */ this._primaryTouchId = null;
403
- /** @internal */ this._rafId = null;
404
- /** @internal */ this._lastTimestamp = null;
405
- /** @internal */ this._running = false;
406
- /** @internal */ this._hasCreated = false;
407
- /** @internal */ this._onResize = () => this._applyResponsiveCanvasLayout();
408
- /** @internal */ this._audioCtx = null;
409
- /** @internal */ this._spriteGlyphCache = new Map();
410
- /** @internal */ this._lastAppliedPageBackground = undefined;
411
- // -------------------------------------------------------------------------
412
- // Public state
413
- // -------------------------------------------------------------------------
414
- /**
415
- * Horizontal gravity acceleration in pixels per second².
416
- * Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
417
- * Positive = accelerates right. Typical value for sideways wind: `200`.
418
- * Default: `0`.
419
- *
420
- * @example
421
- * ```ts
422
- * game.gravityX = 0; // no horizontal gravity
423
- * ```
424
- */
425
- this.gravityX = 0;
426
- /**
427
- * Vertical gravity acceleration in pixels per second².
428
- * Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
429
- * Positive = accelerates downward (canvas Y-axis points down).
430
- * Typical value for platformers: `980` (approximately Earth gravity in px/s²).
431
- * Default: `0`.
432
- *
433
- * @example
434
- * ```ts
435
- * game.gravityY = 980; // standard downward gravity
436
- * ```
437
- */
438
- this.gravityY = 0;
1369
+ constructor(width = 720, height = 1280) {
1370
+ /** @internal */ this._requiredFonts = new Map();
1371
+ /** @internal */ this._hasAppliedGlobalFontRequirements = false;
1372
+ /** @internal */ this._isRegisteringPreloadAssets = false;
1373
+ /** @internal */ this._hasCompletedPreload = false;
1374
+ /** @internal */ this._preloadPromise = null;
1375
+ /** @internal */ this._sceneLoad = null;
1376
+ /** @internal */ this._onCreate = null;
1377
+ /** @internal */ this._currentScene = null;
1378
+ /** @internal */ this._buildersByTag = new Map();
439
1379
  /**
440
1380
  * Horizontal scroll offset of the world camera, in pixels.
441
1381
  * The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
@@ -480,6 +1420,20 @@ export class Game {
480
1420
  * ```
481
1421
  */
482
1422
  this.backgroundGradient = null;
1423
+ /**
1424
+ * When `true`, MinimoJS draws every sprite's collision body as an overlay.
1425
+ *
1426
+ * This is a runtime debugging aid only. It does not change collisions,
1427
+ * input, rendering order, or physics behavior.
1428
+ */
1429
+ this.debugBodies = false;
1430
+ /**
1431
+ * When `true`, MinimoJS draws every sprite's input area as an overlay.
1432
+ *
1433
+ * This is a runtime debugging aid only. It does not change input behavior,
1434
+ * physics, or rendering.
1435
+ */
1436
+ this.debugInputAreas = false;
483
1437
  /**
484
1438
  * Background color for the full web page (`document.body`).
485
1439
  * Set to any valid CSS color string. Default: `null` (engine leaves page background unchanged).
@@ -493,24 +1447,31 @@ export class Game {
493
1447
  */
494
1448
  this.pageBackground = null;
495
1449
  /**
496
- * Scene creation callback.
1450
+ * Asset registration callback invoked once before the first scene is created.
1451
+ *
1452
+ * This is the normal place to load a game's images and sounds. Use it to register them
1453
+ * with {@link Game.loadAssets}, {@link Game.loadImage}, or {@link Game.loadSound}. The callback is synchronous: it should only
1454
+ * queue assets, not await network work.
497
1455
  *
498
- * Called once before the first frame on {@link Game.start}, and again after
499
- * each {@link Game.reset}. Use this to create sprites and timers for a scene.
1456
+ * After `onPreload` returns, MinimoJS loads the queued images and sounds, together with
1457
+ * those of the first scene's {@link IScene.onPreload} if it has one, and
1458
+ * shows a loading line until they are ready. If any file fails, it asks the
1459
+ * player with `window.confirm` whether to retry them; declining leaves the
1460
+ * game on the loading screen and reports the failed files on the console.
500
1461
  *
501
1462
  * @example
502
1463
  * ```ts
503
- * game.onCreate = () => {
504
- * const player = new Sprite("🐢");
505
- * player.x = 200;
506
- * player.y = 300;
507
- * game.add(player);
1464
+ * game.onPreload = () => {
1465
+ * game.loadImage("sky", "assets/sky.png");
1466
+ * game.loadImage("land", "assets/land.png");
508
1467
  * };
509
1468
  * ```
510
1469
  */
511
- this.onCreate = null;
1470
+ this.onPreload = null;
512
1471
  /**
513
- * Callback invoked once per frame after physics and timer updates.
1472
+ * Legacy per-frame callback invoked after physics and timer updates.
1473
+ *
1474
+ * This is used only when no active {@link IScene} is set.
514
1475
  *
515
1476
  * @param dt - Delta time in **seconds** since the last frame.
516
1477
  * Use this for velocity-based movement: `sprite.x += speed * dt`.
@@ -532,25 +1493,30 @@ export class Game {
532
1493
  this._canvas.style.touchAction = "none";
533
1494
  this._canvas.style.width = `${width}px`;
534
1495
  this._canvas.style.height = `${height}px`;
535
- const mountCanvas = () => {
536
- if (document.body && !this._canvas.isConnected) {
537
- document.body.appendChild(this._canvas);
538
- }
539
- this._applyResponsiveCanvasLayout();
540
- };
541
- if (document.body) {
542
- mountCanvas();
543
- }
544
- else {
545
- window.addEventListener("DOMContentLoaded", mountCanvas, { once: true });
546
- }
547
- window.addEventListener("resize", this._onResize);
1496
+ this._canvasSystem = new CanvasSystem(this._canvas);
1497
+ this._canvasSystem.initialize();
548
1498
  const ctx = this._canvas.getContext("2d");
549
1499
  if (!ctx) {
550
1500
  throw new Error("MinimoJS: Could not acquire a 2D rendering context.");
551
1501
  }
552
1502
  this._ctx = ctx;
553
- this._bindInputEvents();
1503
+ this._timerSystem = new TimerSystem();
1504
+ this._animationSystem = new AnimationSystem();
1505
+ this._physicsSystem = new PhysicsSystem();
1506
+ this._spriteSystem = new SpriteSystem(this._animationSystem, this);
1507
+ this._backgroundSystem = new BackgroundSystem();
1508
+ this._soundSystem = new SoundSystem();
1509
+ this._assetSystem = new AssetSystem((src) => this._soundSystem.decode(src));
1510
+ this._inputSystem = new InputSystem(this, this._canvas);
1511
+ this._textSystem = new TextSystem();
1512
+ this._renderSystem = new RenderSystem();
1513
+ this._explosionSystem = new ExplosionSystem();
1514
+ this._trailSystem = new TrailSystem();
1515
+ this._transitionSystem = new TransitionSystem();
1516
+ this._loopSystem = new LoopSystem(this._onLoopFrameCallback.bind(this));
1517
+ this._loopSystem.onCreate = this._invokeCreate.bind(this);
1518
+ this._inputSystem.bindInputEvents();
1519
+ this.applyGlobalFontRequirements();
554
1520
  }
555
1521
  // -------------------------------------------------------------------------
556
1522
  // Canvas dimensions (read-only)
@@ -572,9 +1538,14 @@ export class Game {
572
1538
  * in pixels. Updated every `mousemove` / `touchmove` event.
573
1539
  * `(0, 0)` is the top-left corner of the canvas.
574
1540
  * To convert to world space: `worldX = game.pointerX + game.scrollX`.
1541
+ *
1542
+ * On a touch screen the pointer is one finger: the first to touch while no
1543
+ * finger is followed. Other fingers never move it, and when the followed
1544
+ * finger lifts, the pointer stays where it left the screen even if others
1545
+ * are still down. Use {@link Game.getPointers} to follow several fingers.
575
1546
  */
576
1547
  get pointerX() {
577
- return this._pointerX;
1548
+ return this._inputSystem.pointerX;
578
1549
  }
579
1550
  /**
580
1551
  * Current pointer (mouse or touch) Y position in **canvas/screen space**,
@@ -583,21 +1554,23 @@ export class Game {
583
1554
  * To convert to world space: `worldY = game.pointerY + game.scrollY`.
584
1555
  */
585
1556
  get pointerY() {
586
- return this._pointerY;
1557
+ return this._inputSystem.pointerY;
587
1558
  }
588
1559
  // -------------------------------------------------------------------------
589
- // Sprite management
1560
+ // EmojiSprite management
590
1561
  // -------------------------------------------------------------------------
591
1562
  /**
592
1563
  * Registers a {@link Sprite} (or subclass instance) with the engine.
593
- * After calling `add`, the sprite is rendered and receives physics updates
594
- * every frame until {@link Game.destroySprite} or {@link Game.reset} is called.
1564
+ *
1565
+ * After calling `add`, the sprite is rendered and, if dynamic
1566
+ * (`isStatic = false`), receives built-in velocity/gravity integration every
1567
+ * frame until {@link Game.destroySprite} or {@link Game.reset} is called.
595
1568
  *
596
1569
  * **Ownership:** The game instance takes ownership of the sprite from this
597
1570
  * point forward. It will appear in {@link Game.getSprites} on the same frame.
598
1571
  *
599
1572
  * **Subclasses:** Any class that extends {@link Sprite} can be passed here.
600
- * The engine stores and processes it as a `Sprite`; your custom properties
1573
+ * The engine stores and processes it as a live sprite; your custom properties
601
1574
  * are preserved on the instance.
602
1575
  *
603
1576
  * @param sprite - A {@link Sprite} instance (or subclass) to add.
@@ -606,24 +1579,39 @@ export class Game {
606
1579
  * @example
607
1580
  * ```ts
608
1581
  * // Plain sprite
609
- * const coin = new Sprite("🪙");
610
- * coin.x = 300; coin.y = 200; coin.size = 32;
1582
+ * const coin = new EmojiSprite("🪙", 300, 200, 32);
611
1583
  * game.add(coin);
612
1584
  *
613
1585
  * // Custom subclass
614
- * class Enemy extends Sprite {
1586
+ * class Enemy extends EmojiSprite {
615
1587
  * speed = 150;
616
1588
  * constructor(x: number, y: number) {
617
- * super("👾");
618
- * this.x = x; this.y = y; this.size = 40;
1589
+ * super("👾", x, y, 40);
1590
+ * }
619
1591
  * }
620
- * }
621
1592
  * const enemy = game.add(new Enemy(600, 100));
622
1593
  * ```
623
1594
  */
624
1595
  add(sprite) {
625
- this._sprites.push(sprite);
626
- return sprite;
1596
+ return this._spriteSystem.add(sprite);
1597
+ }
1598
+ /**
1599
+ * Registers a {@link BackgroundLayer} with the engine.
1600
+ *
1601
+ * Background layers are rendered behind all sprites and are not affected by
1602
+ * physics, collisions, or sprite queries.
1603
+ *
1604
+ * @param layer - The background layer to add.
1605
+ * @returns The same background layer instance.
1606
+ *
1607
+ * @example
1608
+ * ```ts
1609
+ * const sky = game.addBackground(new BackgroundLayer("sky"));
1610
+ * sky.fit = "cover";
1611
+ * ```
1612
+ */
1613
+ addBackground(layer) {
1614
+ return this._backgroundSystem.add(layer);
627
1615
  }
628
1616
  /**
629
1617
  * Removes a sprite from the engine, stopping its rendering and physics updates.
@@ -643,10 +1631,18 @@ export class Game {
643
1631
  * ```
644
1632
  */
645
1633
  destroySprite(sprite) {
646
- const idx = this._sprites.indexOf(sprite);
647
- if (idx !== -1)
648
- this._sprites.splice(idx, 1);
649
- this._animations = this._animations.filter((a) => a.sprite !== sprite);
1634
+ this._trailSystem.clearSpriteTrails(sprite);
1635
+ this._spriteSystem.destroySprite(sprite);
1636
+ }
1637
+ /**
1638
+ * Removes a background layer from the engine.
1639
+ *
1640
+ * If the layer is not currently registered, this is a safe no-op.
1641
+ *
1642
+ * @param layer - The background layer to remove.
1643
+ */
1644
+ destroyBackgroundLayer(layer) {
1645
+ this._backgroundSystem.destroyLayer(layer);
650
1646
  }
651
1647
  /**
652
1648
  * Returns a **read-only snapshot** of all currently active sprites.
@@ -664,69 +1660,468 @@ export class Game {
664
1660
  * ```
665
1661
  */
666
1662
  getSprites() {
667
- return [...this._sprites];
1663
+ return this._spriteSystem.getSprites();
1664
+ }
1665
+ /**
1666
+ * Returns a read-only snapshot of all active background layers.
1667
+ *
1668
+ * Background layers are returned in creation order within each layer value.
1669
+ */
1670
+ getBackgroundLayers() {
1671
+ return this._backgroundSystem.getLayers();
1672
+ }
1673
+ // -------------------------------------------------------------------------
1674
+ // Assets
1675
+ // -------------------------------------------------------------------------
1676
+ /**
1677
+ * Queues an image to be loaded during {@link Game.onPreload}.
1678
+ *
1679
+ * This method registers the asset only. The actual network/image fetch begins
1680
+ * after `onPreload` returns and is managed automatically by the engine.
1681
+ *
1682
+ * `loadImage` may only be called from within {@link Game.onPreload} or a
1683
+ * scene's {@link IScene.onPreload}.
1684
+ *
1685
+ * @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
1686
+ * @param src - Image URL or relative path.
1687
+ *
1688
+ * @example
1689
+ * ```ts
1690
+ * game.onPreload = () => {
1691
+ * game.loadImage("sky", "assets/sky.png");
1692
+ * };
1693
+ * ```
1694
+ */
1695
+ loadImage(key, src) {
1696
+ if (!this._isRegisteringPreloadAssets) {
1697
+ throw new Error("MinimoJS: loadImage() may only be called inside game.onPreload or a scene's onPreload().");
1698
+ }
1699
+ this._assetSystem.queueImage(key, src);
1700
+ }
1701
+ /**
1702
+ * Queues an audio file to be loaded during {@link Game.onPreload}, to play
1703
+ * later with {@link Game.playSound}.
1704
+ *
1705
+ * Like {@link Game.loadImage}, it only registers the sound, and may only be
1706
+ * called from within {@link Game.onPreload} or a scene's
1707
+ * {@link IScene.onPreload}. The file loads and decodes with the images,
1708
+ * under the same loading line, and a file that fails is retried with them.
1709
+ * Keys are unique in the whole game: a sound cannot share its key with an
1710
+ * image. Prefer declaring sounds in an asset file, as `sound` entries
1711
+ * loaded with {@link Game.loadAssets}.
1712
+ *
1713
+ * MP3 plays in every browser; WAV does too, but its files are large. OGG
1714
+ * does not play in every Safari.
1715
+ *
1716
+ * @param key - Stable key used later by {@link Game.playSound}.
1717
+ * @param src - Audio file URL or relative path.
1718
+ *
1719
+ * @example
1720
+ * ```ts
1721
+ * game.onPreload = () => {
1722
+ * game.loadSound("jump", "assets/jump.mp3");
1723
+ * game.loadSound("theme", "assets/theme.mp3");
1724
+ * };
1725
+ * ```
1726
+ */
1727
+ loadSound(key, src) {
1728
+ if (!this._isRegisteringPreloadAssets) {
1729
+ throw new Error("MinimoJS: loadSound() may only be called inside game.onPreload or a scene's onPreload().");
1730
+ }
1731
+ this._assetSystem.queueSound(key, src);
1732
+ }
1733
+ /**
1734
+ * Queues every asset an asset file declares, as one {@link Game.loadImage}
1735
+ * call per `image` entry and one {@link Game.loadSound} call per `sound`
1736
+ * entry would.
1737
+ *
1738
+ * Pass the content of a `*.assets.json` file, obtained by JSON import. The
1739
+ * file is checked with {@link parseAssetFile}, and an invalid one throws an
1740
+ * error naming what is wrong. Like `loadImage`, `loadAssets` may only be
1741
+ * called from within {@link Game.onPreload} or a scene's
1742
+ * {@link IScene.onPreload}.
1743
+ *
1744
+ * - Keys are unique in the whole game. A key that another asset file
1745
+ * declares, or that {@link Game.loadImage}, {@link Game.loadSound}, or
1746
+ * {@link Game.createTexture} registered, throws an error naming it, and so does a later
1747
+ * `loadImage` or `createTexture` of a key an asset file declares.
1748
+ * - Loading the same file again, as a scene's `onPreload()` does each time
1749
+ * the scene is entered, queues only its files that are not loaded.
1750
+ * - An entry of a type this engine does not support is skipped, with a
1751
+ * console warning naming it, so the game still loads every asset it can.
1752
+ *
1753
+ * @param assets - The content of a `*.assets.json` file. See {@link AssetFile}.
1754
+ *
1755
+ * @example
1756
+ * ```ts
1757
+ * import common from "./common.assets.json" with { type: "json" };
1758
+ *
1759
+ * game.onPreload = () => game.loadAssets(common);
1760
+ * ```
1761
+ */
1762
+ loadAssets(assets) {
1763
+ if (!this._isRegisteringPreloadAssets) {
1764
+ throw new Error("MinimoJS: loadAssets() may only be called inside game.onPreload or a scene's onPreload().");
1765
+ }
1766
+ const file = parseAssetFile(assets);
1767
+ const isFirstLoad = this._assetSystem.registerAssetFile(file, assets);
1768
+ if (!isFirstLoad)
1769
+ return;
1770
+ for (const entry of file.assets) {
1771
+ if (entry.type !== "image" && entry.type !== "sound") {
1772
+ console.warn(`MinimoJS: Skipped the asset "${entry.key}", whose type "${entry.type}" this version of MinimoJS does not support.`);
1773
+ }
1774
+ }
1775
+ }
1776
+ /**
1777
+ * Creates a dynamic texture immediately and registers it under a stable key.
1778
+ *
1779
+ * Unlike {@link Game.loadImage}, this does not require {@link Game.onPreload}.
1780
+ * The `painter` callback receives a fresh offscreen canvas and should draw the
1781
+ * full texture contents into it.
1782
+ *
1783
+ * The resulting texture can be used anywhere a normal image key is accepted,
1784
+ * including {@link ImageSprite}, background layers, and optional modules.
1785
+ *
1786
+ * @param key - Stable texture key used later by sprites and render helpers.
1787
+ * @param width - Texture width in pixels. Minimum `1`.
1788
+ * @param height - Texture height in pixels. Minimum `1`.
1789
+ * @param painter - Function that paints into the offscreen texture canvas.
1790
+ *
1791
+ * @example
1792
+ * ```ts
1793
+ * game.createTexture("checkpoint", 128, 64, (ctx, canvas) => {
1794
+ * ctx.fillStyle = "#101820";
1795
+ * ctx.fillRect(0, 0, canvas.width, canvas.height);
1796
+ * ctx.fillStyle = "#ffd54f";
1797
+ * ctx.fillRect(8, 8, canvas.width - 16, canvas.height - 16);
1798
+ * });
1799
+ * ```
1800
+ */
1801
+ createTexture(key, width, height, painter) {
1802
+ return this._assetSystem.createTexture(key, width, height, painter);
1803
+ }
1804
+ /**
1805
+ * Returns a loaded image or created texture previously registered with
1806
+ * {@link Game.loadImage} or {@link Game.createTexture}, or `undefined` if it
1807
+ * is not available.
1808
+ */
1809
+ getImage(key) {
1810
+ return this._assetSystem.getImage(key);
1811
+ }
1812
+ /** @internal */
1813
+ getImageVersion(key) {
1814
+ return this._assetSystem.getImageVersion(key);
1815
+ }
1816
+ /**
1817
+ * Returns `true` if a texture key is available.
1818
+ */
1819
+ hasImage(key) {
1820
+ return this._assetSystem.hasImage(key);
1821
+ }
1822
+ // -------------------------------------------------------------------------
1823
+ // Designs
1824
+ // -------------------------------------------------------------------------
1825
+ /**
1826
+ * Creates one runtime object from object data, such as a template found in
1827
+ * a {@link Design}, and adds it to the game.
1828
+ *
1829
+ * The object is built through the same public constructors and
1830
+ * {@link Game.add} or {@link Game.addBackground} that code uses, so it
1831
+ * behaves as the equivalent code. Its type picks its class: `image` builds
1832
+ * an {@link ImageSprite}, `emoji` an {@link EmojiSprite}, `text` a
1833
+ * {@link TextSprite}, and `background` a {@link BackgroundLayer}, unless a
1834
+ * builder registered with {@link Game.registerBuilderByTag} matches one of
1835
+ * its tags. Then every property the data holds is applied, including
1836
+ * `name`, `tags`, and a copy of `data`.
1837
+ *
1838
+ * Each call creates a new object, so one template can be created any number
1839
+ * of times. Set its position afterwards to place it; the object is never
1840
+ * drawn at the template's position.
1841
+ *
1842
+ * Throws an error naming what is wrong when the data is not a valid design
1843
+ * object, or naming the key when it uses an image key that is not loaded.
1844
+ *
1845
+ * @param data - One design object, such as `design.findByName("player")`. Not a `Design`.
1846
+ * @param options - `add: false` creates the object without adding it to the game. Default: added.
1847
+ * @returns The created object. In TypeScript, name its type: `game.createObject<ImageSprite>(data)`.
1848
+ *
1849
+ * @example
1850
+ * ```ts
1851
+ * import charactersJson from "./characters.design.json" with { type: "json" };
1852
+ * const characters = new Design(charactersJson);
1853
+ *
1854
+ * const bat = game.createObject(characters.findByName("bat"));
1855
+ * bat.x = spawn.x;
1856
+ * bat.y = spawn.y;
1857
+ * ```
1858
+ */
1859
+ createObject(data, options = {}) {
1860
+ if (data instanceof Design) {
1861
+ throw new Error("MinimoJS: createObject() takes one object's data, not a Design. Pass a search's result, such as design.findByName(\"player\"), or use createObjectList(design.objects).");
1862
+ }
1863
+ const obj = parseDesignObject(data, "the object", "design object");
1864
+ this._assertDesignImageLoaded(obj);
1865
+ const created = this._buildDesignObject(obj);
1866
+ if (options.add !== false)
1867
+ this._addDesignObject(created);
1868
+ return created;
1869
+ }
1870
+ /**
1871
+ * Creates a runtime object from each object data, in order, and adds them
1872
+ * to the game, as {@link Game.createObject} does for one. Objects in the
1873
+ * same layer draw in the order of the data.
1874
+ *
1875
+ * Every object is checked before any is created, so invalid data or an
1876
+ * image key that is not loaded creates nothing.
1877
+ *
1878
+ * @param objects - Object data, such as `design.objects` or `design.findAllByTag("enemy")`. Not a `Design`.
1879
+ * @param options - `add: false` creates the objects without adding them to the game. Default: added.
1880
+ * @returns An {@link ObjectList} of the created objects, to find them by name or tag.
1881
+ *
1882
+ * @example
1883
+ * ```ts
1884
+ * import menuJson from "./menu.design.json" with { type: "json" };
1885
+ * const menu = new Design(menuJson);
1886
+ *
1887
+ * class MenuScene {
1888
+ * onCreate() {
1889
+ * const ui = game.createObjectList(menu.objects);
1890
+ * this.play = ui.findByName("play");
1891
+ * }
1892
+ * onUpdate() {
1893
+ * if (game.isPointerPressedOverSprite(this.play)) start(this.play.data.action);
1894
+ * }
1895
+ * }
1896
+ * ```
1897
+ */
1898
+ createObjectList(objects, options = {}) {
1899
+ if (!Array.isArray(objects)) {
1900
+ throw new Error("MinimoJS: createObjectList() takes a list of object data, such as design.objects or design.findAllByTag(\"enemy\").");
1901
+ }
1902
+ const parsed = objects.map((data, index) => parseDesignObject(data, `objects[${index}]`, "design object"));
1903
+ for (const obj of parsed)
1904
+ this._assertDesignImageLoaded(obj);
1905
+ const created = parsed.map((obj) => this._buildDesignObject(obj));
1906
+ if (options.add !== false) {
1907
+ for (const obj of created)
1908
+ this._addDesignObject(obj);
1909
+ }
1910
+ return new ObjectList(created);
1911
+ }
1912
+ /**
1913
+ * Makes {@link Game.createObject} and {@link Game.createObjectList} build
1914
+ * the design objects tagged `tag` with `builder`, such as an `Enemy` class
1915
+ * of the game's own.
1916
+ *
1917
+ * - The builder returns an instance of the object's type class or a
1918
+ * subclass of it: {@link ImageSprite} for `image`, {@link EmojiSprite} for
1919
+ * `emoji`, {@link TextSprite} for `text`, and {@link BackgroundLayer} for
1920
+ * `background`. Anything else throws an error naming the object.
1921
+ * - The engine then applies every property the data holds, so the file wins
1922
+ * over the constructor. Properties the data omits keep what the builder set.
1923
+ * An `emoji` object's `size` cannot be changed after construction, so a
1924
+ * builder must pass it on, or the creation throws.
1925
+ * - An object matching the builders of two of its tags throws an error
1926
+ * naming the object and both tags.
1927
+ * - Builders are scene state: {@link Game.reset} clears them, as it clears
1928
+ * timers, so each scene registers its own in `onCreate()`.
1929
+ *
1930
+ * @param tag - The tag whose objects the builder builds.
1931
+ * @param builder - Receives the object's data and the game, and returns the object, not yet added.
1932
+ *
1933
+ * @example
1934
+ * ```ts
1935
+ * class Enemy extends ImageSprite {
1936
+ * update(dt) { this.x += this.data.speed * dt; }
1937
+ * }
1938
+ *
1939
+ * class Level1Scene {
1940
+ * onCreate() {
1941
+ * game.registerBuilderByTag("enemy", (obj, game) => new Enemy(game, obj.imageKey));
1942
+ * this.enemies = game.createObjectList(level1.findAllByTag("enemy")).objects;
1943
+ * }
1944
+ * }
1945
+ * ```
1946
+ */
1947
+ registerBuilderByTag(tag, builder) {
1948
+ if (typeof tag !== "string" || tag.length === 0) {
1949
+ throw new Error("MinimoJS: registerBuilderByTag() requires a non-empty tag.");
1950
+ }
1951
+ if (typeof builder !== "function") {
1952
+ throw new Error(`MinimoJS: registerBuilderByTag("${tag}") requires a builder function.`);
1953
+ }
1954
+ if (this._buildersByTag.has(tag)) {
1955
+ throw new Error(`MinimoJS: A builder is already registered for the tag "${tag}". game.reset() clears builders, so register each in a scene's onCreate().`);
1956
+ }
1957
+ this._buildersByTag.set(tag, builder);
1958
+ }
1959
+ /** @internal */
1960
+ _assertDesignImageLoaded(obj) {
1961
+ if ((obj.type === "image" || obj.type === "background") && !this.hasImage(obj.imageKey)) {
1962
+ throw new Error(`MinimoJS: ${describeDesignObject(obj)} uses the image key "${obj.imageKey}", which is not loaded. Declare it in an asset file and load that file with game.loadAssets() in an onPreload hook.`);
1963
+ }
1964
+ }
1965
+ /** @internal */
1966
+ _buildDesignObject(obj) {
1967
+ const matchingTags = [...new Set(obj.tags ?? [])].filter((tag) => this._buildersByTag.has(tag));
1968
+ if (matchingTags.length > 1) {
1969
+ throw new Error(`MinimoJS: ${describeDesignObject(obj)} matches the builders of two of its tags, "${matchingTags[0]}" and "${matchingTags[1]}". An object may match at most one builder.`);
1970
+ }
1971
+ const tag = matchingTags[0];
1972
+ const builder = tag === undefined ? undefined : this._buildersByTag.get(tag);
1973
+ let created;
1974
+ if (builder === undefined) {
1975
+ created =
1976
+ obj.type === "image"
1977
+ ? new ImageSprite(this, obj.imageKey)
1978
+ : obj.type === "emoji"
1979
+ ? new EmojiSprite(obj.sprite, 0, 0, obj.size)
1980
+ : obj.type === "text"
1981
+ ? new TextSprite(obj.text, 0, 0, obj.fontSize)
1982
+ : new BackgroundLayer(obj.imageKey);
1983
+ }
1984
+ else {
1985
+ created = builder(obj, this);
1986
+ const expected = DESIGN_OBJECT_CLASSES[obj.type];
1987
+ if (!(created instanceof expected)) {
1988
+ throw new Error(`MinimoJS: The builder for the tag "${tag}" did not return an instance of ${expected.name} or of a subclass of it for ${describeDesignObject(obj)}, whose type is "${obj.type}".`);
1989
+ }
1990
+ if (obj.type === "emoji" && obj.size !== undefined && created.size !== obj.size) {
1991
+ throw new Error(`MinimoJS: The builder for the tag "${tag}" built ${describeDesignObject(obj)} with the size ${created.size}, but its data says ${obj.size}. An EmojiSprite's size is set only by its constructor, so pass obj.size to it.`);
1992
+ }
1993
+ }
1994
+ const target = created;
1995
+ for (const [field, value] of Object.entries(obj)) {
1996
+ if (field === "id" || field === "type")
1997
+ continue;
1998
+ if (field === "size" && created instanceof EmojiSprite)
1999
+ continue;
2000
+ if (field === "imageKey" && created instanceof ImageSprite) {
2001
+ created.setTexture(value);
2002
+ continue;
2003
+ }
2004
+ target[field] = value;
2005
+ }
2006
+ return created;
2007
+ }
2008
+ /** @internal */
2009
+ _addDesignObject(obj) {
2010
+ if (obj instanceof BackgroundLayer) {
2011
+ if (!this._backgroundSystem.getMutableLayers().includes(obj))
2012
+ this.addBackground(obj);
2013
+ }
2014
+ else if (!this._spriteSystem.getMutableSprites().includes(obj)) {
2015
+ this.add(obj);
2016
+ }
668
2017
  }
669
2018
  // -------------------------------------------------------------------------
670
2019
  // Collision
671
2020
  // -------------------------------------------------------------------------
672
2021
  /**
673
- * Tests whether two sprites overlap using **Axis-Aligned Bounding Box (AABB)**
674
- * collision detection.
2022
+ * Tests whether two sprites overlap using **Axis-Aligned Bounding Box (AABB)**
2023
+ * collision detection.
2024
+ *
2025
+ * Each sprite's bounding box is a square centered at `(x, y)` with side
2026
+ * length `displaySize`. Rotation is **ignored** — the box is always axis-aligned.
2027
+ *
2028
+ * **No collision resolution is performed.** This method is detection-only.
2029
+ * Use {@link Game.collide} for the engine's basic explicit push-out helper.
2030
+ *
2031
+ * @param a - First sprite.
2032
+ * @param b - Second sprite.
2033
+ * @returns `true` if the bounding boxes overlap; `false` otherwise.
2034
+ *
2035
+ * @example
2036
+ * ```ts
2037
+ * if (game.overlap(player, enemy)) {
2038
+ * game.reset(); // restart on collision
2039
+ * }
2040
+ * ```
2041
+ */
2042
+ overlap(a, b) {
2043
+ return this._physicsSystem.overlap(a, b);
2044
+ }
2045
+ /**
2046
+ * Tests for any overlap between two groups of sprites.
2047
+ * Performs an O(n × m) AABB check for every pair `(a, b)` where `a ∈ listA`
2048
+ * and `b ∈ listB`. Returns the **first** overlapping pair found.
2049
+ *
2050
+ * Internally uses {@link Game.overlap} — same AABB rules apply.
2051
+ * No collision resolution is performed.
2052
+ *
2053
+ * @param listA - First group of sprites.
2054
+ * @param listB - Second group of sprites. May share sprites with `listA`.
2055
+ * @returns A `[EmojiSprite, EmojiSprite]` tuple of the first overlapping pair,
2056
+ * or `null` if no pair overlaps.
2057
+ *
2058
+ * @example
2059
+ * ```ts
2060
+ * const hit = game.overlapAny(bullets, enemies);
2061
+ * if (hit) {
2062
+ * const [bullet, enemy] = hit;
2063
+ * game.destroySprite(bullet);
2064
+ * game.destroySprite(enemy);
2065
+ * }
2066
+ * ```
2067
+ */
2068
+ overlapAny(listA, listB) {
2069
+ return this._physicsSystem.overlapAny(listA, listB);
2070
+ }
2071
+ /**
2072
+ * Tests and resolves a basic AABB collision between two sprites.
675
2073
  *
676
- * Each sprite's bounding box is a square centered at `(x, y)` with side
677
- * length `size`. Rotation is **ignored** — the box is always axis-aligned.
2074
+ * This helper is intended for simple platformer-style collision response.
2075
+ * It uses the same axis-aligned square bounds as {@link Game.overlap}, but
2076
+ * additionally pushes one sprite out of the collision and zeroes velocity on
2077
+ * the resolved axis.
678
2078
  *
679
- * **No collision resolution is performed.** This method is detection-only.
680
- * If you need bounce or push-apart behavior, implement it in `onUpdate`.
2079
+ * **Resolution rules:**
2080
+ * - If the first sprite is dynamic (`isStatic = false`), the first sprite is resolved.
2081
+ * - Otherwise, if the second sprite is dynamic, the second sprite is resolved.
2082
+ * - If both sprites are static, collision is reported but no movement occurs.
681
2083
  *
682
- * @param a - First sprite.
683
- * @param b - Second sprite.
684
- * @returns `true` if the bounding boxes overlap; `false` otherwise.
2084
+ * The returned flags are always reported relative to the **first** sprite.
2085
+ *
2086
+ * @param a - First sprite. Usually the moving actor (for example, the player).
2087
+ * @param b - Second sprite. Usually a static obstacle or platform.
2088
+ * @returns Collision details, or `null` if the sprites do not overlap.
2089
+ * @throws Error if the game's physics helpers are not enabled.
685
2090
  *
686
2091
  * @example
687
2092
  * ```ts
688
- * if (game.overlap(player, enemy)) {
689
- * game.reset(); // restart on collision
2093
+ * const hit = game.collide(player, floorTile);
2094
+ * if (hit?.grounded) {
2095
+ * canJump = true;
690
2096
  * }
691
2097
  * ```
692
2098
  */
693
- overlap(a, b) {
694
- const halfA = a.size / 2;
695
- const halfB = b.size / 2;
696
- return (Math.abs(a.x - b.x) < halfA + halfB &&
697
- Math.abs(a.y - b.y) < halfA + halfB);
2099
+ collide(a, b) {
2100
+ return this._physicsSystem.collide(a, b);
698
2101
  }
699
2102
  /**
700
- * Tests for any overlap between two groups of sprites.
701
- * Performs an O(n × m) AABB check for every pair `(a, b)` where `a ∈ listA`
702
- * and `b ∈ listB`. Returns the **first** overlapping pair found.
2103
+ * Tests and resolves the first collision found between two groups of sprites.
703
2104
  *
704
- * Internally uses {@link Game.overlap} — same AABB rules apply.
705
- * No collision resolution is performed.
2105
+ * This behaves like {@link Game.overlapAny}, but uses the explicit collision
2106
+ * rules from {@link Game.collide} and returns the collision details as the
2107
+ * third tuple item.
706
2108
  *
707
2109
  * @param listA - First group of sprites.
708
- * @param listB - Second group of sprites. May share sprites with `listA`.
709
- * @returns A `[Sprite, Sprite]` tuple of the first overlapping pair,
2110
+ * @param listB - Second group of sprites.
2111
+ * @returns A `[EmojiSprite, EmojiSprite, CollisionInfo]` tuple for the first collision found,
710
2112
  * or `null` if no pair overlaps.
2113
+ * @throws Error if the game's physics helpers are not enabled.
711
2114
  *
712
2115
  * @example
713
2116
  * ```ts
714
- * const hit = game.overlapAny(bullets, enemies);
715
- * if (hit) {
716
- * const [bullet, enemy] = hit;
717
- * game.destroySprite(bullet);
718
- * game.destroySprite(enemy);
2117
+ * const hit = game.collideAny([player], floorTiles);
2118
+ * if (hit?.[2].grounded) {
2119
+ * canJump = true;
719
2120
  * }
720
2121
  * ```
721
2122
  */
722
- overlapAny(listA, listB) {
723
- for (const a of listA) {
724
- for (const b of listB) {
725
- if (this.overlap(a, b))
726
- return [a, b];
727
- }
728
- }
729
- return null;
2123
+ collideAny(listA, listB) {
2124
+ return this._physicsSystem.collideAny(listA, listB);
730
2125
  }
731
2126
  // -------------------------------------------------------------------------
732
2127
  // Input — Keyboard
@@ -750,7 +2145,7 @@ export class Game {
750
2145
  * ```
751
2146
  */
752
2147
  isKeyDown(key) {
753
- return this._keysDown.has(key);
2148
+ return this._inputSystem.isKeyDown(key);
754
2149
  }
755
2150
  /**
756
2151
  * Returns `true` only on the **single frame** the key was first pressed.
@@ -768,7 +2163,7 @@ export class Game {
768
2163
  * ```
769
2164
  */
770
2165
  isKeyPressed(key) {
771
- return this._keysPressed.has(key);
2166
+ return this._inputSystem.isKeyPressed(key);
772
2167
  }
773
2168
  // -------------------------------------------------------------------------
774
2169
  // Input — Pointer (mouse / touch)
@@ -778,6 +2173,8 @@ export class Game {
778
2173
  * Use this for continuous pointer actions.
779
2174
  *
780
2175
  * **Hold behavior:** returns `true` every frame from press until release.
2176
+ * On a touch screen it follows one finger, as {@link Game.pointerX} does,
2177
+ * so it turns `false` when that finger lifts, even if another is down.
781
2178
  *
782
2179
  * @returns `true` if the pointer is currently pressed.
783
2180
  *
@@ -787,7 +2184,7 @@ export class Game {
787
2184
  * ```
788
2185
  */
789
2186
  isPointerDown() {
790
- return this._pointerDown;
2187
+ return this._inputSystem.isPointerDown();
791
2188
  }
792
2189
  /**
793
2190
  * Returns `true` only on the **single frame** the pointer was first pressed.
@@ -804,82 +2201,68 @@ export class Game {
804
2201
  * ```
805
2202
  */
806
2203
  isPointerPressed() {
807
- return this._pointerPressed;
2204
+ return this._inputSystem.isPointerPressed();
2205
+ }
2206
+ /**
2207
+ * True on the frame the primary touch ended in a `touchcancel` rather than
2208
+ * lifting, so a module can drop the gesture instead of reading it as a tap.
2209
+ *
2210
+ * @internal
2211
+ */
2212
+ isPointerCancelled() {
2213
+ return this._inputSystem.isPointerCancelled();
808
2214
  }
809
2215
  /**
810
2216
  * Returns `true` while any active pointer is held down over the target sprite.
811
2217
  * Works with both mouse input and multiple simultaneous touches.
812
2218
  *
813
- * Pointer hit testing uses a circular area centered on the sprite. The radius
814
- * is `sprite.size * radiusScale`. World-space sprites are tested against the
815
- * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
816
- * screen space.
2219
+ * Pointer hit testing uses the sprite's exact displayed rectangular bounds.
2220
+ * World-space sprites are tested against the current camera scroll. HUD
2221
+ * sprites with `ignoreScroll = true` are tested in screen space. For
2222
+ * anchored {@link TextSprite} instances, the engine first resolves
2223
+ * `anchorX` / `anchorY` into the final rendered center, then tests against
2224
+ * that rectangle.
817
2225
  *
818
2226
  * Use this for continuous virtual buttons such as touch movement controls.
819
2227
  *
820
2228
  * @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
821
- * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
822
- * Default: `0.5`.
823
2229
  * @returns `true` if any currently held pointer overlaps the sprite hit area.
824
2230
  *
825
2231
  * @example
826
2232
  * ```ts
827
- * if (game.isPointerDownOverSprite(leftButton, 0.72)) {
2233
+ * if (game.isPointerDownOverSprite(leftButton)) {
828
2234
  * player.x -= 200 * dt;
829
2235
  * }
830
2236
  * ```
831
2237
  */
832
- isPointerDownOverSprite(sprite, radiusScale = 0.5) {
833
- if (!sprite)
834
- return false;
835
- if (this._mouseDown &&
836
- this._isScreenPointOverSprite(this._mouseX, this._mouseY, sprite, radiusScale)) {
837
- return true;
838
- }
839
- for (const pointer of this._touchPointers.values()) {
840
- if (this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale)) {
841
- return true;
842
- }
843
- }
844
- return false;
2238
+ isPointerDownOverSprite(sprite) {
2239
+ return this._inputSystem.isPointerDownOverSprite(sprite);
845
2240
  }
846
2241
  /**
847
2242
  * Returns `true` only on the frame any pointer first pressed over the target
848
2243
  * sprite. Works with both mouse input and multiple simultaneous touches.
849
2244
  *
850
- * Pointer hit testing uses a circular area centered on the sprite. The radius
851
- * is `sprite.size * radiusScale`. World-space sprites are tested against the
852
- * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
853
- * screen space.
2245
+ * Pointer hit testing uses the sprite's exact displayed rectangular bounds.
2246
+ * World-space sprites are tested against the current camera scroll. HUD
2247
+ * sprites with `ignoreScroll = true` are tested in screen space. For
2248
+ * anchored {@link TextSprite} instances, the engine first resolves
2249
+ * `anchorX` / `anchorY` into the final rendered center, then tests against
2250
+ * that rectangle.
854
2251
  *
855
2252
  * Use this for one-shot virtual buttons such as menu taps.
856
2253
  *
857
2254
  * @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
858
- * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
859
- * Default: `0.5`.
860
2255
  * @returns `true` if any pointer began pressing this frame over the sprite hit area.
861
2256
  *
862
2257
  * @example
863
2258
  * ```ts
864
- * if (game.isPointerPressedOverSprite(startButton, 0.8)) {
2259
+ * if (game.isPointerPressedOverSprite(startButton)) {
865
2260
  * startGame();
866
2261
  * }
867
2262
  * ```
868
2263
  */
869
- isPointerPressedOverSprite(sprite, radiusScale = 0.5) {
870
- if (!sprite)
871
- return false;
872
- if (this._mousePressed &&
873
- this._isScreenPointOverSprite(this._mouseX, this._mouseY, sprite, radiusScale)) {
874
- return true;
875
- }
876
- for (const pointer of this._touchPointers.values()) {
877
- if (pointer.pressed &&
878
- this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale)) {
879
- return true;
880
- }
881
- }
882
- return false;
2264
+ isPointerPressedOverSprite(sprite) {
2265
+ return this._inputSystem.isPointerPressedOverSprite(sprite);
883
2266
  }
884
2267
  /**
885
2268
  * Returns a read-only snapshot of all currently active pointers.
@@ -898,189 +2281,551 @@ export class Game {
898
2281
  * const pointers = game.getPointers();
899
2282
  * if (pointers.length > 0) {
900
2283
  * const first = pointers[0];
901
- * game.text(`Pointer: ${first.x}, ${first.y}`, 10, 10);
2284
+ * game.drawText(`Pointer: ${first.x}, ${first.y}`, 10, 10, 14);
902
2285
  * }
903
2286
  * ```
904
2287
  */
905
2288
  getPointers() {
906
- return this._collectPointers();
2289
+ return this._inputSystem.getPointers();
907
2290
  }
908
2291
  /**
909
2292
  * Returns a read-only snapshot of all active pointers currently overlapping
910
2293
  * the target sprite.
911
2294
  *
912
- * Pointer hit testing uses a circular area centered on the sprite. The radius
913
- * is `sprite.size * radiusScale`. World-space sprites are tested against the
914
- * current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
915
- * screen space.
2295
+ * Pointer hit testing uses the sprite's exact displayed rectangular bounds.
2296
+ * World-space sprites are tested against the current camera scroll. HUD
2297
+ * sprites with `ignoreScroll = true` are tested in screen space. For
2298
+ * anchored {@link TextSprite} instances, the engine first resolves
2299
+ * `anchorX` / `anchorY` into the final rendered center, then tests against
2300
+ * that rectangle.
916
2301
  *
917
2302
  * Use this when you need more than a boolean result, such as reading the exact
918
2303
  * pointer position over a virtual joystick or draggable control.
919
2304
  *
920
2305
  * @param sprite - Target sprite to test. If `null` / `undefined`, returns an empty array.
921
- * @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
922
- * Default: `0.5`.
923
2306
  * @returns A read-only array of active pointer snapshots currently over the sprite.
924
2307
  *
925
2308
  * @example
926
2309
  * ```ts
927
- * const touches = game.getPointersOverSprite(joystickBase, 1.0);
2310
+ * const touches = game.getPointersOverSprite(joystickBase);
928
2311
  * if (touches.length > 0) {
929
2312
  * const p = touches[0];
930
2313
  * const dx = p.x - joystickBase.x;
931
2314
  * }
932
2315
  * ```
933
2316
  */
934
- getPointersOverSprite(sprite, radiusScale = 0.5) {
935
- if (!sprite)
936
- return [];
937
- return this._collectPointers().filter((pointer) => this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale));
2317
+ getPointersOverSprite(sprite) {
2318
+ return this._inputSystem.getPointersOverSprite(sprite);
2319
+ }
2320
+ /**
2321
+ * Returns `true` when the current device appears to be mobile/touch-first.
2322
+ *
2323
+ * This is a heuristic helper intended for gameplay UI decisions (for example,
2324
+ * showing on-screen touch controls). It checks user-agent/platform hints and
2325
+ * coarse-pointer/touch capabilities.
2326
+ *
2327
+ * @returns `true` if the runtime likely corresponds to a mobile device.
2328
+ *
2329
+ * @example
2330
+ * ```ts
2331
+ * if (game.isMobileDevice()) {
2332
+ * // Show touch buttons
2333
+ * } else {
2334
+ * // Show keyboard hints
2335
+ * }
2336
+ * ```
2337
+ */
2338
+ isMobileDevice() {
2339
+ return this._inputSystem.isMobileDevice();
2340
+ }
2341
+ // -------------------------------------------------------------------------
2342
+ // Sound
2343
+ // -------------------------------------------------------------------------
2344
+ /**
2345
+ * Plays a sound loaded with {@link Game.loadSound} or an asset file's
2346
+ * `sound` entry, and returns its {@link SoundPlayback}.
2347
+ *
2348
+ * Plays overlap: playing a sound that is already playing starts another
2349
+ * play of it. Keep the returned playback to pause, resume, or stop it, such
2350
+ * as the game's music. A one-shot play stops by itself when it ends; a
2351
+ * looping one plays until stopped.
2352
+ *
2353
+ * - **Before the first tap.** Browsers keep a page silent until the player
2354
+ * taps, clicks, or presses a key. A looping sound started earlier, such as
2355
+ * music started in a scene's `onCreate()`, waits and begins with that
2356
+ * first gesture. A one-shot sound played earlier is dropped, and its
2357
+ * playback is returned already stopped.
2358
+ * - **Scenes.** {@link Game.reset} and {@link Game.transitionTo} do not stop
2359
+ * sounds. Stop the music yourself when a scene should change it.
2360
+ * - **Hidden page.** Every sound pauses while the page is hidden, and
2361
+ * continues when it is shown again.
2362
+ *
2363
+ * Throws an error naming the key when no sound is loaded under it.
2364
+ *
2365
+ * @param key - A key loaded with {@link Game.loadSound} or an asset file.
2366
+ * @param options - `loop` to repeat until stopped, and `volume` from `0` to `1`.
2367
+ *
2368
+ * @example
2369
+ * ```ts
2370
+ * game.playSound("jump");
2371
+ * const music = game.playSound("theme", { loop: true, volume: 0.6 });
2372
+ * music.pause();
2373
+ * music.resume();
2374
+ * music.stop();
2375
+ * ```
2376
+ */
2377
+ playSound(key, options) {
2378
+ const buffer = this._assetSystem.getSound(key);
2379
+ if (buffer === undefined) {
2380
+ throw new Error(`MinimoJS: No sound is loaded under the key "${key}". Load it with game.loadSound() or an asset file's "sound" entry in an onPreload hook.`);
2381
+ }
2382
+ return this._soundSystem.play(key, buffer, options);
2383
+ }
2384
+ /**
2385
+ * Stops every sound playing from a file, such as music and effects started
2386
+ * with {@link Game.playSound}. Synthesized tones are not affected.
2387
+ *
2388
+ * @example
2389
+ * ```ts
2390
+ * game.stopAllSounds();
2391
+ * ```
2392
+ */
2393
+ stopAllSounds() {
2394
+ this._soundSystem.stopAll();
2395
+ }
2396
+ /**
2397
+ * Plays a square-wave beep using the Web Audio API.
2398
+ *
2399
+ * **Square wave only.** This synthesizes a tone and needs no file. To play
2400
+ * a recorded sound, load it with {@link Game.loadSound} and play it with
2401
+ * {@link Game.playSound}.
2402
+ *
2403
+ * The AudioContext is created lazily on first call. Browsers require a user
2404
+ * gesture (click, keypress) before audio can play — always call `sound()` in
2405
+ * response to user input or a game event triggered by input.
2406
+ *
2407
+ * If you need a melody/sequence, use {@link Game.soundSequence} instead of
2408
+ * multiple `sound()` calls in the same frame.
2409
+ *
2410
+ * The tone fades out exponentially over `durationMs` to avoid clicks.
2411
+ *
2412
+ * @param freq - Frequency in Hz. Middle C = 261.6. Typical range: 100–4000 Hz.
2413
+ * @param durationMs - Duration of the sound in **milliseconds**.
2414
+ *
2415
+ * @example
2416
+ * ```ts
2417
+ * game.sound(440, 100); // 440 Hz beep for 100ms
2418
+ * game.sound(261, 500); // middle C for 500ms
2419
+ * game.sound(880, 50); // high beep for 50ms (jump sound)
2420
+ * ```
2421
+ */
2422
+ sound(freq, durationMs) {
2423
+ this._soundSystem.sound(freq, durationMs);
2424
+ }
2425
+ /**
2426
+ * Plays multiple square-wave tones in strict sequence.
2427
+ *
2428
+ * Each tuple is `[frequencyHz, durationMs]`. The next tone starts when the
2429
+ * previous one ends.
2430
+ *
2431
+ * @param tones - Tone tuples in playback order.
2432
+ *
2433
+ * @example
2434
+ * ```ts
2435
+ * game.soundSequence([660, 80], [820, 90], [980, 110], [1180, 150]);
2436
+ * ```
2437
+ */
2438
+ soundSequence(...tones) {
2439
+ this._soundSystem.soundSequence(...tones);
2440
+ }
2441
+ // -------------------------------------------------------------------------
2442
+ // Animations
2443
+ // -------------------------------------------------------------------------
2444
+ /**
2445
+ * Animates a sprite's {@link Sprite.alpha} from its current value to `to`
2446
+ * over `durationMs` milliseconds using **linear interpolation**.
2447
+ *
2448
+ * If an alpha animation is already running on this sprite, it is replaced
2449
+ * by the new one immediately (no queuing).
2450
+ *
2451
+ * **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
2452
+ *
2453
+ * @param sprite - The sprite to animate.
2454
+ * @param to - Target alpha value (0 = transparent, 1 = opaque).
2455
+ * @param durationMs - Duration of the animation in **milliseconds**.
2456
+ * @param onComplete - Optional callback invoked when the animation finishes.
2457
+ *
2458
+ * @example
2459
+ * ```ts
2460
+ * // Fade out a sprite over 1 second, then destroy it
2461
+ * game.animateAlpha(coin, 0, 1000, () => game.destroySprite(coin));
2462
+ * ```
2463
+ */
2464
+ animateAlpha(sprite, to, durationMs, onComplete) {
2465
+ this._animationSystem.animateAlpha(sprite, to, durationMs, onComplete);
2466
+ }
2467
+ /**
2468
+ * Animates a sprite's {@link Sprite.rotation} from its current value to `to`
2469
+ * (in degrees) over `durationMs` milliseconds using **linear interpolation**.
2470
+ *
2471
+ * If a rotation animation is already running on this sprite, it is replaced
2472
+ * immediately (no queuing).
2473
+ *
2474
+ * **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
2475
+ * **Units:** `to` is in **degrees**.
2476
+ *
2477
+ * @param sprite - The sprite to animate.
2478
+ * @param to - Target rotation in **degrees**.
2479
+ * @param durationMs - Duration of the animation in **milliseconds**.
2480
+ * @param onComplete - Optional callback invoked when the animation finishes.
2481
+ *
2482
+ * @example
2483
+ * ```ts
2484
+ * // Spin a sprite 360° over 2 seconds
2485
+ * game.animateRotation(star, 360, 2000);
2486
+ *
2487
+ * // Tilt on hit, then straighten
2488
+ * game.animateRotation(player, 45, 200, () => {
2489
+ * game.animateRotation(player, 0, 200);
2490
+ * });
2491
+ * ```
2492
+ */
2493
+ animateRotation(sprite, to, durationMs, onComplete) {
2494
+ this._animationSystem.animateRotation(sprite, to, durationMs, onComplete);
2495
+ }
2496
+ /**
2497
+ * Animates a sprite's visual deformation using non-uniform scaling around a
2498
+ * normalized pivot point.
2499
+ *
2500
+ * This affects rendering only. Physics and collision bounds continue to use
2501
+ * {@link EmojiSprite.displaySize} and ignore the deform result.
2502
+ *
2503
+ * `pivotX` / `pivotY` are normalized anchors in the sprite's square display box:
2504
+ * - `0` = left/top
2505
+ * - `0.5` = center
2506
+ * - `1` = right/bottom
2507
+ *
2508
+ * For example, `pivotY = 1` keeps the bottom visually pinned while squashing
2509
+ * or stretching, which is useful for floor-contact animation.
2510
+ *
2511
+ * Like other MinimoJS animations, this animates from the sprite's current
2512
+ * deform state to the requested target and leaves the final result applied.
2513
+ *
2514
+ * @param sprite - The sprite to deform.
2515
+ * @param toScaleX - Target horizontal deform scale. `1` = unchanged.
2516
+ * @param toScaleY - Target vertical deform scale. `1` = unchanged.
2517
+ * @param pivotX - Horizontal pivot in normalized `[0, 1]` sprite space.
2518
+ * @param pivotY - Vertical pivot in normalized `[0, 1]` sprite space.
2519
+ * @param durationMs - Duration of the animation in **milliseconds**.
2520
+ * @param onComplete - Optional callback invoked when the animation finishes.
2521
+ *
2522
+ * @example
2523
+ * ```ts
2524
+ * game.animateDeform(player, 1.18, 0.85, 0.5, 1, 90, () => {
2525
+ * game.animateDeform(player, 1, 1, 0.5, 1, 120);
2526
+ * });
2527
+ * ```
2528
+ */
2529
+ animateDeform(sprite, toScaleX, toScaleY, pivotX, pivotY, durationMs, onComplete) {
2530
+ this._animationSystem.animateDeform(sprite, toScaleX, toScaleY, pivotX, pivotY, durationMs, onComplete);
2531
+ }
2532
+ /**
2533
+ * Convenience helper that squashes a sprite around its horizontal center.
2534
+ *
2535
+ * This is equivalent to animating toward a wider, shorter visual pose using
2536
+ * {@link Game.animateDeform}. `pivotY = 1` is the common floor-anchored case.
2537
+ *
2538
+ * @param sprite - The sprite to squash.
2539
+ * @param pivotY - Vertical pivot in normalized `[0, 1]` sprite space.
2540
+ * @param durationMs - Duration of the animation in **milliseconds**.
2541
+ * @param onComplete - Optional callback invoked when the animation finishes.
2542
+ */
2543
+ animateSquash(sprite, pivotY, durationMs, onComplete) {
2544
+ this._animationSystem.animateDeform(sprite, 1.18, 0.85, 0.5, pivotY, durationMs, onComplete);
2545
+ }
2546
+ /**
2547
+ * Convenience helper that stretches a sprite around its horizontal center.
2548
+ *
2549
+ * This is equivalent to animating toward a narrower, taller visual pose using
2550
+ * {@link Game.animateDeform}. `pivotY = 1` is the common floor-anchored case.
2551
+ *
2552
+ * @param sprite - The sprite to stretch.
2553
+ * @param pivotY - Vertical pivot in normalized `[0, 1]` sprite space.
2554
+ * @param durationMs - Duration of the animation in **milliseconds**.
2555
+ * @param onComplete - Optional callback invoked when the animation finishes.
2556
+ */
2557
+ animateStretch(sprite, pivotY, durationMs, onComplete) {
2558
+ this._animationSystem.animateDeform(sprite, 0.85, 1.18, 0.5, pivotY, durationMs, onComplete);
2559
+ }
2560
+ /**
2561
+ * Convenience helper that applies a centered uniform scale pulse and then
2562
+ * returns the sprite to its neutral shape.
2563
+ *
2564
+ * Internally this runs two deform animations back-to-back using
2565
+ * {@link Game.animateDeform}.
2566
+ *
2567
+ * @param sprite - The sprite to pulse.
2568
+ * @param scale - Peak uniform scale reached midway through the pulse. `1` leaves the sprite unchanged.
2569
+ * @param durationMs - Total pulse duration in **milliseconds**.
2570
+ * @param onComplete - Optional callback invoked after the sprite returns to neutral.
2571
+ *
2572
+ * @example
2573
+ * ```ts
2574
+ * game.animatePulse(coin, 1.25, 220);
2575
+ * ```
2576
+ */
2577
+ animatePulse(sprite, scale, durationMs, onComplete) {
2578
+ const target = Number.isFinite(scale) ? Math.max(0, scale) : 1;
2579
+ const firstHalf = Math.max(1, Math.round(durationMs / 2));
2580
+ const secondHalf = Math.max(1, durationMs - firstHalf);
2581
+ this._animationSystem.animateDeform(sprite, target, target, 0.5, 0.5, firstHalf, () => {
2582
+ this._animationSystem.animateDeform(sprite, 1, 1, 0.5, 0.5, secondHalf, onComplete);
2583
+ });
2584
+ }
2585
+ /**
2586
+ * Applies a decaying screen-space shake to a sprite without changing its
2587
+ * logical position.
2588
+ *
2589
+ * This is a render-only effect. Physics, collisions, and the sprite's
2590
+ * `x` / `y` values are not modified.
2591
+ *
2592
+ * @param sprite - The sprite to shake.
2593
+ * @param intensity - Maximum shake offset in pixels.
2594
+ * @param durationMs - Duration of the shake in **milliseconds**.
2595
+ * @param onComplete - Optional callback invoked when the shake ends.
2596
+ *
2597
+ * @example
2598
+ * ```ts
2599
+ * game.animateShake(player, 18, 300);
2600
+ * ```
2601
+ */
2602
+ animateShake(sprite, intensity, durationMs, onComplete) {
2603
+ this._animationSystem.animateShake(sprite, intensity, durationMs, onComplete);
2604
+ }
2605
+ /**
2606
+ * Moves a sprite upward and back down to its starting position along a simple
2607
+ * parabolic arc.
2608
+ *
2609
+ * This animation writes directly to {@link Sprite.x} / {@link Sprite.y} while
2610
+ * it is active.
2611
+ *
2612
+ * @param sprite - The sprite to bounce.
2613
+ * @param height - Peak bounce height in pixels.
2614
+ * @param durationMs - Total bounce duration in **milliseconds**.
2615
+ * @param onComplete - Optional callback invoked when the bounce ends.
2616
+ *
2617
+ * @example
2618
+ * ```ts
2619
+ * game.animateBounce(ball, 120, 600);
2620
+ * ```
2621
+ */
2622
+ animateBounce(sprite, height, durationMs, onComplete) {
2623
+ this._animationSystem.animateBounce(sprite, height, durationMs, onComplete);
2624
+ }
2625
+ /**
2626
+ * Moves a sprite upward and back down with a smooth sine-shaped float motion.
2627
+ *
2628
+ * This animation writes directly to {@link Sprite.x} / {@link Sprite.y} while
2629
+ * it is active.
2630
+ *
2631
+ * @param sprite - The sprite to float.
2632
+ * @param distance - Maximum upward travel in pixels.
2633
+ * @param durationMs - Total float duration in **milliseconds**.
2634
+ * @param onComplete - Optional callback invoked when the float ends.
2635
+ *
2636
+ * @example
2637
+ * ```ts
2638
+ * game.animateFloat(balloon, 90, 1200);
2639
+ * ```
2640
+ */
2641
+ animateFloat(sprite, distance, durationMs, onComplete) {
2642
+ this._animationSystem.animateFloat(sprite, distance, durationMs, onComplete);
2643
+ }
2644
+ /**
2645
+ * Toggles a sprite's rendered opacity on and off a fixed number of times.
2646
+ *
2647
+ * This is a render-only effect. The sprite remains present in the world and
2648
+ * keeps receiving physics updates while blinking.
2649
+ *
2650
+ * @param sprite - The sprite to blink.
2651
+ * @param times - Number of visible/invisible toggle cycles.
2652
+ * @param durationMs - Total blink duration in **milliseconds**.
2653
+ * @param onComplete - Optional callback invoked when blinking ends.
2654
+ *
2655
+ * @example
2656
+ * ```ts
2657
+ * game.animateBlink(player, 4, 700);
2658
+ * ```
2659
+ */
2660
+ animateBlink(sprite, times, durationMs, onComplete) {
2661
+ this._animationSystem.animateBlink(sprite, times, durationMs, onComplete);
2662
+ }
2663
+ /**
2664
+ * Applies a rapid irregular alpha variation to create a damaged, unstable, or
2665
+ * ghost-like flicker.
2666
+ *
2667
+ * This is a render-only effect and does not change the sprite's logical
2668
+ * position or collision state.
2669
+ *
2670
+ * @param sprite - The sprite to flicker.
2671
+ * @param durationMs - Total flicker duration in **milliseconds**.
2672
+ * @param onComplete - Optional callback invoked when the flicker ends.
2673
+ *
2674
+ * @example
2675
+ * ```ts
2676
+ * game.animateFlicker(ghost, 900);
2677
+ * ```
2678
+ */
2679
+ animateFlicker(sprite, durationMs, onComplete) {
2680
+ this._animationSystem.animateFlicker(sprite, durationMs, onComplete);
2681
+ }
2682
+ /**
2683
+ * Moves a sprite from its current position to a target position along a
2684
+ * parabolic arc.
2685
+ *
2686
+ * This animation writes directly to {@link Sprite.x} / {@link Sprite.y} while
2687
+ * it is active.
2688
+ *
2689
+ * @param sprite - The sprite to move.
2690
+ * @param toX - Destination X position in world space.
2691
+ * @param toY - Destination Y position in world space.
2692
+ * @param arcHeight - Maximum height of the arc above the linear path, in pixels.
2693
+ * @param durationMs - Total arc duration in **milliseconds**.
2694
+ * @param onComplete - Optional callback invoked when the arc ends.
2695
+ *
2696
+ * @example
2697
+ * ```ts
2698
+ * game.animateArc(coin, player.x, player.y, 80, 420);
2699
+ * ```
2700
+ */
2701
+ animateArc(sprite, toX, toY, arcHeight, durationMs, onComplete) {
2702
+ this._animationSystem.animateArc(sprite, toX, toY, arcHeight, durationMs, onComplete);
938
2703
  }
939
2704
  /**
940
- * Returns `true` when the current device appears to be mobile/touch-first.
2705
+ * Emits temporary afterimages from a sprite to create a motion trail.
941
2706
  *
942
- * This is a heuristic helper intended for gameplay UI decisions (for example,
943
- * showing on-screen touch controls). It checks user-agent/platform hints and
944
- * coarse-pointer/touch capabilities.
2707
+ * This is a render-only effect. Trail ghosts are not real sprites and do not
2708
+ * appear in {@link Game.getSprites}, collide, or receive physics.
945
2709
  *
946
- * @returns `true` if the runtime likely corresponds to a mobile device.
2710
+ * @param sprite - The sprite to sample for trail ghosts.
2711
+ * @param options - Optional trail tuning values.
2712
+ * @param onComplete - Optional callback invoked when the emitter stops spawning ghosts.
947
2713
  *
948
2714
  * @example
949
2715
  * ```ts
950
- * if (game.isMobileDevice()) {
951
- * // Show touch buttons
952
- * } else {
953
- * // Show keyboard hints
954
- * }
2716
+ * game.animateTrail(player, { durationMs: 300, spacingMs: 30, fadeMs: 180 });
955
2717
  * ```
956
2718
  */
957
- isMobileDevice() {
958
- if (typeof navigator === "undefined")
959
- return false;
960
- const ua = navigator.userAgent ?? "";
961
- const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
962
- const iPadOS = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
963
- const coarsePointer = typeof window !== "undefined" &&
964
- typeof window.matchMedia === "function" &&
965
- window.matchMedia("(pointer: coarse)").matches;
966
- return mobileUA || iPadOS || coarsePointer;
2719
+ animateTrail(sprite, options, onComplete) {
2720
+ this._trailSystem.animateTrail(sprite, options, onComplete);
967
2721
  }
968
- // -------------------------------------------------------------------------
969
- // Sound
970
- // -------------------------------------------------------------------------
971
2722
  /**
972
- * Plays a square-wave beep using the Web Audio API.
973
- *
974
- * **Square wave only.** MinimoJS does not support audio files, samples,
975
- * or other waveforms. Only procedural square-wave tones.
2723
+ * Breaks a sprite into visual pieces that burst outward from its current
2724
+ * rendered appearance.
976
2725
  *
977
- * The AudioContext is created lazily on first call. Browsers require a user
978
- * gesture (click, keypress) before audio can play — always call `sound()` in
979
- * response to user input or a game event triggered by input.
2726
+ * This is a render-only effect. The spawned pieces are not real sprites and do
2727
+ * not appear in {@link Game.getSprites}, receive physics, or collide.
980
2728
  *
981
- * The tone fades out exponentially over `durationMs` to avoid clicks.
2729
+ * By default, the original sprite is destroyed immediately after the explosion
2730
+ * effect is spawned. Set `destroySprite: false` to hide the sprite during the
2731
+ * effect and restore it when the explosion ends.
982
2732
  *
983
- * @param freq - Frequency in Hz. Middle C = 261.6. Typical range: 100–4000 Hz.
984
- * @param durationMs - Duration of the sound in **milliseconds**.
2733
+ * @param sprite - The sprite to explode.
2734
+ * @param options - Optional explosion tuning values.
2735
+ * @param onComplete - Optional callback invoked when the effect finishes.
985
2736
  *
986
2737
  * @example
987
2738
  * ```ts
988
- * game.sound(440, 100); // 440 Hz beep for 100ms
989
- * game.sound(261, 500); // middle C for 500ms
990
- * game.sound(880, 50); // high beep for 50ms (jump sound)
2739
+ * game.animateExplode(enemy, { rows: 4, cols: 4, durationMs: 650, speed: 320 });
991
2740
  * ```
992
2741
  */
993
- sound(freq, durationMs) {
994
- if (!this._audioCtx) {
995
- this._audioCtx = new AudioContext();
996
- }
997
- const ctx = this._audioCtx;
998
- if (ctx.state === "suspended") {
999
- ctx.resume();
1000
- }
1001
- const oscillator = ctx.createOscillator();
1002
- const gain = ctx.createGain();
1003
- oscillator.type = "square";
1004
- oscillator.frequency.setValueAtTime(freq, ctx.currentTime);
1005
- gain.gain.setValueAtTime(0.08, ctx.currentTime);
1006
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000);
1007
- oscillator.connect(gain);
1008
- gain.connect(ctx.destination);
1009
- oscillator.start(ctx.currentTime);
1010
- oscillator.stop(ctx.currentTime + durationMs / 1000);
2742
+ animateExplode(sprite, options, onComplete) {
2743
+ const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
2744
+ this._explosionSystem.animateExplode(sprite, glyphCanvas, options, onComplete);
2745
+ if (options?.destroySprite !== false) {
2746
+ this.destroySprite(sprite);
2747
+ }
1011
2748
  }
1012
- // -------------------------------------------------------------------------
1013
- // Animations
1014
- // -------------------------------------------------------------------------
1015
2749
  /**
1016
- * Animates a sprite's {@link Sprite.alpha} from its current value to `to`
1017
- * over `durationMs` milliseconds using **linear interpolation**.
2750
+ * Spawns visual pieces around a sprite and converges them back into its
2751
+ * current rendered appearance.
1018
2752
  *
1019
- * If an alpha animation is already running on this sprite, it is replaced
1020
- * by the new one immediately (no queuing).
2753
+ * This is a render-only effect. The spawned pieces are not real sprites and
2754
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
1021
2755
  *
1022
- * **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
2756
+ * The original sprite is hidden while the assembly plays and is restored when
2757
+ * the effect finishes.
1023
2758
  *
1024
- * @param sprite - The sprite to animate.
1025
- * @param to - Target alpha value (0 = transparent, 1 = opaque).
1026
- * @param durationMs - Duration of the animation in **milliseconds**.
1027
- * @param onComplete - Optional callback invoked when the animation finishes.
2759
+ * @param sprite - The sprite to assemble.
2760
+ * @param options - Optional assembly tuning values.
2761
+ * @param onComplete - Optional callback invoked when the effect finishes.
2762
+ */
2763
+ animateAssemble(sprite, options, onComplete) {
2764
+ const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
2765
+ this._explosionSystem.animateAssemble(sprite, glyphCanvas, options, onComplete);
2766
+ }
2767
+ /**
2768
+ * Breaks a sprite apart locally inside its own bounds using a directional
2769
+ * dissolve sweep.
1028
2770
  *
1029
- * @example
1030
- * ```ts
1031
- * // Fade out a sprite over 1 second, then destroy it
1032
- * game.animateAlpha(coin, 0, 1000, () => game.destroySprite(coin));
1033
- * ```
2771
+ * This is a render-only effect. The spawned pieces are not real sprites and
2772
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
2773
+ *
2774
+ * By default, the original sprite is destroyed immediately after the effect
2775
+ * is spawned. Set `destroySprite: false` to leave the sprite hidden instead.
2776
+ *
2777
+ * @param sprite - The sprite to disintegrate.
2778
+ * @param options - Optional disintegration tuning values.
2779
+ * @param onComplete - Optional callback invoked when the effect finishes.
1034
2780
  */
1035
- animateAlpha(sprite, to, durationMs, onComplete) {
1036
- this._animations = this._animations.filter((a) => !(a.sprite === sprite && a.property === "alpha"));
1037
- this._animations.push({
1038
- sprite,
1039
- property: "alpha",
1040
- from: sprite.alpha,
1041
- to,
1042
- durationMs,
1043
- elapsed: 0,
1044
- onComplete,
1045
- });
2781
+ animateDisintegrate(sprite, options, onComplete) {
2782
+ const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
2783
+ this._explosionSystem.animateDisintegrate(sprite, glyphCanvas, options, onComplete);
2784
+ if (options?.destroySprite !== false) {
2785
+ this.destroySprite(sprite);
2786
+ }
1046
2787
  }
1047
2788
  /**
1048
- * Animates a sprite's {@link Sprite.rotation} from its current value to `to`
1049
- * (in degrees) over `durationMs` milliseconds using **linear interpolation**.
2789
+ * Reconstructs a sprite locally inside its own bounds using a directional
2790
+ * integration sweep.
1050
2791
  *
1051
- * If a rotation animation is already running on this sprite, it is replaced
1052
- * immediately (no queuing).
2792
+ * This is a render-only effect. The spawned pieces are not real sprites and
2793
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
1053
2794
  *
1054
- * **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
1055
- * **Units:** `to` is in **degrees**.
2795
+ * The original sprite is hidden while the integration plays and is restored
2796
+ * when the effect finishes.
1056
2797
  *
1057
- * @param sprite - The sprite to animate.
1058
- * @param to - Target rotation in **degrees**.
1059
- * @param durationMs - Duration of the animation in **milliseconds**.
1060
- * @param onComplete - Optional callback invoked when the animation finishes.
2798
+ * @param sprite - The sprite to integrate.
2799
+ * @param options - Optional integration tuning values.
2800
+ * @param onComplete - Optional callback invoked when the effect finishes.
2801
+ */
2802
+ animateIntegrate(sprite, options, onComplete) {
2803
+ const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
2804
+ this._explosionSystem.animateIntegrate(sprite, glyphCanvas, options, onComplete);
2805
+ }
2806
+ /**
2807
+ * Reveals or covers a sprite with a directional wipe mask.
1061
2808
  *
1062
- * @example
1063
- * ```ts
1064
- * // Spin a sprite 360° over 2 seconds
1065
- * game.animateRotation(star, 360, 2000);
2809
+ * This is a render-only effect that captures the sprite's current appearance
2810
+ * and animates a clipping region over that snapshot.
1066
2811
  *
1067
- * // Tilt on hit, then straighten
1068
- * game.animateRotation(player, 45, 200, () => {
1069
- * game.animateRotation(player, 0, 200);
1070
- * });
1071
- * ```
2812
+ * For `mode: "reveal"`, the original sprite is hidden during the wipe and is
2813
+ * restored when the effect finishes.
2814
+ *
2815
+ * For `mode: "cover"`, the effect hides the sprite over time. If
2816
+ * `destroySprite` is omitted or `true`, the sprite is destroyed immediately
2817
+ * after the wipe starts and only the wipe snapshot remains visible.
2818
+ *
2819
+ * @param sprite - The sprite to wipe.
2820
+ * @param options - Optional wipe tuning values.
2821
+ * @param onComplete - Optional callback invoked when the effect finishes.
1072
2822
  */
1073
- animateRotation(sprite, to, durationMs, onComplete) {
1074
- this._animations = this._animations.filter((a) => !(a.sprite === sprite && a.property === "rotation"));
1075
- this._animations.push({
1076
- sprite,
1077
- property: "rotation",
1078
- from: sprite.rotation,
1079
- to,
1080
- durationMs,
1081
- elapsed: 0,
1082
- onComplete,
1083
- });
2823
+ animateWipe(sprite, options, onComplete) {
2824
+ const snapshot = this._renderSystem.getSpriteRenderSnapshot(sprite);
2825
+ this._explosionSystem.animateWipe(sprite, snapshot, options, onComplete);
2826
+ if ((options?.mode ?? "reveal") === "cover" && options?.destroySprite !== false) {
2827
+ this.destroySprite(sprite);
2828
+ }
1084
2829
  }
1085
2830
  // -------------------------------------------------------------------------
1086
2831
  // Timers
@@ -1115,9 +2860,7 @@ export class Game {
1115
2860
  * ```
1116
2861
  */
1117
2862
  addTimer(delayMs, repeat, callback) {
1118
- const id = ++this._timerIdCounter;
1119
- this._timers.push({ id, delayMs, elapsed: 0, repeat, callback });
1120
- return id;
2863
+ return this._timerSystem.addTimer(delayMs, repeat, callback);
1121
2864
  }
1122
2865
  /**
1123
2866
  * Cancels a timer previously created with {@link Game.addTimer}.
@@ -1134,24 +2877,32 @@ export class Game {
1134
2877
  * ```
1135
2878
  */
1136
2879
  clearTimer(id) {
1137
- this._timers = this._timers.filter((t) => t.id !== id);
2880
+ this._timerSystem.clearTimer(id);
1138
2881
  }
1139
2882
  // -------------------------------------------------------------------------
1140
2883
  // Text
1141
2884
  // -------------------------------------------------------------------------
1142
2885
  /**
1143
- * Draws text on screen as a **screen-space overlay** this frame.
2886
+ * Draws text on screen as a **simple screen-space overlay** this frame.
1144
2887
  *
1145
2888
  * **Overlay behavior:** Text is drawn in canvas/screen space — it ignores
1146
2889
  * `scrollX` / `scrollY`. Position `(0, 0)` is always the top-left of the canvas.
1147
- * Use this for HUD elements: score, lives, timer, debug info.
2890
+ * Use this for lightweight HUD elements: score, lives, timer, debug info.
1148
2891
  *
1149
2892
  * **Per-frame:** `drawText` must be called every frame to keep text visible.
1150
2893
  * The text overlay list is cleared after each render. Call this inside `onUpdate`.
1151
2894
  *
1152
2895
  * **Layer:** Text is always drawn on top of all sprites.
1153
- * **Font:** Text always uses a fixed `monospace` font family.
1154
- * Font family cannot be customized in MinimoJS v1.
2896
+ * **Font:** Text always uses `monospace`. Use {@link TextSprite} for any
2897
+ * other family.
2898
+ *
2899
+ * Prefer {@link TextSprite} for UI buttons or labels that need persistent
2900
+ * bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
2901
+ * `drawText()` is the lightweight overlay API, not the primary UI API.
2902
+ *
2903
+ * For controls that are only a single emoji, prefer {@link EmojiSprite} over
2904
+ * {@link TextSprite}. Emoji-only buttons do not benefit from text padding and
2905
+ * usually fit more naturally in the sprite pipeline.
1155
2906
  *
1156
2907
  * @param text - The string to render. Supports emoji and Unicode.
1157
2908
  * @param x - X position in **screen space** (pixels from canvas left edge).
@@ -1172,7 +2923,7 @@ export class Game {
1172
2923
  * ```
1173
2924
  */
1174
2925
  drawText(text, x, y, fontSize, color = "#ffffff", centered = false) {
1175
- this._textOverlays.push({ text, x, y, fontSize, color, centered });
2926
+ this._textSystem.drawText(text, x, y, fontSize, color, centered);
1176
2927
  }
1177
2928
  // -------------------------------------------------------------------------
1178
2929
  // Misc
@@ -1193,7 +2944,46 @@ export class Game {
1193
2944
  * ```
1194
2945
  */
1195
2946
  clearSpriteCache() {
1196
- this._spriteGlyphCache.clear();
2947
+ this._renderSystem.clearSpriteCache();
2948
+ }
2949
+ /**
2950
+ * Registers a web font family that must be loaded before the first frame.
2951
+ *
2952
+ * Call this before {@link Game.start}, typically right after constructing the
2953
+ * game or inside {@link Game.onPreload}. MinimoJS waits for every registered
2954
+ * font via `document.fonts.load(...)` before rendering anything. Host
2955
+ * environments may also pre-register fonts by setting
2956
+ * `globalThis.__MINIMO_WEB_FONT_REQUIREMENTS__` before your game code runs.
2957
+ *
2958
+ * The font itself must still be declared by your page (for example with a
2959
+ * `<link rel="stylesheet">` in `index.html`). MinimoJS only waits for it.
2960
+ *
2961
+ * @example
2962
+ * ```ts
2963
+ * game.requireFont('"Press Start 2P"');
2964
+ * game.requireFont('"Bangers"', { weight: "700" });
2965
+ * ```
2966
+ */
2967
+ requireFont(family, options = {}) {
2968
+ const normalizedFamily = family.trim();
2969
+ if (normalizedFamily.length === 0) {
2970
+ return;
2971
+ }
2972
+ const requirement = {
2973
+ family: normalizedFamily,
2974
+ weight: options.weight ?? "400",
2975
+ style: options.style ?? "normal",
2976
+ size: Number.isFinite(options.size) ? Math.max(1, options.size) : 16,
2977
+ sampleText: options.sampleText ?? "",
2978
+ };
2979
+ const key = [
2980
+ requirement.family,
2981
+ requirement.weight,
2982
+ requirement.style,
2983
+ requirement.size,
2984
+ requirement.sampleText,
2985
+ ].join("|");
2986
+ this._requiredFonts.set(key, requirement);
1197
2987
  }
1198
2988
  /**
1199
2989
  * Returns a pseudo-random floating-point number in the range `[0, 1)`.
@@ -1218,9 +3008,12 @@ export class Game {
1218
3008
  * Performs a full engine state reset to enable scene switching.
1219
3009
  *
1220
3010
  * **Cleared by reset:**
3011
+ * - All background layers
1221
3012
  * - All sprites (equivalent to calling {@link Game.destroySprite} on every sprite)
1222
3013
  * - All timers (regardless of repeat state)
1223
3014
  * - All running animations
3015
+ * - All active explosion effects
3016
+ * - All active trail effects
1224
3017
  * - All pending text overlays
1225
3018
  * - Scroll position (`scrollX = 0`, `scrollY = 0`)
1226
3019
  * - Per-frame input state (pressed keys, pressed pointer)
@@ -1233,39 +3026,124 @@ export class Game {
1233
3026
  * - Canvas dimensions
1234
3027
  * - AudioContext
1235
3028
  *
1236
- * After clearing, `reset()` immediately calls `onCreate()` so your callback
1237
- * can rebuild the new scene synchronously.
3029
+ * After clearing, `reset()` immediately calls the active scene's
3030
+ * `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
3031
+ * can rebuild synchronously.
3032
+ *
3033
+ * The exception is a scene whose {@link IScene.onPreload} registers images
3034
+ * that are not loaded yet. Then the current scene freezes and dims under a progress
3035
+ * ring, nothing is cleared until the images load, and `reset()` returns
3036
+ * before the scene is rebuilt; its `onCreate()` runs once they have loaded.
3037
+ * A `reset()` called while a scene is loading is ignored.
3038
+ *
3039
+ * @param scene - Optional new scene to make active before rebuilding.
1238
3040
  *
1239
3041
  * @example
1240
3042
  * ```ts
1241
- * // Switch from gameplay to game-over screen
1242
- * function gameOver() {
1243
- * game.reset(); // onCreate() is called here, rebuild inside it
1244
- * }
1245
- *
1246
- * game.onCreate = () => {
1247
- * // Scene init
1248
- * const skull = new Sprite("💀");
1249
- * skull.x = 400; skull.y = 300; skull.size = 96;
1250
- * game.add(skull);
1251
- * game.addTimer(3000, false, () => game.reset()); // auto-restart
1252
- * };
3043
+ * class GameOverScene {
3044
+ * onCreate() {
3045
+ * const skull = new EmojiSprite("💀", 400, 300, 96);
3046
+ * game.add(skull);
3047
+ * game.addTimer(3000, false, () => game.reset());
3048
+ * }
3049
+ * }
3050
+ *
3051
+ * game.reset(new GameOverScene());
1253
3052
  * ```
1254
3053
  */
1255
- reset() {
1256
- this._sprites = [];
1257
- this._timers = [];
1258
- this._animations = [];
1259
- this._textOverlays = [];
1260
- this._keysPressed.clear();
1261
- this._mousePressed = false;
1262
- for (const pointer of this._touchPointers.values()) {
1263
- pointer.pressed = false;
1264
- }
1265
- this._syncPrimaryPointer();
3054
+ reset(scene) {
3055
+ if (this._sceneLoad !== null) {
3056
+ return;
3057
+ }
3058
+ if (!this._hasCompletedPreload) {
3059
+ if (scene !== undefined) {
3060
+ this._currentScene = scene;
3061
+ }
3062
+ // Boot builds the active scene once its images load, so a scene chosen
3063
+ // now is only recorded. Before start(), a scene with images of its own
3064
+ // waits for start() to load them.
3065
+ if (this._preloadPromise !== null || this._currentScene?.onPreload) {
3066
+ return;
3067
+ }
3068
+ this._resetNow();
3069
+ return;
3070
+ }
3071
+ this._registerSceneAssets(scene ?? this._currentScene);
3072
+ if (this._assetSystem.getQueuedAssetCount() === 0) {
3073
+ this._resetNow(scene);
3074
+ return;
3075
+ }
3076
+ this._beginSceneLoad(() => this._resetNow(scene));
3077
+ }
3078
+ /** @internal */
3079
+ _resetNow(scene) {
3080
+ if (scene !== undefined) {
3081
+ this._currentScene = scene;
3082
+ }
3083
+ this._transitionSystem.clear();
3084
+ this._backgroundSystem.clearAll();
3085
+ this._spriteSystem.clearAll();
3086
+ this._timerSystem.clearAll();
3087
+ this._animationSystem.clearAll();
3088
+ this._explosionSystem.clearAll();
3089
+ this._trailSystem.clearAll();
3090
+ this._textSystem.clear();
3091
+ this._inputSystem.resetPressedState();
3092
+ this._buildersByTag.clear();
1266
3093
  this.scrollX = 0;
1267
3094
  this.scrollY = 0;
1268
- this._invokeCreate();
3095
+ this._loopSystem.invokeCreate();
3096
+ }
3097
+ /**
3098
+ * Changes to a new scene using a full-screen transition between frozen scene
3099
+ * snapshots.
3100
+ *
3101
+ * `transitionTo()` does not change the behavior of {@link Game.reset}. It
3102
+ * captures the current scene as a snapshot, rebuilds the target scene
3103
+ * immediately, captures the new scene as another snapshot, and then animates
3104
+ * between those two images for the requested duration.
3105
+ *
3106
+ * While the transition is active, gameplay updates are paused.
3107
+ *
3108
+ * If the target scene's {@link IScene.onPreload} registers images that are
3109
+ * not loaded yet, the current scene first freezes and dims under a progress ring
3110
+ * until they load, and the transition plays after that. `transitionTo()`
3111
+ * then returns before the target scene exists; continue in its
3112
+ * `onCreate()` or in `onComplete`. If the player declines to retry failed
3113
+ * images, the game stays on the current scene and `onComplete` is not
3114
+ * called.
3115
+ *
3116
+ * A call made while a transition plays or a scene loads is ignored.
3117
+ *
3118
+ * If the game loop is not running yet, this falls back to
3119
+ * {@link Game.reset}.
3120
+ *
3121
+ * @param scene - Target scene to make active.
3122
+ * @param options - Transition style and optional tuning values.
3123
+ * @param onComplete - Optional callback invoked when the transition finishes.
3124
+ */
3125
+ transitionTo(scene, options, onComplete) {
3126
+ if (!this._loopSystem.isRunning || !this._hasCompletedPreload) {
3127
+ this.reset(scene);
3128
+ onComplete?.();
3129
+ return;
3130
+ }
3131
+ if (this._transitionSystem.isActive || this._sceneLoad !== null) {
3132
+ return;
3133
+ }
3134
+ this._registerSceneAssets(scene);
3135
+ if (this._assetSystem.getQueuedAssetCount() === 0) {
3136
+ this._transitionNow(scene, options, onComplete);
3137
+ return;
3138
+ }
3139
+ this._beginSceneLoad(() => this._transitionNow(scene, options, onComplete));
3140
+ }
3141
+ /** @internal */
3142
+ _transitionNow(scene, options, onComplete) {
3143
+ const fromCanvas = this._captureFrameSnapshot();
3144
+ this._resetNow(scene);
3145
+ const toCanvas = this._captureFrameSnapshot();
3146
+ this._transitionSystem.start(fromCanvas, toCanvas, this.width, this.height, options, onComplete);
1269
3147
  }
1270
3148
  // -------------------------------------------------------------------------
1271
3149
  // Loop control
@@ -1273,405 +3151,404 @@ export class Game {
1273
3151
  /**
1274
3152
  * Starts the `requestAnimationFrame` game loop.
1275
3153
  * Safe to call multiple times — does nothing if already running.
1276
- * If this is the first start (or after a reset), `onCreate()` is called
1277
- * before the first frame.
1278
- *
1279
- * The loop calls `onUpdate` once per frame, then renders all sprites and
1280
- * text overlays. Order per frame:
3154
+ * On the first start, MinimoJS runs {@link Game.onPreload} and the active
3155
+ * scene's {@link IScene.onPreload}, loads the images they queued while
3156
+ * showing a thin loading line, and waits for all fonts registered via
3157
+ * {@link Game.requireFont}. After that, the active scene's `onCreate()` is
3158
+ * called before the first frame.
3159
+ *
3160
+ * The loop calls the active scene's `onUpdate(dt)` (or legacy
3161
+ * {@link Game.onUpdate}) once per frame, then renders all sprites and text
3162
+ * overlays. Order per frame:
1281
3163
  * 1. Accumulate timer elapsed time; fire ready callbacks.
1282
3164
  * 2. Advance animations (linear interpolation).
1283
- * 3. Apply gravity to sprite velocities.
1284
- * 4. Apply velocities to sprite positions.
1285
- * 5. Call `onUpdate(dt)`.
1286
- * 6. Render sprites (sorted by layer) with scroll offset.
1287
- * 7. Render text overlays (screen space, on top of sprites).
1288
- * 8. Clear per-frame input state.
3165
+ * 3. Advance active piece and wipe effects.
3166
+ * 4. Apply gravity to sprite velocities.
3167
+ * 5. Apply velocities to sprite positions.
3168
+ * 6. Call `onUpdate(dt)`.
3169
+ * 7. Advance active trail effects.
3170
+ * 8. Render sprites/effects (with scroll offset).
3171
+ * 9. Render text overlays (screen space, on top of sprites).
3172
+ * 10. Clear per-frame input state.
1289
3173
  *
1290
3174
  * @example
1291
3175
  * ```ts
1292
- * game.onUpdate = (dt) => { ... };
1293
- * game.start();
3176
+ * class DemoScene {
3177
+ * onCreate() {}
3178
+ * onUpdate(dt: number) {}
3179
+ * }
3180
+ *
3181
+ * game.start(new DemoScene());
1294
3182
  * ```
1295
3183
  */
1296
- start() {
1297
- if (this._running)
3184
+ start(scene) {
3185
+ if (scene !== undefined) {
3186
+ this._currentScene = scene;
3187
+ }
3188
+ this.applyGlobalFontRequirements();
3189
+ if (this._hasCompletedPreload) {
3190
+ this._loopSystem.start();
3191
+ return;
3192
+ }
3193
+ if (this._preloadPromise !== null) {
1298
3194
  return;
1299
- if (!this._hasCreated)
1300
- this._invokeCreate();
1301
- this._running = true;
1302
- this._lastTimestamp = null;
1303
- this._rafId = requestAnimationFrame(this._loop.bind(this));
3195
+ }
3196
+ if (this.onPreload !== null) {
3197
+ this._isRegisteringPreloadAssets = true;
3198
+ try {
3199
+ this.onPreload();
3200
+ }
3201
+ finally {
3202
+ this._isRegisteringPreloadAssets = false;
3203
+ }
3204
+ }
3205
+ const preloadPromise = Promise.all([
3206
+ this._loadBootImages(),
3207
+ this.waitForDocumentFonts(),
3208
+ ]).then(([loaded]) => {
3209
+ if (this._preloadPromise !== preloadPromise)
3210
+ return;
3211
+ this._preloadPromise = null;
3212
+ if (!loaded)
3213
+ return;
3214
+ this._hasCompletedPreload = true;
3215
+ this._loopSystem.start();
3216
+ });
3217
+ this._preloadPromise = preloadPromise;
3218
+ preloadPromise.catch((error) => {
3219
+ if (this._preloadPromise === preloadPromise) {
3220
+ this._preloadPromise = null;
3221
+ }
3222
+ throw error;
3223
+ });
3224
+ }
3225
+ /**
3226
+ * Loads the boot images, then those of any scene made active while they
3227
+ * loaded, retrying failures while the player agrees. Resolves `false` when
3228
+ * the player declines, which leaves the game on the loading screen.
3229
+ *
3230
+ * @internal
3231
+ */
3232
+ async _loadBootImages() {
3233
+ let registeredScene = undefined;
3234
+ for (;;) {
3235
+ if (registeredScene !== this._currentScene) {
3236
+ registeredScene = this._currentScene;
3237
+ this._registerSceneAssets(registeredScene);
3238
+ }
3239
+ const failed = await this._assetSystem.loadQueuedAssets((settled, total) => {
3240
+ if (total > 0)
3241
+ this._renderLoadingScreen(settled / total);
3242
+ });
3243
+ if (failed.length > 0) {
3244
+ this._reportFailedAssets(failed);
3245
+ if (!this._confirmAssetRetry(failed.length)) {
3246
+ this._assetSystem.discardQueuedAssets();
3247
+ return false;
3248
+ }
3249
+ continue;
3250
+ }
3251
+ if (registeredScene === this._currentScene) {
3252
+ return true;
3253
+ }
3254
+ }
1304
3255
  }
1305
3256
  /**
1306
3257
  * Stops the game loop. The canvas retains its last rendered frame.
1307
3258
  * Call {@link Game.start} to resume.
1308
3259
  */
1309
3260
  stop() {
1310
- this._running = false;
1311
- if (this._rafId !== null) {
1312
- cancelAnimationFrame(this._rafId);
1313
- this._rafId = null;
1314
- }
1315
- this._lastTimestamp = null;
3261
+ this._loopSystem.stop();
1316
3262
  }
1317
3263
  // -------------------------------------------------------------------------
1318
- // Private — input binding
3264
+ // Private — rendering
1319
3265
  // -------------------------------------------------------------------------
1320
- /** @internal */
1321
- _bindInputEvents() {
1322
- window.addEventListener("keydown", (e) => {
1323
- if (!this._keysDown.has(e.key)) {
1324
- this._keysPressed.add(e.key);
1325
- }
1326
- this._keysDown.add(e.key);
1327
- });
1328
- window.addEventListener("keyup", (e) => {
1329
- this._keysDown.delete(e.key);
1330
- });
1331
- const getCanvasPoint = (clientX, clientY) => {
1332
- const rect = this._canvas.getBoundingClientRect();
1333
- const scaleX = this._canvas.width / rect.width;
1334
- const scaleY = this._canvas.height / rect.height;
1335
- return {
1336
- x: (clientX - rect.left) * scaleX,
1337
- y: (clientY - rect.top) * scaleY,
1338
- };
1339
- };
1340
- this._canvas.addEventListener("mousedown", (e) => {
1341
- const p = getCanvasPoint(e.clientX, e.clientY);
1342
- this._mouseX = p.x;
1343
- this._mouseY = p.y;
1344
- this._mouseDown = true;
1345
- this._mousePressed = true;
1346
- this._syncPrimaryPointer();
1347
- });
1348
- this._canvas.addEventListener("mouseup", () => {
1349
- this._mouseDown = false;
1350
- this._syncPrimaryPointer();
1351
- });
1352
- this._canvas.addEventListener("mousemove", (e) => {
1353
- const p = getCanvasPoint(e.clientX, e.clientY);
1354
- this._mouseX = p.x;
1355
- this._mouseY = p.y;
1356
- this._syncPrimaryPointer();
1357
- });
1358
- this._canvas.addEventListener("touchstart", (e) => {
1359
- for (const touch of Array.from(e.changedTouches)) {
1360
- const p = getCanvasPoint(touch.clientX, touch.clientY);
1361
- this._touchPointers.set(touch.identifier, {
1362
- x: p.x,
1363
- y: p.y,
1364
- pressed: true,
1365
- });
1366
- if (this._primaryTouchId === null) {
1367
- this._primaryTouchId = touch.identifier;
1368
- }
1369
- }
1370
- this._syncPrimaryPointer();
1371
- e.preventDefault();
1372
- }, { passive: false });
1373
- this._canvas.addEventListener("touchend", (e) => {
1374
- for (const touch of Array.from(e.changedTouches)) {
1375
- this._touchPointers.delete(touch.identifier);
1376
- if (this._primaryTouchId === touch.identifier) {
1377
- this._primaryTouchId = null;
1378
- }
1379
- }
1380
- this._syncPrimaryPointer();
1381
- e.preventDefault();
1382
- }, { passive: false });
1383
- this._canvas.addEventListener("touchcancel", (e) => {
1384
- for (const touch of Array.from(e.changedTouches)) {
1385
- this._touchPointers.delete(touch.identifier);
1386
- if (this._primaryTouchId === touch.identifier) {
1387
- this._primaryTouchId = null;
1388
- }
3266
+ /** @internal */ _onLoopFrameCallback(dt, dtMs) {
3267
+ this._renderSystem.beginDynamicSurfacePass();
3268
+ if (this._sceneLoad !== null) {
3269
+ this._renderInterruption();
3270
+ return;
3271
+ }
3272
+ if (this._transitionSystem.isActive) {
3273
+ this._transitionSystem.update(dtMs);
3274
+ if (this._transitionSystem.isActive) {
3275
+ this._renderTransition();
1389
3276
  }
1390
- this._syncPrimaryPointer();
1391
- e.preventDefault();
1392
- }, { passive: false });
1393
- this._canvas.addEventListener("touchmove", (e) => {
1394
- for (const touch of Array.from(e.changedTouches)) {
1395
- const p = getCanvasPoint(touch.clientX, touch.clientY);
1396
- const existing = this._touchPointers.get(touch.identifier);
1397
- if (existing) {
1398
- existing.x = p.x;
1399
- existing.y = p.y;
1400
- }
1401
- else {
1402
- this._touchPointers.set(touch.identifier, {
1403
- x: p.x,
1404
- y: p.y,
1405
- pressed: false,
1406
- });
1407
- }
3277
+ else {
3278
+ this._render();
1408
3279
  }
1409
- this._syncPrimaryPointer();
1410
- e.preventDefault();
1411
- }, { passive: false });
3280
+ this._inputSystem.clearFramePressedState();
3281
+ this._textSystem.clear();
3282
+ return;
3283
+ }
3284
+ this._timerSystem.update(dtMs);
3285
+ if (this._isSceneChangeInterrupting()) {
3286
+ this._renderInterruption();
3287
+ return;
3288
+ }
3289
+ this._animationSystem.update(dtMs);
3290
+ this._explosionSystem.update(dt, dtMs);
3291
+ this._physicsSystem.update(this._spriteSystem.getMutableSprites(), dt);
3292
+ if (this._isSceneChangeInterrupting()) {
3293
+ this._renderInterruption();
3294
+ return;
3295
+ }
3296
+ if (this._currentScene?.onUpdate)
3297
+ this._currentScene.onUpdate(dt);
3298
+ else if (this.onUpdate)
3299
+ this.onUpdate(dt);
3300
+ if (this._isSceneChangeInterrupting()) {
3301
+ this._renderInterruption();
3302
+ return;
3303
+ }
3304
+ this._trailSystem.update(dtMs, this._renderSystem.getSpriteRenderSnapshot.bind(this._renderSystem));
3305
+ this._render();
3306
+ this._inputSystem.clearFramePressedState();
3307
+ this._textSystem.clear();
1412
3308
  }
1413
3309
  /** @internal */
1414
- _isScreenPointOverSprite(x, y, sprite, radiusScale) {
1415
- const safeScale = Math.max(0, radiusScale);
1416
- const radius = sprite.size * safeScale;
1417
- const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
1418
- const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
1419
- const dx = x - drawX;
1420
- const dy = y - drawY;
1421
- return dx * dx + dy * dy <= radius * radius;
3310
+ _isSceneChangeInterrupting() {
3311
+ return this._transitionSystem.isActive || this._sceneLoad !== null;
1422
3312
  }
1423
- /** @internal */
1424
- _collectPointers() {
1425
- const pointers = [];
1426
- if (this._mouseDown) {
1427
- pointers.push({
1428
- id: "mouse",
1429
- kind: "mouse",
1430
- x: this._mouseX,
1431
- y: this._mouseY,
1432
- pressed: this._mousePressed,
3313
+ /**
3314
+ * Draws a frame in which gameplay does not advance: a loading scene under
3315
+ * its ring, or a transition.
3316
+ *
3317
+ * @internal
3318
+ */
3319
+ _renderInterruption() {
3320
+ const load = this._sceneLoad;
3321
+ if (load !== null) {
3322
+ this._renderSystem.renderSceneLoading({
3323
+ canvas: this._canvas,
3324
+ context: this._ctx,
3325
+ pageBackground: this.pageBackground,
3326
+ snapshot: load.snapshot,
3327
+ progress: load.progress,
3328
+ reveal: (performance.now() - load.startedAt - SCENE_LOADING_INDICATOR_DELAY_MS) /
3329
+ SCENE_LOADING_FADE_MS,
3330
+ ink: load.ink,
1433
3331
  });
1434
3332
  }
1435
- for (const [id, pointer] of this._touchPointers.entries()) {
1436
- pointers.push({
1437
- id,
1438
- kind: "touch",
1439
- x: pointer.x,
1440
- y: pointer.y,
1441
- pressed: pointer.pressed,
1442
- });
3333
+ else {
3334
+ this._renderTransition();
3335
+ }
3336
+ this._inputSystem.clearFramePressedState();
3337
+ this._textSystem.clear();
3338
+ }
3339
+ /**
3340
+ * Runs a scene's `onPreload()`, if it has one, with image registration
3341
+ * allowed.
3342
+ *
3343
+ * @internal
3344
+ */
3345
+ _registerSceneAssets(scene) {
3346
+ if (!scene?.onPreload)
3347
+ return;
3348
+ this._isRegisteringPreloadAssets = true;
3349
+ try {
3350
+ scene.onPreload();
1443
3351
  }
1444
- return pointers;
3352
+ finally {
3353
+ this._isRegisteringPreloadAssets = false;
3354
+ }
3355
+ }
3356
+ /**
3357
+ * Freezes the current scene under a progress ring while the queued images
3358
+ * load, then calls `finish` to enter the next scene. If images fail and the
3359
+ * player declines to retry, the current scene resumes instead.
3360
+ *
3361
+ * @internal
3362
+ */
3363
+ _beginSceneLoad(finish) {
3364
+ this._transitionSystem.clear();
3365
+ const snapshot = document.createElement("canvas");
3366
+ snapshot.width = this._canvas.width;
3367
+ snapshot.height = this._canvas.height;
3368
+ snapshot.getContext("2d")?.drawImage(this._canvas, 0, 0);
3369
+ const load = {
3370
+ snapshot,
3371
+ ink: this._renderSystem.pickSceneLoadingInk(snapshot),
3372
+ progress: 0,
3373
+ startedAt: performance.now(),
3374
+ };
3375
+ this._sceneLoad = load;
3376
+ void this._loadSceneImages(load, finish);
1445
3377
  }
1446
3378
  /** @internal */
1447
- _syncPrimaryPointer() {
1448
- if (this._primaryTouchId !== null) {
1449
- const primaryTouch = this._touchPointers.get(this._primaryTouchId);
1450
- if (primaryTouch) {
1451
- this._pointerX = primaryTouch.x;
1452
- this._pointerY = primaryTouch.y;
1453
- this._pointerDown = true;
1454
- this._pointerPressed = primaryTouch.pressed;
1455
- return;
1456
- }
1457
- this._primaryTouchId = null;
1458
- }
1459
- if (this._touchPointers.size > 0) {
1460
- const firstTouchEntry = this._touchPointers.entries().next().value;
1461
- if (firstTouchEntry) {
1462
- const [touchId, touch] = firstTouchEntry;
1463
- this._primaryTouchId = touchId;
1464
- this._pointerX = touch.x;
1465
- this._pointerY = touch.y;
1466
- this._pointerDown = true;
1467
- this._pointerPressed = touch.pressed;
3379
+ async _loadSceneImages(load, finish) {
3380
+ for (;;) {
3381
+ const failed = await this._assetSystem.loadQueuedAssets((settled, total) => {
3382
+ load.progress = total === 0 ? 1 : settled / total;
3383
+ });
3384
+ if (failed.length === 0)
3385
+ break;
3386
+ this._reportFailedAssets(failed);
3387
+ if (!this._confirmAssetRetry(failed.length)) {
3388
+ this._assetSystem.discardQueuedAssets();
3389
+ this._sceneLoad = null;
1468
3390
  return;
1469
3391
  }
3392
+ load.progress = 0;
1470
3393
  }
1471
- this._primaryTouchId = null;
1472
- this._pointerX = this._mouseX;
1473
- this._pointerY = this._mouseY;
1474
- this._pointerDown = this._mouseDown;
1475
- this._pointerPressed = this._mousePressed;
3394
+ this._sceneLoad = null;
3395
+ finish();
1476
3396
  }
1477
- // -------------------------------------------------------------------------
1478
- // Private — rAF loop
1479
- // -------------------------------------------------------------------------
1480
3397
  /** @internal */
1481
- _loop(timestamp) {
1482
- if (!this._running)
1483
- return;
1484
- if (this._lastTimestamp === null) {
1485
- this._lastTimestamp = timestamp;
1486
- }
1487
- let dt = (timestamp - this._lastTimestamp) / 1000;
1488
- this._lastTimestamp = timestamp;
1489
- if (dt > 0.1)
1490
- dt = 0.1;
1491
- const dtMs = dt * 1000;
1492
- this._updateTimers(dtMs);
1493
- this._updateAnimations(dtMs);
1494
- this._updatePhysics(dt);
1495
- if (this.onUpdate)
1496
- this.onUpdate(dt);
1497
- this._render();
1498
- this._keysPressed.clear();
1499
- this._mousePressed = false;
1500
- for (const pointer of this._touchPointers.values()) {
1501
- pointer.pressed = false;
1502
- }
1503
- this._syncPrimaryPointer();
1504
- this._textOverlays = [];
1505
- this._rafId = requestAnimationFrame(this._loop.bind(this));
3398
+ _reportFailedAssets(keys) {
3399
+ const list = keys
3400
+ .map((key) => `"${key}" (${this._assetSystem.getAssetSource(key) ?? "no source"})`)
3401
+ .join(", ");
3402
+ console.error(`MinimoJS: ${keys.length} file${keys.length === 1 ? "" : "s"} failed to load: ${list}.`);
1506
3403
  }
1507
- /** @internal */
1508
- _updateTimers(dtMs) {
1509
- const toRemove = [];
1510
- const toFire = [];
1511
- for (const timer of this._timers) {
1512
- timer.elapsed += dtMs;
1513
- if (timer.elapsed >= timer.delayMs) {
1514
- toFire.push(timer);
1515
- if (timer.repeat) {
1516
- timer.elapsed -= timer.delayMs;
1517
- }
1518
- else {
1519
- toRemove.push(timer.id);
1520
- }
1521
- }
3404
+ /**
3405
+ * Asks the player whether to retry failed images and sounds, with the browser's own
3406
+ * confirmation dialog. Returns `false` where no dialog can be shown.
3407
+ *
3408
+ * @internal
3409
+ */
3410
+ _confirmAssetRetry(failedCount) {
3411
+ if (typeof window === "undefined" || typeof window.confirm !== "function") {
3412
+ return false;
1522
3413
  }
1523
- if (toRemove.length > 0) {
1524
- this._timers = this._timers.filter((t) => !toRemove.includes(t.id));
3414
+ const noun = failedCount === 1 ? "file" : "files";
3415
+ try {
3416
+ return window.confirm(`${failedCount} ${noun} could not be loaded. Try again?`);
1525
3417
  }
1526
- for (const timer of toFire) {
1527
- timer.callback();
3418
+ catch {
3419
+ return false;
1528
3420
  }
1529
3421
  }
1530
3422
  /** @internal */
1531
- _updateAnimations(dtMs) {
1532
- const toRemove = [];
1533
- for (let i = 0; i < this._animations.length; i++) {
1534
- const anim = this._animations[i];
1535
- anim.elapsed += dtMs;
1536
- const t = Math.min(anim.elapsed / anim.durationMs, 1);
1537
- anim.sprite[anim.property] = anim.from + (anim.to - anim.from) * t;
1538
- if (t >= 1) {
1539
- toRemove.push(i);
1540
- anim.onComplete?.();
1541
- }
1542
- }
1543
- for (let i = toRemove.length - 1; i >= 0; i--) {
1544
- this._animations.splice(toRemove[i], 1);
1545
- }
3423
+ _invokeCreate() {
3424
+ if (this._currentScene?.onCreate)
3425
+ this._currentScene.onCreate();
3426
+ else
3427
+ this._onCreate?.();
1546
3428
  }
1547
3429
  /** @internal */
1548
- _updatePhysics(dt) {
1549
- for (const sprite of this._sprites) {
1550
- if (sprite.gravityScale !== 0) {
1551
- sprite.vx += this.gravityX * sprite.gravityScale * dt;
1552
- sprite.vy += this.gravityY * sprite.gravityScale * dt;
3430
+ waitForDocumentFonts() {
3431
+ if (typeof document === "undefined") {
3432
+ return Promise.resolve();
3433
+ }
3434
+ const fontSet = document.fonts;
3435
+ if (!fontSet || typeof fontSet.ready?.then !== "function") {
3436
+ return Promise.resolve();
3437
+ }
3438
+ const fontLoads = [];
3439
+ if (typeof fontSet.load === "function") {
3440
+ for (const requirement of this._requiredFonts.values()) {
3441
+ const descriptor = `${requirement.style} ${requirement.weight} ${requirement.size}px ${requirement.family}`;
3442
+ fontLoads.push(fontSet.load(descriptor, requirement.sampleText).catch(() => []));
1553
3443
  }
1554
- sprite.x += sprite.vx * dt;
1555
- sprite.y += sprite.vy * dt;
1556
3444
  }
1557
- }
1558
- // -------------------------------------------------------------------------
1559
- // Private — rendering
1560
- // -------------------------------------------------------------------------
1561
- /** @internal */
1562
- _getSpriteGlyphCanvas(sprite) {
1563
- const size = Math.max(1, Math.round(sprite.size));
1564
- const color = sprite.color;
1565
- const cacheKey = `${sprite.sprite}::${size}::${color}`;
1566
- const cached = this._spriteGlyphCache.get(cacheKey);
1567
- if (cached)
1568
- return cached;
1569
- const glyphCanvas = document.createElement("canvas");
1570
- const boxSize = Math.max(2, Math.ceil(size * 2));
1571
- glyphCanvas.width = boxSize;
1572
- glyphCanvas.height = boxSize;
1573
- const glyphCtx = glyphCanvas.getContext("2d");
1574
- if (!glyphCtx) {
1575
- throw new Error("MinimoJS: Could not acquire a glyph rendering context.");
1576
- }
1577
- glyphCtx.clearRect(0, 0, boxSize, boxSize);
1578
- glyphCtx.shadowColor = "transparent";
1579
- glyphCtx.shadowBlur = 0;
1580
- glyphCtx.shadowOffsetX = 0;
1581
- glyphCtx.shadowOffsetY = 0;
1582
- glyphCtx.fillStyle = color;
1583
- glyphCtx.font = `${size}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
1584
- glyphCtx.textAlign = "center";
1585
- glyphCtx.textBaseline = "middle";
1586
- glyphCtx.fillText(sprite.sprite, boxSize / 2, boxSize / 2);
1587
- this._spriteGlyphCache.set(cacheKey, glyphCanvas);
1588
- return glyphCanvas;
3445
+ fontLoads.push(fontSet.ready);
3446
+ return Promise.all(fontLoads).then(() => undefined);
1589
3447
  }
1590
3448
  /** @internal */
1591
- _render() {
1592
- const ctx = this._ctx;
1593
- const W = this._canvas.width;
1594
- const H = this._canvas.height;
1595
- this._applyPageBackground();
1596
- ctx.clearRect(0, 0, W, H);
1597
- if (this.backgroundGradient !== null) {
1598
- const gradient = ctx.createLinearGradient(0, 0, 0, H);
1599
- gradient.addColorStop(0, this.backgroundGradient.from);
1600
- gradient.addColorStop(1, this.backgroundGradient.to);
1601
- ctx.fillStyle = gradient;
1602
- ctx.fillRect(0, 0, W, H);
1603
- }
1604
- else if (this.background !== null) {
1605
- ctx.fillStyle = this.background;
1606
- ctx.fillRect(0, 0, W, H);
1607
- }
1608
- const sorted = [...this._sprites].sort((a, b) => a.layer - b.layer);
1609
- for (const sprite of sorted) {
1610
- if (!sprite.visible)
3449
+ applyGlobalFontRequirements() {
3450
+ if (this._hasAppliedGlobalFontRequirements) {
3451
+ return;
3452
+ }
3453
+ this._hasAppliedGlobalFontRequirements = true;
3454
+ const root = globalThis;
3455
+ const requirements = root.__MINIMO_WEB_FONT_REQUIREMENTS__;
3456
+ if (!Array.isArray(requirements)) {
3457
+ return;
3458
+ }
3459
+ for (const entry of requirements) {
3460
+ if (!entry || typeof entry !== "object") {
1611
3461
  continue;
1612
- ctx.save();
1613
- ctx.globalAlpha = Math.max(0, Math.min(1, sprite.alpha));
1614
- const drawX = Math.round(sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX);
1615
- const drawY = Math.round(sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY);
1616
- ctx.translate(drawX, drawY);
1617
- if (sprite.rotation !== 0) {
1618
- ctx.rotate((sprite.rotation * Math.PI) / 180);
1619
3462
  }
1620
- if (sprite.flipX || sprite.flipY) {
1621
- ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
3463
+ const requirement = entry;
3464
+ const family = typeof requirement.family === "string" ? requirement.family.trim() : "";
3465
+ if (!family) {
3466
+ continue;
1622
3467
  }
1623
- ctx.shadowColor = "transparent";
1624
- ctx.shadowBlur = 0;
1625
- ctx.shadowOffsetX = 0;
1626
- ctx.shadowOffsetY = 0;
1627
- const glyphCanvas = this._getSpriteGlyphCanvas(sprite);
1628
- ctx.drawImage(glyphCanvas, -glyphCanvas.width / 2, -glyphCanvas.height / 2);
1629
- ctx.restore();
1630
- }
1631
- for (const entry of this._textOverlays) {
1632
- ctx.save();
1633
- ctx.font = `${entry.fontSize}px monospace`;
1634
- ctx.fillStyle = entry.color;
1635
- ctx.textAlign = entry.centered ? "center" : "left";
1636
- ctx.textBaseline = entry.centered ? "middle" : "top";
1637
- ctx.fillText(entry.text, entry.x, entry.y);
1638
- ctx.restore();
3468
+ const sampleText = typeof requirement.sample_text === "string"
3469
+ ? requirement.sample_text
3470
+ : typeof requirement.sampleText === "string"
3471
+ ? requirement.sampleText
3472
+ : "";
3473
+ this.requireFont(`"${family}"`, {
3474
+ weight: typeof requirement.weight === "string" && requirement.weight.trim()
3475
+ ? requirement.weight.trim()
3476
+ : undefined,
3477
+ style: requirement.style === "normal" ||
3478
+ requirement.style === "italic" ||
3479
+ requirement.style === "oblique"
3480
+ ? requirement.style
3481
+ : undefined,
3482
+ size: typeof requirement.size === "number" && Number.isFinite(requirement.size)
3483
+ ? requirement.size
3484
+ : undefined,
3485
+ sampleText,
3486
+ });
1639
3487
  }
1640
3488
  }
1641
- /** @internal */
1642
- _applyPageBackground() {
1643
- if (!document.body)
1644
- return;
1645
- if (this._lastAppliedPageBackground === this.pageBackground)
3489
+ /** @internal */ _render() {
3490
+ this._renderSystem.render({
3491
+ canvas: this._canvas,
3492
+ context: this._ctx,
3493
+ backgroundLayers: this._backgroundSystem.getMutableLayers(),
3494
+ resolveImage: this._assetSystem.getImage.bind(this._assetSystem),
3495
+ sprites: this._spriteSystem.getMutableSprites(),
3496
+ trails: this._trailSystem.getRenderEntries(),
3497
+ explosions: this._explosionSystem.getRenderEntries(),
3498
+ wipes: this._explosionSystem.getWipeEntries(),
3499
+ textEntries: this._textSystem.getEntries(),
3500
+ scrollX: this.scrollX,
3501
+ scrollY: this.scrollY,
3502
+ background: this.background,
3503
+ backgroundGradient: this.backgroundGradient,
3504
+ pageBackground: this.pageBackground,
3505
+ debugBodies: this.debugBodies,
3506
+ debugInputAreas: this.debugInputAreas,
3507
+ });
3508
+ }
3509
+ /** @internal */ _renderTransition() {
3510
+ const transition = this._transitionSystem.getRenderEntry();
3511
+ if (!transition) {
3512
+ this._render();
1646
3513
  return;
1647
- if (this.pageBackground === null) {
1648
- document.body.style.removeProperty("background");
1649
3514
  }
1650
- else {
1651
- document.body.style.background = this.pageBackground;
1652
- }
1653
- this._lastAppliedPageBackground = this.pageBackground;
3515
+ this._renderSystem.renderScreenTransition({
3516
+ canvas: this._canvas,
3517
+ context: this._ctx,
3518
+ pageBackground: this.pageBackground,
3519
+ transition,
3520
+ });
1654
3521
  }
1655
- /** @internal */
1656
- _applyResponsiveCanvasLayout() {
1657
- if (!document.body)
1658
- return;
1659
- document.body.style.margin = "0";
1660
- document.body.style.minHeight = "100vh";
1661
- document.body.style.display = "grid";
1662
- document.body.style.placeItems = "center";
1663
- document.body.style.touchAction = "manipulation";
1664
- const viewportW = Math.max(1, window.innerWidth);
1665
- const viewportH = Math.max(1, window.innerHeight);
1666
- const scale = Math.min(viewportW / this._canvas.width, viewportH / this._canvas.height);
1667
- const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
1668
- this._canvas.style.width = `${Math.floor(this._canvas.width * safeScale)}px`;
1669
- this._canvas.style.height = `${Math.floor(this._canvas.height * safeScale)}px`;
3522
+ /** @internal */ _captureFrameSnapshot() {
3523
+ this._renderSystem.beginDynamicSurfacePass();
3524
+ return this._renderSystem.captureFrame({
3525
+ canvas: this._canvas,
3526
+ context: this._ctx,
3527
+ backgroundLayers: this._backgroundSystem.getMutableLayers(),
3528
+ resolveImage: this._assetSystem.getImage.bind(this._assetSystem),
3529
+ sprites: this._spriteSystem.getMutableSprites(),
3530
+ trails: this._trailSystem.getRenderEntries(),
3531
+ explosions: this._explosionSystem.getRenderEntries(),
3532
+ wipes: this._explosionSystem.getWipeEntries(),
3533
+ textEntries: this._textSystem.getEntries(),
3534
+ scrollX: this.scrollX,
3535
+ scrollY: this.scrollY,
3536
+ background: this.background,
3537
+ backgroundGradient: this.backgroundGradient,
3538
+ pageBackground: this.pageBackground,
3539
+ debugBodies: this.debugBodies,
3540
+ debugInputAreas: this.debugInputAreas,
3541
+ });
1670
3542
  }
1671
3543
  /** @internal */
1672
- _invokeCreate() {
1673
- this._hasCreated = true;
1674
- if (this.onCreate)
1675
- this.onCreate();
3544
+ _renderLoadingScreen(progress) {
3545
+ this._renderSystem.renderLoadingScreen({
3546
+ canvas: this._canvas,
3547
+ context: this._ctx,
3548
+ pageBackground: this.pageBackground,
3549
+ background: this.background,
3550
+ backgroundGradient: this.backgroundGradient,
3551
+ progress,
3552
+ });
1676
3553
  }
1677
3554
  }