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/LICENSE +21 -0
- package/README.md +333 -0
- package/dist/minimo.d.ts +941 -0
- package/dist/minimo.js +1381 -0
- package/package.json +42 -0
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
|
+
}
|