minimojs 1.0.0-alpha.23 → 1.0.0-alpha.25
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 +4 -1
- package/dist/internal/AssetSystem.js +40 -8
- package/dist/internal/RenderSystem.js +132 -21
- package/dist/minimo.d.ts +96 -26
- package/dist/minimo.js +273 -86
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,7 +87,9 @@ class DemoScene implements IScene {
|
|
|
87
87
|
game.start(new DemoScene());
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
Note:
|
|
90
|
+
Note: text defaults to `monospace`, so a game that names no font downloads none. To use a web font, declare it in your HTML, register it with `game.requireFont(...)`, and set it as a `TextSprite`'s `fontFamily`.
|
|
91
|
+
|
|
92
|
+
Load images in `game.onPreload`, once, at boot. Only a game with many scenes that each carry many images of their own, such as one with many levels, should give scenes an `onPreload()` so each loads its images when the player reaches it; see `examples/level-loading/`.
|
|
91
93
|
|
|
92
94
|
## Examples
|
|
93
95
|
|
|
@@ -102,6 +104,7 @@ Note: `drawText()` uses `"Press Start 2P", monospace`. Load the font in your HTM
|
|
|
102
104
|
- `examples/draw-sprite/`
|
|
103
105
|
- `examples/pseudo-3d-racer/`
|
|
104
106
|
- `examples/animations/`
|
|
107
|
+
- `examples/level-loading/`
|
|
105
108
|
|
|
106
109
|
Run locally from the `minimojs` directory:
|
|
107
110
|
|
|
@@ -67,20 +67,52 @@ export class AssetSystem {
|
|
|
67
67
|
}
|
|
68
68
|
return total;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Loads every queued image that is not loaded yet, settling all of them
|
|
72
|
+
* before returning instead of stopping at the first failure.
|
|
73
|
+
*
|
|
74
|
+
* Loaded images leave the queue; failed ones stay in it, so a retry loads
|
|
75
|
+
* only them. Returns the keys that failed, empty when all loaded.
|
|
76
|
+
*/
|
|
70
77
|
async loadQueuedImages(onProgress) {
|
|
71
78
|
const entries = [...this._queuedImages.entries()].filter(([key]) => !this._loadedImages.has(key));
|
|
72
79
|
const total = entries.length;
|
|
73
|
-
let
|
|
80
|
+
let settled = 0;
|
|
81
|
+
const failed = [];
|
|
74
82
|
onProgress?.(0, total, null);
|
|
75
|
-
if (total === 0)
|
|
76
|
-
|
|
83
|
+
if (total === 0) {
|
|
84
|
+
this._queuedImages.clear();
|
|
85
|
+
return failed;
|
|
86
|
+
}
|
|
77
87
|
await Promise.all(entries.map(async ([key, src]) => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
try {
|
|
89
|
+
const image = await this.loadImage(src);
|
|
90
|
+
this._loadedImages.set(key, image);
|
|
91
|
+
this.bumpImageVersion(key);
|
|
92
|
+
this._queuedImages.delete(key);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
failed.push(key);
|
|
96
|
+
}
|
|
97
|
+
settled += 1;
|
|
98
|
+
onProgress?.(settled, total, key);
|
|
83
99
|
}));
|
|
100
|
+
for (const key of [...this._queuedImages.keys()]) {
|
|
101
|
+
if (this._loadedImages.has(key)) {
|
|
102
|
+
this._queuedImages.delete(key);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return failed;
|
|
106
|
+
}
|
|
107
|
+
/** Returns the source registered for an image key, if any. */
|
|
108
|
+
getImageSource(key) {
|
|
109
|
+
return this._imageSources.get(key);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Drops queued images that were not loaded, so a later load does not pick
|
|
113
|
+
* them up. Their keys may be registered again.
|
|
114
|
+
*/
|
|
115
|
+
discardQueuedImages() {
|
|
84
116
|
this._queuedImages.clear();
|
|
85
117
|
}
|
|
86
118
|
/** @internal */
|
|
@@ -6,6 +6,8 @@ export class RenderSystem {
|
|
|
6
6
|
/** @internal */
|
|
7
7
|
this._lastAppliedPageBackground = undefined;
|
|
8
8
|
/** @internal */
|
|
9
|
+
this._bootInk = null;
|
|
10
|
+
/** @internal */
|
|
9
11
|
this._transitionScratchCanvas = null;
|
|
10
12
|
/** @internal */
|
|
11
13
|
this._transitionScratchContext = null;
|
|
@@ -199,7 +201,7 @@ export class RenderSystem {
|
|
|
199
201
|
}
|
|
200
202
|
for (const entry of options.textEntries) {
|
|
201
203
|
ctx.save();
|
|
202
|
-
ctx.font = `${entry.fontSize}px
|
|
204
|
+
ctx.font = `${entry.fontSize}px monospace`;
|
|
203
205
|
ctx.fillStyle = entry.color;
|
|
204
206
|
ctx.textAlign = entry.centered ? "center" : "left";
|
|
205
207
|
ctx.textBaseline = entry.centered ? "middle" : "top";
|
|
@@ -263,35 +265,140 @@ export class RenderSystem {
|
|
|
263
265
|
}
|
|
264
266
|
ctx.restore();
|
|
265
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Draws the boot loading screen: the game's background and one thin
|
|
270
|
+
* progress line across its middle, with no text, so it needs no font.
|
|
271
|
+
*/
|
|
266
272
|
renderLoadingScreen(options) {
|
|
267
273
|
const ctx = options.context;
|
|
268
274
|
const W = options.canvas.width;
|
|
269
275
|
const H = options.canvas.height;
|
|
270
|
-
const
|
|
271
|
-
const safeLoaded = Math.max(0, Math.min(options.loaded, safeTotal));
|
|
272
|
-
const progress = safeTotal === 0 ? 1 : safeLoaded / safeTotal;
|
|
273
|
-
const barWidth = Math.min(420, Math.max(220, Math.floor(W * 0.46)));
|
|
274
|
-
const barHeight = 18;
|
|
275
|
-
const barX = Math.round((W - barWidth) / 2);
|
|
276
|
-
const barY = Math.round(H / 2 + 24);
|
|
276
|
+
const progress = this.clampProgress(options.progress);
|
|
277
277
|
this.applyPageBackground(options.pageBackground);
|
|
278
278
|
this.paintCanvasBackground(ctx, W, H, options.background ?? "#101820", options.backgroundGradient);
|
|
279
|
+
// The line is redrawn on every settled image; read the background once.
|
|
280
|
+
const inkKey = `${options.background}|${options.backgroundGradient?.from}|${options.backgroundGradient?.to}`;
|
|
281
|
+
if (this._bootInk === null || this._bootInk.key !== inkKey) {
|
|
282
|
+
this._bootInk = { key: inkKey, ink: this.pickLoadingInk(options.canvas) };
|
|
283
|
+
}
|
|
284
|
+
const ink = this._bootInk.ink;
|
|
285
|
+
const lineWidth = Math.round(Math.min(320, Math.max(160, W * 0.4)));
|
|
286
|
+
const x = Math.round((W - lineWidth) / 2);
|
|
287
|
+
const y = Math.round(H / 2);
|
|
279
288
|
ctx.save();
|
|
280
|
-
ctx.
|
|
281
|
-
ctx.
|
|
282
|
-
ctx.
|
|
283
|
-
ctx.
|
|
284
|
-
ctx.
|
|
285
|
-
ctx.
|
|
286
|
-
ctx.
|
|
287
|
-
ctx.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
289
|
+
ctx.lineWidth = RenderSystem.LOADING_STROKE;
|
|
290
|
+
ctx.lineCap = "round";
|
|
291
|
+
ctx.strokeStyle = ink;
|
|
292
|
+
ctx.globalAlpha = 0.22;
|
|
293
|
+
ctx.beginPath();
|
|
294
|
+
ctx.moveTo(x, y);
|
|
295
|
+
ctx.lineTo(x + lineWidth, y);
|
|
296
|
+
ctx.stroke();
|
|
297
|
+
if (progress > 0) {
|
|
298
|
+
ctx.globalAlpha = 1;
|
|
299
|
+
ctx.beginPath();
|
|
300
|
+
ctx.moveTo(x, y);
|
|
301
|
+
ctx.lineTo(x + lineWidth * progress, y);
|
|
302
|
+
ctx.stroke();
|
|
303
|
+
}
|
|
293
304
|
ctx.restore();
|
|
294
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Draws the frozen scene being left, dimmed so it reads as paused, and over
|
|
308
|
+
* its center a ring whose outline fills with the share of images loaded.
|
|
309
|
+
* The ring has no fill.
|
|
310
|
+
*/
|
|
311
|
+
renderSceneLoading(options) {
|
|
312
|
+
const ctx = options.context;
|
|
313
|
+
const W = options.canvas.width;
|
|
314
|
+
const H = options.canvas.height;
|
|
315
|
+
const reveal = this.clampProgress(options.reveal);
|
|
316
|
+
this.applyPageBackground(options.pageBackground);
|
|
317
|
+
ctx.clearRect(0, 0, W, H);
|
|
318
|
+
ctx.drawImage(options.snapshot, 0, 0, W, H);
|
|
319
|
+
if (reveal === 0)
|
|
320
|
+
return;
|
|
321
|
+
this.dimScene(ctx, W, H, reveal);
|
|
322
|
+
const progress = this.clampProgress(options.progress);
|
|
323
|
+
const radius = Math.round(Math.min(44, Math.max(20, Math.min(W, H) * 0.06)));
|
|
324
|
+
const start = -Math.PI / 2;
|
|
325
|
+
const isDarkInk = options.ink !== "#ffffff";
|
|
326
|
+
ctx.save();
|
|
327
|
+
ctx.lineWidth = RenderSystem.LOADING_STROKE;
|
|
328
|
+
ctx.lineCap = "round";
|
|
329
|
+
ctx.strokeStyle = options.ink;
|
|
330
|
+
// A soft halo in the opposite tone keeps the ring legible over busy art.
|
|
331
|
+
ctx.shadowColor = isDarkInk ? "rgba(255, 255, 255, 0.5)" : "rgba(0, 0, 0, 0.5)";
|
|
332
|
+
ctx.shadowBlur = 6;
|
|
333
|
+
ctx.globalAlpha = 0.25 * reveal;
|
|
334
|
+
ctx.beginPath();
|
|
335
|
+
ctx.arc(W / 2, H / 2, radius, 0, Math.PI * 2);
|
|
336
|
+
ctx.stroke();
|
|
337
|
+
if (progress > 0) {
|
|
338
|
+
ctx.globalAlpha = reveal;
|
|
339
|
+
ctx.beginPath();
|
|
340
|
+
ctx.arc(W / 2, H / 2, radius, start, start + Math.PI * 2 * progress);
|
|
341
|
+
ctx.stroke();
|
|
342
|
+
}
|
|
343
|
+
ctx.restore();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Chooses the ring's color against the frozen frame as it looks once fully
|
|
347
|
+
* dimmed, since that is what the ring is drawn over.
|
|
348
|
+
*/
|
|
349
|
+
pickSceneLoadingInk(snapshot) {
|
|
350
|
+
const dimmed = document.createElement("canvas");
|
|
351
|
+
dimmed.width = snapshot.width;
|
|
352
|
+
dimmed.height = snapshot.height;
|
|
353
|
+
const ctx = dimmed.getContext("2d");
|
|
354
|
+
if (!ctx)
|
|
355
|
+
return "#ffffff";
|
|
356
|
+
ctx.drawImage(snapshot, 0, 0);
|
|
357
|
+
this.dimScene(ctx, dimmed.width, dimmed.height, 1);
|
|
358
|
+
return this.pickLoadingInk(dimmed);
|
|
359
|
+
}
|
|
360
|
+
/** @internal */
|
|
361
|
+
dimScene(ctx, width, height, reveal) {
|
|
362
|
+
ctx.save();
|
|
363
|
+
ctx.globalAlpha = RenderSystem.SCENE_LOADING_DIM * reveal;
|
|
364
|
+
ctx.fillStyle = "#000000";
|
|
365
|
+
ctx.fillRect(0, 0, width, height);
|
|
366
|
+
ctx.restore();
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Chooses white or near-black loading ink from the average brightness at the
|
|
370
|
+
* center of `source`. Falls back to white when the pixels cannot be read,
|
|
371
|
+
* which happens when a cross-origin image has tainted the canvas.
|
|
372
|
+
*/
|
|
373
|
+
pickLoadingInk(source) {
|
|
374
|
+
const size = Math.max(1, Math.min(96, source.width, source.height));
|
|
375
|
+
const x = Math.floor((source.width - size) / 2);
|
|
376
|
+
const y = Math.floor((source.height - size) / 2);
|
|
377
|
+
try {
|
|
378
|
+
const ctx = source.getContext("2d");
|
|
379
|
+
if (!ctx)
|
|
380
|
+
return "#ffffff";
|
|
381
|
+
const data = ctx.getImageData(x, y, size, size).data;
|
|
382
|
+
let luminance = 0;
|
|
383
|
+
let weight = 0;
|
|
384
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
385
|
+
const alpha = data[i + 3] / 255;
|
|
386
|
+
luminance +=
|
|
387
|
+
alpha * (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]);
|
|
388
|
+
weight += alpha;
|
|
389
|
+
}
|
|
390
|
+
if (weight === 0)
|
|
391
|
+
return "#ffffff";
|
|
392
|
+
return luminance / weight / 255 > 0.6 ? "#101820" : "#ffffff";
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return "#ffffff";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** @internal */
|
|
399
|
+
clampProgress(progress) {
|
|
400
|
+
return Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 0;
|
|
401
|
+
}
|
|
295
402
|
/** @internal */
|
|
296
403
|
drawFadeTransition(ctx, transition, width, height, progress) {
|
|
297
404
|
const firstHalf = progress < 0.5;
|
|
@@ -908,3 +1015,7 @@ export class RenderSystem {
|
|
|
908
1015
|
}
|
|
909
1016
|
/** @internal */
|
|
910
1017
|
RenderSystem.MAX_SURFACE_CACHE_ENTRIES = 256;
|
|
1018
|
+
/** @internal Stroke of the boot line and the scene ring, which match. */
|
|
1019
|
+
RenderSystem.LOADING_STROKE = 3;
|
|
1020
|
+
/** @internal Opacity of the black veil over a scene that is loading the next one. */
|
|
1021
|
+
RenderSystem.SCENE_LOADING_DIM = 0.4;
|
package/dist/minimo.d.ts
CHANGED
|
@@ -186,9 +186,45 @@ export type BackgroundFit = "none" | "stretch" | "contain" | "cover";
|
|
|
186
186
|
* scenes keep their expected `this` value.
|
|
187
187
|
*/
|
|
188
188
|
export interface IScene {
|
|
189
|
+
/**
|
|
190
|
+
* Registers images this scene needs that the rest of the game does not.
|
|
191
|
+
*
|
|
192
|
+
* **Only for games with many scenes that each carry many assets of their
|
|
193
|
+
* own**, such as a game with many levels, where a player does not reach
|
|
194
|
+
* every scene in a session and loading all of them at boot would make the
|
|
195
|
+
* first wait needlessly long. Every other game should load its images in
|
|
196
|
+
* {@link Game.onPreload}, once, at boot.
|
|
197
|
+
*
|
|
198
|
+
* Called whenever the scene is entered — by {@link Game.start},
|
|
199
|
+
* {@link Game.reset}, or {@link Game.transitionTo} — before
|
|
200
|
+
* {@link IScene.onCreate}. Call {@link Game.loadImage} inside it, as inside
|
|
201
|
+
* `game.onPreload`. Images that are already loaded are skipped, so replaying
|
|
202
|
+
* a scene loads nothing.
|
|
203
|
+
*
|
|
204
|
+
* When images are pending, the scene being left freezes, dimmed, under a
|
|
205
|
+
* progress ring until they load, and only then is this scene created.
|
|
206
|
+
* Because of that, `reset()` and `transitionTo()` may return before this
|
|
207
|
+
* scene exists: continue in `onCreate()`, not in the code after the call.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* class Level {
|
|
212
|
+
* constructor(n) { this.n = n; }
|
|
213
|
+
* onPreload() {
|
|
214
|
+
* game.loadImage(`level-${this.n}-bg`, `assets/level-${this.n}-bg.png`);
|
|
215
|
+
* }
|
|
216
|
+
* onCreate() {
|
|
217
|
+
* game.addBackground(new BackgroundLayer(`level-${this.n}-bg`));
|
|
218
|
+
* }
|
|
219
|
+
* }
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
onPreload?(): void;
|
|
189
223
|
/**
|
|
190
224
|
* Called when the scene is created, before the first frame and again after
|
|
191
|
-
* each {@link Game.reset} that targets this scene.
|
|
225
|
+
* each {@link Game.reset} that targets this scene. When the scene has an
|
|
226
|
+
* {@link IScene.onPreload} with images still loading, it is called once they
|
|
227
|
+
* have loaded.
|
|
192
228
|
*/
|
|
193
229
|
onCreate?(): void;
|
|
194
230
|
/**
|
|
@@ -570,6 +606,10 @@ export declare class TextSprite extends Sprite {
|
|
|
570
606
|
anchorX: "left" | "center" | "right";
|
|
571
607
|
anchorY: "top" | "middle" | "bottom";
|
|
572
608
|
text: string;
|
|
609
|
+
/**
|
|
610
|
+
* CSS font family. Default: `monospace`, which needs no download. Name a web
|
|
611
|
+
* font here, and register it with {@link Game.requireFont}, to use one.
|
|
612
|
+
*/
|
|
573
613
|
fontFamily: string;
|
|
574
614
|
fontSize: number;
|
|
575
615
|
fontWeight: string;
|
|
@@ -793,12 +833,12 @@ export interface CollisionInfo {
|
|
|
793
833
|
*
|
|
794
834
|
* ---
|
|
795
835
|
*
|
|
796
|
-
* ##
|
|
836
|
+
* ## Font Setup
|
|
797
837
|
*
|
|
798
|
-
* `drawText()` and `TextSprite` default to `
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
* before calling {@link Game.start}.
|
|
838
|
+
* `drawText()` and `TextSprite` default to `monospace`, which every device
|
|
839
|
+
* has, so a game that names no font downloads none and waits for none. To use
|
|
840
|
+
* a web font, declare it in your page, register it with {@link Game.requireFont}
|
|
841
|
+
* before calling {@link Game.start}, and name it in {@link TextSprite.fontFamily}.
|
|
802
842
|
*
|
|
803
843
|
* Prefer {@link TextSprite} for persistent UI text, interactive labels,
|
|
804
844
|
* buttons, fixed-size text boxes, and styled text elements. Keep
|
|
@@ -1018,6 +1058,15 @@ export interface CollisionInfo {
|
|
|
1018
1058
|
* Legacy `game.onCreate` / `game.onUpdate` callbacks still work when no active
|
|
1019
1059
|
* {@link IScene} is set.
|
|
1020
1060
|
*
|
|
1061
|
+
* ### Loading a Scene's Own Images
|
|
1062
|
+
*
|
|
1063
|
+
* Load images in {@link Game.onPreload}: once, at boot. Only a game with many
|
|
1064
|
+
* scenes that each carry many assets of their own — typically a game with many
|
|
1065
|
+
* levels, whose player does not reach every level in a session — should give
|
|
1066
|
+
* scenes an {@link IScene.onPreload}. Entering such a scene loads its images
|
|
1067
|
+
* first, with the scene being left frozen and dimmed under a progress ring, so `reset()`
|
|
1068
|
+
* and `transitionTo()` may return before the new scene's `onCreate()` runs.
|
|
1069
|
+
*
|
|
1021
1070
|
* ---
|
|
1022
1071
|
*
|
|
1023
1072
|
* ## EmojiSprite Lifecycle Ownership
|
|
@@ -1164,11 +1213,15 @@ export declare class Game {
|
|
|
1164
1213
|
/**
|
|
1165
1214
|
* Asset registration callback invoked once before the first scene is created.
|
|
1166
1215
|
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1216
|
+
* This is the normal place to load a game's images. Use it to register them
|
|
1217
|
+
* with {@link Game.loadImage}. The callback is synchronous: it should only
|
|
1218
|
+
* queue assets, not await network work.
|
|
1169
1219
|
*
|
|
1170
|
-
* After `onPreload` returns, MinimoJS loads the queued images
|
|
1171
|
-
*
|
|
1220
|
+
* After `onPreload` returns, MinimoJS loads the queued images, together with
|
|
1221
|
+
* those of the first scene's {@link IScene.onPreload} if it has one, and
|
|
1222
|
+
* shows a loading line until they are ready. If any image fails, it asks the
|
|
1223
|
+
* player with `window.confirm` whether to retry them; declining leaves the
|
|
1224
|
+
* game on the loading screen and reports the failed images on the console.
|
|
1172
1225
|
*
|
|
1173
1226
|
* @example
|
|
1174
1227
|
* ```ts
|
|
@@ -1223,6 +1276,13 @@ export declare class Game {
|
|
|
1223
1276
|
* Returns `true` while a full-screen scene transition is playing.
|
|
1224
1277
|
*/
|
|
1225
1278
|
get isTransitioning(): boolean;
|
|
1279
|
+
/**
|
|
1280
|
+
* Returns `true` while images are loading: at boot, or while a scene with
|
|
1281
|
+
* pending images in its {@link IScene.onPreload} is being entered. While a
|
|
1282
|
+
* scene loads, the scene being left is frozen and `reset()` and
|
|
1283
|
+
* `transitionTo()` are ignored.
|
|
1284
|
+
*/
|
|
1285
|
+
get isLoading(): boolean;
|
|
1226
1286
|
/**
|
|
1227
1287
|
* Creates a new MinimoJS game instance.
|
|
1228
1288
|
*
|
|
@@ -1369,7 +1429,8 @@ export declare class Game {
|
|
|
1369
1429
|
* This method registers the asset only. The actual network/image fetch begins
|
|
1370
1430
|
* after `onPreload` returns and is managed automatically by the engine.
|
|
1371
1431
|
*
|
|
1372
|
-
* `loadImage` may only be called from within {@link Game.onPreload}
|
|
1432
|
+
* `loadImage` may only be called from within {@link Game.onPreload} or a
|
|
1433
|
+
* scene's {@link IScene.onPreload}.
|
|
1373
1434
|
*
|
|
1374
1435
|
* @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
|
|
1375
1436
|
* @param src - Image URL or relative path.
|
|
@@ -2122,14 +2183,8 @@ export declare class Game {
|
|
|
2122
2183
|
* The text overlay list is cleared after each render. Call this inside `onUpdate`.
|
|
2123
2184
|
*
|
|
2124
2185
|
* **Layer:** Text is always drawn on top of all sprites.
|
|
2125
|
-
* **Font:** Text uses `
|
|
2126
|
-
*
|
|
2127
|
-
* Fonts) before calling {@link Game.start}. MinimoJS does NOT inject or
|
|
2128
|
-
* download external fonts for you.
|
|
2129
|
-
*
|
|
2130
|
-
* `Game.start()` waits for the default font and for any extra families you
|
|
2131
|
-
* registered with {@link Game.requireFont}. If a font is unavailable, the
|
|
2132
|
-
* browser falls back to `monospace`.
|
|
2186
|
+
* **Font:** Text always uses `monospace`. Use {@link TextSprite} for any
|
|
2187
|
+
* other family.
|
|
2133
2188
|
*
|
|
2134
2189
|
* Prefer {@link TextSprite} for UI buttons or labels that need persistent
|
|
2135
2190
|
* bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
|
|
@@ -2236,6 +2291,12 @@ export declare class Game {
|
|
|
2236
2291
|
* `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
|
|
2237
2292
|
* can rebuild synchronously.
|
|
2238
2293
|
*
|
|
2294
|
+
* The exception is a scene whose {@link IScene.onPreload} registers images
|
|
2295
|
+
* that are not loaded yet. Then the current scene freezes and dims under a progress
|
|
2296
|
+
* ring, nothing is cleared until the images load, and `reset()` returns
|
|
2297
|
+
* before the scene is rebuilt; its `onCreate()` runs once they have loaded.
|
|
2298
|
+
* A `reset()` called while a scene is loading is ignored.
|
|
2299
|
+
*
|
|
2239
2300
|
* @param scene - Optional new scene to make active before rebuilding.
|
|
2240
2301
|
*
|
|
2241
2302
|
* @example
|
|
@@ -2263,7 +2324,17 @@ export declare class Game {
|
|
|
2263
2324
|
*
|
|
2264
2325
|
* While the transition is active, gameplay updates are paused.
|
|
2265
2326
|
*
|
|
2266
|
-
* If the
|
|
2327
|
+
* If the target scene's {@link IScene.onPreload} registers images that are
|
|
2328
|
+
* not loaded yet, the current scene first freezes and dims under a progress ring
|
|
2329
|
+
* until they load, and the transition plays after that. `transitionTo()`
|
|
2330
|
+
* then returns before the target scene exists; continue in its
|
|
2331
|
+
* `onCreate()` or in `onComplete`. If the player declines to retry failed
|
|
2332
|
+
* images, the game stays on the current scene and `onComplete` is not
|
|
2333
|
+
* called.
|
|
2334
|
+
*
|
|
2335
|
+
* A call made while a transition plays or a scene loads is ignored.
|
|
2336
|
+
*
|
|
2337
|
+
* If the game loop is not running yet, this falls back to
|
|
2267
2338
|
* {@link Game.reset}.
|
|
2268
2339
|
*
|
|
2269
2340
|
* @param scene - Target scene to make active.
|
|
@@ -2274,12 +2345,11 @@ export declare class Game {
|
|
|
2274
2345
|
/**
|
|
2275
2346
|
* Starts the `requestAnimationFrame` game loop.
|
|
2276
2347
|
* Safe to call multiple times — does nothing if already running.
|
|
2277
|
-
* On the first start,
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2281
|
-
*
|
|
2282
|
-
* the first frame.
|
|
2348
|
+
* On the first start, MinimoJS runs {@link Game.onPreload} and the active
|
|
2349
|
+
* scene's {@link IScene.onPreload}, loads the images they queued while
|
|
2350
|
+
* showing a thin loading line, and waits for all fonts registered via
|
|
2351
|
+
* {@link Game.requireFont}. After that, the active scene's `onCreate()` is
|
|
2352
|
+
* called before the first frame.
|
|
2283
2353
|
*
|
|
2284
2354
|
* The loop calls the active scene's `onUpdate(dt)` (or legacy
|
|
2285
2355
|
* {@link Game.onUpdate}) once per frame, then renders all sprites and text
|
package/dist/minimo.js
CHANGED
|
@@ -22,6 +22,10 @@ import { TextSystem } from "./internal/TextSystem.js";
|
|
|
22
22
|
import { TrailSystem } from "./internal/TrailSystem.js";
|
|
23
23
|
import { TransitionSystem } from "./internal/TransitionSystem.js";
|
|
24
24
|
import { TimerSystem } from "./internal/TimerSystem.js";
|
|
25
|
+
/** How long a scene may load before it dims and its progress ring appears, so a fast load shows neither. */
|
|
26
|
+
const SCENE_LOADING_INDICATOR_DELAY_MS = 250;
|
|
27
|
+
/** How long the dimming and the ring take to fade in once they appear. */
|
|
28
|
+
const SCENE_LOADING_FADE_MS = 200;
|
|
25
29
|
// ---------------------------------------------------------------------------
|
|
26
30
|
// EmojiSprite
|
|
27
31
|
// ---------------------------------------------------------------------------
|
|
@@ -535,7 +539,11 @@ export class TextSprite extends Sprite {
|
|
|
535
539
|
super();
|
|
536
540
|
this.anchorX = "center";
|
|
537
541
|
this.anchorY = "middle";
|
|
538
|
-
|
|
542
|
+
/**
|
|
543
|
+
* CSS font family. Default: `monospace`, which needs no download. Name a web
|
|
544
|
+
* font here, and register it with {@link Game.requireFont}, to use one.
|
|
545
|
+
*/
|
|
546
|
+
this.fontFamily = "monospace";
|
|
539
547
|
this.fontWeight = "400";
|
|
540
548
|
this.lineHeight = 1.2;
|
|
541
549
|
this.maxWidth = 0;
|
|
@@ -866,12 +874,12 @@ export class BackgroundLayer {
|
|
|
866
874
|
*
|
|
867
875
|
* ---
|
|
868
876
|
*
|
|
869
|
-
* ##
|
|
877
|
+
* ## Font Setup
|
|
870
878
|
*
|
|
871
|
-
* `drawText()` and `TextSprite` default to `
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
* before calling {@link Game.start}.
|
|
879
|
+
* `drawText()` and `TextSprite` default to `monospace`, which every device
|
|
880
|
+
* has, so a game that names no font downloads none and waits for none. To use
|
|
881
|
+
* a web font, declare it in your page, register it with {@link Game.requireFont}
|
|
882
|
+
* before calling {@link Game.start}, and name it in {@link TextSprite.fontFamily}.
|
|
875
883
|
*
|
|
876
884
|
* Prefer {@link TextSprite} for persistent UI text, interactive labels,
|
|
877
885
|
* buttons, fixed-size text boxes, and styled text elements. Keep
|
|
@@ -1091,6 +1099,15 @@ export class BackgroundLayer {
|
|
|
1091
1099
|
* Legacy `game.onCreate` / `game.onUpdate` callbacks still work when no active
|
|
1092
1100
|
* {@link IScene} is set.
|
|
1093
1101
|
*
|
|
1102
|
+
* ### Loading a Scene's Own Images
|
|
1103
|
+
*
|
|
1104
|
+
* Load images in {@link Game.onPreload}: once, at boot. Only a game with many
|
|
1105
|
+
* scenes that each carry many assets of their own — typically a game with many
|
|
1106
|
+
* levels, whose player does not reach every level in a session — should give
|
|
1107
|
+
* scenes an {@link IScene.onPreload}. Entering such a scene loads its images
|
|
1108
|
+
* first, with the scene being left frozen and dimmed under a progress ring, so `reset()`
|
|
1109
|
+
* and `transitionTo()` may return before the new scene's `onCreate()` runs.
|
|
1110
|
+
*
|
|
1094
1111
|
* ---
|
|
1095
1112
|
*
|
|
1096
1113
|
* ## EmojiSprite Lifecycle Ownership
|
|
@@ -1210,6 +1227,15 @@ export class Game {
|
|
|
1210
1227
|
get isTransitioning() {
|
|
1211
1228
|
return this._transitionSystem.isActive;
|
|
1212
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Returns `true` while images are loading: at boot, or while a scene with
|
|
1232
|
+
* pending images in its {@link IScene.onPreload} is being entered. While a
|
|
1233
|
+
* scene loads, the scene being left is frozen and `reset()` and
|
|
1234
|
+
* `transitionTo()` are ignored.
|
|
1235
|
+
*/
|
|
1236
|
+
get isLoading() {
|
|
1237
|
+
return this._preloadPromise !== null || this._sceneLoad !== null;
|
|
1238
|
+
}
|
|
1213
1239
|
// -------------------------------------------------------------------------
|
|
1214
1240
|
// Constructor
|
|
1215
1241
|
// -------------------------------------------------------------------------
|
|
@@ -1238,6 +1264,7 @@ export class Game {
|
|
|
1238
1264
|
/** @internal */ this._isRegisteringPreloadAssets = false;
|
|
1239
1265
|
/** @internal */ this._hasCompletedPreload = false;
|
|
1240
1266
|
/** @internal */ this._preloadPromise = null;
|
|
1267
|
+
/** @internal */ this._sceneLoad = null;
|
|
1241
1268
|
/** @internal */ this._onCreate = null;
|
|
1242
1269
|
/** @internal */ this._currentScene = null;
|
|
1243
1270
|
/**
|
|
@@ -1313,11 +1340,15 @@ export class Game {
|
|
|
1313
1340
|
/**
|
|
1314
1341
|
* Asset registration callback invoked once before the first scene is created.
|
|
1315
1342
|
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1343
|
+
* This is the normal place to load a game's images. Use it to register them
|
|
1344
|
+
* with {@link Game.loadImage}. The callback is synchronous: it should only
|
|
1345
|
+
* queue assets, not await network work.
|
|
1318
1346
|
*
|
|
1319
|
-
* After `onPreload` returns, MinimoJS loads the queued images
|
|
1320
|
-
*
|
|
1347
|
+
* After `onPreload` returns, MinimoJS loads the queued images, together with
|
|
1348
|
+
* those of the first scene's {@link IScene.onPreload} if it has one, and
|
|
1349
|
+
* shows a loading line until they are ready. If any image fails, it asks the
|
|
1350
|
+
* player with `window.confirm` whether to retry them; declining leaves the
|
|
1351
|
+
* game on the loading screen and reports the failed images on the console.
|
|
1321
1352
|
*
|
|
1322
1353
|
* @example
|
|
1323
1354
|
* ```ts
|
|
@@ -1376,9 +1407,6 @@ export class Game {
|
|
|
1376
1407
|
this._loopSystem = new LoopSystem(this._onLoopFrameCallback.bind(this));
|
|
1377
1408
|
this._loopSystem.onCreate = this._invokeCreate.bind(this);
|
|
1378
1409
|
this._inputSystem.bindInputEvents();
|
|
1379
|
-
this.requireFont('"Press Start 2P"', {
|
|
1380
|
-
sampleText: "Loading... SCORE LIVES GAME OVER YOU WIN",
|
|
1381
|
-
});
|
|
1382
1410
|
this.applyGlobalFontRequirements();
|
|
1383
1411
|
}
|
|
1384
1412
|
// -------------------------------------------------------------------------
|
|
@@ -1537,7 +1565,8 @@ export class Game {
|
|
|
1537
1565
|
* This method registers the asset only. The actual network/image fetch begins
|
|
1538
1566
|
* after `onPreload` returns and is managed automatically by the engine.
|
|
1539
1567
|
*
|
|
1540
|
-
* `loadImage` may only be called from within {@link Game.onPreload}
|
|
1568
|
+
* `loadImage` may only be called from within {@link Game.onPreload} or a
|
|
1569
|
+
* scene's {@link IScene.onPreload}.
|
|
1541
1570
|
*
|
|
1542
1571
|
* @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
|
|
1543
1572
|
* @param src - Image URL or relative path.
|
|
@@ -1551,7 +1580,7 @@ export class Game {
|
|
|
1551
1580
|
*/
|
|
1552
1581
|
loadImage(key, src) {
|
|
1553
1582
|
if (!this._isRegisteringPreloadAssets) {
|
|
1554
|
-
throw new Error("MinimoJS: loadImage() may only be called inside game.onPreload.");
|
|
1583
|
+
throw new Error("MinimoJS: loadImage() may only be called inside game.onPreload or a scene's onPreload().");
|
|
1555
1584
|
}
|
|
1556
1585
|
this._assetSystem.queueImage(key, src);
|
|
1557
1586
|
}
|
|
@@ -2415,14 +2444,8 @@ export class Game {
|
|
|
2415
2444
|
* The text overlay list is cleared after each render. Call this inside `onUpdate`.
|
|
2416
2445
|
*
|
|
2417
2446
|
* **Layer:** Text is always drawn on top of all sprites.
|
|
2418
|
-
* **Font:** Text uses `
|
|
2419
|
-
*
|
|
2420
|
-
* Fonts) before calling {@link Game.start}. MinimoJS does NOT inject or
|
|
2421
|
-
* download external fonts for you.
|
|
2422
|
-
*
|
|
2423
|
-
* `Game.start()` waits for the default font and for any extra families you
|
|
2424
|
-
* registered with {@link Game.requireFont}. If a font is unavailable, the
|
|
2425
|
-
* browser falls back to `monospace`.
|
|
2447
|
+
* **Font:** Text always uses `monospace`. Use {@link TextSprite} for any
|
|
2448
|
+
* other family.
|
|
2426
2449
|
*
|
|
2427
2450
|
* Prefer {@link TextSprite} for UI buttons or labels that need persistent
|
|
2428
2451
|
* bounds, hit testing, fixed sizes, backgrounds, borders, or text stroke.
|
|
@@ -2558,6 +2581,12 @@ export class Game {
|
|
|
2558
2581
|
* `onCreate()` (or legacy {@link Game.onCreate} if no scene is active) so it
|
|
2559
2582
|
* can rebuild synchronously.
|
|
2560
2583
|
*
|
|
2584
|
+
* The exception is a scene whose {@link IScene.onPreload} registers images
|
|
2585
|
+
* that are not loaded yet. Then the current scene freezes and dims under a progress
|
|
2586
|
+
* ring, nothing is cleared until the images load, and `reset()` returns
|
|
2587
|
+
* before the scene is rebuilt; its `onCreate()` runs once they have loaded.
|
|
2588
|
+
* A `reset()` called while a scene is loading is ignored.
|
|
2589
|
+
*
|
|
2561
2590
|
* @param scene - Optional new scene to make active before rebuilding.
|
|
2562
2591
|
*
|
|
2563
2592
|
* @example
|
|
@@ -2574,6 +2603,31 @@ export class Game {
|
|
|
2574
2603
|
* ```
|
|
2575
2604
|
*/
|
|
2576
2605
|
reset(scene) {
|
|
2606
|
+
if (this._sceneLoad !== null) {
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
if (!this._hasCompletedPreload) {
|
|
2610
|
+
if (scene !== undefined) {
|
|
2611
|
+
this._currentScene = scene;
|
|
2612
|
+
}
|
|
2613
|
+
// Boot builds the active scene once its images load, so a scene chosen
|
|
2614
|
+
// now is only recorded. Before start(), a scene with images of its own
|
|
2615
|
+
// waits for start() to load them.
|
|
2616
|
+
if (this._preloadPromise !== null || this._currentScene?.onPreload) {
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
this._resetNow();
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
this._registerSceneAssets(scene ?? this._currentScene);
|
|
2623
|
+
if (this._assetSystem.getQueuedImageCount() === 0) {
|
|
2624
|
+
this._resetNow(scene);
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
this._beginSceneLoad(() => this._resetNow(scene));
|
|
2628
|
+
}
|
|
2629
|
+
/** @internal */
|
|
2630
|
+
_resetNow(scene) {
|
|
2577
2631
|
if (scene !== undefined) {
|
|
2578
2632
|
this._currentScene = scene;
|
|
2579
2633
|
}
|
|
@@ -2601,7 +2655,17 @@ export class Game {
|
|
|
2601
2655
|
*
|
|
2602
2656
|
* While the transition is active, gameplay updates are paused.
|
|
2603
2657
|
*
|
|
2604
|
-
* If the
|
|
2658
|
+
* If the target scene's {@link IScene.onPreload} registers images that are
|
|
2659
|
+
* not loaded yet, the current scene first freezes and dims under a progress ring
|
|
2660
|
+
* until they load, and the transition plays after that. `transitionTo()`
|
|
2661
|
+
* then returns before the target scene exists; continue in its
|
|
2662
|
+
* `onCreate()` or in `onComplete`. If the player declines to retry failed
|
|
2663
|
+
* images, the game stays on the current scene and `onComplete` is not
|
|
2664
|
+
* called.
|
|
2665
|
+
*
|
|
2666
|
+
* A call made while a transition plays or a scene loads is ignored.
|
|
2667
|
+
*
|
|
2668
|
+
* If the game loop is not running yet, this falls back to
|
|
2605
2669
|
* {@link Game.reset}.
|
|
2606
2670
|
*
|
|
2607
2671
|
* @param scene - Target scene to make active.
|
|
@@ -2614,11 +2678,20 @@ export class Game {
|
|
|
2614
2678
|
onComplete?.();
|
|
2615
2679
|
return;
|
|
2616
2680
|
}
|
|
2617
|
-
if (this._transitionSystem.isActive) {
|
|
2681
|
+
if (this._transitionSystem.isActive || this._sceneLoad !== null) {
|
|
2618
2682
|
return;
|
|
2619
2683
|
}
|
|
2684
|
+
this._registerSceneAssets(scene);
|
|
2685
|
+
if (this._assetSystem.getQueuedImageCount() === 0) {
|
|
2686
|
+
this._transitionNow(scene, options, onComplete);
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
this._beginSceneLoad(() => this._transitionNow(scene, options, onComplete));
|
|
2690
|
+
}
|
|
2691
|
+
/** @internal */
|
|
2692
|
+
_transitionNow(scene, options, onComplete) {
|
|
2620
2693
|
const fromCanvas = this._captureFrameSnapshot();
|
|
2621
|
-
this.
|
|
2694
|
+
this._resetNow(scene);
|
|
2622
2695
|
const toCanvas = this._captureFrameSnapshot();
|
|
2623
2696
|
this._transitionSystem.start(fromCanvas, toCanvas, this.width, this.height, options, onComplete);
|
|
2624
2697
|
}
|
|
@@ -2628,12 +2701,11 @@ export class Game {
|
|
|
2628
2701
|
/**
|
|
2629
2702
|
* Starts the `requestAnimationFrame` game loop.
|
|
2630
2703
|
* Safe to call multiple times — does nothing if already running.
|
|
2631
|
-
* On the first start,
|
|
2632
|
-
*
|
|
2633
|
-
*
|
|
2634
|
-
*
|
|
2635
|
-
*
|
|
2636
|
-
* the first frame.
|
|
2704
|
+
* On the first start, MinimoJS runs {@link Game.onPreload} and the active
|
|
2705
|
+
* scene's {@link IScene.onPreload}, loads the images they queued while
|
|
2706
|
+
* showing a thin loading line, and waits for all fonts registered via
|
|
2707
|
+
* {@link Game.requireFont}. After that, the active scene's `onCreate()` is
|
|
2708
|
+
* called before the first frame.
|
|
2637
2709
|
*
|
|
2638
2710
|
* The loop calls the active scene's `onUpdate(dt)` (or legacy
|
|
2639
2711
|
* {@link Game.onUpdate}) once per frame, then renders all sprites and text
|
|
@@ -2680,51 +2752,57 @@ export class Game {
|
|
|
2680
2752
|
this._isRegisteringPreloadAssets = false;
|
|
2681
2753
|
}
|
|
2682
2754
|
}
|
|
2683
|
-
const
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
if (!fontsReady || totalAssets === 0) {
|
|
2755
|
+
const preloadPromise = Promise.all([
|
|
2756
|
+
this._loadBootImages(),
|
|
2757
|
+
this.waitForDocumentFonts(),
|
|
2758
|
+
]).then(([loaded]) => {
|
|
2759
|
+
if (this._preloadPromise !== preloadPromise)
|
|
2689
2760
|
return;
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
};
|
|
2693
|
-
const fontReadyPromise = this.waitForDocumentFonts().then(() => {
|
|
2694
|
-
fontsReady = true;
|
|
2695
|
-
renderLoadingProgress();
|
|
2696
|
-
});
|
|
2697
|
-
if (totalAssets === 0) {
|
|
2698
|
-
this._preloadPromise = fontReadyPromise
|
|
2699
|
-
.then(() => {
|
|
2700
|
-
this._hasCompletedPreload = true;
|
|
2701
|
-
this._preloadPromise = null;
|
|
2702
|
-
this._loopSystem.start();
|
|
2703
|
-
})
|
|
2704
|
-
.catch((error) => {
|
|
2705
|
-
this._preloadPromise = null;
|
|
2706
|
-
throw error;
|
|
2707
|
-
});
|
|
2708
|
-
return;
|
|
2709
|
-
}
|
|
2710
|
-
const assetPromise = this._assetSystem.loadQueuedImages((loaded, total, currentKey) => {
|
|
2711
|
-
loadingLoaded = loaded;
|
|
2712
|
-
loadingCurrentKey = currentKey;
|
|
2713
|
-
if (!fontsReady)
|
|
2761
|
+
this._preloadPromise = null;
|
|
2762
|
+
if (!loaded)
|
|
2714
2763
|
return;
|
|
2715
|
-
this._renderLoadingScreen(loaded, total, currentKey);
|
|
2716
|
-
});
|
|
2717
|
-
this._preloadPromise = Promise.all([assetPromise, fontReadyPromise])
|
|
2718
|
-
.then(() => {
|
|
2719
2764
|
this._hasCompletedPreload = true;
|
|
2720
|
-
this._preloadPromise = null;
|
|
2721
2765
|
this._loopSystem.start();
|
|
2722
|
-
})
|
|
2723
|
-
|
|
2724
|
-
|
|
2766
|
+
});
|
|
2767
|
+
this._preloadPromise = preloadPromise;
|
|
2768
|
+
preloadPromise.catch((error) => {
|
|
2769
|
+
if (this._preloadPromise === preloadPromise) {
|
|
2770
|
+
this._preloadPromise = null;
|
|
2771
|
+
}
|
|
2725
2772
|
throw error;
|
|
2726
2773
|
});
|
|
2727
2774
|
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Loads the boot images, then those of any scene made active while they
|
|
2777
|
+
* loaded, retrying failures while the player agrees. Resolves `false` when
|
|
2778
|
+
* the player declines, which leaves the game on the loading screen.
|
|
2779
|
+
*
|
|
2780
|
+
* @internal
|
|
2781
|
+
*/
|
|
2782
|
+
async _loadBootImages() {
|
|
2783
|
+
let registeredScene = undefined;
|
|
2784
|
+
for (;;) {
|
|
2785
|
+
if (registeredScene !== this._currentScene) {
|
|
2786
|
+
registeredScene = this._currentScene;
|
|
2787
|
+
this._registerSceneAssets(registeredScene);
|
|
2788
|
+
}
|
|
2789
|
+
const failed = await this._assetSystem.loadQueuedImages((settled, total) => {
|
|
2790
|
+
if (total > 0)
|
|
2791
|
+
this._renderLoadingScreen(settled / total);
|
|
2792
|
+
});
|
|
2793
|
+
if (failed.length > 0) {
|
|
2794
|
+
this._reportFailedImages(failed);
|
|
2795
|
+
if (!this._confirmImageRetry(failed.length)) {
|
|
2796
|
+
this._assetSystem.discardQueuedImages();
|
|
2797
|
+
return false;
|
|
2798
|
+
}
|
|
2799
|
+
continue;
|
|
2800
|
+
}
|
|
2801
|
+
if (registeredScene === this._currentScene) {
|
|
2802
|
+
return true;
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2728
2806
|
/**
|
|
2729
2807
|
* Stops the game loop. The canvas retains its last rendered frame.
|
|
2730
2808
|
* Call {@link Game.start} to resume.
|
|
@@ -2737,6 +2815,10 @@ export class Game {
|
|
|
2737
2815
|
// -------------------------------------------------------------------------
|
|
2738
2816
|
/** @internal */ _onLoopFrameCallback(dt, dtMs) {
|
|
2739
2817
|
this._renderSystem.beginDynamicSurfacePass();
|
|
2818
|
+
if (this._sceneLoad !== null) {
|
|
2819
|
+
this._renderInterruption();
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2740
2822
|
if (this._transitionSystem.isActive) {
|
|
2741
2823
|
this._transitionSystem.update(dtMs);
|
|
2742
2824
|
if (this._transitionSystem.isActive) {
|
|
@@ -2750,29 +2832,23 @@ export class Game {
|
|
|
2750
2832
|
return;
|
|
2751
2833
|
}
|
|
2752
2834
|
this._timerSystem.update(dtMs);
|
|
2753
|
-
if (this.
|
|
2754
|
-
this.
|
|
2755
|
-
this._inputSystem.clearFramePressedState();
|
|
2756
|
-
this._textSystem.clear();
|
|
2835
|
+
if (this._isSceneChangeInterrupting()) {
|
|
2836
|
+
this._renderInterruption();
|
|
2757
2837
|
return;
|
|
2758
2838
|
}
|
|
2759
2839
|
this._animationSystem.update(dtMs);
|
|
2760
2840
|
this._explosionSystem.update(dt, dtMs);
|
|
2761
2841
|
this._physicsSystem.update(this._spriteSystem.getMutableSprites(), dt);
|
|
2762
|
-
if (this.
|
|
2763
|
-
this.
|
|
2764
|
-
this._inputSystem.clearFramePressedState();
|
|
2765
|
-
this._textSystem.clear();
|
|
2842
|
+
if (this._isSceneChangeInterrupting()) {
|
|
2843
|
+
this._renderInterruption();
|
|
2766
2844
|
return;
|
|
2767
2845
|
}
|
|
2768
2846
|
if (this._currentScene?.onUpdate)
|
|
2769
2847
|
this._currentScene.onUpdate(dt);
|
|
2770
2848
|
else if (this.onUpdate)
|
|
2771
2849
|
this.onUpdate(dt);
|
|
2772
|
-
if (this.
|
|
2773
|
-
this.
|
|
2774
|
-
this._inputSystem.clearFramePressedState();
|
|
2775
|
-
this._textSystem.clear();
|
|
2850
|
+
if (this._isSceneChangeInterrupting()) {
|
|
2851
|
+
this._renderInterruption();
|
|
2776
2852
|
return;
|
|
2777
2853
|
}
|
|
2778
2854
|
this._trailSystem.update(dtMs, this._renderSystem.getSpriteRenderSnapshot.bind(this._renderSystem));
|
|
@@ -2781,6 +2857,119 @@ export class Game {
|
|
|
2781
2857
|
this._textSystem.clear();
|
|
2782
2858
|
}
|
|
2783
2859
|
/** @internal */
|
|
2860
|
+
_isSceneChangeInterrupting() {
|
|
2861
|
+
return this._transitionSystem.isActive || this._sceneLoad !== null;
|
|
2862
|
+
}
|
|
2863
|
+
/**
|
|
2864
|
+
* Draws a frame in which gameplay does not advance: a loading scene under
|
|
2865
|
+
* its ring, or a transition.
|
|
2866
|
+
*
|
|
2867
|
+
* @internal
|
|
2868
|
+
*/
|
|
2869
|
+
_renderInterruption() {
|
|
2870
|
+
const load = this._sceneLoad;
|
|
2871
|
+
if (load !== null) {
|
|
2872
|
+
this._renderSystem.renderSceneLoading({
|
|
2873
|
+
canvas: this._canvas,
|
|
2874
|
+
context: this._ctx,
|
|
2875
|
+
pageBackground: this.pageBackground,
|
|
2876
|
+
snapshot: load.snapshot,
|
|
2877
|
+
progress: load.progress,
|
|
2878
|
+
reveal: (performance.now() - load.startedAt - SCENE_LOADING_INDICATOR_DELAY_MS) /
|
|
2879
|
+
SCENE_LOADING_FADE_MS,
|
|
2880
|
+
ink: load.ink,
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
else {
|
|
2884
|
+
this._renderTransition();
|
|
2885
|
+
}
|
|
2886
|
+
this._inputSystem.clearFramePressedState();
|
|
2887
|
+
this._textSystem.clear();
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Runs a scene's `onPreload()`, if it has one, with image registration
|
|
2891
|
+
* allowed.
|
|
2892
|
+
*
|
|
2893
|
+
* @internal
|
|
2894
|
+
*/
|
|
2895
|
+
_registerSceneAssets(scene) {
|
|
2896
|
+
if (!scene?.onPreload)
|
|
2897
|
+
return;
|
|
2898
|
+
this._isRegisteringPreloadAssets = true;
|
|
2899
|
+
try {
|
|
2900
|
+
scene.onPreload();
|
|
2901
|
+
}
|
|
2902
|
+
finally {
|
|
2903
|
+
this._isRegisteringPreloadAssets = false;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Freezes the current scene under a progress ring while the queued images
|
|
2908
|
+
* load, then calls `finish` to enter the next scene. If images fail and the
|
|
2909
|
+
* player declines to retry, the current scene resumes instead.
|
|
2910
|
+
*
|
|
2911
|
+
* @internal
|
|
2912
|
+
*/
|
|
2913
|
+
_beginSceneLoad(finish) {
|
|
2914
|
+
this._transitionSystem.clear();
|
|
2915
|
+
const snapshot = document.createElement("canvas");
|
|
2916
|
+
snapshot.width = this._canvas.width;
|
|
2917
|
+
snapshot.height = this._canvas.height;
|
|
2918
|
+
snapshot.getContext("2d")?.drawImage(this._canvas, 0, 0);
|
|
2919
|
+
const load = {
|
|
2920
|
+
snapshot,
|
|
2921
|
+
ink: this._renderSystem.pickSceneLoadingInk(snapshot),
|
|
2922
|
+
progress: 0,
|
|
2923
|
+
startedAt: performance.now(),
|
|
2924
|
+
};
|
|
2925
|
+
this._sceneLoad = load;
|
|
2926
|
+
void this._loadSceneImages(load, finish);
|
|
2927
|
+
}
|
|
2928
|
+
/** @internal */
|
|
2929
|
+
async _loadSceneImages(load, finish) {
|
|
2930
|
+
for (;;) {
|
|
2931
|
+
const failed = await this._assetSystem.loadQueuedImages((settled, total) => {
|
|
2932
|
+
load.progress = total === 0 ? 1 : settled / total;
|
|
2933
|
+
});
|
|
2934
|
+
if (failed.length === 0)
|
|
2935
|
+
break;
|
|
2936
|
+
this._reportFailedImages(failed);
|
|
2937
|
+
if (!this._confirmImageRetry(failed.length)) {
|
|
2938
|
+
this._assetSystem.discardQueuedImages();
|
|
2939
|
+
this._sceneLoad = null;
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
load.progress = 0;
|
|
2943
|
+
}
|
|
2944
|
+
this._sceneLoad = null;
|
|
2945
|
+
finish();
|
|
2946
|
+
}
|
|
2947
|
+
/** @internal */
|
|
2948
|
+
_reportFailedImages(keys) {
|
|
2949
|
+
const list = keys
|
|
2950
|
+
.map((key) => `"${key}" (${this._assetSystem.getImageSource(key) ?? "no source"})`)
|
|
2951
|
+
.join(", ");
|
|
2952
|
+
console.error(`MinimoJS: ${keys.length} image${keys.length === 1 ? "" : "s"} failed to load: ${list}.`);
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* Asks the player whether to retry failed images, with the browser's own
|
|
2956
|
+
* confirmation dialog. Returns `false` where no dialog can be shown.
|
|
2957
|
+
*
|
|
2958
|
+
* @internal
|
|
2959
|
+
*/
|
|
2960
|
+
_confirmImageRetry(failedCount) {
|
|
2961
|
+
if (typeof window === "undefined" || typeof window.confirm !== "function") {
|
|
2962
|
+
return false;
|
|
2963
|
+
}
|
|
2964
|
+
const noun = failedCount === 1 ? "image" : "images";
|
|
2965
|
+
try {
|
|
2966
|
+
return window.confirm(`${failedCount} ${noun} could not be loaded. Try again?`);
|
|
2967
|
+
}
|
|
2968
|
+
catch {
|
|
2969
|
+
return false;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
/** @internal */
|
|
2784
2973
|
_invokeCreate() {
|
|
2785
2974
|
if (this._currentScene?.onCreate)
|
|
2786
2975
|
this._currentScene.onCreate();
|
|
@@ -2902,16 +3091,14 @@ export class Game {
|
|
|
2902
3091
|
});
|
|
2903
3092
|
}
|
|
2904
3093
|
/** @internal */
|
|
2905
|
-
_renderLoadingScreen(
|
|
3094
|
+
_renderLoadingScreen(progress) {
|
|
2906
3095
|
this._renderSystem.renderLoadingScreen({
|
|
2907
3096
|
canvas: this._canvas,
|
|
2908
3097
|
context: this._ctx,
|
|
2909
3098
|
pageBackground: this.pageBackground,
|
|
2910
3099
|
background: this.background,
|
|
2911
3100
|
backgroundGradient: this.backgroundGradient,
|
|
2912
|
-
|
|
2913
|
-
total,
|
|
2914
|
-
currentKey,
|
|
3101
|
+
progress,
|
|
2915
3102
|
});
|
|
2916
3103
|
}
|
|
2917
3104
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimojs",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.25",
|
|
4
4
|
"description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Image-first sprites with emoji as a zero-asset alternative, rAF loop, TypeScript-first, LLM-friendly.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/minimo.js",
|