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