minimojs 1.0.0-alpha.1 → 1.0.0-alpha.2

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