minimojs 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/minimo.js ADDED
@@ -0,0 +1,1381 @@
1
+ /**
2
+ * @module minimojs
3
+ *
4
+ * MinimoJS v1 — Ultra-minimal 2D web game engine for TypeScript.
5
+ *
6
+ * ALL TIME VALUES ARE IN MILLISECONDS.
7
+ * ALL ROTATIONS ARE IN DEGREES.
8
+ * THE ENGINE LOOP USES requestAnimationFrame ONLY.
9
+ */
10
+ // ---------------------------------------------------------------------------
11
+ // Sprite
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * A 2D game object rendered as an emoji on the canvas.
15
+ *
16
+ * Instantiate directly or extend to create custom sprite types.
17
+ * Register with the engine by passing the instance to {@link Game.add}.
18
+ *
19
+ * **Coordinate system:** center-based world space. `(x, y)` is the center of
20
+ * the sprite. Positive X = right, positive Y = down.
21
+ *
22
+ * **Lifecycle:** A sprite exists until {@link Game.destroySprite} is called or
23
+ * {@link Game.reset} is invoked. After destruction, do not read or write its fields.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // Direct instantiation
28
+ * const coin = new Sprite("🪙");
29
+ * coin.x = 300;
30
+ * coin.y = 200;
31
+ * coin.size = 32;
32
+ * game.add(coin);
33
+ * ```
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * // Subclassing for custom game objects
38
+ * class Player extends Sprite {
39
+ * health = 3;
40
+ *
41
+ * constructor(x: number, y: number) {
42
+ * super("🐢");
43
+ * this.x = x;
44
+ * this.y = y;
45
+ * this.size = 48;
46
+ * this.gravityScale = 1;
47
+ * }
48
+ * }
49
+ *
50
+ * const player = new Player(400, 300);
51
+ * game.add(player);
52
+ * ```
53
+ */
54
+ export class Sprite {
55
+ /**
56
+ * Creates a new Sprite with the given emoji and optional position.
57
+ * All other properties use their defaults and can be set after construction.
58
+ *
59
+ * @param sprite - The emoji character to render. Must be a single emoji.
60
+ * Image sprites are NOT supported — emoji only.
61
+ * @example "🔥", "⭐", "🐢", "💣", "👾"
62
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
63
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * const enemy = new Sprite("👾", 200, 100);
68
+ * enemy.size = 40;
69
+ * game.add(enemy);
70
+ * ```
71
+ */
72
+ constructor(sprite, x = 0, y = 0) {
73
+ /**
74
+ * X position in world space (horizontal center of the sprite), in pixels.
75
+ * Positive X points right. Updated each frame by: `x += vx * dt`.
76
+ * May be set directly to teleport the sprite.
77
+ */
78
+ this.x = 0;
79
+ /**
80
+ * Y position in world space (vertical center of the sprite), in pixels.
81
+ * Positive Y points down. Updated each frame by: `y += vy * dt`.
82
+ * May be set directly to teleport the sprite.
83
+ */
84
+ this.y = 0;
85
+ /**
86
+ * Width and height of the sprite's bounding square, in pixels.
87
+ * Used for both canvas rendering (font size) and AABB collision detection.
88
+ */
89
+ this.size = 32;
90
+ /**
91
+ * Visual rotation of the sprite in degrees.
92
+ * `0` = upright. Positive values rotate clockwise.
93
+ * **Note:** rotation does NOT affect the AABB collision box — it remains axis-aligned.
94
+ */
95
+ this.rotation = 0;
96
+ /**
97
+ * Horizontal visual flip.
98
+ * When `true`, the sprite is mirrored left-right during rendering.
99
+ * This is visual-only and does NOT affect collision detection.
100
+ */
101
+ this.flipX = false;
102
+ /**
103
+ * Vertical visual flip.
104
+ * When `true`, the sprite is mirrored top-bottom during rendering.
105
+ * This is visual-only and does NOT affect collision detection.
106
+ */
107
+ this.flipY = false;
108
+ /**
109
+ * Camera scroll toggle for this sprite.
110
+ * When `false` (default), the sprite is rendered in world space and is affected
111
+ * by {@link Game.scrollX} and {@link Game.scrollY}.
112
+ * When `true`, the sprite is rendered in canvas/screen space and ignores camera
113
+ * scrolling. Useful for HUD-like sprites that should stay fixed on screen.
114
+ */
115
+ this.ignoreScroll = false;
116
+ /**
117
+ * Opacity of the sprite. Must be in range `[0, 1]`.
118
+ * `0` = fully transparent, `1` = fully opaque.
119
+ * Clamped to `[0, 1]` at render time.
120
+ */
121
+ this.alpha = 1;
122
+ /**
123
+ * Controls whether this sprite is drawn each frame.
124
+ * When `false`, the sprite still receives physics updates (gravity, velocity)
125
+ * but is not rendered. Use {@link Game.destroySprite} to fully remove a sprite.
126
+ */
127
+ this.visible = true;
128
+ /**
129
+ * Render layer order. Sprites with higher `layer` values are drawn on top.
130
+ * Sprites on the same layer render in creation order (first created = bottom).
131
+ * Changing this at runtime takes effect on the next frame.
132
+ */
133
+ this.layer = 0;
134
+ /**
135
+ * Horizontal velocity in pixels per second.
136
+ * Applied each frame: `x += vx * dt`.
137
+ * Gravity also modifies this: `vx += gravityX * gravityScale * dt`.
138
+ */
139
+ this.vx = 0;
140
+ /**
141
+ * Vertical velocity in pixels per second.
142
+ * Applied each frame: `y += vy * dt`.
143
+ * Gravity also modifies this: `vy += gravityY * gravityScale * dt`.
144
+ */
145
+ this.vy = 0;
146
+ /**
147
+ * Gravity multiplier for this sprite.
148
+ * Each frame: `vx += game.gravityX * gravityScale * dt`
149
+ * `vy += game.gravityY * gravityScale * dt`.
150
+ * `0` = immune to gravity (default). `1` = full gravity.
151
+ * Values > 1 amplify gravity; negative values invert it.
152
+ */
153
+ this.gravityScale = 0;
154
+ this.sprite = sprite;
155
+ this.x = x;
156
+ this.y = y;
157
+ }
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Game
161
+ // ---------------------------------------------------------------------------
162
+ /**
163
+ * # MinimoJS v1 — AI Agent Integration Guide
164
+ *
165
+ * `Game` is the single entry point to the entire engine.
166
+ * Every feature — sprites, input, physics, sound, timers, text — is accessed
167
+ * directly on the `game` object. There are no sub-systems or nested namespaces.
168
+ *
169
+ * ---
170
+ *
171
+ * ## Quick Start
172
+ *
173
+ * ```ts
174
+ * import { Game, Sprite } from "minimojs";
175
+ *
176
+ * const game = new Game(800, 600);
177
+ *
178
+ * const player = new Sprite("🐢");
179
+ * player.x = 400;
180
+ * player.y = 500;
181
+ * player.size = 48;
182
+ * game.add(player);
183
+ *
184
+ * game.onUpdate = (dt) => {
185
+ * if (game.isKeyDown("ArrowLeft")) player.vx = -200;
186
+ * else if (game.isKeyDown("ArrowRight")) player.vx = 200;
187
+ * else player.vx = 0;
188
+ *
189
+ * game.drawText(`x: ${Math.round(player.x)}`, 10, 10, 16);
190
+ * };
191
+ *
192
+ * game.start();
193
+ * ```
194
+ *
195
+ * ---
196
+ *
197
+ * ## Engine Philosophy
198
+ *
199
+ * MinimoJS is **flat, deterministic, and ultra-minimal**. It is intentionally
200
+ * designed to be easy for AI agents to reason about. The full API fits in a
201
+ * single file. There are no plugins, no registries, no event buses.
202
+ *
203
+ * ---
204
+ *
205
+ * ## Flat API
206
+ *
207
+ * ALL engine functionality is on the `game` object.
208
+ * Do not look for sub-objects like `game.physics`, `game.input`, etc. They do not exist.
209
+ *
210
+ * ---
211
+ *
212
+ * ## Responsive Canvas
213
+ *
214
+ * The engine auto-centers the canvas on the page and responsively scales it
215
+ * to use as much viewport space as possible while preserving aspect ratio.
216
+ *
217
+ * ---
218
+ *
219
+ * ## Emoji-Only Sprites
220
+ *
221
+ * Every sprite MUST use a single emoji character as its visual representation.
222
+ * PNG, SVG, spritesheet, and image sprites are NOT supported.
223
+ * Use Unicode emoji: `"🔥"`, `"⭐"`, `"💣"`, `"🐢"`, `"👾"`, `"🧱"`, etc.
224
+ * AI agents can also use text-like emojis (regional indicators, symbols, letters)
225
+ * to build fun title art, HUD labels, and expressive in-game text.
226
+ *
227
+ * ---
228
+ *
229
+ * ## Degrees-Only Rotation
230
+ *
231
+ * ALL rotations are in **degrees**. Never use radians.
232
+ * `0` = upright, `90` = 90° clockwise, `-90` = 90° counter-clockwise.
233
+ * This applies to {@link Sprite.rotation} and {@link Game.animateRotation}.
234
+ *
235
+ * ---
236
+ *
237
+ * ## Milliseconds-Only Timing
238
+ *
239
+ * ALL time parameters to engine methods are in **milliseconds (ms)**.
240
+ * This includes: {@link Game.addTimer}, {@link Game.animateAlpha},
241
+ * {@link Game.animateRotation}, and {@link Game.sound}.
242
+ *
243
+ * The `dt` parameter in {@link Game.onUpdate} is an exception — it is in
244
+ * **seconds** for convenient velocity math (`position += velocity * dt`).
245
+ *
246
+ * ---
247
+ *
248
+ * ## rAF-Only Loop
249
+ *
250
+ * The engine loop runs exclusively on `requestAnimationFrame`.
251
+ * There are **no** `setTimeout` or `setInterval` calls anywhere.
252
+ * All timers, animations, and physics are driven by the rAF loop.
253
+ * Delta time is automatically capped at 100ms to prevent spiral-of-death.
254
+ *
255
+ * ---
256
+ *
257
+ * ## Gravity Model
258
+ *
259
+ * Gravity is a constant per-frame acceleration in pixels/second².
260
+ * Set {@link Game.gravityX} and {@link Game.gravityY} to define the direction.
261
+ * For standard downward gravity: `game.gravityY = 980`.
262
+ *
263
+ * Per-sprite effect is controlled by {@link Sprite.gravityScale}:
264
+ * - `gravityScale = 0` (default): sprite is unaffected by gravity.
265
+ * - `gravityScale = 1`: full gravity applied.
266
+ * - `gravityScale = 0.5`: half gravity.
267
+ *
268
+ * Each frame: `vx += gravityX * gravityScale * dt`, same for Y.
269
+ *
270
+ * ---
271
+ *
272
+ * ## Timer System
273
+ *
274
+ * Timers are rAF-driven countdown callbacks — NOT `setTimeout`.
275
+ * They accumulate elapsed time each frame and fire when the delay is reached.
276
+ *
277
+ * - {@link Game.addTimer}`(delayMs, repeat, callback)` — schedule a callback.
278
+ * - {@link Game.clearTimer}`(id)` — cancel a scheduled timer.
279
+ * - ALL timers are cleared on {@link Game.reset}.
280
+ *
281
+ * ```ts
282
+ * // Fire once after 2 seconds
283
+ * game.addTimer(2000, false, () => { player.sprite = "💥"; });
284
+ *
285
+ * // Repeat every 1 second
286
+ * const id = game.addTimer(1000, true, () => { spawnEnemy(); });
287
+ * // Later:
288
+ * game.clearTimer(id);
289
+ * ```
290
+ *
291
+ * ---
292
+ *
293
+ * ## Scene Initialization with `onCreate`
294
+ *
295
+ * MinimoJS has NO scene system. Use {@link Game.onCreate} to build a scene.
296
+ * The engine calls `onCreate`:
297
+ * - Once before the first frame (when {@link Game.start} is called).
298
+ * - Again after each {@link Game.reset}.
299
+ *
300
+ * `reset()` clears:
301
+ * - All sprites
302
+ * - All timers
303
+ * - All running animations
304
+ * - All text overlays
305
+ * - Scroll position (scrollX and scrollY reset to 0)
306
+ * - Per-frame input state (pressed keys/pointer)
307
+ *
308
+ * After clearing, `reset()` calls `onCreate()` so your callback can rebuild the
309
+ * new scene immediately. Simply re-add sprites and re-register timers.
310
+ *
311
+ * ```ts
312
+ * game.onCreate = () => {
313
+ * // scene init: add sprites, setup timers
314
+ * const skull = new Sprite("💀");
315
+ * skull.x = 400; skull.y = 300; skull.size = 96;
316
+ * game.add(skull);
317
+ * };
318
+ *
319
+ * game.onUpdate = (dt) => {
320
+ * // normal per-frame update
321
+ * };
322
+ *
323
+ * game.start(); // calls onCreate() once before first frame
324
+ * // later:
325
+ * game.reset(); // clears + calls onCreate() again
326
+ * ```
327
+ *
328
+ * ---
329
+ *
330
+ * ## Sprite Lifecycle Ownership
331
+ *
332
+ * The `Game` instance owns all sprites.
333
+ * - Create sprites with {@link Game.add}.
334
+ * - Destroy sprites with {@link Game.destroySprite}.
335
+ * - Read the live sprite list with {@link Game.getSprites} (returns a read-only snapshot).
336
+ * - Do NOT hold references to destroyed sprites.
337
+ * - All sprites are destroyed on {@link Game.reset}.
338
+ *
339
+ * ---
340
+ *
341
+ * ## Forbidden Features
342
+ *
343
+ * The following do NOT exist in MinimoJS v1. Do NOT attempt to use them:
344
+ * - Scene system or scene manager
345
+ * - Entity Component System (ECS)
346
+ * - Physics engine (no Box2D, Matter.js, etc.)
347
+ * - Camera zoom or scale
348
+ * - Nested APIs or namespaces
349
+ * - Text input / HTML form elements
350
+ * - Parallax layers
351
+ * - Multiple cameras
352
+ * - Collision resolution (only detection is supported)
353
+ * - Image sprites (PNG, SVG, canvas, etc.)
354
+ * - `setTimeout` or `setInterval`
355
+ */
356
+ export class Game {
357
+ // -------------------------------------------------------------------------
358
+ // Constructor
359
+ // -------------------------------------------------------------------------
360
+ /**
361
+ * Creates a new MinimoJS game instance.
362
+ *
363
+ * The engine creates its own `<canvas>`, sets its dimensions, and appends it
364
+ * to `document.body`. The canvas is automatically centered and responsively
365
+ * scaled to use the maximum available viewport space while preserving aspect ratio.
366
+ *
367
+ * @param width - Canvas width in pixels. Default: `800`.
368
+ * @param height - Canvas height in pixels. Default: `600`.
369
+ *
370
+ * @throws Error if a 2D context cannot be obtained.
371
+ *
372
+ * @example
373
+ * ```ts
374
+ * const game = new Game(800, 600);
375
+ * ```
376
+ */
377
+ constructor(width = 800, height = 600) {
378
+ /** @internal */ this._sprites = [];
379
+ /** @internal */ this._timers = [];
380
+ /** @internal */ this._animations = [];
381
+ /** @internal */ this._textOverlays = [];
382
+ /** @internal */ this._timerIdCounter = 0;
383
+ /** @internal */ this._keysDown = new Set();
384
+ /** @internal */ this._keysPressed = new Set();
385
+ /** @internal */ this._pointerDown = false;
386
+ /** @internal */ this._pointerPressed = false;
387
+ /** @internal */ this._pointerX = 0;
388
+ /** @internal */ this._pointerY = 0;
389
+ /** @internal */ this._rafId = null;
390
+ /** @internal */ this._lastTimestamp = null;
391
+ /** @internal */ this._running = false;
392
+ /** @internal */ this._hasCreated = false;
393
+ /** @internal */ this._onResize = () => this._applyResponsiveCanvasLayout();
394
+ /** @internal */ this._audioCtx = null;
395
+ /** @internal */ this._lastAppliedPageBackground = undefined;
396
+ // -------------------------------------------------------------------------
397
+ // Public state
398
+ // -------------------------------------------------------------------------
399
+ /**
400
+ * Horizontal gravity acceleration in pixels per second².
401
+ * Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
402
+ * Positive = accelerates right. Typical value for sideways wind: `200`.
403
+ * Default: `0`.
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * game.gravityX = 0; // no horizontal gravity
408
+ * ```
409
+ */
410
+ this.gravityX = 0;
411
+ /**
412
+ * Vertical gravity acceleration in pixels per second².
413
+ * Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
414
+ * Positive = accelerates downward (canvas Y-axis points down).
415
+ * Typical value for platformers: `980` (approximately Earth gravity in px/s²).
416
+ * Default: `0`.
417
+ *
418
+ * @example
419
+ * ```ts
420
+ * game.gravityY = 980; // standard downward gravity
421
+ * ```
422
+ */
423
+ this.gravityY = 0;
424
+ /**
425
+ * Horizontal scroll offset of the world camera, in pixels.
426
+ * The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
427
+ * values are revealed as `scrollX` increases.
428
+ * Default: `0`. Reset to `0` by {@link Game.reset}.
429
+ *
430
+ * @example
431
+ * ```ts
432
+ * game.scrollX = player.x - game.width / 2; // center camera on player
433
+ * ```
434
+ */
435
+ this.scrollX = 0;
436
+ /**
437
+ * Vertical scroll offset of the world camera, in pixels.
438
+ * The canvas viewport is shifted up by `scrollY` — sprites with higher `y`
439
+ * values are revealed as `scrollY` increases.
440
+ * Default: `0`. Reset to `0` by {@link Game.reset}.
441
+ */
442
+ this.scrollY = 0;
443
+ /**
444
+ * Solid background color for the full canvas.
445
+ * Set to any valid CSS color string (e.g. `"#000"`, `"skyblue"`, `"rgba(0,0,0,0.5)"`).
446
+ * Default: `null` (transparent canvas background).
447
+ *
448
+ * If {@link Game.backgroundGradient} is set, the gradient takes priority over
449
+ * this solid color.
450
+ *
451
+ * @example
452
+ * ```ts
453
+ * game.background = "#101820";
454
+ * ```
455
+ */
456
+ this.background = null;
457
+ /**
458
+ * Vertical gradient background for the full canvas.
459
+ * `from` is the top color and `to` is the bottom color.
460
+ * Set to `null` to disable gradient mode.
461
+ *
462
+ * @example
463
+ * ```ts
464
+ * game.backgroundGradient = { from: "#92d7ff", to: "#5ca44f" };
465
+ * ```
466
+ */
467
+ this.backgroundGradient = null;
468
+ /**
469
+ * Background color for the full web page (`document.body`).
470
+ * Set to any valid CSS color string. Default: `null` (engine leaves page background unchanged).
471
+ *
472
+ * This is independent from {@link Game.background}, which affects the canvas only.
473
+ *
474
+ * @example
475
+ * ```ts
476
+ * game.pageBackground = "#f4e4bc";
477
+ * ```
478
+ */
479
+ this.pageBackground = null;
480
+ /**
481
+ * Scene creation callback.
482
+ *
483
+ * Called once before the first frame on {@link Game.start}, and again after
484
+ * each {@link Game.reset}. Use this to create sprites and timers for a scene.
485
+ *
486
+ * @example
487
+ * ```ts
488
+ * game.onCreate = () => {
489
+ * const player = new Sprite("🐢");
490
+ * player.x = 200;
491
+ * player.y = 300;
492
+ * game.add(player);
493
+ * };
494
+ * ```
495
+ */
496
+ this.onCreate = null;
497
+ /**
498
+ * Callback invoked once per frame after physics and timer updates.
499
+ *
500
+ * @param dt - Delta time in **seconds** since the last frame.
501
+ * Use this for velocity-based movement: `sprite.x += speed * dt`.
502
+ * Capped at `0.1` seconds (100ms) to prevent spiral-of-death.
503
+ *
504
+ * @example
505
+ * ```ts
506
+ * game.onUpdate = (dt) => {
507
+ * if (game.isKeyDown("ArrowRight")) player.vx = 200;
508
+ * else player.vx = 0;
509
+ * };
510
+ * ```
511
+ */
512
+ this.onUpdate = null;
513
+ this._canvas = document.createElement("canvas");
514
+ this._canvas.width = width;
515
+ this._canvas.height = height;
516
+ this._canvas.style.display = "block";
517
+ this._canvas.style.touchAction = "none";
518
+ this._canvas.style.width = `${width}px`;
519
+ this._canvas.style.height = `${height}px`;
520
+ const mountCanvas = () => {
521
+ if (document.body && !this._canvas.isConnected) {
522
+ document.body.appendChild(this._canvas);
523
+ }
524
+ this._applyResponsiveCanvasLayout();
525
+ };
526
+ if (document.body) {
527
+ mountCanvas();
528
+ }
529
+ else {
530
+ window.addEventListener("DOMContentLoaded", mountCanvas, { once: true });
531
+ }
532
+ window.addEventListener("resize", this._onResize);
533
+ const ctx = this._canvas.getContext("2d");
534
+ if (!ctx) {
535
+ throw new Error("MinimoJS: Could not acquire a 2D rendering context.");
536
+ }
537
+ this._ctx = ctx;
538
+ this._bindInputEvents();
539
+ }
540
+ // -------------------------------------------------------------------------
541
+ // Canvas dimensions (read-only)
542
+ // -------------------------------------------------------------------------
543
+ /**
544
+ * The width of the canvas in pixels. Read-only — set via constructor.
545
+ */
546
+ get width() {
547
+ return this._canvas.width;
548
+ }
549
+ /**
550
+ * The height of the canvas in pixels. Read-only — set via constructor.
551
+ */
552
+ get height() {
553
+ return this._canvas.height;
554
+ }
555
+ /**
556
+ * Current pointer (mouse or touch) X position in **canvas/screen space**,
557
+ * in pixels. Updated every `mousemove` / `touchmove` event.
558
+ * `(0, 0)` is the top-left corner of the canvas.
559
+ * To convert to world space: `worldX = game.pointerX + game.scrollX`.
560
+ */
561
+ get pointerX() {
562
+ return this._pointerX;
563
+ }
564
+ /**
565
+ * Current pointer (mouse or touch) Y position in **canvas/screen space**,
566
+ * in pixels. Updated every `mousemove` / `touchmove` event.
567
+ * `(0, 0)` is the top-left corner of the canvas.
568
+ * To convert to world space: `worldY = game.pointerY + game.scrollY`.
569
+ */
570
+ get pointerY() {
571
+ return this._pointerY;
572
+ }
573
+ // -------------------------------------------------------------------------
574
+ // Sprite management
575
+ // -------------------------------------------------------------------------
576
+ /**
577
+ * Registers a {@link Sprite} (or subclass instance) with the engine.
578
+ * After calling `add`, the sprite is rendered and receives physics updates
579
+ * every frame until {@link Game.destroySprite} or {@link Game.reset} is called.
580
+ *
581
+ * **Ownership:** The game instance takes ownership of the sprite from this
582
+ * point forward. It will appear in {@link Game.getSprites} on the same frame.
583
+ *
584
+ * **Subclasses:** Any class that extends {@link Sprite} can be passed here.
585
+ * The engine stores and processes it as a `Sprite`; your custom properties
586
+ * are preserved on the instance.
587
+ *
588
+ * @param sprite - A {@link Sprite} instance (or subclass) to add.
589
+ * @returns The same sprite instance, for chaining or inline assignment.
590
+ *
591
+ * @example
592
+ * ```ts
593
+ * // Plain sprite
594
+ * const coin = new Sprite("🪙");
595
+ * coin.x = 300; coin.y = 200; coin.size = 32;
596
+ * game.add(coin);
597
+ *
598
+ * // Custom subclass
599
+ * class Enemy extends Sprite {
600
+ * speed = 150;
601
+ * constructor(x: number, y: number) {
602
+ * super("👾");
603
+ * this.x = x; this.y = y; this.size = 40;
604
+ * }
605
+ * }
606
+ * const enemy = game.add(new Enemy(600, 100));
607
+ * ```
608
+ */
609
+ add(sprite) {
610
+ this._sprites.push(sprite);
611
+ return sprite;
612
+ }
613
+ /**
614
+ * Removes a sprite from the engine, stopping its rendering and physics updates.
615
+ * Also cancels any running animations targeting this sprite.
616
+ *
617
+ * **Side effects:** The sprite is removed immediately. Accessing the sprite's
618
+ * fields after destruction has no effect on the engine, but reading them
619
+ * returns stale values. Do not keep references to destroyed sprites.
620
+ *
621
+ * If `sprite` is not found (already destroyed or never added), this is a no-op.
622
+ *
623
+ * @param sprite - The sprite instance to destroy, as passed to {@link Game.add}.
624
+ *
625
+ * @example
626
+ * ```ts
627
+ * game.destroySprite(enemy); // remove enemy from game
628
+ * ```
629
+ */
630
+ destroySprite(sprite) {
631
+ const idx = this._sprites.indexOf(sprite);
632
+ if (idx !== -1)
633
+ this._sprites.splice(idx, 1);
634
+ this._animations = this._animations.filter((a) => a.sprite !== sprite);
635
+ }
636
+ /**
637
+ * Returns a **read-only snapshot** of all currently active sprites.
638
+ * The array is a shallow copy — mutating it has no effect on the engine.
639
+ * Individual sprite objects in the array are the live instances.
640
+ *
641
+ * **Deterministic guarantee:** Sprites appear in creation order within each
642
+ * layer. The order matches the render order (lower layer = earlier in array).
643
+ *
644
+ * @returns A read-only array of active {@link Sprite} instances.
645
+ *
646
+ * @example
647
+ * ```ts
648
+ * const enemies = game.getSprites().filter(s => s.sprite === "👾");
649
+ * ```
650
+ */
651
+ getSprites() {
652
+ return [...this._sprites];
653
+ }
654
+ // -------------------------------------------------------------------------
655
+ // Collision
656
+ // -------------------------------------------------------------------------
657
+ /**
658
+ * Tests whether two sprites overlap using **Axis-Aligned Bounding Box (AABB)**
659
+ * collision detection.
660
+ *
661
+ * Each sprite's bounding box is a square centered at `(x, y)` with side
662
+ * length `size`. Rotation is **ignored** — the box is always axis-aligned.
663
+ *
664
+ * **No collision resolution is performed.** This method is detection-only.
665
+ * If you need bounce or push-apart behavior, implement it in `onUpdate`.
666
+ *
667
+ * @param a - First sprite.
668
+ * @param b - Second sprite.
669
+ * @returns `true` if the bounding boxes overlap; `false` otherwise.
670
+ *
671
+ * @example
672
+ * ```ts
673
+ * if (game.overlap(player, enemy)) {
674
+ * game.reset(); // restart on collision
675
+ * }
676
+ * ```
677
+ */
678
+ overlap(a, b) {
679
+ const halfA = a.size / 2;
680
+ const halfB = b.size / 2;
681
+ return (Math.abs(a.x - b.x) < halfA + halfB &&
682
+ Math.abs(a.y - b.y) < halfA + halfB);
683
+ }
684
+ /**
685
+ * Tests for any overlap between two groups of sprites.
686
+ * Performs an O(n × m) AABB check for every pair `(a, b)` where `a ∈ listA`
687
+ * and `b ∈ listB`. Returns the **first** overlapping pair found.
688
+ *
689
+ * Internally uses {@link Game.overlap} — same AABB rules apply.
690
+ * No collision resolution is performed.
691
+ *
692
+ * @param listA - First group of sprites.
693
+ * @param listB - Second group of sprites. May share sprites with `listA`.
694
+ * @returns A `[Sprite, Sprite]` tuple of the first overlapping pair,
695
+ * or `null` if no pair overlaps.
696
+ *
697
+ * @example
698
+ * ```ts
699
+ * const hit = game.overlapAny(bullets, enemies);
700
+ * if (hit) {
701
+ * const [bullet, enemy] = hit;
702
+ * game.destroySprite(bullet);
703
+ * game.destroySprite(enemy);
704
+ * }
705
+ * ```
706
+ */
707
+ overlapAny(listA, listB) {
708
+ for (const a of listA) {
709
+ for (const b of listB) {
710
+ if (this.overlap(a, b))
711
+ return [a, b];
712
+ }
713
+ }
714
+ return null;
715
+ }
716
+ // -------------------------------------------------------------------------
717
+ // Input — Keyboard
718
+ // -------------------------------------------------------------------------
719
+ /**
720
+ * Returns `true` while the specified key is held down (every frame it is held).
721
+ * Use this for continuous actions like movement.
722
+ *
723
+ * Uses the `KeyboardEvent.key` string (e.g. `"ArrowLeft"`, `"a"`, `" "`, `"Enter"`).
724
+ * Key names are case-sensitive.
725
+ *
726
+ * **Hold behavior:** returns `true` on the first frame the key is pressed AND
727
+ * every subsequent frame until the key is released.
728
+ *
729
+ * @param key - The `KeyboardEvent.key` value to check.
730
+ * @returns `true` if the key is currently held down.
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * if (game.isKeyDown("ArrowRight")) player.vx = 200;
735
+ * ```
736
+ */
737
+ isKeyDown(key) {
738
+ return this._keysDown.has(key);
739
+ }
740
+ /**
741
+ * Returns `true` only on the **single frame** the key was first pressed.
742
+ * Use this for one-shot actions like jumping or firing.
743
+ *
744
+ * **Edge behavior:** returns `true` exactly once per press, on the frame the
745
+ * key transitions from up → down. Subsequent frames while held return `false`.
746
+ *
747
+ * @param key - The `KeyboardEvent.key` value to check.
748
+ * @returns `true` only on the first frame of the key press.
749
+ *
750
+ * @example
751
+ * ```ts
752
+ * if (game.isKeyPressed(" ")) player.vy = -500; // jump on spacebar press
753
+ * ```
754
+ */
755
+ isKeyPressed(key) {
756
+ return this._keysPressed.has(key);
757
+ }
758
+ // -------------------------------------------------------------------------
759
+ // Input — Pointer (mouse / touch)
760
+ // -------------------------------------------------------------------------
761
+ /**
762
+ * Returns `true` while the pointer (mouse button or touch) is held down.
763
+ * Use this for continuous pointer actions.
764
+ *
765
+ * **Hold behavior:** returns `true` every frame from press until release.
766
+ *
767
+ * @returns `true` if the pointer is currently pressed.
768
+ *
769
+ * @example
770
+ * ```ts
771
+ * if (game.isPointerDown()) fireContinuously();
772
+ * ```
773
+ */
774
+ isPointerDown() {
775
+ return this._pointerDown;
776
+ }
777
+ /**
778
+ * Returns `true` only on the **single frame** the pointer was first pressed.
779
+ * Use this for tap/click one-shot actions.
780
+ *
781
+ * **Edge behavior:** returns `true` exactly once per press, on the frame the
782
+ * pointer transitions from up → down. Subsequent frames while held return `false`.
783
+ *
784
+ * @returns `true` only on the first frame of the pointer press.
785
+ *
786
+ * @example
787
+ * ```ts
788
+ * if (game.isPointerPressed()) spawnAt(game.pointerX, game.pointerY);
789
+ * ```
790
+ */
791
+ isPointerPressed() {
792
+ return this._pointerPressed;
793
+ }
794
+ /**
795
+ * Returns `true` when the current device appears to be mobile/touch-first.
796
+ *
797
+ * This is a heuristic helper intended for gameplay UI decisions (for example,
798
+ * showing on-screen touch controls). It checks user-agent/platform hints and
799
+ * coarse-pointer/touch capabilities.
800
+ *
801
+ * @returns `true` if the runtime likely corresponds to a mobile device.
802
+ *
803
+ * @example
804
+ * ```ts
805
+ * if (game.isMobileDevice()) {
806
+ * // Show touch buttons
807
+ * } else {
808
+ * // Show keyboard hints
809
+ * }
810
+ * ```
811
+ */
812
+ isMobileDevice() {
813
+ if (typeof navigator === "undefined")
814
+ return false;
815
+ const ua = navigator.userAgent ?? "";
816
+ const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
817
+ const iPadOS = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
818
+ const coarsePointer = typeof window !== "undefined" &&
819
+ typeof window.matchMedia === "function" &&
820
+ window.matchMedia("(pointer: coarse)").matches;
821
+ return mobileUA || iPadOS || coarsePointer;
822
+ }
823
+ // -------------------------------------------------------------------------
824
+ // Sound
825
+ // -------------------------------------------------------------------------
826
+ /**
827
+ * Plays a square-wave beep using the Web Audio API.
828
+ *
829
+ * **Square wave only.** MinimoJS does not support audio files, samples,
830
+ * or other waveforms. Only procedural square-wave tones.
831
+ *
832
+ * The AudioContext is created lazily on first call. Browsers require a user
833
+ * gesture (click, keypress) before audio can play — always call `sound()` in
834
+ * response to user input or a game event triggered by input.
835
+ *
836
+ * The tone fades out exponentially over `durationMs` to avoid clicks.
837
+ *
838
+ * @param freq - Frequency in Hz. Middle C = 261.6. Typical range: 100–4000 Hz.
839
+ * @param durationMs - Duration of the sound in **milliseconds**.
840
+ *
841
+ * @example
842
+ * ```ts
843
+ * game.sound(440, 100); // 440 Hz beep for 100ms
844
+ * game.sound(261, 500); // middle C for 500ms
845
+ * game.sound(880, 50); // high beep for 50ms (jump sound)
846
+ * ```
847
+ */
848
+ sound(freq, durationMs) {
849
+ if (!this._audioCtx) {
850
+ this._audioCtx = new AudioContext();
851
+ }
852
+ const ctx = this._audioCtx;
853
+ if (ctx.state === "suspended") {
854
+ ctx.resume();
855
+ }
856
+ const oscillator = ctx.createOscillator();
857
+ const gain = ctx.createGain();
858
+ oscillator.type = "square";
859
+ oscillator.frequency.setValueAtTime(freq, ctx.currentTime);
860
+ gain.gain.setValueAtTime(0.08, ctx.currentTime);
861
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000);
862
+ oscillator.connect(gain);
863
+ gain.connect(ctx.destination);
864
+ oscillator.start(ctx.currentTime);
865
+ oscillator.stop(ctx.currentTime + durationMs / 1000);
866
+ }
867
+ // -------------------------------------------------------------------------
868
+ // Animations
869
+ // -------------------------------------------------------------------------
870
+ /**
871
+ * Animates a sprite's {@link Sprite.alpha} from its current value to `to`
872
+ * over `durationMs` milliseconds using **linear interpolation**.
873
+ *
874
+ * If an alpha animation is already running on this sprite, it is replaced
875
+ * by the new one immediately (no queuing).
876
+ *
877
+ * **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
878
+ *
879
+ * @param sprite - The sprite to animate.
880
+ * @param to - Target alpha value (0 = transparent, 1 = opaque).
881
+ * @param durationMs - Duration of the animation in **milliseconds**.
882
+ * @param onComplete - Optional callback invoked when the animation finishes.
883
+ *
884
+ * @example
885
+ * ```ts
886
+ * // Fade out a sprite over 1 second, then destroy it
887
+ * game.animateAlpha(coin, 0, 1000, () => game.destroySprite(coin));
888
+ * ```
889
+ */
890
+ animateAlpha(sprite, to, durationMs, onComplete) {
891
+ this._animations = this._animations.filter((a) => !(a.sprite === sprite && a.property === "alpha"));
892
+ this._animations.push({
893
+ sprite,
894
+ property: "alpha",
895
+ from: sprite.alpha,
896
+ to,
897
+ durationMs,
898
+ elapsed: 0,
899
+ onComplete,
900
+ });
901
+ }
902
+ /**
903
+ * Animates a sprite's {@link Sprite.rotation} from its current value to `to`
904
+ * (in degrees) over `durationMs` milliseconds using **linear interpolation**.
905
+ *
906
+ * If a rotation animation is already running on this sprite, it is replaced
907
+ * immediately (no queuing).
908
+ *
909
+ * **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
910
+ * **Units:** `to` is in **degrees**.
911
+ *
912
+ * @param sprite - The sprite to animate.
913
+ * @param to - Target rotation in **degrees**.
914
+ * @param durationMs - Duration of the animation in **milliseconds**.
915
+ * @param onComplete - Optional callback invoked when the animation finishes.
916
+ *
917
+ * @example
918
+ * ```ts
919
+ * // Spin a sprite 360° over 2 seconds
920
+ * game.animateRotation(star, 360, 2000);
921
+ *
922
+ * // Tilt on hit, then straighten
923
+ * game.animateRotation(player, 45, 200, () => {
924
+ * game.animateRotation(player, 0, 200);
925
+ * });
926
+ * ```
927
+ */
928
+ animateRotation(sprite, to, durationMs, onComplete) {
929
+ this._animations = this._animations.filter((a) => !(a.sprite === sprite && a.property === "rotation"));
930
+ this._animations.push({
931
+ sprite,
932
+ property: "rotation",
933
+ from: sprite.rotation,
934
+ to,
935
+ durationMs,
936
+ elapsed: 0,
937
+ onComplete,
938
+ });
939
+ }
940
+ // -------------------------------------------------------------------------
941
+ // Timers
942
+ // -------------------------------------------------------------------------
943
+ /**
944
+ * Schedules a callback to fire after `delayMs` milliseconds, driven by the
945
+ * rAF loop (not `setTimeout`). Timers accumulate elapsed time each frame and
946
+ * fire once the threshold is reached.
947
+ *
948
+ * **Repeat behavior:**
949
+ * - `repeat = false`: fires once, then auto-removes.
950
+ * - `repeat = true`: fires every `delayMs` ms until cleared with {@link Game.clearTimer}.
951
+ * The elapsed time wraps (excess time carries over), so repeat timers are
952
+ * accurate over long durations.
953
+ *
954
+ * **Reset behavior:** ALL timers are cleared on {@link Game.reset}.
955
+ *
956
+ * @param delayMs - Delay (and period for repeating timers) in **milliseconds**.
957
+ * @param repeat - If `true`, the timer fires repeatedly every `delayMs` ms.
958
+ * @param callback - Function to call when the timer fires.
959
+ * @returns A numeric timer ID for use with {@link Game.clearTimer}.
960
+ *
961
+ * @example
962
+ * ```ts
963
+ * // One-shot: explode after 3 seconds
964
+ * game.addTimer(3000, false, () => { bomb.sprite = "💥"; });
965
+ *
966
+ * // Repeating: spawn enemy every 2 seconds
967
+ * const spawnId = game.addTimer(2000, true, spawnEnemy);
968
+ * // Stop spawning:
969
+ * game.clearTimer(spawnId);
970
+ * ```
971
+ */
972
+ addTimer(delayMs, repeat, callback) {
973
+ const id = ++this._timerIdCounter;
974
+ this._timers.push({ id, delayMs, elapsed: 0, repeat, callback });
975
+ return id;
976
+ }
977
+ /**
978
+ * Cancels a timer previously created with {@link Game.addTimer}.
979
+ * If the ID does not exist (already fired and removed, or never created),
980
+ * this is a safe no-op.
981
+ *
982
+ * @param id - The timer ID returned by {@link Game.addTimer}.
983
+ *
984
+ * @example
985
+ * ```ts
986
+ * const id = game.addTimer(5000, true, spawnEnemy);
987
+ * // Stop spawning when player reaches the end:
988
+ * game.clearTimer(id);
989
+ * ```
990
+ */
991
+ clearTimer(id) {
992
+ this._timers = this._timers.filter((t) => t.id !== id);
993
+ }
994
+ // -------------------------------------------------------------------------
995
+ // Text
996
+ // -------------------------------------------------------------------------
997
+ /**
998
+ * Draws text on screen as a **screen-space overlay** this frame.
999
+ *
1000
+ * **Overlay behavior:** Text is drawn in canvas/screen space — it ignores
1001
+ * `scrollX` / `scrollY`. Position `(0, 0)` is always the top-left of the canvas.
1002
+ * Use this for HUD elements: score, lives, timer, debug info.
1003
+ *
1004
+ * **Per-frame:** `drawText` must be called every frame to keep text visible.
1005
+ * The text overlay list is cleared after each render. Call this inside `onUpdate`.
1006
+ *
1007
+ * **Layer:** Text is always drawn on top of all sprites.
1008
+ * **Font:** Text always uses a fixed `monospace` font family.
1009
+ * Font family cannot be customized in MinimoJS v1.
1010
+ *
1011
+ * @param text - The string to render. Supports emoji and Unicode.
1012
+ * @param x - X position in **screen space** (pixels from canvas left edge).
1013
+ * @param y - Y position in **screen space** (pixels from canvas top edge).
1014
+ * Text baseline is at the top of the text.
1015
+ * @param fontSize - Font size in pixels (e.g. `16`, `24`, `32`).
1016
+ * @param color - CSS color string. Default: `"#ffffff"` (white).
1017
+ * @param centered - If `true`, text is centered on `(x, y)` (both axes).
1018
+ * If `false`, `(x, y)` is the top-left anchor. Default: `false`.
1019
+ *
1020
+ * @example
1021
+ * ```ts
1022
+ * game.onUpdate = (dt) => {
1023
+ * game.drawText(`Score: ${score}`, 10, 10, 20, "#ffff00");
1024
+ * game.drawText(`Lives: ${"❤️".repeat(lives)}`, 10, 40, 20);
1025
+ * game.drawText("PAUSED", game.width / 2, game.height / 2, 28, "#ffffff", true);
1026
+ * };
1027
+ * ```
1028
+ */
1029
+ drawText(text, x, y, fontSize, color = "#ffffff", centered = false) {
1030
+ this._textOverlays.push({ text, x, y, fontSize, color, centered });
1031
+ }
1032
+ // -------------------------------------------------------------------------
1033
+ // Misc
1034
+ // -------------------------------------------------------------------------
1035
+ /**
1036
+ * Returns a pseudo-random floating-point number in the range `[0, 1)`.
1037
+ * Delegates to `Math.random()`.
1038
+ *
1039
+ * **Determinism note:** This method is NOT seeded and NOT deterministic across
1040
+ * runs. The output changes every invocation. If you need reproducible randomness,
1041
+ * implement a seeded PRNG (e.g. a simple LCG) in your game code.
1042
+ *
1043
+ * @returns A random number in `[0, 1)`.
1044
+ *
1045
+ * @example
1046
+ * ```ts
1047
+ * const x = game.random() * game.width;
1048
+ * const emoji = ["⭐", "🔥", "💎"][Math.floor(game.random() * 3)];
1049
+ * ```
1050
+ */
1051
+ random() {
1052
+ return Math.random();
1053
+ }
1054
+ /**
1055
+ * Performs a full engine state reset to enable scene switching.
1056
+ *
1057
+ * **Cleared by reset:**
1058
+ * - All sprites (equivalent to calling {@link Game.destroySprite} on every sprite)
1059
+ * - All timers (regardless of repeat state)
1060
+ * - All running animations
1061
+ * - All pending text overlays
1062
+ * - Scroll position (`scrollX = 0`, `scrollY = 0`)
1063
+ * - Per-frame input state (pressed keys, pressed pointer)
1064
+ *
1065
+ * **NOT cleared by reset:**
1066
+ * - `gravityX` / `gravityY` (global engine setting)
1067
+ * - `background` / `backgroundGradient` / `pageBackground`
1068
+ * - `onCreate` callback (your handler stays registered)
1069
+ * - `onUpdate` callback (your handler stays registered)
1070
+ * - Canvas dimensions
1071
+ * - AudioContext
1072
+ *
1073
+ * After clearing, `reset()` immediately calls `onCreate()` so your callback
1074
+ * can rebuild the new scene synchronously.
1075
+ *
1076
+ * @example
1077
+ * ```ts
1078
+ * // Switch from gameplay to game-over screen
1079
+ * function gameOver() {
1080
+ * game.reset(); // onCreate() is called here, rebuild inside it
1081
+ * }
1082
+ *
1083
+ * game.onCreate = () => {
1084
+ * // Scene init
1085
+ * const skull = new Sprite("💀");
1086
+ * skull.x = 400; skull.y = 300; skull.size = 96;
1087
+ * game.add(skull);
1088
+ * game.addTimer(3000, false, () => game.reset()); // auto-restart
1089
+ * };
1090
+ * ```
1091
+ */
1092
+ reset() {
1093
+ this._sprites = [];
1094
+ this._timers = [];
1095
+ this._animations = [];
1096
+ this._textOverlays = [];
1097
+ this._keysPressed.clear();
1098
+ this._pointerPressed = false;
1099
+ this.scrollX = 0;
1100
+ this.scrollY = 0;
1101
+ this._invokeCreate();
1102
+ }
1103
+ // -------------------------------------------------------------------------
1104
+ // Loop control
1105
+ // -------------------------------------------------------------------------
1106
+ /**
1107
+ * Starts the `requestAnimationFrame` game loop.
1108
+ * Safe to call multiple times — does nothing if already running.
1109
+ * If this is the first start (or after a reset), `onCreate()` is called
1110
+ * before the first frame.
1111
+ *
1112
+ * The loop calls `onUpdate` once per frame, then renders all sprites and
1113
+ * text overlays. Order per frame:
1114
+ * 1. Accumulate timer elapsed time; fire ready callbacks.
1115
+ * 2. Advance animations (linear interpolation).
1116
+ * 3. Apply gravity to sprite velocities.
1117
+ * 4. Apply velocities to sprite positions.
1118
+ * 5. Call `onUpdate(dt)`.
1119
+ * 6. Render sprites (sorted by layer) with scroll offset.
1120
+ * 7. Render text overlays (screen space, on top of sprites).
1121
+ * 8. Clear per-frame input state.
1122
+ *
1123
+ * @example
1124
+ * ```ts
1125
+ * game.onUpdate = (dt) => { ... };
1126
+ * game.start();
1127
+ * ```
1128
+ */
1129
+ start() {
1130
+ if (this._running)
1131
+ return;
1132
+ if (!this._hasCreated)
1133
+ this._invokeCreate();
1134
+ this._running = true;
1135
+ this._lastTimestamp = null;
1136
+ this._rafId = requestAnimationFrame(this._loop.bind(this));
1137
+ }
1138
+ /**
1139
+ * Stops the game loop. The canvas retains its last rendered frame.
1140
+ * Call {@link Game.start} to resume.
1141
+ */
1142
+ stop() {
1143
+ this._running = false;
1144
+ if (this._rafId !== null) {
1145
+ cancelAnimationFrame(this._rafId);
1146
+ this._rafId = null;
1147
+ }
1148
+ this._lastTimestamp = null;
1149
+ }
1150
+ // -------------------------------------------------------------------------
1151
+ // Private — input binding
1152
+ // -------------------------------------------------------------------------
1153
+ /** @internal */
1154
+ _bindInputEvents() {
1155
+ window.addEventListener("keydown", (e) => {
1156
+ if (!this._keysDown.has(e.key)) {
1157
+ this._keysPressed.add(e.key);
1158
+ }
1159
+ this._keysDown.add(e.key);
1160
+ });
1161
+ window.addEventListener("keyup", (e) => {
1162
+ this._keysDown.delete(e.key);
1163
+ });
1164
+ const getCanvasPoint = (e) => {
1165
+ const rect = this._canvas.getBoundingClientRect();
1166
+ const scaleX = this._canvas.width / rect.width;
1167
+ const scaleY = this._canvas.height / rect.height;
1168
+ let clientX;
1169
+ let clientY;
1170
+ if (e instanceof MouseEvent) {
1171
+ clientX = e.clientX;
1172
+ clientY = e.clientY;
1173
+ }
1174
+ else {
1175
+ clientX = e.touches[0]?.clientX ?? this._pointerX;
1176
+ clientY = e.touches[0]?.clientY ?? this._pointerY;
1177
+ }
1178
+ return {
1179
+ x: (clientX - rect.left) * scaleX,
1180
+ y: (clientY - rect.top) * scaleY,
1181
+ };
1182
+ };
1183
+ this._canvas.addEventListener("mousedown", (e) => {
1184
+ const p = getCanvasPoint(e);
1185
+ this._pointerX = p.x;
1186
+ this._pointerY = p.y;
1187
+ this._pointerDown = true;
1188
+ this._pointerPressed = true;
1189
+ });
1190
+ this._canvas.addEventListener("mouseup", () => {
1191
+ this._pointerDown = false;
1192
+ });
1193
+ this._canvas.addEventListener("mousemove", (e) => {
1194
+ const p = getCanvasPoint(e);
1195
+ this._pointerX = p.x;
1196
+ this._pointerY = p.y;
1197
+ });
1198
+ this._canvas.addEventListener("touchstart", (e) => {
1199
+ const p = getCanvasPoint(e);
1200
+ this._pointerX = p.x;
1201
+ this._pointerY = p.y;
1202
+ this._pointerDown = true;
1203
+ this._pointerPressed = true;
1204
+ e.preventDefault();
1205
+ }, { passive: false });
1206
+ this._canvas.addEventListener("touchend", () => {
1207
+ this._pointerDown = false;
1208
+ });
1209
+ this._canvas.addEventListener("touchmove", (e) => {
1210
+ const p = getCanvasPoint(e);
1211
+ this._pointerX = p.x;
1212
+ this._pointerY = p.y;
1213
+ e.preventDefault();
1214
+ }, { passive: false });
1215
+ }
1216
+ // -------------------------------------------------------------------------
1217
+ // Private — rAF loop
1218
+ // -------------------------------------------------------------------------
1219
+ /** @internal */
1220
+ _loop(timestamp) {
1221
+ if (!this._running)
1222
+ return;
1223
+ if (this._lastTimestamp === null) {
1224
+ this._lastTimestamp = timestamp;
1225
+ }
1226
+ let dt = (timestamp - this._lastTimestamp) / 1000;
1227
+ this._lastTimestamp = timestamp;
1228
+ if (dt > 0.1)
1229
+ dt = 0.1;
1230
+ const dtMs = dt * 1000;
1231
+ this._updateTimers(dtMs);
1232
+ this._updateAnimations(dtMs);
1233
+ this._updatePhysics(dt);
1234
+ if (this.onUpdate)
1235
+ this.onUpdate(dt);
1236
+ this._render();
1237
+ this._keysPressed.clear();
1238
+ this._pointerPressed = false;
1239
+ this._textOverlays = [];
1240
+ this._rafId = requestAnimationFrame(this._loop.bind(this));
1241
+ }
1242
+ /** @internal */
1243
+ _updateTimers(dtMs) {
1244
+ const toRemove = [];
1245
+ const toFire = [];
1246
+ for (const timer of this._timers) {
1247
+ timer.elapsed += dtMs;
1248
+ if (timer.elapsed >= timer.delayMs) {
1249
+ toFire.push(timer);
1250
+ if (timer.repeat) {
1251
+ timer.elapsed -= timer.delayMs;
1252
+ }
1253
+ else {
1254
+ toRemove.push(timer.id);
1255
+ }
1256
+ }
1257
+ }
1258
+ if (toRemove.length > 0) {
1259
+ this._timers = this._timers.filter((t) => !toRemove.includes(t.id));
1260
+ }
1261
+ for (const timer of toFire) {
1262
+ timer.callback();
1263
+ }
1264
+ }
1265
+ /** @internal */
1266
+ _updateAnimations(dtMs) {
1267
+ const toRemove = [];
1268
+ for (let i = 0; i < this._animations.length; i++) {
1269
+ const anim = this._animations[i];
1270
+ anim.elapsed += dtMs;
1271
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
1272
+ anim.sprite[anim.property] = anim.from + (anim.to - anim.from) * t;
1273
+ if (t >= 1) {
1274
+ toRemove.push(i);
1275
+ anim.onComplete?.();
1276
+ }
1277
+ }
1278
+ for (let i = toRemove.length - 1; i >= 0; i--) {
1279
+ this._animations.splice(toRemove[i], 1);
1280
+ }
1281
+ }
1282
+ /** @internal */
1283
+ _updatePhysics(dt) {
1284
+ for (const sprite of this._sprites) {
1285
+ if (sprite.gravityScale !== 0) {
1286
+ sprite.vx += this.gravityX * sprite.gravityScale * dt;
1287
+ sprite.vy += this.gravityY * sprite.gravityScale * dt;
1288
+ }
1289
+ sprite.x += sprite.vx * dt;
1290
+ sprite.y += sprite.vy * dt;
1291
+ }
1292
+ }
1293
+ // -------------------------------------------------------------------------
1294
+ // Private — rendering
1295
+ // -------------------------------------------------------------------------
1296
+ /** @internal */
1297
+ _render() {
1298
+ const ctx = this._ctx;
1299
+ const W = this._canvas.width;
1300
+ const H = this._canvas.height;
1301
+ this._applyPageBackground();
1302
+ ctx.clearRect(0, 0, W, H);
1303
+ if (this.backgroundGradient !== null) {
1304
+ const gradient = ctx.createLinearGradient(0, 0, 0, H);
1305
+ gradient.addColorStop(0, this.backgroundGradient.from);
1306
+ gradient.addColorStop(1, this.backgroundGradient.to);
1307
+ ctx.fillStyle = gradient;
1308
+ ctx.fillRect(0, 0, W, H);
1309
+ }
1310
+ else if (this.background !== null) {
1311
+ ctx.fillStyle = this.background;
1312
+ ctx.fillRect(0, 0, W, H);
1313
+ }
1314
+ const sorted = [...this._sprites].sort((a, b) => a.layer - b.layer);
1315
+ for (const sprite of sorted) {
1316
+ if (!sprite.visible)
1317
+ continue;
1318
+ ctx.save();
1319
+ ctx.globalAlpha = Math.max(0, Math.min(1, sprite.alpha));
1320
+ const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
1321
+ const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
1322
+ ctx.translate(drawX, drawY);
1323
+ if (sprite.rotation !== 0) {
1324
+ ctx.rotate((sprite.rotation * Math.PI) / 180);
1325
+ }
1326
+ if (sprite.flipX || sprite.flipY) {
1327
+ ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
1328
+ }
1329
+ ctx.font = `${sprite.size}px serif`;
1330
+ ctx.textAlign = "center";
1331
+ ctx.textBaseline = "middle";
1332
+ ctx.fillText(sprite.sprite, 0, 0);
1333
+ ctx.restore();
1334
+ }
1335
+ for (const entry of this._textOverlays) {
1336
+ ctx.save();
1337
+ ctx.font = `${entry.fontSize}px monospace`;
1338
+ ctx.fillStyle = entry.color;
1339
+ ctx.textAlign = entry.centered ? "center" : "left";
1340
+ ctx.textBaseline = entry.centered ? "middle" : "top";
1341
+ ctx.fillText(entry.text, entry.x, entry.y);
1342
+ ctx.restore();
1343
+ }
1344
+ }
1345
+ /** @internal */
1346
+ _applyPageBackground() {
1347
+ if (!document.body)
1348
+ return;
1349
+ if (this._lastAppliedPageBackground === this.pageBackground)
1350
+ return;
1351
+ if (this.pageBackground === null) {
1352
+ document.body.style.removeProperty("background");
1353
+ }
1354
+ else {
1355
+ document.body.style.background = this.pageBackground;
1356
+ }
1357
+ this._lastAppliedPageBackground = this.pageBackground;
1358
+ }
1359
+ /** @internal */
1360
+ _applyResponsiveCanvasLayout() {
1361
+ if (!document.body)
1362
+ return;
1363
+ document.body.style.margin = "0";
1364
+ document.body.style.minHeight = "100vh";
1365
+ document.body.style.display = "grid";
1366
+ document.body.style.placeItems = "center";
1367
+ document.body.style.touchAction = "manipulation";
1368
+ const viewportW = Math.max(1, window.innerWidth);
1369
+ const viewportH = Math.max(1, window.innerHeight);
1370
+ const scale = Math.min(viewportW / this._canvas.width, viewportH / this._canvas.height);
1371
+ const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
1372
+ this._canvas.style.width = `${Math.floor(this._canvas.width * safeScale)}px`;
1373
+ this._canvas.style.height = `${Math.floor(this._canvas.height * safeScale)}px`;
1374
+ }
1375
+ /** @internal */
1376
+ _invokeCreate() {
1377
+ this._hasCreated = true;
1378
+ if (this.onCreate)
1379
+ this.onCreate();
1380
+ }
1381
+ }