minimojs 1.0.0-alpha.2 → 1.0.0-alpha.21
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/README.md +82 -285
- package/dist/internal/AnimationSystem.js +345 -0
- package/dist/internal/AssetSystem.d.ts +1 -0
- package/dist/internal/AssetSystem.js +101 -0
- package/dist/internal/BackgroundSystem.d.ts +1 -0
- package/dist/internal/BackgroundSystem.js +26 -0
- package/dist/internal/CanvasSystem.d.ts +1 -0
- package/dist/internal/CanvasSystem.js +51 -0
- package/dist/internal/ExplosionSystem.d.ts +1 -0
- package/dist/internal/ExplosionSystem.js +540 -0
- package/dist/internal/InputSystem.d.ts +1 -0
- package/dist/internal/InputSystem.js +265 -0
- package/dist/internal/LoopSystem.d.ts +1 -0
- package/dist/internal/LoopSystem.js +61 -0
- package/dist/internal/PhysicsSystem.d.ts +1 -0
- package/dist/internal/PhysicsSystem.js +174 -0
- package/dist/internal/RenderSystem.d.ts +1 -0
- package/dist/internal/RenderSystem.js +910 -0
- package/dist/internal/SoundSystem.d.ts +1 -0
- package/dist/internal/SoundSystem.js +55 -0
- package/dist/internal/SpriteSystem.d.ts +1 -0
- package/dist/internal/SpriteSystem.js +32 -0
- package/dist/internal/TextSystem.d.ts +1 -0
- package/dist/internal/TextSystem.js +16 -0
- package/dist/internal/TimerSystem.d.ts +1 -0
- package/dist/internal/TimerSystem.js +43 -0
- package/dist/internal/TrailSystem.d.ts +1 -0
- package/dist/internal/TrailSystem.js +116 -0
- package/dist/internal/TransitionSystem.d.ts +1 -0
- package/dist/internal/TransitionSystem.js +74 -0
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
- package/dist/minimo-arcaderacer.d.ts +1431 -0
- package/dist/minimo-arcaderacer.js +2060 -0
- package/dist/minimo.d.ts +1412 -162
- package/dist/minimo.js +2083 -816
- package/package.json +3 -2
- package/dist/animations.js +0 -30
- package/dist/audio.js +0 -17
- package/dist/game.js +0 -1105
- package/dist/input.js +0 -185
- package/dist/internal-types.js +0 -4
- package/dist/physics.js +0 -10
- package/dist/render.js +0 -75
- package/dist/sprite.js +0 -149
- package/dist/timers.js +0 -23
- /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
package/dist/minimo.js
CHANGED
|
@@ -7,69 +7,33 @@
|
|
|
7
7
|
* ALL ROTATIONS ARE IN DEGREES.
|
|
8
8
|
* THE ENGINE LOOP USES requestAnimationFrame ONLY.
|
|
9
9
|
*/
|
|
10
|
+
import { AnimationSystem } from "./internal/AnimationSystem.js";
|
|
11
|
+
import { AssetSystem } from "./internal/AssetSystem.js";
|
|
12
|
+
import { BackgroundSystem } from "./internal/BackgroundSystem.js";
|
|
13
|
+
import { CanvasSystem } from "./internal/CanvasSystem.js";
|
|
14
|
+
import { ExplosionSystem } from "./internal/ExplosionSystem.js";
|
|
15
|
+
import { InputSystem } from "./internal/InputSystem.js";
|
|
16
|
+
import { LoopSystem } from "./internal/LoopSystem.js";
|
|
17
|
+
import { PhysicsSystem } from "./internal/PhysicsSystem.js";
|
|
18
|
+
import { RenderSystem } from "./internal/RenderSystem.js";
|
|
19
|
+
import { SoundSystem } from "./internal/SoundSystem.js";
|
|
20
|
+
import { SpriteSystem } from "./internal/SpriteSystem.js";
|
|
21
|
+
import { TextSystem } from "./internal/TextSystem.js";
|
|
22
|
+
import { TrailSystem } from "./internal/TrailSystem.js";
|
|
23
|
+
import { TransitionSystem } from "./internal/TransitionSystem.js";
|
|
24
|
+
import { TimerSystem } from "./internal/TimerSystem.js";
|
|
10
25
|
// ---------------------------------------------------------------------------
|
|
11
|
-
//
|
|
26
|
+
// EmojiSprite
|
|
12
27
|
// ---------------------------------------------------------------------------
|
|
13
28
|
/**
|
|
14
|
-
*
|
|
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
|
-
* ```
|
|
29
|
+
* Base class for all renderable MinimoJS actors.
|
|
34
30
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
* ```
|
|
31
|
+
* Concrete subclasses provide their own visual content while inheriting the
|
|
32
|
+
* shared transform, visibility, physics, and animation state required by the
|
|
33
|
+
* engine.
|
|
53
34
|
*/
|
|
54
35
|
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) {
|
|
36
|
+
constructor(game = null) {
|
|
73
37
|
/**
|
|
74
38
|
* X position in world space (horizontal center of the sprite), in pixels.
|
|
75
39
|
* Positive X points right. Updated each frame by: `x += vx * dt`.
|
|
@@ -83,10 +47,63 @@ export class Sprite {
|
|
|
83
47
|
*/
|
|
84
48
|
this.y = 0;
|
|
85
49
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
50
|
+
* Visual scale multiplier applied to this sprite's logical width/height.
|
|
51
|
+
* Default: `1`.
|
|
52
|
+
*/
|
|
53
|
+
this.scale = 1;
|
|
54
|
+
/** @internal */
|
|
55
|
+
this._renderData = null;
|
|
56
|
+
/**
|
|
57
|
+
* Optional logical body width used by physics helpers and collision checks.
|
|
58
|
+
*
|
|
59
|
+
* When `null` (default), MinimoJS uses the sprite's visual {@link Sprite.width}.
|
|
60
|
+
* When set, this value is scaled by {@link Sprite.scale} the same way as the
|
|
61
|
+
* visual sprite size.
|
|
62
|
+
*/
|
|
63
|
+
this.bodyWidth = null;
|
|
64
|
+
/**
|
|
65
|
+
* Optional logical body height used by physics helpers and collision checks.
|
|
66
|
+
*
|
|
67
|
+
* When `null` (default), MinimoJS uses the sprite's visual {@link Sprite.height}.
|
|
68
|
+
* When set, this value is scaled by {@link Sprite.scale} the same way as the
|
|
69
|
+
* visual sprite size.
|
|
70
|
+
*/
|
|
71
|
+
this.bodyHeight = null;
|
|
72
|
+
/**
|
|
73
|
+
* Horizontal body offset, in local sprite pixels before scale is applied.
|
|
74
|
+
*
|
|
75
|
+
* Positive values move the collision body to the right of the sprite's rendered center.
|
|
76
|
+
* Negative values move it to the left.
|
|
88
77
|
*/
|
|
89
|
-
this.
|
|
78
|
+
this.bodyOffsetX = 0;
|
|
79
|
+
/**
|
|
80
|
+
* Vertical body offset, in local sprite pixels before scale is applied.
|
|
81
|
+
*
|
|
82
|
+
* Positive values move the collision body downward relative to the sprite's
|
|
83
|
+
* rendered center. Negative values move it upward.
|
|
84
|
+
*/
|
|
85
|
+
this.bodyOffsetY = 0;
|
|
86
|
+
/**
|
|
87
|
+
* CSS text color used when rendering this sprite.
|
|
88
|
+
*
|
|
89
|
+
* This mainly affects monochrome glyphs and symbol-style sprites.
|
|
90
|
+
* Full-color emoji may ignore this and render with their native colors,
|
|
91
|
+
* depending on browser behavior.
|
|
92
|
+
*/
|
|
93
|
+
this.color = "#000000";
|
|
94
|
+
/**
|
|
95
|
+
* Physics body type flag.
|
|
96
|
+
*
|
|
97
|
+
* - `false` (default): the sprite is dynamic and can be moved by velocity,
|
|
98
|
+
* gravity, and explicit collision resolution.
|
|
99
|
+
* - `true`: the sprite is static and is not moved by the engine's built-in
|
|
100
|
+
* velocity/gravity integration. Static sprites act as stable obstacles for
|
|
101
|
+
* simple platform collisions.
|
|
102
|
+
*
|
|
103
|
+
* Static sprites can still be repositioned manually by setting `x` and `y`
|
|
104
|
+
* directly in your own game code.
|
|
105
|
+
*/
|
|
106
|
+
this.isStatic = false;
|
|
90
107
|
/**
|
|
91
108
|
* Visual rotation of the sprite in degrees.
|
|
92
109
|
* `0` = upright. Positive values rotate clockwise.
|
|
@@ -151,12 +168,664 @@ export class Sprite {
|
|
|
151
168
|
* Values > 1 amplify gravity; negative values invert it.
|
|
152
169
|
*/
|
|
153
170
|
this.gravityScale = 0;
|
|
171
|
+
// `abstract` and `protected` are erased by the TypeScript compiler, and games
|
|
172
|
+
// are written in JavaScript, so nothing but this check stops old code from
|
|
173
|
+
// calling `new Sprite(glyph, x, y, size)`. Without it the glyph would be
|
|
174
|
+
// stored as this sprite's game reference, the position and size would be
|
|
175
|
+
// discarded, and the failure would surface one call later inside `Game.add`
|
|
176
|
+
// as an unrelated "attached to multiple Game instances" error.
|
|
177
|
+
if (new.target === Sprite) {
|
|
178
|
+
throw new Error("MinimoJS: Sprite is an abstract type and cannot be constructed directly. " +
|
|
179
|
+
"Use EmojiSprite for an emoji, ImageSprite for a preloaded image, " +
|
|
180
|
+
"TextSprite for text, or DrawSprite for procedural drawing.");
|
|
181
|
+
}
|
|
182
|
+
this._game = game;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Game instance associated with this sprite, if any.
|
|
186
|
+
*/
|
|
187
|
+
get game() {
|
|
188
|
+
return this._game;
|
|
189
|
+
}
|
|
190
|
+
/** @internal */
|
|
191
|
+
_setGame(game) {
|
|
192
|
+
if (this._game !== null && this._game !== game) {
|
|
193
|
+
throw new Error("MinimoJS: A sprite cannot be attached to multiple Game instances.");
|
|
194
|
+
}
|
|
195
|
+
this._game = game;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolved center X used internally by rendering, hit testing, and collisions.
|
|
199
|
+
*/
|
|
200
|
+
get renderX() {
|
|
201
|
+
return this.x + this.getAnchorOffsetX();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Resolved center Y used internally by rendering, hit testing, and collisions.
|
|
205
|
+
*/
|
|
206
|
+
get renderY() {
|
|
207
|
+
return this.y + this.getAnchorOffsetY();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Effective rendered/collision width in pixels.
|
|
211
|
+
*/
|
|
212
|
+
get displayWidth() {
|
|
213
|
+
const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
|
|
214
|
+
return this.width * Math.max(0, safeScale);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Effective rendered/collision height in pixels.
|
|
218
|
+
*/
|
|
219
|
+
get displayHeight() {
|
|
220
|
+
const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
|
|
221
|
+
return this.height * Math.max(0, safeScale);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Effective collision-body width in pixels after applying {@link Sprite.scale}.
|
|
225
|
+
*/
|
|
226
|
+
get bodyDisplayWidth() {
|
|
227
|
+
const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
|
|
228
|
+
const baseWidth = typeof this.bodyWidth === "number" &&
|
|
229
|
+
Number.isFinite(this.bodyWidth) &&
|
|
230
|
+
this.bodyWidth > 0
|
|
231
|
+
? this.bodyWidth
|
|
232
|
+
: this.width;
|
|
233
|
+
return baseWidth * Math.max(0, safeScale);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Effective collision-body height in pixels after applying {@link Sprite.scale}.
|
|
237
|
+
*/
|
|
238
|
+
get bodyDisplayHeight() {
|
|
239
|
+
const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
|
|
240
|
+
const baseHeight = typeof this.bodyHeight === "number" &&
|
|
241
|
+
Number.isFinite(this.bodyHeight) &&
|
|
242
|
+
this.bodyHeight > 0
|
|
243
|
+
? this.bodyHeight
|
|
244
|
+
: this.height;
|
|
245
|
+
return baseHeight * Math.max(0, safeScale);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Resolved body center X used internally by physics helpers and collision checks.
|
|
249
|
+
*/
|
|
250
|
+
get bodyCenterX() {
|
|
251
|
+
const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
|
|
252
|
+
return this.renderX + this.bodyOffsetX * Math.max(0, safeScale);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Resolved body center Y used internally by physics helpers and collision checks.
|
|
256
|
+
*/
|
|
257
|
+
get bodyCenterY() {
|
|
258
|
+
const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
|
|
259
|
+
return this.renderY + this.bodyOffsetY * Math.max(0, safeScale);
|
|
260
|
+
}
|
|
261
|
+
getAnchorOffsetX() {
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
getAnchorOffsetY() {
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* A 2D game object rendered as an emoji on the canvas.
|
|
270
|
+
*
|
|
271
|
+
* Instantiate directly or extend to create custom sprite types.
|
|
272
|
+
* Register with the engine by passing the instance to {@link Game.add}.
|
|
273
|
+
*
|
|
274
|
+
* **Coordinate system:** center-based world space. `(x, y)` is the center of
|
|
275
|
+
* the sprite. Positive X = right, positive Y = down.
|
|
276
|
+
*
|
|
277
|
+
* **Lifecycle:** A sprite exists until {@link Game.destroySprite} is called or
|
|
278
|
+
* {@link Game.reset} is invoked. After destruction, do not read or write its fields.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```ts
|
|
282
|
+
* const coin = new EmojiSprite("🪙", 300, 200, 32);
|
|
283
|
+
* game.add(coin);
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
export class EmojiSprite extends Sprite {
|
|
287
|
+
get size() {
|
|
288
|
+
return this._size;
|
|
289
|
+
}
|
|
290
|
+
get width() {
|
|
291
|
+
return this._size;
|
|
292
|
+
}
|
|
293
|
+
get height() {
|
|
294
|
+
return this._size;
|
|
295
|
+
}
|
|
296
|
+
get displaySize() {
|
|
297
|
+
return this.displayWidth;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Creates a new EmojiSprite with the given emoji, optional position, and base size.
|
|
301
|
+
* All other properties use their defaults and can be set after construction.
|
|
302
|
+
*
|
|
303
|
+
* @param sprite - The emoji character to render. Must be a single emoji.
|
|
304
|
+
* Use {@link ImageSprite} for preloaded bitmap textures.
|
|
305
|
+
* @example "🔥", "⭐", "🐢", "💣", "👾"
|
|
306
|
+
* @param x - Initial X position in world space (center), in pixels. Default: `0`.
|
|
307
|
+
* @param y - Initial Y position in world space (center), in pixels. Default: `0`.
|
|
308
|
+
* @param size - Base sprite size in pixels. Default: `32`.
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* ```ts
|
|
312
|
+
* const enemy = new EmojiSprite("👾", 200, 100, 40);
|
|
313
|
+
* game.add(enemy);
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
constructor(sprite, x = 0, y = 0, size = 32) {
|
|
317
|
+
super();
|
|
154
318
|
this.sprite = sprite;
|
|
155
319
|
this.x = x;
|
|
156
320
|
this.y = y;
|
|
321
|
+
const safeSize = Number.isFinite(size) ? size : 32;
|
|
322
|
+
this._size = Math.max(1, safeSize);
|
|
323
|
+
}
|
|
324
|
+
getRenderCacheKey() {
|
|
325
|
+
return `emoji:${this.sprite}|size:${this._size}|color:${this.color}`;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A renderable sprite backed by a preloaded image asset.
|
|
330
|
+
*
|
|
331
|
+
* Width and height are always resolved from the current texture. To resize the
|
|
332
|
+
* sprite visually, use {@link Sprite.scale}.
|
|
333
|
+
*/
|
|
334
|
+
export class ImageSprite extends Sprite {
|
|
335
|
+
get imageKey() {
|
|
336
|
+
return this._imageKey;
|
|
337
|
+
}
|
|
338
|
+
get width() {
|
|
339
|
+
const image = this.getResolvedImage();
|
|
340
|
+
return image instanceof HTMLImageElement
|
|
341
|
+
? image.naturalWidth || image.width
|
|
342
|
+
: image.width;
|
|
343
|
+
}
|
|
344
|
+
get height() {
|
|
345
|
+
const image = this.getResolvedImage();
|
|
346
|
+
return image instanceof HTMLImageElement
|
|
347
|
+
? image.naturalHeight || image.height
|
|
348
|
+
: image.height;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Creates a new image-backed sprite.
|
|
352
|
+
*
|
|
353
|
+
* @param game - Game instance used to resolve the texture key.
|
|
354
|
+
* @param imageKey - Texture key previously registered with {@link Game.loadImage} or {@link Game.createTexture}.
|
|
355
|
+
* @param x - Initial X position in world space (center), in pixels. Default: `0`.
|
|
356
|
+
* @param y - Initial Y position in world space (center), in pixels. Default: `0`.
|
|
357
|
+
*/
|
|
358
|
+
constructor(game, imageKey, x = 0, y = 0) {
|
|
359
|
+
super(game);
|
|
360
|
+
this._imageKey = imageKey;
|
|
361
|
+
this.x = x;
|
|
362
|
+
this.y = y;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Replaces the current texture with another preloaded image.
|
|
366
|
+
*
|
|
367
|
+
* @param imageKey - New texture key to render.
|
|
368
|
+
*/
|
|
369
|
+
setTexture(imageKey) {
|
|
370
|
+
if (imageKey === this._imageKey)
|
|
371
|
+
return;
|
|
372
|
+
this.assertTextureAvailable(imageKey);
|
|
373
|
+
this._imageKey = imageKey;
|
|
374
|
+
}
|
|
375
|
+
getRenderCacheKey() {
|
|
376
|
+
const image = this.getResolvedImage();
|
|
377
|
+
const width = image instanceof HTMLImageElement ? image.naturalWidth || image.width : image.width;
|
|
378
|
+
const height = image instanceof HTMLImageElement ? image.naturalHeight || image.height : image.height;
|
|
379
|
+
return [
|
|
380
|
+
"image",
|
|
381
|
+
this._imageKey,
|
|
382
|
+
this.game?.getImageVersion(this._imageKey) ?? 0,
|
|
383
|
+
width,
|
|
384
|
+
height,
|
|
385
|
+
].join("|");
|
|
386
|
+
}
|
|
387
|
+
/** @internal */
|
|
388
|
+
getResolvedImage() {
|
|
389
|
+
this.assertTextureAvailable(this._imageKey);
|
|
390
|
+
const image = this.game?.getImage(this._imageKey);
|
|
391
|
+
if (!image) {
|
|
392
|
+
throw new Error(`MinimoJS: Image '${this._imageKey}' is not loaded.`);
|
|
393
|
+
}
|
|
394
|
+
return image;
|
|
395
|
+
}
|
|
396
|
+
/** @internal */
|
|
397
|
+
assertTextureAvailable(imageKey) {
|
|
398
|
+
if (!this.game) {
|
|
399
|
+
throw new Error("MinimoJS: ImageSprite requires an associated Game instance.");
|
|
400
|
+
}
|
|
401
|
+
if (!this.game.hasImage(imageKey)) {
|
|
402
|
+
throw new Error(`MinimoJS: Image '${imageKey}' is not loaded.`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A renderable sprite backed by a per-instance canvas that is repainted on demand.
|
|
408
|
+
*
|
|
409
|
+
* `DrawSprite` is useful for procedural shapes, gauges, charts, minimaps, and
|
|
410
|
+
* HUD widgets whose appearance changes often and is easier to express with
|
|
411
|
+
* Canvas 2D drawing commands than with emoji, text, or image swaps.
|
|
412
|
+
*
|
|
413
|
+
* Treat `DrawSprite` as a specialized tool, not the default sprite type.
|
|
414
|
+
* Because it may execute custom Canvas 2D drawing code repeatedly, overusing it
|
|
415
|
+
* can affect game performance much more than regular {@link EmojiSprite},
|
|
416
|
+
* {@link ImageSprite}, or {@link TextSprite} instances.
|
|
417
|
+
*
|
|
418
|
+
* Prefer the other sprite types whenever they can express the same result more
|
|
419
|
+
* simply. Reach for `DrawSprite` only when you truly need procedural drawing or
|
|
420
|
+
* custom per-sprite canvas rendering that the built-in sprite types cannot
|
|
421
|
+
* provide cleanly.
|
|
422
|
+
*
|
|
423
|
+
* MinimoJS creates and owns an internal canvas for each `DrawSprite` instance.
|
|
424
|
+
* Before every engine render that needs this sprite's surface, the engine:
|
|
425
|
+
*
|
|
426
|
+
* 1. Resolves the sprite's internal canvas size
|
|
427
|
+
* 2. Optionally clears and repaints that internal canvas
|
|
428
|
+
* 3. Draws the resulting canvas like any other sprite surface
|
|
429
|
+
*
|
|
430
|
+
* By default, `DrawSprite` is live-rendered: the engine clears the internal
|
|
431
|
+
* canvas and calls {@link DrawSprite.redraw} every frame.
|
|
432
|
+
*
|
|
433
|
+
* Set {@link DrawSprite.frozen} to `true` if you want to keep and reuse the
|
|
434
|
+
* last rendered canvas contents without repainting on each frame. This is
|
|
435
|
+
* useful for shapes or procedural art that you only want to draw once.
|
|
436
|
+
*
|
|
437
|
+
* While `frozen` is `true`, MinimoJS reuses the existing surface exactly as-is:
|
|
438
|
+
* it does not clear the canvas and does not call {@link DrawSprite.redraw}
|
|
439
|
+
* again, unless the surface does not exist yet or its size had to be rebuilt.
|
|
440
|
+
*
|
|
441
|
+
* This means freezing is a rendering optimization and content-preservation
|
|
442
|
+
* flag, not a separate caching system. You can switch `frozen` on or off at
|
|
443
|
+
* runtime whenever it makes sense for your sprite.
|
|
444
|
+
*
|
|
445
|
+
* The local drawing coordinate system uses the sprite surface itself:
|
|
446
|
+
* - `(0, 0)` is the top-left corner of the internal canvas
|
|
447
|
+
* - `width` / `height` match the sprite's logical size before `scale`
|
|
448
|
+
* - draw centered content yourself if you want the visual origin in the middle
|
|
449
|
+
*
|
|
450
|
+
* Override {@link DrawSprite.redraw} in a subclass, or assign your own method
|
|
451
|
+
* on an instance if you prefer an inline style in JavaScript.
|
|
452
|
+
*/
|
|
453
|
+
export class DrawSprite extends Sprite {
|
|
454
|
+
get width() {
|
|
455
|
+
return this._width;
|
|
456
|
+
}
|
|
457
|
+
get height() {
|
|
458
|
+
return this._height;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Creates a new dynamic canvas-backed sprite.
|
|
462
|
+
*
|
|
463
|
+
* @param width - Internal canvas width in pixels. Minimum `1`.
|
|
464
|
+
* @param height - Internal canvas height in pixels. Minimum `1`.
|
|
465
|
+
* @param x - Initial X position in world space (center), in pixels. Default: `0`.
|
|
466
|
+
* @param y - Initial Y position in world space (center), in pixels. Default: `0`.
|
|
467
|
+
*/
|
|
468
|
+
constructor(width, height, x = 0, y = 0) {
|
|
469
|
+
super();
|
|
470
|
+
/** @internal */
|
|
471
|
+
this._surface = null;
|
|
472
|
+
/** @internal */
|
|
473
|
+
this._surfaceCtx = null;
|
|
474
|
+
/** @internal */
|
|
475
|
+
this._lastRedrawPassId = -1;
|
|
476
|
+
/**
|
|
477
|
+
* When `false` (default), MinimoJS clears this sprite's internal canvas and
|
|
478
|
+
* calls {@link DrawSprite.redraw} on every render pass.
|
|
479
|
+
*
|
|
480
|
+
* When `true`, MinimoJS keeps and reuses the existing canvas contents without
|
|
481
|
+
* clearing or repainting them again, unless the internal surface does not yet
|
|
482
|
+
* exist or had to be resized.
|
|
483
|
+
*
|
|
484
|
+
* Use this when your procedural drawing becomes static after its first paint,
|
|
485
|
+
* or when you want explicit manual control over when the sprite is redrawn by
|
|
486
|
+
* toggling `frozen` at runtime.
|
|
487
|
+
*/
|
|
488
|
+
this.frozen = false;
|
|
489
|
+
this._surfaceId = DrawSprite._nextSurfaceId++;
|
|
490
|
+
this._width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
|
|
491
|
+
this._height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
|
|
492
|
+
this.x = x;
|
|
493
|
+
this.y = y;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Called by the engine whenever the sprite's internal surface must be repainted.
|
|
497
|
+
*
|
|
498
|
+
* The provided context is already cleared and reset to the default 2D canvas
|
|
499
|
+
* state for the current surface size. MinimoJS repaints each `DrawSprite` at
|
|
500
|
+
* most once per render pass while it is not {@link DrawSprite.frozen}, so
|
|
501
|
+
* repeated snapshot reads during the same frame reuse the already-redrawn
|
|
502
|
+
* surface.
|
|
503
|
+
*
|
|
504
|
+
* @param ctx - The sprite's internal 2D drawing context.
|
|
505
|
+
*/
|
|
506
|
+
redraw(_ctx) { }
|
|
507
|
+
getRenderCacheKey() {
|
|
508
|
+
return `draw:${this._surfaceId}`;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/** @internal */
|
|
512
|
+
DrawSprite._nextSurfaceId = 1;
|
|
513
|
+
/**
|
|
514
|
+
* A renderable text actor that participates in the same animation/effects
|
|
515
|
+
* pipeline as regular sprites.
|
|
516
|
+
*
|
|
517
|
+
* Use `TextSprite` when the content is primarily text, or when you need text
|
|
518
|
+
* layout features such as wrapping, fixed button sizes, background/border, or
|
|
519
|
+
* text stroke.
|
|
520
|
+
*
|
|
521
|
+
* For controls that are only a single emoji, prefer {@link EmojiSprite}. Emoji-only
|
|
522
|
+
* buttons usually behave better as sprites because they do not need text
|
|
523
|
+
* padding/layout and their bounds match the rendered emoji more directly.
|
|
524
|
+
*/
|
|
525
|
+
export class TextSprite extends Sprite {
|
|
526
|
+
get width() {
|
|
527
|
+
this.ensureMeasured();
|
|
528
|
+
return this._measuredWidth;
|
|
529
|
+
}
|
|
530
|
+
get height() {
|
|
531
|
+
this.ensureMeasured();
|
|
532
|
+
return this._measuredHeight;
|
|
533
|
+
}
|
|
534
|
+
constructor(text, x = 0, y = 0, fontSizeOrConfig = 16, config = {}) {
|
|
535
|
+
super();
|
|
536
|
+
this.anchorX = "center";
|
|
537
|
+
this.anchorY = "middle";
|
|
538
|
+
this.fontFamily = '"Press Start 2P", monospace';
|
|
539
|
+
this.fontWeight = "400";
|
|
540
|
+
this.lineHeight = 1.2;
|
|
541
|
+
this.maxWidth = 0;
|
|
542
|
+
this.fixedWidth = 0;
|
|
543
|
+
this.fixedHeight = 0;
|
|
544
|
+
this.paddingX = 2;
|
|
545
|
+
this.paddingY = 2;
|
|
546
|
+
this.backgroundColor = null;
|
|
547
|
+
this.borderColor = null;
|
|
548
|
+
this.borderWidth = 0;
|
|
549
|
+
this.cornerRadius = 0;
|
|
550
|
+
this.strokeColor = null;
|
|
551
|
+
this.strokeWidth = 0;
|
|
552
|
+
this.textAlign = "center";
|
|
553
|
+
/** @internal */
|
|
554
|
+
this._measuredWidth = 1;
|
|
555
|
+
/** @internal */
|
|
556
|
+
this._measuredHeight = 1;
|
|
557
|
+
/** @internal */
|
|
558
|
+
this._measurementCacheKey = "";
|
|
559
|
+
this.text = text;
|
|
560
|
+
this.x = x;
|
|
561
|
+
this.y = y;
|
|
562
|
+
this.fontSize =
|
|
563
|
+
typeof fontSizeOrConfig === "number" ? fontSizeOrConfig : 16;
|
|
564
|
+
this.color = "#ffffff";
|
|
565
|
+
if (typeof fontSizeOrConfig === "number") {
|
|
566
|
+
this.applyConfig(config);
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
this.applyConfig(fontSizeOrConfig);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
getRenderCacheKey() {
|
|
573
|
+
return [
|
|
574
|
+
"text",
|
|
575
|
+
this.text,
|
|
576
|
+
this.anchorX,
|
|
577
|
+
this.anchorY,
|
|
578
|
+
this.fontFamily,
|
|
579
|
+
this.fontSize,
|
|
580
|
+
this.fontWeight,
|
|
581
|
+
this.lineHeight,
|
|
582
|
+
this.maxWidth,
|
|
583
|
+
this.fixedWidth,
|
|
584
|
+
this.fixedHeight,
|
|
585
|
+
this.color,
|
|
586
|
+
this.textAlign,
|
|
587
|
+
this.paddingX,
|
|
588
|
+
this.paddingY,
|
|
589
|
+
this.backgroundColor ?? "",
|
|
590
|
+
this.borderColor ?? "",
|
|
591
|
+
this.borderWidth,
|
|
592
|
+
this.cornerRadius,
|
|
593
|
+
this.strokeColor ?? "",
|
|
594
|
+
this.strokeWidth,
|
|
595
|
+
].join("|");
|
|
596
|
+
}
|
|
597
|
+
get align() {
|
|
598
|
+
return this.textAlign;
|
|
599
|
+
}
|
|
600
|
+
set align(value) {
|
|
601
|
+
this.textAlign = value;
|
|
602
|
+
}
|
|
603
|
+
getAnchorOffsetX() {
|
|
604
|
+
if (this.anchorX === "left") {
|
|
605
|
+
return this.displayWidth / 2;
|
|
606
|
+
}
|
|
607
|
+
if (this.anchorX === "right") {
|
|
608
|
+
return -this.displayWidth / 2;
|
|
609
|
+
}
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
getAnchorOffsetY() {
|
|
613
|
+
if (this.anchorY === "top") {
|
|
614
|
+
return this.displayHeight / 2;
|
|
615
|
+
}
|
|
616
|
+
if (this.anchorY === "bottom") {
|
|
617
|
+
return -this.displayHeight / 2;
|
|
618
|
+
}
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
/** @internal */
|
|
622
|
+
applyConfig(config) {
|
|
623
|
+
if (config.anchorX !== undefined)
|
|
624
|
+
this.anchorX = config.anchorX;
|
|
625
|
+
if (config.anchorY !== undefined)
|
|
626
|
+
this.anchorY = config.anchorY;
|
|
627
|
+
if (config.fontFamily !== undefined)
|
|
628
|
+
this.fontFamily = config.fontFamily;
|
|
629
|
+
if (config.fontSize !== undefined)
|
|
630
|
+
this.fontSize = config.fontSize;
|
|
631
|
+
if (config.fontWeight !== undefined)
|
|
632
|
+
this.fontWeight = config.fontWeight;
|
|
633
|
+
if (config.lineHeight !== undefined)
|
|
634
|
+
this.lineHeight = config.lineHeight;
|
|
635
|
+
if (config.maxWidth !== undefined)
|
|
636
|
+
this.maxWidth = config.maxWidth;
|
|
637
|
+
if (config.fixedWidth !== undefined)
|
|
638
|
+
this.fixedWidth = config.fixedWidth;
|
|
639
|
+
if (config.fixedHeight !== undefined)
|
|
640
|
+
this.fixedHeight = config.fixedHeight;
|
|
641
|
+
if (config.paddingX !== undefined)
|
|
642
|
+
this.paddingX = config.paddingX;
|
|
643
|
+
if (config.paddingY !== undefined)
|
|
644
|
+
this.paddingY = config.paddingY;
|
|
645
|
+
if (config.backgroundColor !== undefined)
|
|
646
|
+
this.backgroundColor = config.backgroundColor;
|
|
647
|
+
if (config.borderColor !== undefined)
|
|
648
|
+
this.borderColor = config.borderColor;
|
|
649
|
+
if (config.borderWidth !== undefined)
|
|
650
|
+
this.borderWidth = config.borderWidth;
|
|
651
|
+
if (config.cornerRadius !== undefined)
|
|
652
|
+
this.cornerRadius = config.cornerRadius;
|
|
653
|
+
if (config.strokeColor !== undefined)
|
|
654
|
+
this.strokeColor = config.strokeColor;
|
|
655
|
+
if (config.strokeWidth !== undefined)
|
|
656
|
+
this.strokeWidth = config.strokeWidth;
|
|
657
|
+
if (config.textAlign !== undefined)
|
|
658
|
+
this.textAlign = config.textAlign;
|
|
659
|
+
if (config.align !== undefined)
|
|
660
|
+
this.textAlign = config.align;
|
|
661
|
+
}
|
|
662
|
+
/** @internal */
|
|
663
|
+
ensureMeasured() {
|
|
664
|
+
const nextKey = [
|
|
665
|
+
this.text,
|
|
666
|
+
this.fontFamily,
|
|
667
|
+
this.fontSize,
|
|
668
|
+
this.fontWeight,
|
|
669
|
+
this.lineHeight,
|
|
670
|
+
this.maxWidth,
|
|
671
|
+
this.fixedWidth,
|
|
672
|
+
this.fixedHeight,
|
|
673
|
+
this.paddingX,
|
|
674
|
+
this.paddingY,
|
|
675
|
+
this.borderWidth,
|
|
676
|
+
this.strokeWidth,
|
|
677
|
+
].join("|");
|
|
678
|
+
if (this._measurementCacheKey === nextKey) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (typeof document === "undefined") {
|
|
682
|
+
this._measuredWidth = 1;
|
|
683
|
+
this._measuredHeight = 1;
|
|
684
|
+
this._measurementCacheKey = nextKey;
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (TextSprite._measurementCanvas === null) {
|
|
688
|
+
TextSprite._measurementCanvas = document.createElement("canvas");
|
|
689
|
+
TextSprite._measurementContext =
|
|
690
|
+
TextSprite._measurementCanvas.getContext("2d");
|
|
691
|
+
}
|
|
692
|
+
const ctx = TextSprite._measurementContext;
|
|
693
|
+
if (!ctx) {
|
|
694
|
+
this._measuredWidth = 1;
|
|
695
|
+
this._measuredHeight = 1;
|
|
696
|
+
this._measurementCacheKey = nextKey;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
ctx.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`;
|
|
700
|
+
const lines = this.layoutMeasuredLines(ctx);
|
|
701
|
+
const measuredWidth = Math.max(1, Math.ceil(lines.reduce((maxWidth, line) => Math.max(maxWidth, ctx.measureText(line).width), 0)));
|
|
702
|
+
const lineHeightPx = Math.max(1, Math.ceil(this.fontSize * this.lineHeight));
|
|
703
|
+
const contentHeight = Math.max(1, lineHeightPx * Math.max(lines.length, 1));
|
|
704
|
+
const borderPad = Math.max(0, this.borderWidth) * 2;
|
|
705
|
+
const strokePad = Math.max(0, this.strokeWidth) * 2;
|
|
706
|
+
this._measuredWidth = Math.max(1, Math.ceil(this.fixedWidth > 0
|
|
707
|
+
? this.fixedWidth
|
|
708
|
+
: measuredWidth + this.paddingX * 2 + borderPad + strokePad));
|
|
709
|
+
this._measuredHeight = Math.max(1, Math.ceil(this.fixedHeight > 0
|
|
710
|
+
? this.fixedHeight
|
|
711
|
+
: contentHeight + this.paddingY * 2 + borderPad + strokePad));
|
|
712
|
+
this._measurementCacheKey = nextKey;
|
|
713
|
+
}
|
|
714
|
+
/** @internal */
|
|
715
|
+
layoutMeasuredLines(ctx) {
|
|
716
|
+
const rawLines = this.text.split("\n");
|
|
717
|
+
const availableTextWidth = this.getAvailableTextWidth();
|
|
718
|
+
if (availableTextWidth <= 0) {
|
|
719
|
+
return rawLines.length > 0 ? rawLines : [""];
|
|
720
|
+
}
|
|
721
|
+
const wrappedLines = [];
|
|
722
|
+
for (const rawLine of rawLines) {
|
|
723
|
+
const words = rawLine.split(/\s+/).filter((word) => word.length > 0);
|
|
724
|
+
if (words.length === 0) {
|
|
725
|
+
wrappedLines.push("");
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
let currentLine = words[0];
|
|
729
|
+
for (let i = 1; i < words.length; i++) {
|
|
730
|
+
const candidate = `${currentLine} ${words[i]}`;
|
|
731
|
+
if (ctx.measureText(candidate).width <= availableTextWidth) {
|
|
732
|
+
currentLine = candidate;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
wrappedLines.push(currentLine);
|
|
736
|
+
currentLine = words[i];
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
wrappedLines.push(currentLine);
|
|
740
|
+
}
|
|
741
|
+
return wrappedLines.length > 0 ? wrappedLines : [""];
|
|
742
|
+
}
|
|
743
|
+
/** @internal */
|
|
744
|
+
getAvailableTextWidth() {
|
|
745
|
+
const borderPad = Math.max(0, this.borderWidth) * 2;
|
|
746
|
+
const strokePad = Math.max(0, this.strokeWidth) * 2;
|
|
747
|
+
const limits = [];
|
|
748
|
+
if (this.maxWidth > 0) {
|
|
749
|
+
limits.push(this.maxWidth - this.paddingX * 2 - borderPad - strokePad);
|
|
750
|
+
}
|
|
751
|
+
if (this.fixedWidth > 0) {
|
|
752
|
+
limits.push(this.fixedWidth - this.paddingX * 2 - borderPad - strokePad);
|
|
753
|
+
}
|
|
754
|
+
if (limits.length === 0) {
|
|
755
|
+
return 0;
|
|
756
|
+
}
|
|
757
|
+
return Math.max(1, Math.floor(Math.min(...limits)));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/** @internal */
|
|
761
|
+
TextSprite._measurementCanvas = null;
|
|
762
|
+
/** @internal */
|
|
763
|
+
TextSprite._measurementContext = null;
|
|
764
|
+
/**
|
|
765
|
+
* A static image-based background layer rendered behind all sprites.
|
|
766
|
+
*
|
|
767
|
+
* Background layers are visual-only: they do not participate in physics,
|
|
768
|
+
* collisions, input hit-testing, or sprite queries.
|
|
769
|
+
*
|
|
770
|
+
* `x` / `y` use **top-left screen-space coordinates**, unlike {@link Sprite},
|
|
771
|
+
* which uses center-based world space.
|
|
772
|
+
*
|
|
773
|
+
* The texture referenced by {@link BackgroundLayer.imageKey} must be registered
|
|
774
|
+
* with {@link Game.loadImage} during {@link Game.onPreload}, or created
|
|
775
|
+
* dynamically with {@link Game.createTexture}.
|
|
776
|
+
*
|
|
777
|
+
* @example
|
|
778
|
+
* ```ts
|
|
779
|
+
* game.onPreload = () => {
|
|
780
|
+
* game.loadImage("sky", "assets/sky.png");
|
|
781
|
+
* };
|
|
782
|
+
*
|
|
783
|
+
* game.onCreate = () => {
|
|
784
|
+
* const bg = game.addBackground(new BackgroundLayer("sky"));
|
|
785
|
+
* bg.fit = "cover";
|
|
786
|
+
* };
|
|
787
|
+
* ```
|
|
788
|
+
*/
|
|
789
|
+
export class BackgroundLayer {
|
|
790
|
+
/**
|
|
791
|
+
* Creates a new background layer.
|
|
792
|
+
*
|
|
793
|
+
* @param imageKey - Preloaded image key previously registered with {@link Game.loadImage}.
|
|
794
|
+
* @param x - Destination rectangle left edge in screen space. Default: `0`.
|
|
795
|
+
* @param y - Destination rectangle top edge in screen space. Default: `0`.
|
|
796
|
+
* @param width - Destination rectangle width. `0` means canvas width. Default: `0`.
|
|
797
|
+
* @param height - Destination rectangle height. `0` means canvas height. Default: `0`.
|
|
798
|
+
*/
|
|
799
|
+
constructor(imageKey, x = 0, y = 0, width = 0, height = 0) {
|
|
800
|
+
/**
|
|
801
|
+
* Opacity of the layer in range `[0, 1]`.
|
|
802
|
+
*/
|
|
803
|
+
this.alpha = 1;
|
|
804
|
+
/**
|
|
805
|
+
* Controls whether the layer is drawn.
|
|
806
|
+
*/
|
|
807
|
+
this.visible = true;
|
|
808
|
+
/**
|
|
809
|
+
* Orders background layers relative to each other.
|
|
810
|
+
* Higher values render on top of lower background layers, but still behind sprites.
|
|
811
|
+
*/
|
|
812
|
+
this.layer = 0;
|
|
813
|
+
/**
|
|
814
|
+
* How the image is fit inside the destination rectangle.
|
|
815
|
+
* Default: `"cover"`.
|
|
816
|
+
*/
|
|
817
|
+
this.fit = "cover";
|
|
818
|
+
this.imageKey = imageKey;
|
|
819
|
+
this.x = x;
|
|
820
|
+
this.y = y;
|
|
821
|
+
this.width = width;
|
|
822
|
+
this.height = height;
|
|
157
823
|
}
|
|
158
824
|
}
|
|
159
825
|
// ---------------------------------------------------------------------------
|
|
826
|
+
// Internal types (not part of the public API declarations)
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
160
829
|
// Game
|
|
161
830
|
// ---------------------------------------------------------------------------
|
|
162
831
|
/**
|
|
@@ -168,18 +837,88 @@ export class Sprite {
|
|
|
168
837
|
*
|
|
169
838
|
* ---
|
|
170
839
|
*
|
|
840
|
+
* ## ES Module Only
|
|
841
|
+
*
|
|
842
|
+
* MinimoJS is an **ES module only** package.
|
|
843
|
+
* You must import it with standard ESM syntax such as:
|
|
844
|
+
*
|
|
845
|
+
* ```ts
|
|
846
|
+
* import { Game, EmojiSprite } from "minimojs";
|
|
847
|
+
* ```
|
|
848
|
+
*
|
|
849
|
+
* In the browser, use it from a module script:
|
|
850
|
+
*
|
|
851
|
+
* ```html
|
|
852
|
+
* <script type="module">
|
|
853
|
+
* import { Game, EmojiSprite } from "./dist/minimo.js";
|
|
854
|
+
* </script>
|
|
855
|
+
* ```
|
|
856
|
+
*
|
|
857
|
+
* Or from a CDN:
|
|
858
|
+
*
|
|
859
|
+
* ```ts
|
|
860
|
+
* import { Game, EmojiSprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
|
|
861
|
+
* ```
|
|
862
|
+
*
|
|
863
|
+
* Do NOT use a classic `<script>` tag without `type="module"`.
|
|
864
|
+
* Do NOT expect a global `window.MinimoJS`.
|
|
865
|
+
* Do NOT use `require("minimojs")` (CommonJS is not supported).
|
|
866
|
+
*
|
|
867
|
+
* ---
|
|
868
|
+
*
|
|
869
|
+
* ## Required Font Setup
|
|
870
|
+
*
|
|
871
|
+
* `drawText()` and `TextSprite` default to `"Press Start 2P", monospace`.
|
|
872
|
+
* MinimoJS automatically waits for that default family before the first frame.
|
|
873
|
+
* If you use additional families, register them with {@link Game.requireFont}
|
|
874
|
+
* before calling {@link Game.start}.
|
|
875
|
+
*
|
|
876
|
+
* Prefer {@link TextSprite} for persistent UI text, interactive labels,
|
|
877
|
+
* buttons, fixed-size text boxes, and styled text elements. Keep
|
|
878
|
+
* `drawText()` for simple screen-space overlays such as HUD counters, debug
|
|
879
|
+
* text, or other text that is redrawn every frame.
|
|
880
|
+
*
|
|
881
|
+
* You MUST still declare the fonts yourself in your `index.html`. MinimoJS
|
|
882
|
+
* does NOT download, inject, or manage web fonts for you. If a font is missing,
|
|
883
|
+
* the browser falls back to `monospace`.
|
|
884
|
+
*
|
|
885
|
+
* Example `index.html` `<head>` setup:
|
|
886
|
+
*
|
|
887
|
+
* ```html
|
|
888
|
+
* <link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
889
|
+
* <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
890
|
+
* <link
|
|
891
|
+
* href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
|
|
892
|
+
* rel="stylesheet"
|
|
893
|
+
* />
|
|
894
|
+
* ```
|
|
895
|
+
*
|
|
896
|
+
* Example custom font registration:
|
|
897
|
+
*
|
|
898
|
+
* ```ts
|
|
899
|
+
* const game = new Game(720, 1280);
|
|
900
|
+
* game.requireFont('"Bangers"', { weight: "400" });
|
|
901
|
+
* game.start();
|
|
902
|
+
* ```
|
|
903
|
+
*
|
|
904
|
+
* ---
|
|
905
|
+
*
|
|
171
906
|
* ## Quick Start
|
|
172
907
|
*
|
|
173
908
|
* ```ts
|
|
174
|
-
* import { Game,
|
|
909
|
+
* import { Game, ImageSprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
|
|
175
910
|
*
|
|
176
|
-
* const game = new Game(
|
|
911
|
+
* const game = new Game(720, 1280);
|
|
177
912
|
*
|
|
178
|
-
*
|
|
179
|
-
* player.
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
913
|
+
* game.onPreload = () => {
|
|
914
|
+
* game.loadImage("player", "assets/player.png");
|
|
915
|
+
* };
|
|
916
|
+
*
|
|
917
|
+
* let player: ImageSprite;
|
|
918
|
+
*
|
|
919
|
+
* game.onCreate = () => {
|
|
920
|
+
* player = game.add(new ImageSprite(game, "player", 400, 500));
|
|
921
|
+
* };
|
|
183
922
|
*
|
|
184
923
|
* game.onUpdate = (dt) => {
|
|
185
924
|
* if (game.isKeyDown("ArrowLeft")) player.vx = -200;
|
|
@@ -192,6 +931,13 @@ export class Sprite {
|
|
|
192
931
|
* game.start();
|
|
193
932
|
* ```
|
|
194
933
|
*
|
|
934
|
+
* Prototyping, and have no art yet? Swap the texture for an emoji and drop the
|
|
935
|
+
* preload entirely — everything else stays the same:
|
|
936
|
+
*
|
|
937
|
+
* ```ts
|
|
938
|
+
* const player = game.add(new EmojiSprite("🐢", 400, 500, 48));
|
|
939
|
+
* ```
|
|
940
|
+
*
|
|
195
941
|
* ---
|
|
196
942
|
*
|
|
197
943
|
* ## Engine Philosophy
|
|
@@ -205,7 +951,8 @@ export class Sprite {
|
|
|
205
951
|
* ## Flat API
|
|
206
952
|
*
|
|
207
953
|
* ALL engine functionality is on the `game` object.
|
|
208
|
-
* Do not look for
|
|
954
|
+
* Do not look for nested subsystem objects like `game.input.keyboard`,
|
|
955
|
+
* `game.physics.world`, etc. They do not exist in MinimoJS v1.
|
|
209
956
|
*
|
|
210
957
|
* ---
|
|
211
958
|
*
|
|
@@ -216,13 +963,28 @@ export class Sprite {
|
|
|
216
963
|
*
|
|
217
964
|
* ---
|
|
218
965
|
*
|
|
219
|
-
* ##
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
966
|
+
* ## Choosing a Visual Source
|
|
967
|
+
*
|
|
968
|
+
* A sprite's look comes from one of four concrete types, all of which extend the
|
|
969
|
+
* shared abstract {@link Sprite} type. Use `Sprite` as the type in your own
|
|
970
|
+
* signatures; never construct it directly.
|
|
971
|
+
*
|
|
972
|
+
* 1. {@link ImageSprite} — the game's own images, and the first option to reach
|
|
973
|
+
* for. Register a texture with {@link Game.loadImage} inside
|
|
974
|
+
* {@link Game.onPreload}, or build one at runtime with
|
|
975
|
+
* {@link Game.createTexture}, then render it with
|
|
976
|
+
* `new ImageSprite(game, key, x, y)`. An image looks identical on every
|
|
977
|
+
* device and belongs to the game.
|
|
978
|
+
* 2. {@link EmojiSprite} — a single Unicode emoji, and the shortest path to a
|
|
979
|
+
* first prototype: `new EmojiSprite("🐢", x, y, size)`. Note that an
|
|
980
|
+
* emoji is drawn with the player's own system emoji font, so the same game
|
|
981
|
+
* looks different across iOS, Android, and Windows, and a glyph the platform
|
|
982
|
+
* lacks renders as an empty box. Prefer an image whenever the look matters.
|
|
983
|
+
* 3. {@link TextSprite} — text, labels, and buttons.
|
|
984
|
+
* 4. {@link DrawSprite} — procedural Canvas 2D drawing, for shapes and gauges
|
|
985
|
+
* the other three cannot express cleanly.
|
|
986
|
+
*
|
|
987
|
+
* Emoji and images mix freely in one game.
|
|
226
988
|
*
|
|
227
989
|
* ---
|
|
228
990
|
*
|
|
@@ -238,7 +1000,7 @@ export class Sprite {
|
|
|
238
1000
|
*
|
|
239
1001
|
* ALL time parameters to engine methods are in **milliseconds (ms)**.
|
|
240
1002
|
* This includes: {@link Game.addTimer}, {@link Game.animateAlpha},
|
|
241
|
-
* {@link Game.animateRotation}, and {@link Game.sound}.
|
|
1003
|
+
* {@link Game.animateRotation}, {@link Game.animateDeform}, and {@link Game.sound}.
|
|
242
1004
|
*
|
|
243
1005
|
* The `dt` parameter in {@link Game.onUpdate} is an exception — it is in
|
|
244
1006
|
* **seconds** for convenient velocity math (`position += velocity * dt`).
|
|
@@ -290,10 +1052,10 @@ export class Sprite {
|
|
|
290
1052
|
*
|
|
291
1053
|
* ---
|
|
292
1054
|
*
|
|
293
|
-
* ## Scene Initialization with `
|
|
1055
|
+
* ## Scene Initialization with `IScene`
|
|
294
1056
|
*
|
|
295
|
-
* MinimoJS
|
|
296
|
-
*
|
|
1057
|
+
* MinimoJS supports simple scene objects via {@link IScene}. The engine calls
|
|
1058
|
+
* a scene's `onCreate()`:
|
|
297
1059
|
* - Once before the first frame (when {@link Game.start} is called).
|
|
298
1060
|
* - Again after each {@link Game.reset}.
|
|
299
1061
|
*
|
|
@@ -305,29 +1067,33 @@ export class Sprite {
|
|
|
305
1067
|
* - Scroll position (scrollX and scrollY reset to 0)
|
|
306
1068
|
* - Per-frame input state (pressed keys/pointer)
|
|
307
1069
|
*
|
|
308
|
-
* After clearing, `reset()` calls `onCreate()` so
|
|
309
|
-
*
|
|
1070
|
+
* After clearing, `reset()` calls the active scene's `onCreate()` so it can
|
|
1071
|
+
* rebuild immediately. Simply re-add sprites and re-register timers.
|
|
310
1072
|
*
|
|
311
1073
|
* ```ts
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* };
|
|
1074
|
+
* class SkullScene implements IScene {
|
|
1075
|
+
* onCreate() {
|
|
1076
|
+
* const skull = new EmojiSprite("💀", 400, 300, 96);
|
|
1077
|
+
* game.add(skull);
|
|
1078
|
+
* }
|
|
318
1079
|
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
1080
|
+
* onUpdate(dt: number) {
|
|
1081
|
+
* // normal per-frame update
|
|
1082
|
+
* }
|
|
1083
|
+
* }
|
|
322
1084
|
*
|
|
323
|
-
*
|
|
1085
|
+
* const scene = new SkullScene();
|
|
1086
|
+
* game.start(scene); // calls onCreate() once before first frame
|
|
324
1087
|
* // later:
|
|
325
1088
|
* game.reset(); // clears + calls onCreate() again
|
|
326
1089
|
* ```
|
|
327
1090
|
*
|
|
1091
|
+
* Legacy `game.onCreate` / `game.onUpdate` callbacks still work when no active
|
|
1092
|
+
* {@link IScene} is set.
|
|
1093
|
+
*
|
|
328
1094
|
* ---
|
|
329
1095
|
*
|
|
330
|
-
* ##
|
|
1096
|
+
* ## EmojiSprite Lifecycle Ownership
|
|
331
1097
|
*
|
|
332
1098
|
* The `Game` instance owns all sprites.
|
|
333
1099
|
* - Create sprites with {@link Game.add}.
|
|
@@ -341,7 +1107,7 @@ export class Sprite {
|
|
|
341
1107
|
* ## Forbidden Features
|
|
342
1108
|
*
|
|
343
1109
|
* The following do NOT exist in MinimoJS v1. Do NOT attempt to use them:
|
|
344
|
-
* -
|
|
1110
|
+
* - Full scene manager architecture (scene stacks, transitions, loaders, etc.)
|
|
345
1111
|
* - Entity Component System (ECS)
|
|
346
1112
|
* - Physics engine (no Box2D, Matter.js, etc.)
|
|
347
1113
|
* - Camera zoom or scale
|
|
@@ -349,11 +1115,101 @@ export class Sprite {
|
|
|
349
1115
|
* - Text input / HTML form elements
|
|
350
1116
|
* - Parallax layers
|
|
351
1117
|
* - Multiple cameras
|
|
352
|
-
* -
|
|
353
|
-
* - Image sprites (PNG, SVG, canvas, etc.)
|
|
1118
|
+
* - Full physics engine (only basic explicit AABB collision helpers are provided)
|
|
354
1119
|
* - `setTimeout` or `setInterval`
|
|
355
1120
|
*/
|
|
356
1121
|
export class Game {
|
|
1122
|
+
// -------------------------------------------------------------------------
|
|
1123
|
+
// Public state
|
|
1124
|
+
// -------------------------------------------------------------------------
|
|
1125
|
+
/**
|
|
1126
|
+
* Horizontal gravity acceleration in pixels per second².
|
|
1127
|
+
* Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
|
|
1128
|
+
* Positive = accelerates right. Typical value for sideways wind: `200`.
|
|
1129
|
+
* Default: `0`.
|
|
1130
|
+
*
|
|
1131
|
+
* @example
|
|
1132
|
+
* ```ts
|
|
1133
|
+
* game.gravityX = 0; // no horizontal gravity
|
|
1134
|
+
* ```
|
|
1135
|
+
*/
|
|
1136
|
+
get gravityX() {
|
|
1137
|
+
return this._physicsSystem.gravityX;
|
|
1138
|
+
}
|
|
1139
|
+
set gravityX(value) {
|
|
1140
|
+
this._physicsSystem.gravityX = value;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Vertical gravity acceleration in pixels per second².
|
|
1144
|
+
* Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
|
|
1145
|
+
* Positive = accelerates downward (canvas Y-axis points down).
|
|
1146
|
+
* Typical value for platformers: `980` (approximately Earth gravity in px/s²).
|
|
1147
|
+
* Default: `0`.
|
|
1148
|
+
*
|
|
1149
|
+
* @example
|
|
1150
|
+
* ```ts
|
|
1151
|
+
* game.gravityY = 980; // standard downward gravity
|
|
1152
|
+
* ```
|
|
1153
|
+
*/
|
|
1154
|
+
get gravityY() {
|
|
1155
|
+
return this._physicsSystem.gravityY;
|
|
1156
|
+
}
|
|
1157
|
+
set gravityY(value) {
|
|
1158
|
+
this._physicsSystem.gravityY = value;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Enables the basic collision-resolution helpers (`collide` / `collideAny`).
|
|
1162
|
+
*
|
|
1163
|
+
* When `false` (default), overlap detection still works, but collision
|
|
1164
|
+
* resolution helpers are unavailable.
|
|
1165
|
+
*
|
|
1166
|
+
* Set this to `true` for simple platformer-style collision handling.
|
|
1167
|
+
*
|
|
1168
|
+
* @example
|
|
1169
|
+
* ```ts
|
|
1170
|
+
* game.physics = true;
|
|
1171
|
+
* ```
|
|
1172
|
+
*/
|
|
1173
|
+
get physics() {
|
|
1174
|
+
return this._physicsSystem.enabled;
|
|
1175
|
+
}
|
|
1176
|
+
set physics(value) {
|
|
1177
|
+
this._physicsSystem.enabled = value;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Legacy scene creation callback.
|
|
1181
|
+
*
|
|
1182
|
+
* Called once before the first frame on {@link Game.start}, and again after
|
|
1183
|
+
* each {@link Game.reset} when no active {@link IScene} is set.
|
|
1184
|
+
*
|
|
1185
|
+
* @example
|
|
1186
|
+
* ```ts
|
|
1187
|
+
* game.onCreate = () => {
|
|
1188
|
+
* const player = new EmojiSprite("🐢");
|
|
1189
|
+
* player.x = 200;
|
|
1190
|
+
* player.y = 300;
|
|
1191
|
+
* game.add(player);
|
|
1192
|
+
* };
|
|
1193
|
+
* ```
|
|
1194
|
+
*/
|
|
1195
|
+
get onCreate() {
|
|
1196
|
+
return this._onCreate;
|
|
1197
|
+
}
|
|
1198
|
+
set onCreate(callback) {
|
|
1199
|
+
this._onCreate = callback;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Currently active scene object, if any.
|
|
1203
|
+
*/
|
|
1204
|
+
get currentScene() {
|
|
1205
|
+
return this._currentScene;
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Returns `true` while a full-screen scene transition is playing.
|
|
1209
|
+
*/
|
|
1210
|
+
get isTransitioning() {
|
|
1211
|
+
return this._transitionSystem.isActive;
|
|
1212
|
+
}
|
|
357
1213
|
// -------------------------------------------------------------------------
|
|
358
1214
|
// Constructor
|
|
359
1215
|
// -------------------------------------------------------------------------
|
|
@@ -363,71 +1219,27 @@ export class Game {
|
|
|
363
1219
|
* The engine creates its own `<canvas>`, sets its dimensions, and appends it
|
|
364
1220
|
* to `document.body`. The canvas is automatically centered and responsively
|
|
365
1221
|
* scaled to use the maximum available viewport space while preserving aspect ratio.
|
|
1222
|
+
* For new mobile-first Minimo Games, prefer a portrait canvas such as `720x1280`.
|
|
366
1223
|
*
|
|
367
|
-
* @param width - Canvas width in pixels. Default: `
|
|
368
|
-
* @param height - Canvas height in pixels. Default: `
|
|
1224
|
+
* @param width - Canvas width in pixels. Default: `720`.
|
|
1225
|
+
* @param height - Canvas height in pixels. Default: `1280`.
|
|
369
1226
|
*
|
|
370
1227
|
* @throws Error if a 2D context cannot be obtained.
|
|
371
1228
|
*
|
|
372
1229
|
* @example
|
|
373
1230
|
* ```ts
|
|
374
|
-
* const game = new Game(
|
|
1231
|
+
* const game = new Game(720, 1280);
|
|
1232
|
+
* game.physics = true;
|
|
375
1233
|
* ```
|
|
376
1234
|
*/
|
|
377
|
-
constructor(width =
|
|
378
|
-
/** @internal */ this.
|
|
379
|
-
/** @internal */ this.
|
|
380
|
-
/** @internal */ this.
|
|
381
|
-
/** @internal */ this.
|
|
382
|
-
/** @internal */ this.
|
|
383
|
-
/** @internal */ this.
|
|
384
|
-
/** @internal */ this.
|
|
385
|
-
/** @internal */ this._pointerDown = false;
|
|
386
|
-
/** @internal */ this._pointerPressed = false;
|
|
387
|
-
/** @internal */ this._pointerX = 0;
|
|
388
|
-
/** @internal */ this._pointerY = 0;
|
|
389
|
-
/** @internal */ this._mouseDown = false;
|
|
390
|
-
/** @internal */ this._mousePressed = false;
|
|
391
|
-
/** @internal */ this._mouseX = 0;
|
|
392
|
-
/** @internal */ this._mouseY = 0;
|
|
393
|
-
/** @internal */ this._touchPointers = new Map();
|
|
394
|
-
/** @internal */ this._primaryTouchId = null;
|
|
395
|
-
/** @internal */ this._rafId = null;
|
|
396
|
-
/** @internal */ this._lastTimestamp = null;
|
|
397
|
-
/** @internal */ this._running = false;
|
|
398
|
-
/** @internal */ this._hasCreated = false;
|
|
399
|
-
/** @internal */ this._onResize = () => this._applyResponsiveCanvasLayout();
|
|
400
|
-
/** @internal */ this._audioCtx = null;
|
|
401
|
-
/** @internal */ this._spriteGlyphCache = new Map();
|
|
402
|
-
/** @internal */ this._lastAppliedPageBackground = undefined;
|
|
403
|
-
// -------------------------------------------------------------------------
|
|
404
|
-
// Public state
|
|
405
|
-
// -------------------------------------------------------------------------
|
|
406
|
-
/**
|
|
407
|
-
* Horizontal gravity acceleration in pixels per second².
|
|
408
|
-
* Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
|
|
409
|
-
* Positive = accelerates right. Typical value for sideways wind: `200`.
|
|
410
|
-
* Default: `0`.
|
|
411
|
-
*
|
|
412
|
-
* @example
|
|
413
|
-
* ```ts
|
|
414
|
-
* game.gravityX = 0; // no horizontal gravity
|
|
415
|
-
* ```
|
|
416
|
-
*/
|
|
417
|
-
this.gravityX = 0;
|
|
418
|
-
/**
|
|
419
|
-
* Vertical gravity acceleration in pixels per second².
|
|
420
|
-
* Applied to every sprite whose {@link Sprite.gravityScale} is non-zero.
|
|
421
|
-
* Positive = accelerates downward (canvas Y-axis points down).
|
|
422
|
-
* Typical value for platformers: `980` (approximately Earth gravity in px/s²).
|
|
423
|
-
* Default: `0`.
|
|
424
|
-
*
|
|
425
|
-
* @example
|
|
426
|
-
* ```ts
|
|
427
|
-
* game.gravityY = 980; // standard downward gravity
|
|
428
|
-
* ```
|
|
429
|
-
*/
|
|
430
|
-
this.gravityY = 0;
|
|
1235
|
+
constructor(width = 720, height = 1280) {
|
|
1236
|
+
/** @internal */ this._requiredFonts = new Map();
|
|
1237
|
+
/** @internal */ this._hasAppliedGlobalFontRequirements = false;
|
|
1238
|
+
/** @internal */ this._isRegisteringPreloadAssets = false;
|
|
1239
|
+
/** @internal */ this._hasCompletedPreload = false;
|
|
1240
|
+
/** @internal */ this._preloadPromise = null;
|
|
1241
|
+
/** @internal */ this._onCreate = null;
|
|
1242
|
+
/** @internal */ this._currentScene = null;
|
|
431
1243
|
/**
|
|
432
1244
|
* Horizontal scroll offset of the world camera, in pixels.
|
|
433
1245
|
* The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
|
|
@@ -472,6 +1284,20 @@ export class Game {
|
|
|
472
1284
|
* ```
|
|
473
1285
|
*/
|
|
474
1286
|
this.backgroundGradient = null;
|
|
1287
|
+
/**
|
|
1288
|
+
* When `true`, MinimoJS draws every sprite's collision body as an overlay.
|
|
1289
|
+
*
|
|
1290
|
+
* This is a runtime debugging aid only. It does not change collisions,
|
|
1291
|
+
* input, rendering order, or physics behavior.
|
|
1292
|
+
*/
|
|
1293
|
+
this.debugBodies = false;
|
|
1294
|
+
/**
|
|
1295
|
+
* When `true`, MinimoJS draws every sprite's input area as an overlay.
|
|
1296
|
+
*
|
|
1297
|
+
* This is a runtime debugging aid only. It does not change input behavior,
|
|
1298
|
+
* physics, or rendering.
|
|
1299
|
+
*/
|
|
1300
|
+
this.debugInputAreas = false;
|
|
475
1301
|
/**
|
|
476
1302
|
* Background color for the full web page (`document.body`).
|
|
477
1303
|
* Set to any valid CSS color string. Default: `null` (engine leaves page background unchanged).
|
|
@@ -485,24 +1311,27 @@ export class Game {
|
|
|
485
1311
|
*/
|
|
486
1312
|
this.pageBackground = null;
|
|
487
1313
|
/**
|
|
488
|
-
*
|
|
1314
|
+
* Asset registration callback invoked once before the first scene is created.
|
|
489
1315
|
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
1316
|
+
* Use this to register images with {@link Game.loadImage}. The callback is
|
|
1317
|
+
* synchronous: it should only queue assets, not await network work.
|
|
1318
|
+
*
|
|
1319
|
+
* After `onPreload` returns, MinimoJS loads the queued images automatically
|
|
1320
|
+
* and shows a default loading screen until they are ready.
|
|
492
1321
|
*
|
|
493
1322
|
* @example
|
|
494
1323
|
* ```ts
|
|
495
|
-
* game.
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* player.y = 300;
|
|
499
|
-
* game.add(player);
|
|
1324
|
+
* game.onPreload = () => {
|
|
1325
|
+
* game.loadImage("sky", "assets/sky.png");
|
|
1326
|
+
* game.loadImage("land", "assets/land.png");
|
|
500
1327
|
* };
|
|
501
1328
|
* ```
|
|
502
1329
|
*/
|
|
503
|
-
this.
|
|
1330
|
+
this.onPreload = null;
|
|
504
1331
|
/**
|
|
505
|
-
*
|
|
1332
|
+
* Legacy per-frame callback invoked after physics and timer updates.
|
|
1333
|
+
*
|
|
1334
|
+
* This is used only when no active {@link IScene} is set.
|
|
506
1335
|
*
|
|
507
1336
|
* @param dt - Delta time in **seconds** since the last frame.
|
|
508
1337
|
* Use this for velocity-based movement: `sprite.x += speed * dt`.
|
|
@@ -524,25 +1353,33 @@ export class Game {
|
|
|
524
1353
|
this._canvas.style.touchAction = "none";
|
|
525
1354
|
this._canvas.style.width = `${width}px`;
|
|
526
1355
|
this._canvas.style.height = `${height}px`;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
document.body.appendChild(this._canvas);
|
|
530
|
-
}
|
|
531
|
-
this._applyResponsiveCanvasLayout();
|
|
532
|
-
};
|
|
533
|
-
if (document.body) {
|
|
534
|
-
mountCanvas();
|
|
535
|
-
}
|
|
536
|
-
else {
|
|
537
|
-
window.addEventListener("DOMContentLoaded", mountCanvas, { once: true });
|
|
538
|
-
}
|
|
539
|
-
window.addEventListener("resize", this._onResize);
|
|
1356
|
+
this._canvasSystem = new CanvasSystem(this._canvas);
|
|
1357
|
+
this._canvasSystem.initialize();
|
|
540
1358
|
const ctx = this._canvas.getContext("2d");
|
|
541
1359
|
if (!ctx) {
|
|
542
1360
|
throw new Error("MinimoJS: Could not acquire a 2D rendering context.");
|
|
543
1361
|
}
|
|
544
1362
|
this._ctx = ctx;
|
|
545
|
-
this.
|
|
1363
|
+
this._timerSystem = new TimerSystem();
|
|
1364
|
+
this._animationSystem = new AnimationSystem();
|
|
1365
|
+
this._physicsSystem = new PhysicsSystem();
|
|
1366
|
+
this._spriteSystem = new SpriteSystem(this._animationSystem, this);
|
|
1367
|
+
this._backgroundSystem = new BackgroundSystem();
|
|
1368
|
+
this._assetSystem = new AssetSystem();
|
|
1369
|
+
this._inputSystem = new InputSystem(this, this._canvas);
|
|
1370
|
+
this._soundSystem = new SoundSystem();
|
|
1371
|
+
this._textSystem = new TextSystem();
|
|
1372
|
+
this._renderSystem = new RenderSystem();
|
|
1373
|
+
this._explosionSystem = new ExplosionSystem();
|
|
1374
|
+
this._trailSystem = new TrailSystem();
|
|
1375
|
+
this._transitionSystem = new TransitionSystem();
|
|
1376
|
+
this._loopSystem = new LoopSystem(this._onLoopFrameCallback.bind(this));
|
|
1377
|
+
this._loopSystem.onCreate = this._invokeCreate.bind(this);
|
|
1378
|
+
this._inputSystem.bindInputEvents();
|
|
1379
|
+
this.requireFont('"Press Start 2P"', {
|
|
1380
|
+
sampleText: "Loading... SCORE LIVES GAME OVER YOU WIN",
|
|
1381
|
+
});
|
|
1382
|
+
this.applyGlobalFontRequirements();
|
|
546
1383
|
}
|
|
547
1384
|
// -------------------------------------------------------------------------
|
|
548
1385
|
// Canvas dimensions (read-only)
|
|
@@ -566,7 +1403,7 @@ export class Game {
|
|
|
566
1403
|
* To convert to world space: `worldX = game.pointerX + game.scrollX`.
|
|
567
1404
|
*/
|
|
568
1405
|
get pointerX() {
|
|
569
|
-
return this.
|
|
1406
|
+
return this._inputSystem.pointerX;
|
|
570
1407
|
}
|
|
571
1408
|
/**
|
|
572
1409
|
* Current pointer (mouse or touch) Y position in **canvas/screen space**,
|
|
@@ -575,21 +1412,23 @@ export class Game {
|
|
|
575
1412
|
* To convert to world space: `worldY = game.pointerY + game.scrollY`.
|
|
576
1413
|
*/
|
|
577
1414
|
get pointerY() {
|
|
578
|
-
return this.
|
|
1415
|
+
return this._inputSystem.pointerY;
|
|
579
1416
|
}
|
|
580
1417
|
// -------------------------------------------------------------------------
|
|
581
|
-
//
|
|
1418
|
+
// EmojiSprite management
|
|
582
1419
|
// -------------------------------------------------------------------------
|
|
583
1420
|
/**
|
|
584
1421
|
* Registers a {@link Sprite} (or subclass instance) with the engine.
|
|
585
|
-
*
|
|
586
|
-
*
|
|
1422
|
+
*
|
|
1423
|
+
* After calling `add`, the sprite is rendered and, if dynamic
|
|
1424
|
+
* (`isStatic = false`), receives built-in velocity/gravity integration every
|
|
1425
|
+
* frame until {@link Game.destroySprite} or {@link Game.reset} is called.
|
|
587
1426
|
*
|
|
588
1427
|
* **Ownership:** The game instance takes ownership of the sprite from this
|
|
589
1428
|
* point forward. It will appear in {@link Game.getSprites} on the same frame.
|
|
590
1429
|
*
|
|
591
1430
|
* **Subclasses:** Any class that extends {@link Sprite} can be passed here.
|
|
592
|
-
* The engine stores and processes it as a
|
|
1431
|
+
* The engine stores and processes it as a live sprite; your custom properties
|
|
593
1432
|
* are preserved on the instance.
|
|
594
1433
|
*
|
|
595
1434
|
* @param sprite - A {@link Sprite} instance (or subclass) to add.
|
|
@@ -598,24 +1437,39 @@ export class Game {
|
|
|
598
1437
|
* @example
|
|
599
1438
|
* ```ts
|
|
600
1439
|
* // Plain sprite
|
|
601
|
-
* const coin = new
|
|
602
|
-
* coin.x = 300; coin.y = 200; coin.size = 32;
|
|
1440
|
+
* const coin = new EmojiSprite("🪙", 300, 200, 32);
|
|
603
1441
|
* game.add(coin);
|
|
604
1442
|
*
|
|
605
1443
|
* // Custom subclass
|
|
606
|
-
* class Enemy extends
|
|
1444
|
+
* class Enemy extends EmojiSprite {
|
|
607
1445
|
* speed = 150;
|
|
608
1446
|
* constructor(x: number, y: number) {
|
|
609
|
-
* super("👾");
|
|
610
|
-
*
|
|
1447
|
+
* super("👾", x, y, 40);
|
|
1448
|
+
* }
|
|
611
1449
|
* }
|
|
612
|
-
* }
|
|
613
1450
|
* const enemy = game.add(new Enemy(600, 100));
|
|
614
1451
|
* ```
|
|
615
1452
|
*/
|
|
616
1453
|
add(sprite) {
|
|
617
|
-
this.
|
|
618
|
-
|
|
1454
|
+
return this._spriteSystem.add(sprite);
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Registers a {@link BackgroundLayer} with the engine.
|
|
1458
|
+
*
|
|
1459
|
+
* Background layers are rendered behind all sprites and are not affected by
|
|
1460
|
+
* physics, collisions, or sprite queries.
|
|
1461
|
+
*
|
|
1462
|
+
* @param layer - The background layer to add.
|
|
1463
|
+
* @returns The same background layer instance.
|
|
1464
|
+
*
|
|
1465
|
+
* @example
|
|
1466
|
+
* ```ts
|
|
1467
|
+
* const sky = game.addBackground(new BackgroundLayer("sky"));
|
|
1468
|
+
* sky.fit = "cover";
|
|
1469
|
+
* ```
|
|
1470
|
+
*/
|
|
1471
|
+
addBackground(layer) {
|
|
1472
|
+
return this._backgroundSystem.add(layer);
|
|
619
1473
|
}
|
|
620
1474
|
/**
|
|
621
1475
|
* Removes a sprite from the engine, stopping its rendering and physics updates.
|
|
@@ -635,10 +1489,18 @@ export class Game {
|
|
|
635
1489
|
* ```
|
|
636
1490
|
*/
|
|
637
1491
|
destroySprite(sprite) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1492
|
+
this._trailSystem.clearSpriteTrails(sprite);
|
|
1493
|
+
this._spriteSystem.destroySprite(sprite);
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Removes a background layer from the engine.
|
|
1497
|
+
*
|
|
1498
|
+
* If the layer is not currently registered, this is a safe no-op.
|
|
1499
|
+
*
|
|
1500
|
+
* @param layer - The background layer to remove.
|
|
1501
|
+
*/
|
|
1502
|
+
destroyBackgroundLayer(layer) {
|
|
1503
|
+
this._backgroundSystem.destroyLayer(layer);
|
|
642
1504
|
}
|
|
643
1505
|
/**
|
|
644
1506
|
* Returns a **read-only snapshot** of all currently active sprites.
|
|
@@ -656,7 +1518,88 @@ export class Game {
|
|
|
656
1518
|
* ```
|
|
657
1519
|
*/
|
|
658
1520
|
getSprites() {
|
|
659
|
-
return
|
|
1521
|
+
return this._spriteSystem.getSprites();
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Returns a read-only snapshot of all active background layers.
|
|
1525
|
+
*
|
|
1526
|
+
* Background layers are returned in creation order within each layer value.
|
|
1527
|
+
*/
|
|
1528
|
+
getBackgroundLayers() {
|
|
1529
|
+
return this._backgroundSystem.getLayers();
|
|
1530
|
+
}
|
|
1531
|
+
// -------------------------------------------------------------------------
|
|
1532
|
+
// Assets
|
|
1533
|
+
// -------------------------------------------------------------------------
|
|
1534
|
+
/**
|
|
1535
|
+
* Queues an image to be loaded during {@link Game.onPreload}.
|
|
1536
|
+
*
|
|
1537
|
+
* This method registers the asset only. The actual network/image fetch begins
|
|
1538
|
+
* after `onPreload` returns and is managed automatically by the engine.
|
|
1539
|
+
*
|
|
1540
|
+
* `loadImage` may only be called from within {@link Game.onPreload}.
|
|
1541
|
+
*
|
|
1542
|
+
* @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
|
|
1543
|
+
* @param src - Image URL or relative path.
|
|
1544
|
+
*
|
|
1545
|
+
* @example
|
|
1546
|
+
* ```ts
|
|
1547
|
+
* game.onPreload = () => {
|
|
1548
|
+
* game.loadImage("sky", "assets/sky.png");
|
|
1549
|
+
* };
|
|
1550
|
+
* ```
|
|
1551
|
+
*/
|
|
1552
|
+
loadImage(key, src) {
|
|
1553
|
+
if (!this._isRegisteringPreloadAssets) {
|
|
1554
|
+
throw new Error("MinimoJS: loadImage() may only be called inside game.onPreload.");
|
|
1555
|
+
}
|
|
1556
|
+
this._assetSystem.queueImage(key, src);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Creates a dynamic texture immediately and registers it under a stable key.
|
|
1560
|
+
*
|
|
1561
|
+
* Unlike {@link Game.loadImage}, this does not require {@link Game.onPreload}.
|
|
1562
|
+
* The `painter` callback receives a fresh offscreen canvas and should draw the
|
|
1563
|
+
* full texture contents into it.
|
|
1564
|
+
*
|
|
1565
|
+
* The resulting texture can be used anywhere a normal image key is accepted,
|
|
1566
|
+
* including {@link ImageSprite}, background layers, and optional modules.
|
|
1567
|
+
*
|
|
1568
|
+
* @param key - Stable texture key used later by sprites and render helpers.
|
|
1569
|
+
* @param width - Texture width in pixels. Minimum `1`.
|
|
1570
|
+
* @param height - Texture height in pixels. Minimum `1`.
|
|
1571
|
+
* @param painter - Function that paints into the offscreen texture canvas.
|
|
1572
|
+
*
|
|
1573
|
+
* @example
|
|
1574
|
+
* ```ts
|
|
1575
|
+
* game.createTexture("checkpoint", 128, 64, (ctx, canvas) => {
|
|
1576
|
+
* ctx.fillStyle = "#101820";
|
|
1577
|
+
* ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
1578
|
+
* ctx.fillStyle = "#ffd54f";
|
|
1579
|
+
* ctx.fillRect(8, 8, canvas.width - 16, canvas.height - 16);
|
|
1580
|
+
* });
|
|
1581
|
+
* ```
|
|
1582
|
+
*/
|
|
1583
|
+
createTexture(key, width, height, painter) {
|
|
1584
|
+
return this._assetSystem.createTexture(key, width, height, painter);
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Returns a loaded image or created texture previously registered with
|
|
1588
|
+
* {@link Game.loadImage} or {@link Game.createTexture}, or `undefined` if it
|
|
1589
|
+
* is not available.
|
|
1590
|
+
*/
|
|
1591
|
+
getImage(key) {
|
|
1592
|
+
return this._assetSystem.getImage(key);
|
|
1593
|
+
}
|
|
1594
|
+
/** @internal */
|
|
1595
|
+
getImageVersion(key) {
|
|
1596
|
+
return this._assetSystem.getImageVersion(key);
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Returns `true` if a texture key is available.
|
|
1600
|
+
*/
|
|
1601
|
+
hasImage(key) {
|
|
1602
|
+
return this._assetSystem.hasImage(key);
|
|
660
1603
|
}
|
|
661
1604
|
// -------------------------------------------------------------------------
|
|
662
1605
|
// Collision
|
|
@@ -665,60 +1608,106 @@ export class Game {
|
|
|
665
1608
|
* Tests whether two sprites overlap using **Axis-Aligned Bounding Box (AABB)**
|
|
666
1609
|
* collision detection.
|
|
667
1610
|
*
|
|
668
|
-
* Each sprite's bounding box is a square centered at `(x, y)` with side
|
|
669
|
-
* length `
|
|
1611
|
+
* Each sprite's bounding box is a square centered at `(x, y)` with side
|
|
1612
|
+
* length `displaySize`. Rotation is **ignored** — the box is always axis-aligned.
|
|
1613
|
+
*
|
|
1614
|
+
* **No collision resolution is performed.** This method is detection-only.
|
|
1615
|
+
* Use {@link Game.collide} for the engine's basic explicit push-out helper.
|
|
1616
|
+
*
|
|
1617
|
+
* @param a - First sprite.
|
|
1618
|
+
* @param b - Second sprite.
|
|
1619
|
+
* @returns `true` if the bounding boxes overlap; `false` otherwise.
|
|
1620
|
+
*
|
|
1621
|
+
* @example
|
|
1622
|
+
* ```ts
|
|
1623
|
+
* if (game.overlap(player, enemy)) {
|
|
1624
|
+
* game.reset(); // restart on collision
|
|
1625
|
+
* }
|
|
1626
|
+
* ```
|
|
1627
|
+
*/
|
|
1628
|
+
overlap(a, b) {
|
|
1629
|
+
return this._physicsSystem.overlap(a, b);
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Tests for any overlap between two groups of sprites.
|
|
1633
|
+
* Performs an O(n × m) AABB check for every pair `(a, b)` where `a ∈ listA`
|
|
1634
|
+
* and `b ∈ listB`. Returns the **first** overlapping pair found.
|
|
1635
|
+
*
|
|
1636
|
+
* Internally uses {@link Game.overlap} — same AABB rules apply.
|
|
1637
|
+
* No collision resolution is performed.
|
|
1638
|
+
*
|
|
1639
|
+
* @param listA - First group of sprites.
|
|
1640
|
+
* @param listB - Second group of sprites. May share sprites with `listA`.
|
|
1641
|
+
* @returns A `[EmojiSprite, EmojiSprite]` tuple of the first overlapping pair,
|
|
1642
|
+
* or `null` if no pair overlaps.
|
|
1643
|
+
*
|
|
1644
|
+
* @example
|
|
1645
|
+
* ```ts
|
|
1646
|
+
* const hit = game.overlapAny(bullets, enemies);
|
|
1647
|
+
* if (hit) {
|
|
1648
|
+
* const [bullet, enemy] = hit;
|
|
1649
|
+
* game.destroySprite(bullet);
|
|
1650
|
+
* game.destroySprite(enemy);
|
|
1651
|
+
* }
|
|
1652
|
+
* ```
|
|
1653
|
+
*/
|
|
1654
|
+
overlapAny(listA, listB) {
|
|
1655
|
+
return this._physicsSystem.overlapAny(listA, listB);
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Tests and resolves a basic AABB collision between two sprites.
|
|
1659
|
+
*
|
|
1660
|
+
* This helper is intended for simple platformer-style collision response.
|
|
1661
|
+
* It uses the same axis-aligned square bounds as {@link Game.overlap}, but
|
|
1662
|
+
* additionally pushes one sprite out of the collision and zeroes velocity on
|
|
1663
|
+
* the resolved axis.
|
|
1664
|
+
*
|
|
1665
|
+
* **Resolution rules:**
|
|
1666
|
+
* - If the first sprite is dynamic (`isStatic = false`), the first sprite is resolved.
|
|
1667
|
+
* - Otherwise, if the second sprite is dynamic, the second sprite is resolved.
|
|
1668
|
+
* - If both sprites are static, collision is reported but no movement occurs.
|
|
670
1669
|
*
|
|
671
|
-
*
|
|
672
|
-
* If you need bounce or push-apart behavior, implement it in `onUpdate`.
|
|
1670
|
+
* The returned flags are always reported relative to the **first** sprite.
|
|
673
1671
|
*
|
|
674
|
-
* @param a - First sprite.
|
|
675
|
-
* @param b - Second sprite.
|
|
676
|
-
* @returns `
|
|
1672
|
+
* @param a - First sprite. Usually the moving actor (for example, the player).
|
|
1673
|
+
* @param b - Second sprite. Usually a static obstacle or platform.
|
|
1674
|
+
* @returns Collision details, or `null` if the sprites do not overlap.
|
|
1675
|
+
* @throws Error if the game's physics helpers are not enabled.
|
|
677
1676
|
*
|
|
678
1677
|
* @example
|
|
679
1678
|
* ```ts
|
|
680
|
-
*
|
|
681
|
-
*
|
|
1679
|
+
* const hit = game.collide(player, floorTile);
|
|
1680
|
+
* if (hit?.grounded) {
|
|
1681
|
+
* canJump = true;
|
|
682
1682
|
* }
|
|
683
1683
|
* ```
|
|
684
1684
|
*/
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const halfB = b.size / 2;
|
|
688
|
-
return (Math.abs(a.x - b.x) < halfA + halfB &&
|
|
689
|
-
Math.abs(a.y - b.y) < halfA + halfB);
|
|
1685
|
+
collide(a, b) {
|
|
1686
|
+
return this._physicsSystem.collide(a, b);
|
|
690
1687
|
}
|
|
691
1688
|
/**
|
|
692
|
-
* Tests
|
|
693
|
-
* Performs an O(n × m) AABB check for every pair `(a, b)` where `a ∈ listA`
|
|
694
|
-
* and `b ∈ listB`. Returns the **first** overlapping pair found.
|
|
1689
|
+
* Tests and resolves the first collision found between two groups of sprites.
|
|
695
1690
|
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
1691
|
+
* This behaves like {@link Game.overlapAny}, but uses the explicit collision
|
|
1692
|
+
* rules from {@link Game.collide} and returns the collision details as the
|
|
1693
|
+
* third tuple item.
|
|
698
1694
|
*
|
|
699
1695
|
* @param listA - First group of sprites.
|
|
700
|
-
* @param listB - Second group of sprites.
|
|
701
|
-
* @returns A `[
|
|
1696
|
+
* @param listB - Second group of sprites.
|
|
1697
|
+
* @returns A `[EmojiSprite, EmojiSprite, CollisionInfo]` tuple for the first collision found,
|
|
702
1698
|
* or `null` if no pair overlaps.
|
|
1699
|
+
* @throws Error if the game's physics helpers are not enabled.
|
|
703
1700
|
*
|
|
704
1701
|
* @example
|
|
705
1702
|
* ```ts
|
|
706
|
-
* const hit = game.
|
|
707
|
-
* if (hit) {
|
|
708
|
-
*
|
|
709
|
-
* game.destroySprite(bullet);
|
|
710
|
-
* game.destroySprite(enemy);
|
|
1703
|
+
* const hit = game.collideAny([player], floorTiles);
|
|
1704
|
+
* if (hit?.[2].grounded) {
|
|
1705
|
+
* canJump = true;
|
|
711
1706
|
* }
|
|
712
1707
|
* ```
|
|
713
1708
|
*/
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
for (const b of listB) {
|
|
717
|
-
if (this.overlap(a, b))
|
|
718
|
-
return [a, b];
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
return null;
|
|
1709
|
+
collideAny(listA, listB) {
|
|
1710
|
+
return this._physicsSystem.collideAny(listA, listB);
|
|
722
1711
|
}
|
|
723
1712
|
// -------------------------------------------------------------------------
|
|
724
1713
|
// Input — Keyboard
|
|
@@ -742,7 +1731,7 @@ export class Game {
|
|
|
742
1731
|
* ```
|
|
743
1732
|
*/
|
|
744
1733
|
isKeyDown(key) {
|
|
745
|
-
return this.
|
|
1734
|
+
return this._inputSystem.isKeyDown(key);
|
|
746
1735
|
}
|
|
747
1736
|
/**
|
|
748
1737
|
* Returns `true` only on the **single frame** the key was first pressed.
|
|
@@ -760,7 +1749,7 @@ export class Game {
|
|
|
760
1749
|
* ```
|
|
761
1750
|
*/
|
|
762
1751
|
isKeyPressed(key) {
|
|
763
|
-
return this.
|
|
1752
|
+
return this._inputSystem.isKeyPressed(key);
|
|
764
1753
|
}
|
|
765
1754
|
// -------------------------------------------------------------------------
|
|
766
1755
|
// Input — Pointer (mouse / touch)
|
|
@@ -779,7 +1768,7 @@ export class Game {
|
|
|
779
1768
|
* ```
|
|
780
1769
|
*/
|
|
781
1770
|
isPointerDown() {
|
|
782
|
-
return this.
|
|
1771
|
+
return this._inputSystem.isPointerDown();
|
|
783
1772
|
}
|
|
784
1773
|
/**
|
|
785
1774
|
* Returns `true` only on the **single frame** the pointer was first pressed.
|
|
@@ -796,283 +1785,569 @@ export class Game {
|
|
|
796
1785
|
* ```
|
|
797
1786
|
*/
|
|
798
1787
|
isPointerPressed() {
|
|
799
|
-
return this.
|
|
1788
|
+
return this._inputSystem.isPointerPressed();
|
|
800
1789
|
}
|
|
801
1790
|
/**
|
|
802
1791
|
* Returns `true` while any active pointer is held down over the target sprite.
|
|
803
1792
|
* Works with both mouse input and multiple simultaneous touches.
|
|
804
1793
|
*
|
|
805
|
-
* Pointer hit testing uses
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
1794
|
+
* Pointer hit testing uses the sprite's exact displayed rectangular bounds.
|
|
1795
|
+
* World-space sprites are tested against the current camera scroll. HUD
|
|
1796
|
+
* sprites with `ignoreScroll = true` are tested in screen space. For
|
|
1797
|
+
* anchored {@link TextSprite} instances, the engine first resolves
|
|
1798
|
+
* `anchorX` / `anchorY` into the final rendered center, then tests against
|
|
1799
|
+
* that rectangle.
|
|
809
1800
|
*
|
|
810
1801
|
* Use this for continuous virtual buttons such as touch movement controls.
|
|
811
1802
|
*
|
|
812
1803
|
* @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
|
|
813
|
-
* @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
|
|
814
|
-
* Default: `0.5`.
|
|
815
1804
|
* @returns `true` if any currently held pointer overlaps the sprite hit area.
|
|
816
1805
|
*
|
|
817
1806
|
* @example
|
|
818
1807
|
* ```ts
|
|
819
|
-
* if (game.isPointerDownOverSprite(leftButton
|
|
1808
|
+
* if (game.isPointerDownOverSprite(leftButton)) {
|
|
820
1809
|
* player.x -= 200 * dt;
|
|
821
1810
|
* }
|
|
822
1811
|
* ```
|
|
823
1812
|
*/
|
|
824
|
-
isPointerDownOverSprite(sprite
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1813
|
+
isPointerDownOverSprite(sprite) {
|
|
1814
|
+
return this._inputSystem.isPointerDownOverSprite(sprite);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Returns `true` only on the frame any pointer first pressed over the target
|
|
1818
|
+
* sprite. Works with both mouse input and multiple simultaneous touches.
|
|
1819
|
+
*
|
|
1820
|
+
* Pointer hit testing uses the sprite's exact displayed rectangular bounds.
|
|
1821
|
+
* World-space sprites are tested against the current camera scroll. HUD
|
|
1822
|
+
* sprites with `ignoreScroll = true` are tested in screen space. For
|
|
1823
|
+
* anchored {@link TextSprite} instances, the engine first resolves
|
|
1824
|
+
* `anchorX` / `anchorY` into the final rendered center, then tests against
|
|
1825
|
+
* that rectangle.
|
|
1826
|
+
*
|
|
1827
|
+
* Use this for one-shot virtual buttons such as menu taps.
|
|
1828
|
+
*
|
|
1829
|
+
* @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
|
|
1830
|
+
* @returns `true` if any pointer began pressing this frame over the sprite hit area.
|
|
1831
|
+
*
|
|
1832
|
+
* @example
|
|
1833
|
+
* ```ts
|
|
1834
|
+
* if (game.isPointerPressedOverSprite(startButton)) {
|
|
1835
|
+
* startGame();
|
|
1836
|
+
* }
|
|
1837
|
+
* ```
|
|
1838
|
+
*/
|
|
1839
|
+
isPointerPressedOverSprite(sprite) {
|
|
1840
|
+
return this._inputSystem.isPointerPressedOverSprite(sprite);
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Returns a read-only snapshot of all currently active pointers.
|
|
1844
|
+
*
|
|
1845
|
+
* Mouse appears in the list only while the mouse button is held down. Touches
|
|
1846
|
+
* appear while they remain active on the canvas. Coordinates are returned in
|
|
1847
|
+
* canvas/screen space.
|
|
1848
|
+
*
|
|
1849
|
+
* Use this for advanced multitouch controls such as joysticks, drag handles,
|
|
1850
|
+
* or gesture-like gameplay logic.
|
|
1851
|
+
*
|
|
1852
|
+
* @returns A read-only array of active pointer snapshots.
|
|
1853
|
+
*
|
|
1854
|
+
* @example
|
|
1855
|
+
* ```ts
|
|
1856
|
+
* const pointers = game.getPointers();
|
|
1857
|
+
* if (pointers.length > 0) {
|
|
1858
|
+
* const first = pointers[0];
|
|
1859
|
+
* game.drawText(`Pointer: ${first.x}, ${first.y}`, 10, 10, 14);
|
|
1860
|
+
* }
|
|
1861
|
+
* ```
|
|
1862
|
+
*/
|
|
1863
|
+
getPointers() {
|
|
1864
|
+
return this._inputSystem.getPointers();
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Returns a read-only snapshot of all active pointers currently overlapping
|
|
1868
|
+
* the target sprite.
|
|
1869
|
+
*
|
|
1870
|
+
* Pointer hit testing uses the sprite's exact displayed rectangular bounds.
|
|
1871
|
+
* World-space sprites are tested against the current camera scroll. HUD
|
|
1872
|
+
* sprites with `ignoreScroll = true` are tested in screen space. For
|
|
1873
|
+
* anchored {@link TextSprite} instances, the engine first resolves
|
|
1874
|
+
* `anchorX` / `anchorY` into the final rendered center, then tests against
|
|
1875
|
+
* that rectangle.
|
|
1876
|
+
*
|
|
1877
|
+
* Use this when you need more than a boolean result, such as reading the exact
|
|
1878
|
+
* pointer position over a virtual joystick or draggable control.
|
|
1879
|
+
*
|
|
1880
|
+
* @param sprite - Target sprite to test. If `null` / `undefined`, returns an empty array.
|
|
1881
|
+
* @returns A read-only array of active pointer snapshots currently over the sprite.
|
|
1882
|
+
*
|
|
1883
|
+
* @example
|
|
1884
|
+
* ```ts
|
|
1885
|
+
* const touches = game.getPointersOverSprite(joystickBase);
|
|
1886
|
+
* if (touches.length > 0) {
|
|
1887
|
+
* const p = touches[0];
|
|
1888
|
+
* const dx = p.x - joystickBase.x;
|
|
1889
|
+
* }
|
|
1890
|
+
* ```
|
|
1891
|
+
*/
|
|
1892
|
+
getPointersOverSprite(sprite) {
|
|
1893
|
+
return this._inputSystem.getPointersOverSprite(sprite);
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Returns `true` when the current device appears to be mobile/touch-first.
|
|
1897
|
+
*
|
|
1898
|
+
* This is a heuristic helper intended for gameplay UI decisions (for example,
|
|
1899
|
+
* showing on-screen touch controls). It checks user-agent/platform hints and
|
|
1900
|
+
* coarse-pointer/touch capabilities.
|
|
1901
|
+
*
|
|
1902
|
+
* @returns `true` if the runtime likely corresponds to a mobile device.
|
|
1903
|
+
*
|
|
1904
|
+
* @example
|
|
1905
|
+
* ```ts
|
|
1906
|
+
* if (game.isMobileDevice()) {
|
|
1907
|
+
* // Show touch buttons
|
|
1908
|
+
* } else {
|
|
1909
|
+
* // Show keyboard hints
|
|
1910
|
+
* }
|
|
1911
|
+
* ```
|
|
1912
|
+
*/
|
|
1913
|
+
isMobileDevice() {
|
|
1914
|
+
return this._inputSystem.isMobileDevice();
|
|
1915
|
+
}
|
|
1916
|
+
// -------------------------------------------------------------------------
|
|
1917
|
+
// Sound
|
|
1918
|
+
// -------------------------------------------------------------------------
|
|
1919
|
+
/**
|
|
1920
|
+
* Plays a square-wave beep using the Web Audio API.
|
|
1921
|
+
*
|
|
1922
|
+
* **Square wave only.** MinimoJS does not support audio files, samples,
|
|
1923
|
+
* or other waveforms. Only procedural square-wave tones.
|
|
1924
|
+
*
|
|
1925
|
+
* The AudioContext is created lazily on first call. Browsers require a user
|
|
1926
|
+
* gesture (click, keypress) before audio can play — always call `sound()` in
|
|
1927
|
+
* response to user input or a game event triggered by input.
|
|
1928
|
+
*
|
|
1929
|
+
* If you need a melody/sequence, use {@link Game.soundSequence} instead of
|
|
1930
|
+
* multiple `sound()` calls in the same frame.
|
|
1931
|
+
*
|
|
1932
|
+
* The tone fades out exponentially over `durationMs` to avoid clicks.
|
|
1933
|
+
*
|
|
1934
|
+
* @param freq - Frequency in Hz. Middle C = 261.6. Typical range: 100–4000 Hz.
|
|
1935
|
+
* @param durationMs - Duration of the sound in **milliseconds**.
|
|
1936
|
+
*
|
|
1937
|
+
* @example
|
|
1938
|
+
* ```ts
|
|
1939
|
+
* game.sound(440, 100); // 440 Hz beep for 100ms
|
|
1940
|
+
* game.sound(261, 500); // middle C for 500ms
|
|
1941
|
+
* game.sound(880, 50); // high beep for 50ms (jump sound)
|
|
1942
|
+
* ```
|
|
1943
|
+
*/
|
|
1944
|
+
sound(freq, durationMs) {
|
|
1945
|
+
this._soundSystem.sound(freq, durationMs);
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Plays multiple square-wave tones in strict sequence.
|
|
1949
|
+
*
|
|
1950
|
+
* Each tuple is `[frequencyHz, durationMs]`. The next tone starts when the
|
|
1951
|
+
* previous one ends.
|
|
1952
|
+
*
|
|
1953
|
+
* @param tones - Tone tuples in playback order.
|
|
1954
|
+
*
|
|
1955
|
+
* @example
|
|
1956
|
+
* ```ts
|
|
1957
|
+
* game.soundSequence([660, 80], [820, 90], [980, 110], [1180, 150]);
|
|
1958
|
+
* ```
|
|
1959
|
+
*/
|
|
1960
|
+
soundSequence(...tones) {
|
|
1961
|
+
this._soundSystem.soundSequence(...tones);
|
|
1962
|
+
}
|
|
1963
|
+
// -------------------------------------------------------------------------
|
|
1964
|
+
// Animations
|
|
1965
|
+
// -------------------------------------------------------------------------
|
|
1966
|
+
/**
|
|
1967
|
+
* Animates a sprite's {@link Sprite.alpha} from its current value to `to`
|
|
1968
|
+
* over `durationMs` milliseconds using **linear interpolation**.
|
|
1969
|
+
*
|
|
1970
|
+
* If an alpha animation is already running on this sprite, it is replaced
|
|
1971
|
+
* by the new one immediately (no queuing).
|
|
1972
|
+
*
|
|
1973
|
+
* **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
|
|
1974
|
+
*
|
|
1975
|
+
* @param sprite - The sprite to animate.
|
|
1976
|
+
* @param to - Target alpha value (0 = transparent, 1 = opaque).
|
|
1977
|
+
* @param durationMs - Duration of the animation in **milliseconds**.
|
|
1978
|
+
* @param onComplete - Optional callback invoked when the animation finishes.
|
|
1979
|
+
*
|
|
1980
|
+
* @example
|
|
1981
|
+
* ```ts
|
|
1982
|
+
* // Fade out a sprite over 1 second, then destroy it
|
|
1983
|
+
* game.animateAlpha(coin, 0, 1000, () => game.destroySprite(coin));
|
|
1984
|
+
* ```
|
|
1985
|
+
*/
|
|
1986
|
+
animateAlpha(sprite, to, durationMs, onComplete) {
|
|
1987
|
+
this._animationSystem.animateAlpha(sprite, to, durationMs, onComplete);
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Animates a sprite's {@link Sprite.rotation} from its current value to `to`
|
|
1991
|
+
* (in degrees) over `durationMs` milliseconds using **linear interpolation**.
|
|
1992
|
+
*
|
|
1993
|
+
* If a rotation animation is already running on this sprite, it is replaced
|
|
1994
|
+
* immediately (no queuing).
|
|
1995
|
+
*
|
|
1996
|
+
* **Timing:** driven by the rAF loop, not `setTimeout`. Duration is in ms.
|
|
1997
|
+
* **Units:** `to` is in **degrees**.
|
|
1998
|
+
*
|
|
1999
|
+
* @param sprite - The sprite to animate.
|
|
2000
|
+
* @param to - Target rotation in **degrees**.
|
|
2001
|
+
* @param durationMs - Duration of the animation in **milliseconds**.
|
|
2002
|
+
* @param onComplete - Optional callback invoked when the animation finishes.
|
|
2003
|
+
*
|
|
2004
|
+
* @example
|
|
2005
|
+
* ```ts
|
|
2006
|
+
* // Spin a sprite 360° over 2 seconds
|
|
2007
|
+
* game.animateRotation(star, 360, 2000);
|
|
2008
|
+
*
|
|
2009
|
+
* // Tilt on hit, then straighten
|
|
2010
|
+
* game.animateRotation(player, 45, 200, () => {
|
|
2011
|
+
* game.animateRotation(player, 0, 200);
|
|
2012
|
+
* });
|
|
2013
|
+
* ```
|
|
2014
|
+
*/
|
|
2015
|
+
animateRotation(sprite, to, durationMs, onComplete) {
|
|
2016
|
+
this._animationSystem.animateRotation(sprite, to, durationMs, onComplete);
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Animates a sprite's visual deformation using non-uniform scaling around a
|
|
2020
|
+
* normalized pivot point.
|
|
2021
|
+
*
|
|
2022
|
+
* This affects rendering only. Physics and collision bounds continue to use
|
|
2023
|
+
* {@link EmojiSprite.displaySize} and ignore the deform result.
|
|
2024
|
+
*
|
|
2025
|
+
* `pivotX` / `pivotY` are normalized anchors in the sprite's square display box:
|
|
2026
|
+
* - `0` = left/top
|
|
2027
|
+
* - `0.5` = center
|
|
2028
|
+
* - `1` = right/bottom
|
|
2029
|
+
*
|
|
2030
|
+
* For example, `pivotY = 1` keeps the bottom visually pinned while squashing
|
|
2031
|
+
* or stretching, which is useful for floor-contact animation.
|
|
2032
|
+
*
|
|
2033
|
+
* Like other MinimoJS animations, this animates from the sprite's current
|
|
2034
|
+
* deform state to the requested target and leaves the final result applied.
|
|
2035
|
+
*
|
|
2036
|
+
* @param sprite - The sprite to deform.
|
|
2037
|
+
* @param toScaleX - Target horizontal deform scale. `1` = unchanged.
|
|
2038
|
+
* @param toScaleY - Target vertical deform scale. `1` = unchanged.
|
|
2039
|
+
* @param pivotX - Horizontal pivot in normalized `[0, 1]` sprite space.
|
|
2040
|
+
* @param pivotY - Vertical pivot in normalized `[0, 1]` sprite space.
|
|
2041
|
+
* @param durationMs - Duration of the animation in **milliseconds**.
|
|
2042
|
+
* @param onComplete - Optional callback invoked when the animation finishes.
|
|
2043
|
+
*
|
|
2044
|
+
* @example
|
|
2045
|
+
* ```ts
|
|
2046
|
+
* game.animateDeform(player, 1.18, 0.85, 0.5, 1, 90, () => {
|
|
2047
|
+
* game.animateDeform(player, 1, 1, 0.5, 1, 120);
|
|
2048
|
+
* });
|
|
2049
|
+
* ```
|
|
2050
|
+
*/
|
|
2051
|
+
animateDeform(sprite, toScaleX, toScaleY, pivotX, pivotY, durationMs, onComplete) {
|
|
2052
|
+
this._animationSystem.animateDeform(sprite, toScaleX, toScaleY, pivotX, pivotY, durationMs, onComplete);
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Convenience helper that squashes a sprite around its horizontal center.
|
|
2056
|
+
*
|
|
2057
|
+
* This is equivalent to animating toward a wider, shorter visual pose using
|
|
2058
|
+
* {@link Game.animateDeform}. `pivotY = 1` is the common floor-anchored case.
|
|
2059
|
+
*
|
|
2060
|
+
* @param sprite - The sprite to squash.
|
|
2061
|
+
* @param pivotY - Vertical pivot in normalized `[0, 1]` sprite space.
|
|
2062
|
+
* @param durationMs - Duration of the animation in **milliseconds**.
|
|
2063
|
+
* @param onComplete - Optional callback invoked when the animation finishes.
|
|
2064
|
+
*/
|
|
2065
|
+
animateSquash(sprite, pivotY, durationMs, onComplete) {
|
|
2066
|
+
this._animationSystem.animateDeform(sprite, 1.18, 0.85, 0.5, pivotY, durationMs, onComplete);
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Convenience helper that stretches a sprite around its horizontal center.
|
|
2070
|
+
*
|
|
2071
|
+
* This is equivalent to animating toward a narrower, taller visual pose using
|
|
2072
|
+
* {@link Game.animateDeform}. `pivotY = 1` is the common floor-anchored case.
|
|
2073
|
+
*
|
|
2074
|
+
* @param sprite - The sprite to stretch.
|
|
2075
|
+
* @param pivotY - Vertical pivot in normalized `[0, 1]` sprite space.
|
|
2076
|
+
* @param durationMs - Duration of the animation in **milliseconds**.
|
|
2077
|
+
* @param onComplete - Optional callback invoked when the animation finishes.
|
|
2078
|
+
*/
|
|
2079
|
+
animateStretch(sprite, pivotY, durationMs, onComplete) {
|
|
2080
|
+
this._animationSystem.animateDeform(sprite, 0.85, 1.18, 0.5, pivotY, durationMs, onComplete);
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Convenience helper that applies a centered uniform scale pulse and then
|
|
2084
|
+
* returns the sprite to its neutral shape.
|
|
2085
|
+
*
|
|
2086
|
+
* Internally this runs two deform animations back-to-back using
|
|
2087
|
+
* {@link Game.animateDeform}.
|
|
2088
|
+
*
|
|
2089
|
+
* @param sprite - The sprite to pulse.
|
|
2090
|
+
* @param scale - Peak uniform scale reached midway through the pulse. `1` leaves the sprite unchanged.
|
|
2091
|
+
* @param durationMs - Total pulse duration in **milliseconds**.
|
|
2092
|
+
* @param onComplete - Optional callback invoked after the sprite returns to neutral.
|
|
2093
|
+
*
|
|
2094
|
+
* @example
|
|
2095
|
+
* ```ts
|
|
2096
|
+
* game.animatePulse(coin, 1.25, 220);
|
|
2097
|
+
* ```
|
|
2098
|
+
*/
|
|
2099
|
+
animatePulse(sprite, scale, durationMs, onComplete) {
|
|
2100
|
+
const target = Number.isFinite(scale) ? Math.max(0, scale) : 1;
|
|
2101
|
+
const firstHalf = Math.max(1, Math.round(durationMs / 2));
|
|
2102
|
+
const secondHalf = Math.max(1, durationMs - firstHalf);
|
|
2103
|
+
this._animationSystem.animateDeform(sprite, target, target, 0.5, 0.5, firstHalf, () => {
|
|
2104
|
+
this._animationSystem.animateDeform(sprite, 1, 1, 0.5, 0.5, secondHalf, onComplete);
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Applies a decaying screen-space shake to a sprite without changing its
|
|
2109
|
+
* logical position.
|
|
2110
|
+
*
|
|
2111
|
+
* This is a render-only effect. Physics, collisions, and the sprite's
|
|
2112
|
+
* `x` / `y` values are not modified.
|
|
2113
|
+
*
|
|
2114
|
+
* @param sprite - The sprite to shake.
|
|
2115
|
+
* @param intensity - Maximum shake offset in pixels.
|
|
2116
|
+
* @param durationMs - Duration of the shake in **milliseconds**.
|
|
2117
|
+
* @param onComplete - Optional callback invoked when the shake ends.
|
|
2118
|
+
*
|
|
2119
|
+
* @example
|
|
2120
|
+
* ```ts
|
|
2121
|
+
* game.animateShake(player, 18, 300);
|
|
2122
|
+
* ```
|
|
2123
|
+
*/
|
|
2124
|
+
animateShake(sprite, intensity, durationMs, onComplete) {
|
|
2125
|
+
this._animationSystem.animateShake(sprite, intensity, durationMs, onComplete);
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Moves a sprite upward and back down to its starting position along a simple
|
|
2129
|
+
* parabolic arc.
|
|
2130
|
+
*
|
|
2131
|
+
* This animation writes directly to {@link Sprite.x} / {@link Sprite.y} while
|
|
2132
|
+
* it is active.
|
|
2133
|
+
*
|
|
2134
|
+
* @param sprite - The sprite to bounce.
|
|
2135
|
+
* @param height - Peak bounce height in pixels.
|
|
2136
|
+
* @param durationMs - Total bounce duration in **milliseconds**.
|
|
2137
|
+
* @param onComplete - Optional callback invoked when the bounce ends.
|
|
2138
|
+
*
|
|
2139
|
+
* @example
|
|
2140
|
+
* ```ts
|
|
2141
|
+
* game.animateBounce(ball, 120, 600);
|
|
2142
|
+
* ```
|
|
2143
|
+
*/
|
|
2144
|
+
animateBounce(sprite, height, durationMs, onComplete) {
|
|
2145
|
+
this._animationSystem.animateBounce(sprite, height, durationMs, onComplete);
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Moves a sprite upward and back down with a smooth sine-shaped float motion.
|
|
2149
|
+
*
|
|
2150
|
+
* This animation writes directly to {@link Sprite.x} / {@link Sprite.y} while
|
|
2151
|
+
* it is active.
|
|
2152
|
+
*
|
|
2153
|
+
* @param sprite - The sprite to float.
|
|
2154
|
+
* @param distance - Maximum upward travel in pixels.
|
|
2155
|
+
* @param durationMs - Total float duration in **milliseconds**.
|
|
2156
|
+
* @param onComplete - Optional callback invoked when the float ends.
|
|
2157
|
+
*
|
|
2158
|
+
* @example
|
|
2159
|
+
* ```ts
|
|
2160
|
+
* game.animateFloat(balloon, 90, 1200);
|
|
2161
|
+
* ```
|
|
2162
|
+
*/
|
|
2163
|
+
animateFloat(sprite, distance, durationMs, onComplete) {
|
|
2164
|
+
this._animationSystem.animateFloat(sprite, distance, durationMs, onComplete);
|
|
837
2165
|
}
|
|
838
2166
|
/**
|
|
839
|
-
*
|
|
840
|
-
* sprite. Works with both mouse input and multiple simultaneous touches.
|
|
2167
|
+
* Toggles a sprite's rendered opacity on and off a fixed number of times.
|
|
841
2168
|
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
|
|
845
|
-
* screen space.
|
|
846
|
-
*
|
|
847
|
-
* Use this for one-shot virtual buttons such as menu taps.
|
|
2169
|
+
* This is a render-only effect. The sprite remains present in the world and
|
|
2170
|
+
* keeps receiving physics updates while blinking.
|
|
848
2171
|
*
|
|
849
|
-
* @param sprite -
|
|
850
|
-
* @param
|
|
851
|
-
*
|
|
852
|
-
* @
|
|
2172
|
+
* @param sprite - The sprite to blink.
|
|
2173
|
+
* @param times - Number of visible/invisible toggle cycles.
|
|
2174
|
+
* @param durationMs - Total blink duration in **milliseconds**.
|
|
2175
|
+
* @param onComplete - Optional callback invoked when blinking ends.
|
|
853
2176
|
*
|
|
854
2177
|
* @example
|
|
855
2178
|
* ```ts
|
|
856
|
-
*
|
|
857
|
-
* startGame();
|
|
858
|
-
* }
|
|
2179
|
+
* game.animateBlink(player, 4, 700);
|
|
859
2180
|
* ```
|
|
860
2181
|
*/
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
return false;
|
|
864
|
-
if (this._mousePressed &&
|
|
865
|
-
this._isScreenPointOverSprite(this._mouseX, this._mouseY, sprite, radiusScale)) {
|
|
866
|
-
return true;
|
|
867
|
-
}
|
|
868
|
-
for (const pointer of this._touchPointers.values()) {
|
|
869
|
-
if (pointer.pressed &&
|
|
870
|
-
this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale)) {
|
|
871
|
-
return true;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
return false;
|
|
2182
|
+
animateBlink(sprite, times, durationMs, onComplete) {
|
|
2183
|
+
this._animationSystem.animateBlink(sprite, times, durationMs, onComplete);
|
|
875
2184
|
}
|
|
876
2185
|
/**
|
|
877
|
-
*
|
|
2186
|
+
* Applies a rapid irregular alpha variation to create a damaged, unstable, or
|
|
2187
|
+
* ghost-like flicker.
|
|
878
2188
|
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
* canvas/screen space.
|
|
882
|
-
*
|
|
883
|
-
* Use this for advanced multitouch controls such as joysticks, drag handles,
|
|
884
|
-
* or gesture-like gameplay logic.
|
|
2189
|
+
* This is a render-only effect and does not change the sprite's logical
|
|
2190
|
+
* position or collision state.
|
|
885
2191
|
*
|
|
886
|
-
* @
|
|
2192
|
+
* @param sprite - The sprite to flicker.
|
|
2193
|
+
* @param durationMs - Total flicker duration in **milliseconds**.
|
|
2194
|
+
* @param onComplete - Optional callback invoked when the flicker ends.
|
|
887
2195
|
*
|
|
888
2196
|
* @example
|
|
889
2197
|
* ```ts
|
|
890
|
-
*
|
|
891
|
-
* if (pointers.length > 0) {
|
|
892
|
-
* const first = pointers[0];
|
|
893
|
-
* game.text(`Pointer: ${first.x}, ${first.y}`, 10, 10);
|
|
894
|
-
* }
|
|
2198
|
+
* game.animateFlicker(ghost, 900);
|
|
895
2199
|
* ```
|
|
896
2200
|
*/
|
|
897
|
-
|
|
898
|
-
|
|
2201
|
+
animateFlicker(sprite, durationMs, onComplete) {
|
|
2202
|
+
this._animationSystem.animateFlicker(sprite, durationMs, onComplete);
|
|
899
2203
|
}
|
|
900
2204
|
/**
|
|
901
|
-
*
|
|
902
|
-
*
|
|
2205
|
+
* Moves a sprite from its current position to a target position along a
|
|
2206
|
+
* parabolic arc.
|
|
903
2207
|
*
|
|
904
|
-
*
|
|
905
|
-
* is
|
|
906
|
-
* current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
|
|
907
|
-
* screen space.
|
|
908
|
-
*
|
|
909
|
-
* Use this when you need more than a boolean result, such as reading the exact
|
|
910
|
-
* pointer position over a virtual joystick or draggable control.
|
|
2208
|
+
* This animation writes directly to {@link Sprite.x} / {@link Sprite.y} while
|
|
2209
|
+
* it is active.
|
|
911
2210
|
*
|
|
912
|
-
* @param sprite -
|
|
913
|
-
* @param
|
|
914
|
-
*
|
|
915
|
-
* @
|
|
2211
|
+
* @param sprite - The sprite to move.
|
|
2212
|
+
* @param toX - Destination X position in world space.
|
|
2213
|
+
* @param toY - Destination Y position in world space.
|
|
2214
|
+
* @param arcHeight - Maximum height of the arc above the linear path, in pixels.
|
|
2215
|
+
* @param durationMs - Total arc duration in **milliseconds**.
|
|
2216
|
+
* @param onComplete - Optional callback invoked when the arc ends.
|
|
916
2217
|
*
|
|
917
2218
|
* @example
|
|
918
2219
|
* ```ts
|
|
919
|
-
*
|
|
920
|
-
* if (touches.length > 0) {
|
|
921
|
-
* const p = touches[0];
|
|
922
|
-
* const dx = p.x - joystickBase.x;
|
|
923
|
-
* }
|
|
2220
|
+
* game.animateArc(coin, player.x, player.y, 80, 420);
|
|
924
2221
|
* ```
|
|
925
2222
|
*/
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
return [];
|
|
929
|
-
return this._collectPointers().filter((pointer) => this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale));
|
|
2223
|
+
animateArc(sprite, toX, toY, arcHeight, durationMs, onComplete) {
|
|
2224
|
+
this._animationSystem.animateArc(sprite, toX, toY, arcHeight, durationMs, onComplete);
|
|
930
2225
|
}
|
|
931
2226
|
/**
|
|
932
|
-
*
|
|
2227
|
+
* Emits temporary afterimages from a sprite to create a motion trail.
|
|
933
2228
|
*
|
|
934
|
-
* This is a
|
|
935
|
-
*
|
|
936
|
-
* coarse-pointer/touch capabilities.
|
|
2229
|
+
* This is a render-only effect. Trail ghosts are not real sprites and do not
|
|
2230
|
+
* appear in {@link Game.getSprites}, collide, or receive physics.
|
|
937
2231
|
*
|
|
938
|
-
* @
|
|
2232
|
+
* @param sprite - The sprite to sample for trail ghosts.
|
|
2233
|
+
* @param options - Optional trail tuning values.
|
|
2234
|
+
* @param onComplete - Optional callback invoked when the emitter stops spawning ghosts.
|
|
939
2235
|
*
|
|
940
2236
|
* @example
|
|
941
2237
|
* ```ts
|
|
942
|
-
*
|
|
943
|
-
* // Show touch buttons
|
|
944
|
-
* } else {
|
|
945
|
-
* // Show keyboard hints
|
|
946
|
-
* }
|
|
2238
|
+
* game.animateTrail(player, { durationMs: 300, spacingMs: 30, fadeMs: 180 });
|
|
947
2239
|
* ```
|
|
948
2240
|
*/
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
return false;
|
|
952
|
-
const ua = navigator.userAgent ?? "";
|
|
953
|
-
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
|
954
|
-
const iPadOS = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
|
|
955
|
-
const coarsePointer = typeof window !== "undefined" &&
|
|
956
|
-
typeof window.matchMedia === "function" &&
|
|
957
|
-
window.matchMedia("(pointer: coarse)").matches;
|
|
958
|
-
return mobileUA || iPadOS || coarsePointer;
|
|
2241
|
+
animateTrail(sprite, options, onComplete) {
|
|
2242
|
+
this._trailSystem.animateTrail(sprite, options, onComplete);
|
|
959
2243
|
}
|
|
960
|
-
// -------------------------------------------------------------------------
|
|
961
|
-
// Sound
|
|
962
|
-
// -------------------------------------------------------------------------
|
|
963
2244
|
/**
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
* **Square wave only.** MinimoJS does not support audio files, samples,
|
|
967
|
-
* or other waveforms. Only procedural square-wave tones.
|
|
2245
|
+
* Breaks a sprite into visual pieces that burst outward from its current
|
|
2246
|
+
* rendered appearance.
|
|
968
2247
|
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
* response to user input or a game event triggered by input.
|
|
2248
|
+
* This is a render-only effect. The spawned pieces are not real sprites and do
|
|
2249
|
+
* not appear in {@link Game.getSprites}, receive physics, or collide.
|
|
972
2250
|
*
|
|
973
|
-
*
|
|
2251
|
+
* By default, the original sprite is destroyed immediately after the explosion
|
|
2252
|
+
* effect is spawned. Set `destroySprite: false` to hide the sprite during the
|
|
2253
|
+
* effect and restore it when the explosion ends.
|
|
974
2254
|
*
|
|
975
|
-
* @param
|
|
976
|
-
* @param
|
|
2255
|
+
* @param sprite - The sprite to explode.
|
|
2256
|
+
* @param options - Optional explosion tuning values.
|
|
2257
|
+
* @param onComplete - Optional callback invoked when the effect finishes.
|
|
977
2258
|
*
|
|
978
2259
|
* @example
|
|
979
2260
|
* ```ts
|
|
980
|
-
* game.
|
|
981
|
-
* game.sound(261, 500); // middle C for 500ms
|
|
982
|
-
* game.sound(880, 50); // high beep for 50ms (jump sound)
|
|
2261
|
+
* game.animateExplode(enemy, { rows: 4, cols: 4, durationMs: 650, speed: 320 });
|
|
983
2262
|
* ```
|
|
984
2263
|
*/
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
if (ctx.state === "suspended") {
|
|
991
|
-
ctx.resume();
|
|
2264
|
+
animateExplode(sprite, options, onComplete) {
|
|
2265
|
+
const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
|
|
2266
|
+
this._explosionSystem.animateExplode(sprite, glyphCanvas, options, onComplete);
|
|
2267
|
+
if (options?.destroySprite !== false) {
|
|
2268
|
+
this.destroySprite(sprite);
|
|
992
2269
|
}
|
|
993
|
-
const oscillator = ctx.createOscillator();
|
|
994
|
-
const gain = ctx.createGain();
|
|
995
|
-
oscillator.type = "square";
|
|
996
|
-
oscillator.frequency.setValueAtTime(freq, ctx.currentTime);
|
|
997
|
-
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
|
998
|
-
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000);
|
|
999
|
-
oscillator.connect(gain);
|
|
1000
|
-
gain.connect(ctx.destination);
|
|
1001
|
-
oscillator.start(ctx.currentTime);
|
|
1002
|
-
oscillator.stop(ctx.currentTime + durationMs / 1000);
|
|
1003
2270
|
}
|
|
1004
|
-
// -------------------------------------------------------------------------
|
|
1005
|
-
// Animations
|
|
1006
|
-
// -------------------------------------------------------------------------
|
|
1007
2271
|
/**
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
2272
|
+
* Spawns visual pieces around a sprite and converges them back into its
|
|
2273
|
+
* current rendered appearance.
|
|
1010
2274
|
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
2275
|
+
* This is a render-only effect. The spawned pieces are not real sprites and
|
|
2276
|
+
* do not appear in {@link Game.getSprites}, receive physics, or collide.
|
|
1013
2277
|
*
|
|
1014
|
-
*
|
|
2278
|
+
* The original sprite is hidden while the assembly plays and is restored when
|
|
2279
|
+
* the effect finishes.
|
|
1015
2280
|
*
|
|
1016
|
-
* @param sprite - The sprite to
|
|
1017
|
-
* @param
|
|
1018
|
-
* @param
|
|
1019
|
-
|
|
2281
|
+
* @param sprite - The sprite to assemble.
|
|
2282
|
+
* @param options - Optional assembly tuning values.
|
|
2283
|
+
* @param onComplete - Optional callback invoked when the effect finishes.
|
|
2284
|
+
*/
|
|
2285
|
+
animateAssemble(sprite, options, onComplete) {
|
|
2286
|
+
const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
|
|
2287
|
+
this._explosionSystem.animateAssemble(sprite, glyphCanvas, options, onComplete);
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Breaks a sprite apart locally inside its own bounds using a directional
|
|
2291
|
+
* dissolve sweep.
|
|
1020
2292
|
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
2293
|
+
* This is a render-only effect. The spawned pieces are not real sprites and
|
|
2294
|
+
* do not appear in {@link Game.getSprites}, receive physics, or collide.
|
|
2295
|
+
*
|
|
2296
|
+
* By default, the original sprite is destroyed immediately after the effect
|
|
2297
|
+
* is spawned. Set `destroySprite: false` to leave the sprite hidden instead.
|
|
2298
|
+
*
|
|
2299
|
+
* @param sprite - The sprite to disintegrate.
|
|
2300
|
+
* @param options - Optional disintegration tuning values.
|
|
2301
|
+
* @param onComplete - Optional callback invoked when the effect finishes.
|
|
1026
2302
|
*/
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
this.
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
to,
|
|
1034
|
-
durationMs,
|
|
1035
|
-
elapsed: 0,
|
|
1036
|
-
onComplete,
|
|
1037
|
-
});
|
|
2303
|
+
animateDisintegrate(sprite, options, onComplete) {
|
|
2304
|
+
const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
|
|
2305
|
+
this._explosionSystem.animateDisintegrate(sprite, glyphCanvas, options, onComplete);
|
|
2306
|
+
if (options?.destroySprite !== false) {
|
|
2307
|
+
this.destroySprite(sprite);
|
|
2308
|
+
}
|
|
1038
2309
|
}
|
|
1039
2310
|
/**
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
2311
|
+
* Reconstructs a sprite locally inside its own bounds using a directional
|
|
2312
|
+
* integration sweep.
|
|
1042
2313
|
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
2314
|
+
* This is a render-only effect. The spawned pieces are not real sprites and
|
|
2315
|
+
* do not appear in {@link Game.getSprites}, receive physics, or collide.
|
|
1045
2316
|
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
2317
|
+
* The original sprite is hidden while the integration plays and is restored
|
|
2318
|
+
* when the effect finishes.
|
|
1048
2319
|
*
|
|
1049
|
-
* @param sprite - The sprite to
|
|
1050
|
-
* @param
|
|
1051
|
-
* @param
|
|
1052
|
-
|
|
2320
|
+
* @param sprite - The sprite to integrate.
|
|
2321
|
+
* @param options - Optional integration tuning values.
|
|
2322
|
+
* @param onComplete - Optional callback invoked when the effect finishes.
|
|
2323
|
+
*/
|
|
2324
|
+
animateIntegrate(sprite, options, onComplete) {
|
|
2325
|
+
const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
|
|
2326
|
+
this._explosionSystem.animateIntegrate(sprite, glyphCanvas, options, onComplete);
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Reveals or covers a sprite with a directional wipe mask.
|
|
1053
2330
|
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
* // Spin a sprite 360° over 2 seconds
|
|
1057
|
-
* game.animateRotation(star, 360, 2000);
|
|
2331
|
+
* This is a render-only effect that captures the sprite's current appearance
|
|
2332
|
+
* and animates a clipping region over that snapshot.
|
|
1058
2333
|
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
2334
|
+
* For `mode: "reveal"`, the original sprite is hidden during the wipe and is
|
|
2335
|
+
* restored when the effect finishes.
|
|
2336
|
+
*
|
|
2337
|
+
* For `mode: "cover"`, the effect hides the sprite over time. If
|
|
2338
|
+
* `destroySprite` is omitted or `true`, the sprite is destroyed immediately
|
|
2339
|
+
* after the wipe starts and only the wipe snapshot remains visible.
|
|
2340
|
+
*
|
|
2341
|
+
* @param sprite - The sprite to wipe.
|
|
2342
|
+
* @param options - Optional wipe tuning values.
|
|
2343
|
+
* @param onComplete - Optional callback invoked when the effect finishes.
|
|
1064
2344
|
*/
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
this.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
to,
|
|
1072
|
-
durationMs,
|
|
1073
|
-
elapsed: 0,
|
|
1074
|
-
onComplete,
|
|
1075
|
-
});
|
|
2345
|
+
animateWipe(sprite, options, onComplete) {
|
|
2346
|
+
const snapshot = this._renderSystem.getSpriteRenderSnapshot(sprite);
|
|
2347
|
+
this._explosionSystem.animateWipe(sprite, snapshot, options, onComplete);
|
|
2348
|
+
if ((options?.mode ?? "reveal") === "cover" && options?.destroySprite !== false) {
|
|
2349
|
+
this.destroySprite(sprite);
|
|
2350
|
+
}
|
|
1076
2351
|
}
|
|
1077
2352
|
// -------------------------------------------------------------------------
|
|
1078
2353
|
// Timers
|
|
@@ -1107,9 +2382,7 @@ export class Game {
|
|
|
1107
2382
|
* ```
|
|
1108
2383
|
*/
|
|
1109
2384
|
addTimer(delayMs, repeat, callback) {
|
|
1110
|
-
|
|
1111
|
-
this._timers.push({ id, delayMs, elapsed: 0, repeat, callback });
|
|
1112
|
-
return id;
|
|
2385
|
+
return this._timerSystem.addTimer(delayMs, repeat, callback);
|
|
1113
2386
|
}
|
|
1114
2387
|
/**
|
|
1115
2388
|
* Cancels a timer previously created with {@link Game.addTimer}.
|
|
@@ -1126,24 +2399,38 @@ export class Game {
|
|
|
1126
2399
|
* ```
|
|
1127
2400
|
*/
|
|
1128
2401
|
clearTimer(id) {
|
|
1129
|
-
this.
|
|
2402
|
+
this._timerSystem.clearTimer(id);
|
|
1130
2403
|
}
|
|
1131
2404
|
// -------------------------------------------------------------------------
|
|
1132
2405
|
// Text
|
|
1133
2406
|
// -------------------------------------------------------------------------
|
|
1134
2407
|
/**
|
|
1135
|
-
* Draws text on screen as a **screen-space overlay** this frame.
|
|
2408
|
+
* Draws text on screen as a **simple screen-space overlay** this frame.
|
|
1136
2409
|
*
|
|
1137
2410
|
* **Overlay behavior:** Text is drawn in canvas/screen space — it ignores
|
|
1138
2411
|
* `scrollX` / `scrollY`. Position `(0, 0)` is always the top-left of the canvas.
|
|
1139
|
-
* Use this for HUD elements: score, lives, timer, debug info.
|
|
2412
|
+
* Use this for lightweight HUD elements: score, lives, timer, debug info.
|
|
1140
2413
|
*
|
|
1141
2414
|
* **Per-frame:** `drawText` must be called every frame to keep text visible.
|
|
1142
2415
|
* The text overlay list is cleared after each render. Call this inside `onUpdate`.
|
|
1143
2416
|
*
|
|
1144
2417
|
* **Layer:** Text is always drawn on top of all sprites.
|
|
1145
|
-
* **Font:** Text
|
|
1146
|
-
*
|
|
2418
|
+
* **Font:** Text uses `"Press Start 2P", monospace` by default.
|
|
2419
|
+
* You MUST declare that font yourself in `index.html` (for example via Google
|
|
2420
|
+
* Fonts) before calling {@link Game.start}. MinimoJS does NOT inject or
|
|
2421
|
+
* download external fonts for you.
|
|
2422
|
+
*
|
|
2423
|
+
* `Game.start()` waits for the default font and for any extra families you
|
|
2424
|
+
* registered with {@link Game.requireFont}. If a font is unavailable, the
|
|
2425
|
+
* browser falls back to `monospace`.
|
|
2426
|
+
*
|
|
2427
|
+
* Prefer {@link TextSprite} for UI buttons or labels that need persistent
|
|
2428
|
+
* bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
|
|
2429
|
+
* `drawText()` is the lightweight overlay API, not the primary UI API.
|
|
2430
|
+
*
|
|
2431
|
+
* For controls that are only a single emoji, prefer {@link EmojiSprite} over
|
|
2432
|
+
* {@link TextSprite}. Emoji-only buttons do not benefit from text padding and
|
|
2433
|
+
* usually fit more naturally in the sprite pipeline.
|
|
1147
2434
|
*
|
|
1148
2435
|
* @param text - The string to render. Supports emoji and Unicode.
|
|
1149
2436
|
* @param x - X position in **screen space** (pixels from canvas left edge).
|
|
@@ -1164,11 +2451,68 @@ export class Game {
|
|
|
1164
2451
|
* ```
|
|
1165
2452
|
*/
|
|
1166
2453
|
drawText(text, x, y, fontSize, color = "#ffffff", centered = false) {
|
|
1167
|
-
this.
|
|
2454
|
+
this._textSystem.drawText(text, x, y, fontSize, color, centered);
|
|
1168
2455
|
}
|
|
1169
2456
|
// -------------------------------------------------------------------------
|
|
1170
2457
|
// Misc
|
|
1171
2458
|
// -------------------------------------------------------------------------
|
|
2459
|
+
/**
|
|
2460
|
+
* Clears the internal sprite glyph cache.
|
|
2461
|
+
*
|
|
2462
|
+
* MinimoJS prerenders sprite glyphs into offscreen canvases for more stable
|
|
2463
|
+
* emoji rendering and better performance. In long-running sessions, you can
|
|
2464
|
+
* call this to release cached glyph variants and force them to be rebuilt on
|
|
2465
|
+
* the next render.
|
|
2466
|
+
*
|
|
2467
|
+
* This does not change any sprite state. It only clears cached render data.
|
|
2468
|
+
*
|
|
2469
|
+
* @example
|
|
2470
|
+
* ```ts
|
|
2471
|
+
* game.clearSpriteCache();
|
|
2472
|
+
* ```
|
|
2473
|
+
*/
|
|
2474
|
+
clearSpriteCache() {
|
|
2475
|
+
this._renderSystem.clearSpriteCache();
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Registers a web font family that must be loaded before the first frame.
|
|
2479
|
+
*
|
|
2480
|
+
* Call this before {@link Game.start}, typically right after constructing the
|
|
2481
|
+
* game or inside {@link Game.onPreload}. MinimoJS waits for every registered
|
|
2482
|
+
* font via `document.fonts.load(...)` before rendering anything. Host
|
|
2483
|
+
* environments may also pre-register fonts by setting
|
|
2484
|
+
* `globalThis.__MINIMO_WEB_FONT_REQUIREMENTS__` before your game code runs.
|
|
2485
|
+
*
|
|
2486
|
+
* The font itself must still be declared by your page (for example with a
|
|
2487
|
+
* `<link rel="stylesheet">` in `index.html`). MinimoJS only waits for it.
|
|
2488
|
+
*
|
|
2489
|
+
* @example
|
|
2490
|
+
* ```ts
|
|
2491
|
+
* game.requireFont('"Press Start 2P"');
|
|
2492
|
+
* game.requireFont('"Bangers"', { weight: "700" });
|
|
2493
|
+
* ```
|
|
2494
|
+
*/
|
|
2495
|
+
requireFont(family, options = {}) {
|
|
2496
|
+
const normalizedFamily = family.trim();
|
|
2497
|
+
if (normalizedFamily.length === 0) {
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
const requirement = {
|
|
2501
|
+
family: normalizedFamily,
|
|
2502
|
+
weight: options.weight ?? "400",
|
|
2503
|
+
style: options.style ?? "normal",
|
|
2504
|
+
size: Number.isFinite(options.size) ? Math.max(1, options.size) : 16,
|
|
2505
|
+
sampleText: options.sampleText ?? "",
|
|
2506
|
+
};
|
|
2507
|
+
const key = [
|
|
2508
|
+
requirement.family,
|
|
2509
|
+
requirement.weight,
|
|
2510
|
+
requirement.style,
|
|
2511
|
+
requirement.size,
|
|
2512
|
+
requirement.sampleText,
|
|
2513
|
+
].join("|");
|
|
2514
|
+
this._requiredFonts.set(key, requirement);
|
|
2515
|
+
}
|
|
1172
2516
|
/**
|
|
1173
2517
|
* Returns a pseudo-random floating-point number in the range `[0, 1)`.
|
|
1174
2518
|
* Delegates to `Math.random()`.
|
|
@@ -1192,9 +2536,12 @@ export class Game {
|
|
|
1192
2536
|
* Performs a full engine state reset to enable scene switching.
|
|
1193
2537
|
*
|
|
1194
2538
|
* **Cleared by reset:**
|
|
2539
|
+
* - All background layers
|
|
1195
2540
|
* - All sprites (equivalent to calling {@link Game.destroySprite} on every sprite)
|
|
1196
2541
|
* - All timers (regardless of repeat state)
|
|
1197
2542
|
* - All running animations
|
|
2543
|
+
* - All active explosion effects
|
|
2544
|
+
* - All active trail effects
|
|
1198
2545
|
* - All pending text overlays
|
|
1199
2546
|
* - Scroll position (`scrollX = 0`, `scrollY = 0`)
|
|
1200
2547
|
* - Per-frame input state (pressed keys, pressed pointer)
|
|
@@ -1207,39 +2554,73 @@ export class Game {
|
|
|
1207
2554
|
* - Canvas dimensions
|
|
1208
2555
|
* - AudioContext
|
|
1209
2556
|
*
|
|
1210
|
-
* After clearing, `reset()` immediately calls
|
|
1211
|
-
*
|
|
2557
|
+
* After clearing, `reset()` immediately calls the active scene's
|
|
2558
|
+
* `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
|
|
2559
|
+
* can rebuild synchronously.
|
|
2560
|
+
*
|
|
2561
|
+
* @param scene - Optional new scene to make active before rebuilding.
|
|
1212
2562
|
*
|
|
1213
2563
|
* @example
|
|
1214
2564
|
* ```ts
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
*
|
|
1224
|
-
* game.add(skull);
|
|
1225
|
-
* game.addTimer(3000, false, () => game.reset()); // auto-restart
|
|
1226
|
-
* };
|
|
2565
|
+
* class GameOverScene {
|
|
2566
|
+
* onCreate() {
|
|
2567
|
+
* const skull = new EmojiSprite("💀", 400, 300, 96);
|
|
2568
|
+
* game.add(skull);
|
|
2569
|
+
* game.addTimer(3000, false, () => game.reset());
|
|
2570
|
+
* }
|
|
2571
|
+
* }
|
|
2572
|
+
*
|
|
2573
|
+
* game.reset(new GameOverScene());
|
|
1227
2574
|
* ```
|
|
1228
2575
|
*/
|
|
1229
|
-
reset() {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
this._animations = [];
|
|
1233
|
-
this._textOverlays = [];
|
|
1234
|
-
this._keysPressed.clear();
|
|
1235
|
-
this._mousePressed = false;
|
|
1236
|
-
for (const pointer of this._touchPointers.values()) {
|
|
1237
|
-
pointer.pressed = false;
|
|
2576
|
+
reset(scene) {
|
|
2577
|
+
if (scene !== undefined) {
|
|
2578
|
+
this._currentScene = scene;
|
|
1238
2579
|
}
|
|
1239
|
-
this.
|
|
2580
|
+
this._transitionSystem.clear();
|
|
2581
|
+
this._backgroundSystem.clearAll();
|
|
2582
|
+
this._spriteSystem.clearAll();
|
|
2583
|
+
this._timerSystem.clearAll();
|
|
2584
|
+
this._animationSystem.clearAll();
|
|
2585
|
+
this._explosionSystem.clearAll();
|
|
2586
|
+
this._trailSystem.clearAll();
|
|
2587
|
+
this._textSystem.clear();
|
|
2588
|
+
this._inputSystem.resetPressedState();
|
|
1240
2589
|
this.scrollX = 0;
|
|
1241
2590
|
this.scrollY = 0;
|
|
1242
|
-
this.
|
|
2591
|
+
this._loopSystem.invokeCreate();
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Changes to a new scene using a full-screen transition between frozen scene
|
|
2595
|
+
* snapshots.
|
|
2596
|
+
*
|
|
2597
|
+
* `transitionTo()` does not change the behavior of {@link Game.reset}. It
|
|
2598
|
+
* captures the current scene as a snapshot, rebuilds the target scene
|
|
2599
|
+
* immediately, captures the new scene as another snapshot, and then animates
|
|
2600
|
+
* between those two images for the requested duration.
|
|
2601
|
+
*
|
|
2602
|
+
* While the transition is active, gameplay updates are paused.
|
|
2603
|
+
*
|
|
2604
|
+
* If the game loop is not running yet, this falls back to an immediate
|
|
2605
|
+
* {@link Game.reset}.
|
|
2606
|
+
*
|
|
2607
|
+
* @param scene - Target scene to make active.
|
|
2608
|
+
* @param options - Transition style and optional tuning values.
|
|
2609
|
+
* @param onComplete - Optional callback invoked when the transition finishes.
|
|
2610
|
+
*/
|
|
2611
|
+
transitionTo(scene, options, onComplete) {
|
|
2612
|
+
if (!this._loopSystem.isRunning || !this._hasCompletedPreload) {
|
|
2613
|
+
this.reset(scene);
|
|
2614
|
+
onComplete?.();
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
if (this._transitionSystem.isActive) {
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
const fromCanvas = this._captureFrameSnapshot();
|
|
2621
|
+
this.reset(scene);
|
|
2622
|
+
const toCanvas = this._captureFrameSnapshot();
|
|
2623
|
+
this._transitionSystem.start(fromCanvas, toCanvas, this.width, this.height, options, onComplete);
|
|
1243
2624
|
}
|
|
1244
2625
|
// -------------------------------------------------------------------------
|
|
1245
2626
|
// Loop control
|
|
@@ -1247,404 +2628,290 @@ export class Game {
|
|
|
1247
2628
|
/**
|
|
1248
2629
|
* Starts the `requestAnimationFrame` game loop.
|
|
1249
2630
|
* Safe to call multiple times — does nothing if already running.
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
2631
|
+
* On the first start, if {@link Game.onPreload} is set, MinimoJS first runs
|
|
2632
|
+
* asset registration, loads queued images, waits for all fonts registered via
|
|
2633
|
+
* {@link Game.requireFont}, and only then, if images were queued, shows a
|
|
2634
|
+
* default loading screen while those image assets are still loading.
|
|
2635
|
+
* After preload completes, the active scene's `onCreate()` is called before
|
|
2636
|
+
* the first frame.
|
|
2637
|
+
*
|
|
2638
|
+
* The loop calls the active scene's `onUpdate(dt)` (or legacy
|
|
2639
|
+
* {@link Game.onUpdate}) once per frame, then renders all sprites and text
|
|
2640
|
+
* overlays. Order per frame:
|
|
1255
2641
|
* 1. Accumulate timer elapsed time; fire ready callbacks.
|
|
1256
2642
|
* 2. Advance animations (linear interpolation).
|
|
1257
|
-
* 3.
|
|
1258
|
-
* 4. Apply
|
|
1259
|
-
* 5.
|
|
1260
|
-
* 6.
|
|
1261
|
-
* 7.
|
|
1262
|
-
* 8.
|
|
2643
|
+
* 3. Advance active piece and wipe effects.
|
|
2644
|
+
* 4. Apply gravity to sprite velocities.
|
|
2645
|
+
* 5. Apply velocities to sprite positions.
|
|
2646
|
+
* 6. Call `onUpdate(dt)`.
|
|
2647
|
+
* 7. Advance active trail effects.
|
|
2648
|
+
* 8. Render sprites/effects (with scroll offset).
|
|
2649
|
+
* 9. Render text overlays (screen space, on top of sprites).
|
|
2650
|
+
* 10. Clear per-frame input state.
|
|
1263
2651
|
*
|
|
1264
2652
|
* @example
|
|
1265
2653
|
* ```ts
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
2654
|
+
* class DemoScene {
|
|
2655
|
+
* onCreate() {}
|
|
2656
|
+
* onUpdate(dt: number) {}
|
|
2657
|
+
* }
|
|
2658
|
+
*
|
|
2659
|
+
* game.start(new DemoScene());
|
|
1268
2660
|
* ```
|
|
1269
2661
|
*/
|
|
1270
|
-
start() {
|
|
1271
|
-
if (
|
|
2662
|
+
start(scene) {
|
|
2663
|
+
if (scene !== undefined) {
|
|
2664
|
+
this._currentScene = scene;
|
|
2665
|
+
}
|
|
2666
|
+
this.applyGlobalFontRequirements();
|
|
2667
|
+
if (this._hasCompletedPreload) {
|
|
2668
|
+
this._loopSystem.start();
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
if (this._preloadPromise !== null) {
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
if (this.onPreload !== null) {
|
|
2675
|
+
this._isRegisteringPreloadAssets = true;
|
|
2676
|
+
try {
|
|
2677
|
+
this.onPreload();
|
|
2678
|
+
}
|
|
2679
|
+
finally {
|
|
2680
|
+
this._isRegisteringPreloadAssets = false;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
const totalAssets = this._assetSystem.getQueuedImageCount();
|
|
2684
|
+
let fontsReady = false;
|
|
2685
|
+
let loadingLoaded = 0;
|
|
2686
|
+
let loadingCurrentKey = null;
|
|
2687
|
+
const renderLoadingProgress = () => {
|
|
2688
|
+
if (!fontsReady || totalAssets === 0) {
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
this._renderLoadingScreen(loadingLoaded, totalAssets, loadingCurrentKey);
|
|
2692
|
+
};
|
|
2693
|
+
const fontReadyPromise = this.waitForDocumentFonts().then(() => {
|
|
2694
|
+
fontsReady = true;
|
|
2695
|
+
renderLoadingProgress();
|
|
2696
|
+
});
|
|
2697
|
+
if (totalAssets === 0) {
|
|
2698
|
+
this._preloadPromise = fontReadyPromise
|
|
2699
|
+
.then(() => {
|
|
2700
|
+
this._hasCompletedPreload = true;
|
|
2701
|
+
this._preloadPromise = null;
|
|
2702
|
+
this._loopSystem.start();
|
|
2703
|
+
})
|
|
2704
|
+
.catch((error) => {
|
|
2705
|
+
this._preloadPromise = null;
|
|
2706
|
+
throw error;
|
|
2707
|
+
});
|
|
1272
2708
|
return;
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
2709
|
+
}
|
|
2710
|
+
const assetPromise = this._assetSystem.loadQueuedImages((loaded, total, currentKey) => {
|
|
2711
|
+
loadingLoaded = loaded;
|
|
2712
|
+
loadingCurrentKey = currentKey;
|
|
2713
|
+
if (!fontsReady)
|
|
2714
|
+
return;
|
|
2715
|
+
this._renderLoadingScreen(loaded, total, currentKey);
|
|
2716
|
+
});
|
|
2717
|
+
this._preloadPromise = Promise.all([assetPromise, fontReadyPromise])
|
|
2718
|
+
.then(() => {
|
|
2719
|
+
this._hasCompletedPreload = true;
|
|
2720
|
+
this._preloadPromise = null;
|
|
2721
|
+
this._loopSystem.start();
|
|
2722
|
+
})
|
|
2723
|
+
.catch((error) => {
|
|
2724
|
+
this._preloadPromise = null;
|
|
2725
|
+
throw error;
|
|
2726
|
+
});
|
|
1278
2727
|
}
|
|
1279
2728
|
/**
|
|
1280
2729
|
* Stops the game loop. The canvas retains its last rendered frame.
|
|
1281
2730
|
* Call {@link Game.start} to resume.
|
|
1282
2731
|
*/
|
|
1283
2732
|
stop() {
|
|
1284
|
-
this.
|
|
1285
|
-
if (this._rafId !== null) {
|
|
1286
|
-
cancelAnimationFrame(this._rafId);
|
|
1287
|
-
this._rafId = null;
|
|
1288
|
-
}
|
|
1289
|
-
this._lastTimestamp = null;
|
|
2733
|
+
this._loopSystem.stop();
|
|
1290
2734
|
}
|
|
1291
2735
|
// -------------------------------------------------------------------------
|
|
1292
|
-
// Private —
|
|
2736
|
+
// Private — rendering
|
|
1293
2737
|
// -------------------------------------------------------------------------
|
|
1294
|
-
/** @internal */
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
this._keysDown.add(e.key);
|
|
1301
|
-
});
|
|
1302
|
-
window.addEventListener("keyup", (e) => {
|
|
1303
|
-
this._keysDown.delete(e.key);
|
|
1304
|
-
});
|
|
1305
|
-
const getCanvasPoint = (clientX, clientY) => {
|
|
1306
|
-
const rect = this._canvas.getBoundingClientRect();
|
|
1307
|
-
const scaleX = this._canvas.width / rect.width;
|
|
1308
|
-
const scaleY = this._canvas.height / rect.height;
|
|
1309
|
-
return {
|
|
1310
|
-
x: (clientX - rect.left) * scaleX,
|
|
1311
|
-
y: (clientY - rect.top) * scaleY,
|
|
1312
|
-
};
|
|
1313
|
-
};
|
|
1314
|
-
this._canvas.addEventListener("mousedown", (e) => {
|
|
1315
|
-
const p = getCanvasPoint(e.clientX, e.clientY);
|
|
1316
|
-
this._mouseX = p.x;
|
|
1317
|
-
this._mouseY = p.y;
|
|
1318
|
-
this._mouseDown = true;
|
|
1319
|
-
this._mousePressed = true;
|
|
1320
|
-
this._syncPrimaryPointer();
|
|
1321
|
-
});
|
|
1322
|
-
this._canvas.addEventListener("mouseup", () => {
|
|
1323
|
-
this._mouseDown = false;
|
|
1324
|
-
this._syncPrimaryPointer();
|
|
1325
|
-
});
|
|
1326
|
-
this._canvas.addEventListener("mousemove", (e) => {
|
|
1327
|
-
const p = getCanvasPoint(e.clientX, e.clientY);
|
|
1328
|
-
this._mouseX = p.x;
|
|
1329
|
-
this._mouseY = p.y;
|
|
1330
|
-
this._syncPrimaryPointer();
|
|
1331
|
-
});
|
|
1332
|
-
this._canvas.addEventListener("touchstart", (e) => {
|
|
1333
|
-
for (const touch of Array.from(e.changedTouches)) {
|
|
1334
|
-
const p = getCanvasPoint(touch.clientX, touch.clientY);
|
|
1335
|
-
this._touchPointers.set(touch.identifier, {
|
|
1336
|
-
x: p.x,
|
|
1337
|
-
y: p.y,
|
|
1338
|
-
pressed: true,
|
|
1339
|
-
});
|
|
1340
|
-
if (this._primaryTouchId === null) {
|
|
1341
|
-
this._primaryTouchId = touch.identifier;
|
|
1342
|
-
}
|
|
2738
|
+
/** @internal */ _onLoopFrameCallback(dt, dtMs) {
|
|
2739
|
+
this._renderSystem.beginDynamicSurfacePass();
|
|
2740
|
+
if (this._transitionSystem.isActive) {
|
|
2741
|
+
this._transitionSystem.update(dtMs);
|
|
2742
|
+
if (this._transitionSystem.isActive) {
|
|
2743
|
+
this._renderTransition();
|
|
1343
2744
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
}, { passive: false });
|
|
1347
|
-
this._canvas.addEventListener("touchend", (e) => {
|
|
1348
|
-
for (const touch of Array.from(e.changedTouches)) {
|
|
1349
|
-
this._touchPointers.delete(touch.identifier);
|
|
1350
|
-
if (this._primaryTouchId === touch.identifier) {
|
|
1351
|
-
this._primaryTouchId = null;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
this._syncPrimaryPointer();
|
|
1355
|
-
e.preventDefault();
|
|
1356
|
-
}, { passive: false });
|
|
1357
|
-
this._canvas.addEventListener("touchcancel", (e) => {
|
|
1358
|
-
for (const touch of Array.from(e.changedTouches)) {
|
|
1359
|
-
this._touchPointers.delete(touch.identifier);
|
|
1360
|
-
if (this._primaryTouchId === touch.identifier) {
|
|
1361
|
-
this._primaryTouchId = null;
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
this._syncPrimaryPointer();
|
|
1365
|
-
e.preventDefault();
|
|
1366
|
-
}, { passive: false });
|
|
1367
|
-
this._canvas.addEventListener("touchmove", (e) => {
|
|
1368
|
-
for (const touch of Array.from(e.changedTouches)) {
|
|
1369
|
-
const p = getCanvasPoint(touch.clientX, touch.clientY);
|
|
1370
|
-
const existing = this._touchPointers.get(touch.identifier);
|
|
1371
|
-
if (existing) {
|
|
1372
|
-
existing.x = p.x;
|
|
1373
|
-
existing.y = p.y;
|
|
1374
|
-
}
|
|
1375
|
-
else {
|
|
1376
|
-
this._touchPointers.set(touch.identifier, {
|
|
1377
|
-
x: p.x,
|
|
1378
|
-
y: p.y,
|
|
1379
|
-
pressed: false,
|
|
1380
|
-
});
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
this._syncPrimaryPointer();
|
|
1384
|
-
e.preventDefault();
|
|
1385
|
-
}, { passive: false });
|
|
1386
|
-
}
|
|
1387
|
-
/** @internal */
|
|
1388
|
-
_isScreenPointOverSprite(x, y, sprite, radiusScale) {
|
|
1389
|
-
const safeScale = Math.max(0, radiusScale);
|
|
1390
|
-
const radius = sprite.size * safeScale;
|
|
1391
|
-
const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
|
|
1392
|
-
const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
|
|
1393
|
-
const dx = x - drawX;
|
|
1394
|
-
const dy = y - drawY;
|
|
1395
|
-
return dx * dx + dy * dy <= radius * radius;
|
|
1396
|
-
}
|
|
1397
|
-
/** @internal */
|
|
1398
|
-
_collectPointers() {
|
|
1399
|
-
const pointers = [];
|
|
1400
|
-
if (this._mouseDown) {
|
|
1401
|
-
pointers.push({
|
|
1402
|
-
id: "mouse",
|
|
1403
|
-
kind: "mouse",
|
|
1404
|
-
x: this._mouseX,
|
|
1405
|
-
y: this._mouseY,
|
|
1406
|
-
pressed: this._mousePressed,
|
|
1407
|
-
});
|
|
1408
|
-
}
|
|
1409
|
-
for (const [id, pointer] of this._touchPointers.entries()) {
|
|
1410
|
-
pointers.push({
|
|
1411
|
-
id,
|
|
1412
|
-
kind: "touch",
|
|
1413
|
-
x: pointer.x,
|
|
1414
|
-
y: pointer.y,
|
|
1415
|
-
pressed: pointer.pressed,
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
|
-
return pointers;
|
|
1419
|
-
}
|
|
1420
|
-
/** @internal */
|
|
1421
|
-
_syncPrimaryPointer() {
|
|
1422
|
-
if (this._primaryTouchId !== null) {
|
|
1423
|
-
const primaryTouch = this._touchPointers.get(this._primaryTouchId);
|
|
1424
|
-
if (primaryTouch) {
|
|
1425
|
-
this._pointerX = primaryTouch.x;
|
|
1426
|
-
this._pointerY = primaryTouch.y;
|
|
1427
|
-
this._pointerDown = true;
|
|
1428
|
-
this._pointerPressed = primaryTouch.pressed;
|
|
1429
|
-
return;
|
|
2745
|
+
else {
|
|
2746
|
+
this._render();
|
|
1430
2747
|
}
|
|
1431
|
-
this.
|
|
2748
|
+
this._inputSystem.clearFramePressedState();
|
|
2749
|
+
this._textSystem.clear();
|
|
2750
|
+
return;
|
|
1432
2751
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
this._pointerY = touch.y;
|
|
1440
|
-
this._pointerDown = true;
|
|
1441
|
-
this._pointerPressed = touch.pressed;
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
2752
|
+
this._timerSystem.update(dtMs);
|
|
2753
|
+
if (this._transitionSystem.isActive) {
|
|
2754
|
+
this._renderTransition();
|
|
2755
|
+
this._inputSystem.clearFramePressedState();
|
|
2756
|
+
this._textSystem.clear();
|
|
2757
|
+
return;
|
|
1444
2758
|
}
|
|
1445
|
-
this.
|
|
1446
|
-
this.
|
|
1447
|
-
this.
|
|
1448
|
-
this.
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
// Private — rAF loop
|
|
1453
|
-
// -------------------------------------------------------------------------
|
|
1454
|
-
/** @internal */
|
|
1455
|
-
_loop(timestamp) {
|
|
1456
|
-
if (!this._running)
|
|
2759
|
+
this._animationSystem.update(dtMs);
|
|
2760
|
+
this._explosionSystem.update(dt, dtMs);
|
|
2761
|
+
this._physicsSystem.update(this._spriteSystem.getMutableSprites(), dt);
|
|
2762
|
+
if (this._transitionSystem.isActive) {
|
|
2763
|
+
this._renderTransition();
|
|
2764
|
+
this._inputSystem.clearFramePressedState();
|
|
2765
|
+
this._textSystem.clear();
|
|
1457
2766
|
return;
|
|
1458
|
-
if (this._lastTimestamp === null) {
|
|
1459
|
-
this._lastTimestamp = timestamp;
|
|
1460
2767
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
if (
|
|
1464
|
-
dt = 0.1;
|
|
1465
|
-
const dtMs = dt * 1000;
|
|
1466
|
-
this._updateTimers(dtMs);
|
|
1467
|
-
this._updateAnimations(dtMs);
|
|
1468
|
-
this._updatePhysics(dt);
|
|
1469
|
-
if (this.onUpdate)
|
|
2768
|
+
if (this._currentScene?.onUpdate)
|
|
2769
|
+
this._currentScene.onUpdate(dt);
|
|
2770
|
+
else if (this.onUpdate)
|
|
1470
2771
|
this.onUpdate(dt);
|
|
1471
|
-
this.
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
2772
|
+
if (this._transitionSystem.isActive) {
|
|
2773
|
+
this._renderTransition();
|
|
2774
|
+
this._inputSystem.clearFramePressedState();
|
|
2775
|
+
this._textSystem.clear();
|
|
2776
|
+
return;
|
|
1476
2777
|
}
|
|
1477
|
-
this.
|
|
1478
|
-
this.
|
|
1479
|
-
this.
|
|
2778
|
+
this._trailSystem.update(dtMs, this._renderSystem.getSpriteRenderSnapshot.bind(this._renderSystem));
|
|
2779
|
+
this._render();
|
|
2780
|
+
this._inputSystem.clearFramePressedState();
|
|
2781
|
+
this._textSystem.clear();
|
|
1480
2782
|
}
|
|
1481
2783
|
/** @internal */
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
if (timer.elapsed >= timer.delayMs) {
|
|
1488
|
-
toFire.push(timer);
|
|
1489
|
-
if (timer.repeat) {
|
|
1490
|
-
timer.elapsed -= timer.delayMs;
|
|
1491
|
-
}
|
|
1492
|
-
else {
|
|
1493
|
-
toRemove.push(timer.id);
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
if (toRemove.length > 0) {
|
|
1498
|
-
this._timers = this._timers.filter((t) => !toRemove.includes(t.id));
|
|
1499
|
-
}
|
|
1500
|
-
for (const timer of toFire) {
|
|
1501
|
-
timer.callback();
|
|
1502
|
-
}
|
|
2784
|
+
_invokeCreate() {
|
|
2785
|
+
if (this._currentScene?.onCreate)
|
|
2786
|
+
this._currentScene.onCreate();
|
|
2787
|
+
else
|
|
2788
|
+
this._onCreate?.();
|
|
1503
2789
|
}
|
|
1504
2790
|
/** @internal */
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
const anim = this._animations[i];
|
|
1509
|
-
anim.elapsed += dtMs;
|
|
1510
|
-
const t = Math.min(anim.elapsed / anim.durationMs, 1);
|
|
1511
|
-
anim.sprite[anim.property] = anim.from + (anim.to - anim.from) * t;
|
|
1512
|
-
if (t >= 1) {
|
|
1513
|
-
toRemove.push(i);
|
|
1514
|
-
anim.onComplete?.();
|
|
1515
|
-
}
|
|
2791
|
+
waitForDocumentFonts() {
|
|
2792
|
+
if (typeof document === "undefined") {
|
|
2793
|
+
return Promise.resolve();
|
|
1516
2794
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
2795
|
+
const fontSet = document.fonts;
|
|
2796
|
+
if (!fontSet || typeof fontSet.ready?.then !== "function") {
|
|
2797
|
+
return Promise.resolve();
|
|
1519
2798
|
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
sprite.vx += this.gravityX * sprite.gravityScale * dt;
|
|
1526
|
-
sprite.vy += this.gravityY * sprite.gravityScale * dt;
|
|
2799
|
+
const fontLoads = [];
|
|
2800
|
+
if (typeof fontSet.load === "function") {
|
|
2801
|
+
for (const requirement of this._requiredFonts.values()) {
|
|
2802
|
+
const descriptor = `${requirement.style} ${requirement.weight} ${requirement.size}px ${requirement.family}`;
|
|
2803
|
+
fontLoads.push(fontSet.load(descriptor, requirement.sampleText).catch(() => []));
|
|
1527
2804
|
}
|
|
1528
|
-
sprite.x += sprite.vx * dt;
|
|
1529
|
-
sprite.y += sprite.vy * dt;
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
// -------------------------------------------------------------------------
|
|
1533
|
-
// Private — rendering
|
|
1534
|
-
// -------------------------------------------------------------------------
|
|
1535
|
-
/** @internal */
|
|
1536
|
-
_getSpriteGlyphCanvas(sprite) {
|
|
1537
|
-
const size = Math.max(1, Math.round(sprite.size));
|
|
1538
|
-
const cacheKey = `${sprite.sprite}::${size}`;
|
|
1539
|
-
const cached = this._spriteGlyphCache.get(cacheKey);
|
|
1540
|
-
if (cached)
|
|
1541
|
-
return cached;
|
|
1542
|
-
const glyphCanvas = document.createElement("canvas");
|
|
1543
|
-
const boxSize = Math.max(2, Math.ceil(size * 2));
|
|
1544
|
-
glyphCanvas.width = boxSize;
|
|
1545
|
-
glyphCanvas.height = boxSize;
|
|
1546
|
-
const glyphCtx = glyphCanvas.getContext("2d");
|
|
1547
|
-
if (!glyphCtx) {
|
|
1548
|
-
throw new Error("MinimoJS: Could not acquire a glyph rendering context.");
|
|
1549
2805
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
glyphCtx.shadowBlur = 0;
|
|
1553
|
-
glyphCtx.shadowOffsetX = 0;
|
|
1554
|
-
glyphCtx.shadowOffsetY = 0;
|
|
1555
|
-
glyphCtx.fillStyle = "#ffffff";
|
|
1556
|
-
glyphCtx.font = `${size}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
|
1557
|
-
glyphCtx.textAlign = "center";
|
|
1558
|
-
glyphCtx.textBaseline = "middle";
|
|
1559
|
-
glyphCtx.fillText(sprite.sprite, boxSize / 2, boxSize / 2);
|
|
1560
|
-
this._spriteGlyphCache.set(cacheKey, glyphCanvas);
|
|
1561
|
-
return glyphCanvas;
|
|
2806
|
+
fontLoads.push(fontSet.ready);
|
|
2807
|
+
return Promise.all(fontLoads).then(() => undefined);
|
|
1562
2808
|
}
|
|
1563
2809
|
/** @internal */
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
const H = this._canvas.height;
|
|
1568
|
-
this._applyPageBackground();
|
|
1569
|
-
ctx.clearRect(0, 0, W, H);
|
|
1570
|
-
if (this.backgroundGradient !== null) {
|
|
1571
|
-
const gradient = ctx.createLinearGradient(0, 0, 0, H);
|
|
1572
|
-
gradient.addColorStop(0, this.backgroundGradient.from);
|
|
1573
|
-
gradient.addColorStop(1, this.backgroundGradient.to);
|
|
1574
|
-
ctx.fillStyle = gradient;
|
|
1575
|
-
ctx.fillRect(0, 0, W, H);
|
|
2810
|
+
applyGlobalFontRequirements() {
|
|
2811
|
+
if (this._hasAppliedGlobalFontRequirements) {
|
|
2812
|
+
return;
|
|
1576
2813
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
2814
|
+
this._hasAppliedGlobalFontRequirements = true;
|
|
2815
|
+
const root = globalThis;
|
|
2816
|
+
const requirements = root.__MINIMO_WEB_FONT_REQUIREMENTS__;
|
|
2817
|
+
if (!Array.isArray(requirements)) {
|
|
2818
|
+
return;
|
|
1580
2819
|
}
|
|
1581
|
-
const
|
|
1582
|
-
|
|
1583
|
-
if (!sprite.visible)
|
|
2820
|
+
for (const entry of requirements) {
|
|
2821
|
+
if (!entry || typeof entry !== "object") {
|
|
1584
2822
|
continue;
|
|
1585
|
-
ctx.save();
|
|
1586
|
-
ctx.globalAlpha = Math.max(0, Math.min(1, sprite.alpha));
|
|
1587
|
-
const drawX = Math.round(sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX);
|
|
1588
|
-
const drawY = Math.round(sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY);
|
|
1589
|
-
ctx.translate(drawX, drawY);
|
|
1590
|
-
if (sprite.rotation !== 0) {
|
|
1591
|
-
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
|
1592
2823
|
}
|
|
1593
|
-
|
|
1594
|
-
|
|
2824
|
+
const requirement = entry;
|
|
2825
|
+
const family = typeof requirement.family === "string" ? requirement.family.trim() : "";
|
|
2826
|
+
if (!family) {
|
|
2827
|
+
continue;
|
|
1595
2828
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
2829
|
+
const sampleText = typeof requirement.sample_text === "string"
|
|
2830
|
+
? requirement.sample_text
|
|
2831
|
+
: typeof requirement.sampleText === "string"
|
|
2832
|
+
? requirement.sampleText
|
|
2833
|
+
: "";
|
|
2834
|
+
this.requireFont(`"${family}"`, {
|
|
2835
|
+
weight: typeof requirement.weight === "string" && requirement.weight.trim()
|
|
2836
|
+
? requirement.weight.trim()
|
|
2837
|
+
: undefined,
|
|
2838
|
+
style: requirement.style === "normal" ||
|
|
2839
|
+
requirement.style === "italic" ||
|
|
2840
|
+
requirement.style === "oblique"
|
|
2841
|
+
? requirement.style
|
|
2842
|
+
: undefined,
|
|
2843
|
+
size: typeof requirement.size === "number" && Number.isFinite(requirement.size)
|
|
2844
|
+
? requirement.size
|
|
2845
|
+
: undefined,
|
|
2846
|
+
sampleText,
|
|
2847
|
+
});
|
|
1612
2848
|
}
|
|
1613
2849
|
}
|
|
1614
|
-
/** @internal */
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
2850
|
+
/** @internal */ _render() {
|
|
2851
|
+
this._renderSystem.render({
|
|
2852
|
+
canvas: this._canvas,
|
|
2853
|
+
context: this._ctx,
|
|
2854
|
+
backgroundLayers: this._backgroundSystem.getMutableLayers(),
|
|
2855
|
+
resolveImage: this._assetSystem.getImage.bind(this._assetSystem),
|
|
2856
|
+
sprites: this._spriteSystem.getMutableSprites(),
|
|
2857
|
+
trails: this._trailSystem.getRenderEntries(),
|
|
2858
|
+
explosions: this._explosionSystem.getRenderEntries(),
|
|
2859
|
+
wipes: this._explosionSystem.getWipeEntries(),
|
|
2860
|
+
textEntries: this._textSystem.getEntries(),
|
|
2861
|
+
scrollX: this.scrollX,
|
|
2862
|
+
scrollY: this.scrollY,
|
|
2863
|
+
background: this.background,
|
|
2864
|
+
backgroundGradient: this.backgroundGradient,
|
|
2865
|
+
pageBackground: this.pageBackground,
|
|
2866
|
+
debugBodies: this.debugBodies,
|
|
2867
|
+
debugInputAreas: this.debugInputAreas,
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
/** @internal */ _renderTransition() {
|
|
2871
|
+
const transition = this._transitionSystem.getRenderEntry();
|
|
2872
|
+
if (!transition) {
|
|
2873
|
+
this._render();
|
|
1619
2874
|
return;
|
|
1620
|
-
if (this.pageBackground === null) {
|
|
1621
|
-
document.body.style.removeProperty("background");
|
|
1622
2875
|
}
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
2876
|
+
this._renderSystem.renderScreenTransition({
|
|
2877
|
+
canvas: this._canvas,
|
|
2878
|
+
context: this._ctx,
|
|
2879
|
+
pageBackground: this.pageBackground,
|
|
2880
|
+
transition,
|
|
2881
|
+
});
|
|
1627
2882
|
}
|
|
1628
|
-
/** @internal */
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2883
|
+
/** @internal */ _captureFrameSnapshot() {
|
|
2884
|
+
this._renderSystem.beginDynamicSurfacePass();
|
|
2885
|
+
return this._renderSystem.captureFrame({
|
|
2886
|
+
canvas: this._canvas,
|
|
2887
|
+
context: this._ctx,
|
|
2888
|
+
backgroundLayers: this._backgroundSystem.getMutableLayers(),
|
|
2889
|
+
resolveImage: this._assetSystem.getImage.bind(this._assetSystem),
|
|
2890
|
+
sprites: this._spriteSystem.getMutableSprites(),
|
|
2891
|
+
trails: this._trailSystem.getRenderEntries(),
|
|
2892
|
+
explosions: this._explosionSystem.getRenderEntries(),
|
|
2893
|
+
wipes: this._explosionSystem.getWipeEntries(),
|
|
2894
|
+
textEntries: this._textSystem.getEntries(),
|
|
2895
|
+
scrollX: this.scrollX,
|
|
2896
|
+
scrollY: this.scrollY,
|
|
2897
|
+
background: this.background,
|
|
2898
|
+
backgroundGradient: this.backgroundGradient,
|
|
2899
|
+
pageBackground: this.pageBackground,
|
|
2900
|
+
debugBodies: this.debugBodies,
|
|
2901
|
+
debugInputAreas: this.debugInputAreas,
|
|
2902
|
+
});
|
|
1643
2903
|
}
|
|
1644
2904
|
/** @internal */
|
|
1645
|
-
|
|
1646
|
-
this.
|
|
1647
|
-
|
|
1648
|
-
this.
|
|
2905
|
+
_renderLoadingScreen(loaded, total, currentKey) {
|
|
2906
|
+
this._renderSystem.renderLoadingScreen({
|
|
2907
|
+
canvas: this._canvas,
|
|
2908
|
+
context: this._ctx,
|
|
2909
|
+
pageBackground: this.pageBackground,
|
|
2910
|
+
background: this.background,
|
|
2911
|
+
backgroundGradient: this.backgroundGradient,
|
|
2912
|
+
loaded,
|
|
2913
|
+
total,
|
|
2914
|
+
currentKey,
|
|
2915
|
+
});
|
|
1649
2916
|
}
|
|
1650
2917
|
}
|