minimojs 1.0.0-alpha.4 → 1.0.0-alpha.41

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