minimojs 1.0.0-alpha.4 → 1.0.0-alpha.41
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 +58 -20
- package/dist/internal/AnimationSystem.js +345 -0
- package/dist/internal/AssetSystem.d.ts +6 -0
- package/dist/internal/AssetSystem.js +281 -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 +39 -0
- package/dist/internal/SoundSystem.js +292 -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-dice.d.ts +206 -0
- package/dist/minimo-dice.js +719 -0
- package/dist/minimo-fire.d.ts +282 -0
- package/dist/minimo-fire.js +930 -0
- package/dist/minimo-format.d.ts +632 -0
- package/dist/minimo-format.js +669 -0
- package/dist/minimo-leaderboard.d.ts +199 -0
- package/dist/minimo-leaderboard.js +566 -0
- package/dist/minimo-multiplayer.d.ts +387 -0
- package/dist/minimo-multiplayer.js +1323 -0
- package/dist/minimo.d.ts +1690 -233
- package/dist/minimo.js +2587 -892
- 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
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export class RenderSystem {
|
|
3
|
+
constructor() {
|
|
4
|
+
/** @internal */
|
|
5
|
+
this._surfaceCache = new Map();
|
|
6
|
+
/** @internal */
|
|
7
|
+
this._lastAppliedPageBackground = undefined;
|
|
8
|
+
/** @internal */
|
|
9
|
+
this._bootInk = null;
|
|
10
|
+
/** @internal */
|
|
11
|
+
this._transitionScratchCanvas = null;
|
|
12
|
+
/** @internal */
|
|
13
|
+
this._transitionScratchContext = null;
|
|
14
|
+
/** @internal */
|
|
15
|
+
this._dynamicSurfacePassId = 0;
|
|
16
|
+
}
|
|
17
|
+
clearSpriteCache() {
|
|
18
|
+
this._surfaceCache.clear();
|
|
19
|
+
}
|
|
20
|
+
beginDynamicSurfacePass() {
|
|
21
|
+
this._dynamicSurfacePassId += 1;
|
|
22
|
+
}
|
|
23
|
+
getSpriteGlyphCanvasForEffects(sprite) {
|
|
24
|
+
return this.getRenderSurface(sprite);
|
|
25
|
+
}
|
|
26
|
+
getSpriteRenderSnapshot(sprite) {
|
|
27
|
+
const canvas = this.getRenderSurface(sprite);
|
|
28
|
+
const renderData = sprite._renderData;
|
|
29
|
+
const deformScaleX = this.getSafeRenderScale(renderData?.scaleX);
|
|
30
|
+
const deformScaleY = this.getSafeRenderScale(renderData?.scaleY);
|
|
31
|
+
const pivotX = this.getSafePivot(renderData?.pivotX);
|
|
32
|
+
const pivotY = this.getSafePivot(renderData?.pivotY);
|
|
33
|
+
const offsetX = this.getSafeNumber(renderData?.offsetX, 0);
|
|
34
|
+
const offsetY = this.getSafeNumber(renderData?.offsetY, 0);
|
|
35
|
+
const alphaMultiplier = this.getSafeNumber(renderData?.alphaMultiplier, 1);
|
|
36
|
+
const safeRenderScale = this.getSafeRenderScale(sprite.scale);
|
|
37
|
+
const finalScaleX = safeRenderScale * deformScaleX;
|
|
38
|
+
const finalScaleY = safeRenderScale * deformScaleY;
|
|
39
|
+
const baseDisplayWidth = sprite.displayWidth;
|
|
40
|
+
const baseDisplayHeight = sprite.displayHeight;
|
|
41
|
+
const pivotOffsetX = (pivotX - 0.5) * baseDisplayWidth * (1 - deformScaleX);
|
|
42
|
+
const pivotOffsetY = (pivotY - 0.5) * baseDisplayHeight * (1 - deformScaleY);
|
|
43
|
+
return {
|
|
44
|
+
canvas,
|
|
45
|
+
x: sprite.renderX + pivotOffsetX + offsetX,
|
|
46
|
+
y: sprite.renderY + pivotOffsetY + offsetY,
|
|
47
|
+
rotation: sprite.rotation,
|
|
48
|
+
alpha: Math.max(0, Math.min(1, sprite.alpha * alphaMultiplier)),
|
|
49
|
+
flipX: sprite.flipX,
|
|
50
|
+
flipY: sprite.flipY,
|
|
51
|
+
drawWidth: canvas.width * Math.abs(finalScaleX),
|
|
52
|
+
drawHeight: canvas.height * Math.abs(finalScaleY),
|
|
53
|
+
layer: sprite.layer,
|
|
54
|
+
ignoreScroll: sprite.ignoreScroll,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
render(options) {
|
|
58
|
+
this.applyPageBackground(options.pageBackground);
|
|
59
|
+
this.renderScene(options);
|
|
60
|
+
}
|
|
61
|
+
captureFrame(options) {
|
|
62
|
+
const snapshot = document.createElement("canvas");
|
|
63
|
+
snapshot.width = options.canvas.width;
|
|
64
|
+
snapshot.height = options.canvas.height;
|
|
65
|
+
const snapshotContext = snapshot.getContext("2d");
|
|
66
|
+
if (!snapshotContext) {
|
|
67
|
+
throw new Error("MinimoJS: Could not acquire a transition snapshot context.");
|
|
68
|
+
}
|
|
69
|
+
this.renderScene({
|
|
70
|
+
...options,
|
|
71
|
+
canvas: snapshot,
|
|
72
|
+
context: snapshotContext,
|
|
73
|
+
});
|
|
74
|
+
return snapshot;
|
|
75
|
+
}
|
|
76
|
+
renderScreenTransition(options) {
|
|
77
|
+
const ctx = options.context;
|
|
78
|
+
const width = options.canvas.width;
|
|
79
|
+
const height = options.canvas.height;
|
|
80
|
+
const transition = options.transition;
|
|
81
|
+
const progress = this.easeTransitionProgress(transition.progress);
|
|
82
|
+
this.applyPageBackground(options.pageBackground);
|
|
83
|
+
ctx.clearRect(0, 0, width, height);
|
|
84
|
+
switch (transition.type) {
|
|
85
|
+
case "fade":
|
|
86
|
+
this.drawFadeTransition(ctx, transition, width, height, progress);
|
|
87
|
+
return;
|
|
88
|
+
case "wipe":
|
|
89
|
+
this.drawWipeTransition(ctx, transition, width, height, progress);
|
|
90
|
+
return;
|
|
91
|
+
case "slide":
|
|
92
|
+
this.drawSlideTransition(ctx, transition, width, height, progress);
|
|
93
|
+
return;
|
|
94
|
+
case "iris":
|
|
95
|
+
this.drawIrisTransition(ctx, transition, width, height, progress);
|
|
96
|
+
return;
|
|
97
|
+
case "pixelate":
|
|
98
|
+
this.drawPixelateTransition(ctx, transition, width, height, progress);
|
|
99
|
+
return;
|
|
100
|
+
case "flash":
|
|
101
|
+
this.drawFlashTransition(ctx, transition, width, height, progress);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** @internal */
|
|
106
|
+
renderScene(options) {
|
|
107
|
+
const ctx = options.context;
|
|
108
|
+
const W = options.canvas.width;
|
|
109
|
+
const H = options.canvas.height;
|
|
110
|
+
this.paintCanvasBackground(ctx, W, H, options.background, options.backgroundGradient);
|
|
111
|
+
const backgrounds = [...options.backgroundLayers].sort((a, b) => a.layer - b.layer);
|
|
112
|
+
for (const layer of backgrounds) {
|
|
113
|
+
if (!layer.visible)
|
|
114
|
+
continue;
|
|
115
|
+
const image = options.resolveImage(layer.imageKey);
|
|
116
|
+
if (!image)
|
|
117
|
+
continue;
|
|
118
|
+
this.drawBackgroundLayer(ctx, options.canvas, layer, image);
|
|
119
|
+
}
|
|
120
|
+
const sorted = [...options.sprites].sort((a, b) => a.layer - b.layer);
|
|
121
|
+
const trailEntries = [...options.trails].sort((a, b) => a.layer - b.layer);
|
|
122
|
+
for (const ghost of trailEntries) {
|
|
123
|
+
ctx.save();
|
|
124
|
+
ctx.globalAlpha = Math.max(0, Math.min(1, ghost.alpha));
|
|
125
|
+
const drawX = ghost.ignoreScroll ? ghost.x : ghost.x - options.scrollX;
|
|
126
|
+
const drawY = ghost.ignoreScroll ? ghost.y : ghost.y - options.scrollY;
|
|
127
|
+
ctx.translate(drawX, drawY);
|
|
128
|
+
if (ghost.rotation !== 0) {
|
|
129
|
+
ctx.rotate((ghost.rotation * Math.PI) / 180);
|
|
130
|
+
}
|
|
131
|
+
if (ghost.flipX || ghost.flipY) {
|
|
132
|
+
ctx.scale(ghost.flipX ? -1 : 1, ghost.flipY ? -1 : 1);
|
|
133
|
+
}
|
|
134
|
+
ctx.drawImage(ghost.canvas, -ghost.drawWidth / 2, -ghost.drawHeight / 2, ghost.drawWidth, ghost.drawHeight);
|
|
135
|
+
ctx.restore();
|
|
136
|
+
}
|
|
137
|
+
for (const sprite of sorted) {
|
|
138
|
+
if (!sprite.visible)
|
|
139
|
+
continue;
|
|
140
|
+
ctx.save();
|
|
141
|
+
const snapshot = this.getSpriteRenderSnapshot(sprite);
|
|
142
|
+
ctx.globalAlpha = snapshot.alpha;
|
|
143
|
+
const drawX = Math.round(snapshot.ignoreScroll ? snapshot.x : snapshot.x - options.scrollX);
|
|
144
|
+
const drawY = Math.round(snapshot.ignoreScroll ? snapshot.y : snapshot.y - options.scrollY);
|
|
145
|
+
ctx.translate(drawX, drawY);
|
|
146
|
+
if (snapshot.rotation !== 0) {
|
|
147
|
+
ctx.rotate((snapshot.rotation * Math.PI) / 180);
|
|
148
|
+
}
|
|
149
|
+
if (snapshot.flipX || snapshot.flipY) {
|
|
150
|
+
ctx.scale(snapshot.flipX ? -1 : 1, snapshot.flipY ? -1 : 1);
|
|
151
|
+
}
|
|
152
|
+
ctx.shadowColor = "transparent";
|
|
153
|
+
ctx.shadowBlur = 0;
|
|
154
|
+
ctx.shadowOffsetX = 0;
|
|
155
|
+
ctx.shadowOffsetY = 0;
|
|
156
|
+
ctx.drawImage(snapshot.canvas, -snapshot.drawWidth / 2, -snapshot.drawHeight / 2, snapshot.drawWidth, snapshot.drawHeight);
|
|
157
|
+
ctx.restore();
|
|
158
|
+
}
|
|
159
|
+
const wipeEntries = [...options.wipes].sort((a, b) => a.layer - b.layer);
|
|
160
|
+
for (const entry of wipeEntries) {
|
|
161
|
+
const visibleProgress = Math.max(0, Math.min(1, entry.progress));
|
|
162
|
+
if (visibleProgress <= 0)
|
|
163
|
+
continue;
|
|
164
|
+
ctx.save();
|
|
165
|
+
ctx.globalAlpha = entry.alpha;
|
|
166
|
+
const drawX = Math.round(entry.ignoreScroll ? entry.x : entry.x - options.scrollX);
|
|
167
|
+
const drawY = Math.round(entry.ignoreScroll ? entry.y : entry.y - options.scrollY);
|
|
168
|
+
ctx.translate(drawX, drawY);
|
|
169
|
+
if (entry.rotation !== 0) {
|
|
170
|
+
ctx.rotate((entry.rotation * Math.PI) / 180);
|
|
171
|
+
}
|
|
172
|
+
if (entry.flipX || entry.flipY) {
|
|
173
|
+
ctx.scale(entry.flipX ? -1 : 1, entry.flipY ? -1 : 1);
|
|
174
|
+
}
|
|
175
|
+
const clip = this.getWipeClipRect(entry.drawWidth, entry.drawHeight, entry.direction, visibleProgress);
|
|
176
|
+
if (clip.width > 0 && clip.height > 0) {
|
|
177
|
+
ctx.beginPath();
|
|
178
|
+
ctx.rect(clip.x, clip.y, clip.width, clip.height);
|
|
179
|
+
ctx.clip();
|
|
180
|
+
ctx.drawImage(entry.canvas, -entry.drawWidth / 2, -entry.drawHeight / 2, entry.drawWidth, entry.drawHeight);
|
|
181
|
+
}
|
|
182
|
+
ctx.restore();
|
|
183
|
+
}
|
|
184
|
+
const explosionEntries = [...options.explosions].sort((a, b) => a.layer - b.layer);
|
|
185
|
+
for (const entry of explosionEntries) {
|
|
186
|
+
for (const piece of entry.pieces) {
|
|
187
|
+
ctx.save();
|
|
188
|
+
ctx.globalAlpha = Math.max(0, Math.min(1, piece.alpha));
|
|
189
|
+
const drawX = entry.ignoreScroll ? piece.x : piece.x - options.scrollX;
|
|
190
|
+
const drawY = entry.ignoreScroll ? piece.y : piece.y - options.scrollY;
|
|
191
|
+
ctx.translate(drawX, drawY);
|
|
192
|
+
if (piece.rotation !== 0) {
|
|
193
|
+
ctx.rotate((piece.rotation * Math.PI) / 180);
|
|
194
|
+
}
|
|
195
|
+
if (piece.flipX || piece.flipY) {
|
|
196
|
+
ctx.scale(piece.flipX ? -1 : 1, piece.flipY ? -1 : 1);
|
|
197
|
+
}
|
|
198
|
+
ctx.drawImage(entry.canvas, piece.sx, piece.sy, piece.sw, piece.sh, -piece.drawWidth / 2, -piece.drawHeight / 2, piece.drawWidth, piece.drawHeight);
|
|
199
|
+
ctx.restore();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const entry of options.textEntries) {
|
|
203
|
+
ctx.save();
|
|
204
|
+
ctx.font = `${entry.fontSize}px monospace`;
|
|
205
|
+
ctx.fillStyle = entry.color;
|
|
206
|
+
ctx.textAlign = entry.centered ? "center" : "left";
|
|
207
|
+
ctx.textBaseline = entry.centered ? "middle" : "top";
|
|
208
|
+
ctx.fillText(entry.text, entry.x, entry.y);
|
|
209
|
+
ctx.restore();
|
|
210
|
+
}
|
|
211
|
+
if (options.debugBodies) {
|
|
212
|
+
this.drawBodyDebugOverlay(ctx, sorted, options.scrollX, options.scrollY);
|
|
213
|
+
}
|
|
214
|
+
if (options.debugInputAreas) {
|
|
215
|
+
this.drawInputDebugOverlay(ctx, sorted, options.scrollX, options.scrollY);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** @internal */
|
|
219
|
+
drawBodyDebugOverlay(ctx, sprites, scrollX, scrollY) {
|
|
220
|
+
ctx.save();
|
|
221
|
+
ctx.setLineDash([6, 4]);
|
|
222
|
+
ctx.lineWidth = 2;
|
|
223
|
+
for (const sprite of sprites) {
|
|
224
|
+
const halfWidth = sprite.bodyDisplayWidth / 2;
|
|
225
|
+
const halfHeight = sprite.bodyDisplayHeight / 2;
|
|
226
|
+
const centerX = sprite.ignoreScroll ? sprite.bodyCenterX : sprite.bodyCenterX - scrollX;
|
|
227
|
+
const centerY = sprite.ignoreScroll ? sprite.bodyCenterY : sprite.bodyCenterY - scrollY;
|
|
228
|
+
const left = Math.round(centerX - halfWidth) + 0.5;
|
|
229
|
+
const top = Math.round(centerY - halfHeight) + 0.5;
|
|
230
|
+
const width = Math.max(1, Math.round(sprite.bodyDisplayWidth) - 1);
|
|
231
|
+
const height = Math.max(1, Math.round(sprite.bodyDisplayHeight) - 1);
|
|
232
|
+
const stroke = sprite.isStatic
|
|
233
|
+
? 'rgba(0, 217, 255, 0.95)'
|
|
234
|
+
: 'rgba(255, 77, 109, 0.95)';
|
|
235
|
+
const fill = sprite.isStatic
|
|
236
|
+
? 'rgba(0, 217, 255, 0.12)'
|
|
237
|
+
: 'rgba(255, 77, 109, 0.12)';
|
|
238
|
+
ctx.strokeStyle = stroke;
|
|
239
|
+
ctx.fillStyle = fill;
|
|
240
|
+
ctx.fillRect(left, top, width, height);
|
|
241
|
+
ctx.strokeRect(left, top, width, height);
|
|
242
|
+
}
|
|
243
|
+
ctx.restore();
|
|
244
|
+
}
|
|
245
|
+
/** @internal */
|
|
246
|
+
drawInputDebugOverlay(ctx, sprites, scrollX, scrollY) {
|
|
247
|
+
ctx.save();
|
|
248
|
+
ctx.setLineDash([3, 5]);
|
|
249
|
+
ctx.lineWidth = 2;
|
|
250
|
+
for (const sprite of sprites) {
|
|
251
|
+
const halfWidth = sprite.displayWidth / 2;
|
|
252
|
+
const halfHeight = sprite.displayHeight / 2;
|
|
253
|
+
const centerX = sprite.ignoreScroll ? sprite.renderX : sprite.renderX - scrollX;
|
|
254
|
+
const centerY = sprite.ignoreScroll ? sprite.renderY : sprite.renderY - scrollY;
|
|
255
|
+
const left = Math.round(centerX - halfWidth) + 0.5;
|
|
256
|
+
const top = Math.round(centerY - halfHeight) + 0.5;
|
|
257
|
+
const width = Math.max(1, Math.round(sprite.displayWidth) - 1);
|
|
258
|
+
const height = Math.max(1, Math.round(sprite.displayHeight) - 1);
|
|
259
|
+
const stroke = "rgba(255, 196, 0, 0.98)";
|
|
260
|
+
const fill = "rgba(255, 196, 0, 0.10)";
|
|
261
|
+
ctx.strokeStyle = stroke;
|
|
262
|
+
ctx.fillStyle = fill;
|
|
263
|
+
ctx.fillRect(left, top, width, height);
|
|
264
|
+
ctx.strokeRect(left, top, width, height);
|
|
265
|
+
}
|
|
266
|
+
ctx.restore();
|
|
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
|
+
*/
|
|
272
|
+
renderLoadingScreen(options) {
|
|
273
|
+
const ctx = options.context;
|
|
274
|
+
const W = options.canvas.width;
|
|
275
|
+
const H = options.canvas.height;
|
|
276
|
+
const progress = this.clampProgress(options.progress);
|
|
277
|
+
this.applyPageBackground(options.pageBackground);
|
|
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);
|
|
288
|
+
ctx.save();
|
|
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
|
+
}
|
|
304
|
+
ctx.restore();
|
|
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
|
+
}
|
|
402
|
+
/** @internal */
|
|
403
|
+
drawFadeTransition(ctx, transition, width, height, progress) {
|
|
404
|
+
const firstHalf = progress < 0.5;
|
|
405
|
+
const localT = firstHalf ? progress / 0.5 : (progress - 0.5) / 0.5;
|
|
406
|
+
ctx.drawImage(firstHalf ? transition.fromCanvas : transition.toCanvas, 0, 0, width, height);
|
|
407
|
+
ctx.save();
|
|
408
|
+
ctx.globalAlpha = firstHalf ? localT : 1 - localT;
|
|
409
|
+
ctx.fillStyle = transition.color;
|
|
410
|
+
ctx.fillRect(0, 0, width, height);
|
|
411
|
+
ctx.restore();
|
|
412
|
+
}
|
|
413
|
+
/** @internal */
|
|
414
|
+
drawWipeTransition(ctx, transition, width, height, progress) {
|
|
415
|
+
ctx.drawImage(transition.fromCanvas, 0, 0, width, height);
|
|
416
|
+
const clip = this.getScreenWipeClipRect(width, height, transition.direction, progress);
|
|
417
|
+
if (clip.width <= 0 || clip.height <= 0)
|
|
418
|
+
return;
|
|
419
|
+
ctx.save();
|
|
420
|
+
ctx.beginPath();
|
|
421
|
+
ctx.rect(clip.x, clip.y, clip.width, clip.height);
|
|
422
|
+
ctx.clip();
|
|
423
|
+
ctx.drawImage(transition.toCanvas, 0, 0, width, height);
|
|
424
|
+
ctx.restore();
|
|
425
|
+
}
|
|
426
|
+
/** @internal */
|
|
427
|
+
drawSlideTransition(ctx, transition, width, height, progress) {
|
|
428
|
+
const offset = this.getSlideOffset(transition.direction, width, height, progress);
|
|
429
|
+
ctx.drawImage(transition.fromCanvas, offset.fromX, offset.fromY, width, height);
|
|
430
|
+
ctx.drawImage(transition.toCanvas, offset.toX, offset.toY, width, height);
|
|
431
|
+
}
|
|
432
|
+
/** @internal */
|
|
433
|
+
drawIrisTransition(ctx, transition, width, height, progress) {
|
|
434
|
+
ctx.drawImage(transition.fromCanvas, 0, 0, width, height);
|
|
435
|
+
const maxRadius = Math.max(Math.hypot(transition.centerX, transition.centerY), Math.hypot(width - transition.centerX, transition.centerY), Math.hypot(transition.centerX, height - transition.centerY), Math.hypot(width - transition.centerX, height - transition.centerY));
|
|
436
|
+
ctx.save();
|
|
437
|
+
ctx.beginPath();
|
|
438
|
+
ctx.arc(transition.centerX, transition.centerY, maxRadius * progress, 0, Math.PI * 2);
|
|
439
|
+
ctx.clip();
|
|
440
|
+
ctx.drawImage(transition.toCanvas, 0, 0, width, height);
|
|
441
|
+
ctx.restore();
|
|
442
|
+
}
|
|
443
|
+
/** @internal */
|
|
444
|
+
drawPixelateTransition(ctx, transition, width, height, progress) {
|
|
445
|
+
const firstHalf = progress < 0.5;
|
|
446
|
+
const localT = firstHalf ? progress / 0.5 : (progress - 0.5) / 0.5;
|
|
447
|
+
const source = firstHalf ? transition.fromCanvas : transition.toCanvas;
|
|
448
|
+
const pixelSize = firstHalf
|
|
449
|
+
? this.lerp(1, transition.pixelSize, localT)
|
|
450
|
+
: this.lerp(transition.pixelSize, 1, localT);
|
|
451
|
+
this.drawPixelatedCanvas(ctx, source, width, height, pixelSize);
|
|
452
|
+
}
|
|
453
|
+
/** @internal */
|
|
454
|
+
drawFlashTransition(ctx, transition, width, height, progress) {
|
|
455
|
+
const swapPoint = 0.28;
|
|
456
|
+
const overlayPeak = 0.52;
|
|
457
|
+
const source = progress < swapPoint ? transition.fromCanvas : transition.toCanvas;
|
|
458
|
+
let alpha = 0;
|
|
459
|
+
if (progress < swapPoint) {
|
|
460
|
+
alpha = progress / Math.max(0.001, swapPoint);
|
|
461
|
+
}
|
|
462
|
+
else if (progress < overlayPeak) {
|
|
463
|
+
alpha = 1;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
alpha = 1 - (progress - overlayPeak) / Math.max(0.001, 1 - overlayPeak);
|
|
467
|
+
}
|
|
468
|
+
ctx.drawImage(source, 0, 0, width, height);
|
|
469
|
+
ctx.save();
|
|
470
|
+
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
|
|
471
|
+
ctx.fillStyle = transition.color;
|
|
472
|
+
ctx.fillRect(0, 0, width, height);
|
|
473
|
+
ctx.restore();
|
|
474
|
+
}
|
|
475
|
+
/** @internal */
|
|
476
|
+
getRenderSurface(sprite) {
|
|
477
|
+
if (this.isDrawSprite(sprite)) {
|
|
478
|
+
return this.getDrawSurface(sprite);
|
|
479
|
+
}
|
|
480
|
+
const cacheKey = sprite.getRenderCacheKey();
|
|
481
|
+
const cached = this._surfaceCache.get(cacheKey);
|
|
482
|
+
if (cached) {
|
|
483
|
+
this._surfaceCache.delete(cacheKey);
|
|
484
|
+
this._surfaceCache.set(cacheKey, cached);
|
|
485
|
+
return cached;
|
|
486
|
+
}
|
|
487
|
+
const surface = this.isTextSprite(sprite)
|
|
488
|
+
? this.createTextSurface(sprite)
|
|
489
|
+
: this.isImageSprite(sprite)
|
|
490
|
+
? this.createImageSurface(sprite)
|
|
491
|
+
: this.createEmojiSurface(this.asEmojiSprite(sprite));
|
|
492
|
+
this._surfaceCache.set(cacheKey, surface);
|
|
493
|
+
this.trimSurfaceCache();
|
|
494
|
+
return surface;
|
|
495
|
+
}
|
|
496
|
+
/** @internal */
|
|
497
|
+
trimSurfaceCache() {
|
|
498
|
+
while (this._surfaceCache.size > RenderSystem.MAX_SURFACE_CACHE_ENTRIES) {
|
|
499
|
+
const oldestKey = this._surfaceCache.keys().next().value;
|
|
500
|
+
if (oldestKey === undefined) {
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
this._surfaceCache.delete(oldestKey);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** @internal */
|
|
507
|
+
createEmojiSurface(sprite) {
|
|
508
|
+
const size = Math.max(1, Math.round(sprite.size));
|
|
509
|
+
const color = sprite.color;
|
|
510
|
+
const glyphCanvas = document.createElement("canvas");
|
|
511
|
+
const boxSize = Math.max(2, Math.ceil(size * 2));
|
|
512
|
+
glyphCanvas.width = boxSize;
|
|
513
|
+
glyphCanvas.height = boxSize;
|
|
514
|
+
const glyphCtx = glyphCanvas.getContext("2d");
|
|
515
|
+
if (!glyphCtx) {
|
|
516
|
+
throw new Error("MinimoJS: Could not acquire a glyph rendering context.");
|
|
517
|
+
}
|
|
518
|
+
glyphCtx.clearRect(0, 0, boxSize, boxSize);
|
|
519
|
+
glyphCtx.shadowColor = "transparent";
|
|
520
|
+
glyphCtx.shadowBlur = 0;
|
|
521
|
+
glyphCtx.shadowOffsetX = 0;
|
|
522
|
+
glyphCtx.shadowOffsetY = 0;
|
|
523
|
+
glyphCtx.fillStyle = color;
|
|
524
|
+
glyphCtx.font = `${size}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
|
525
|
+
glyphCtx.textAlign = "center";
|
|
526
|
+
glyphCtx.textBaseline = "middle";
|
|
527
|
+
glyphCtx.fillText(sprite.sprite, boxSize / 2, boxSize / 2);
|
|
528
|
+
return glyphCanvas;
|
|
529
|
+
}
|
|
530
|
+
/** @internal */
|
|
531
|
+
createTextSurface(sprite) {
|
|
532
|
+
const canvas = document.createElement("canvas");
|
|
533
|
+
const ctx = canvas.getContext("2d");
|
|
534
|
+
if (!ctx) {
|
|
535
|
+
throw new Error("MinimoJS: Could not acquire a text rendering context.");
|
|
536
|
+
}
|
|
537
|
+
const lines = this.layoutTextLines(ctx, sprite);
|
|
538
|
+
const measuredWidth = Math.max(1, Math.ceil(lines.reduce((maxWidth, line) => Math.max(maxWidth, ctx.measureText(line).width), 0)));
|
|
539
|
+
const lineHeightPx = Math.max(1, Math.ceil(sprite.fontSize * sprite.lineHeight));
|
|
540
|
+
const contentHeight = Math.max(1, lineHeightPx * Math.max(lines.length, 1));
|
|
541
|
+
const borderPad = Math.max(0, sprite.borderWidth) * 2;
|
|
542
|
+
const strokePad = Math.max(0, sprite.strokeWidth) * 2;
|
|
543
|
+
const totalWidth = Math.max(1, Math.ceil(sprite.fixedWidth > 0
|
|
544
|
+
? sprite.fixedWidth
|
|
545
|
+
: measuredWidth + sprite.paddingX * 2 + borderPad + strokePad));
|
|
546
|
+
const totalHeight = Math.max(1, Math.ceil(sprite.fixedHeight > 0
|
|
547
|
+
? sprite.fixedHeight
|
|
548
|
+
: contentHeight + sprite.paddingY * 2 + borderPad + strokePad));
|
|
549
|
+
sprite._measuredWidth = totalWidth;
|
|
550
|
+
sprite._measuredHeight = totalHeight;
|
|
551
|
+
canvas.width = totalWidth;
|
|
552
|
+
canvas.height = totalHeight;
|
|
553
|
+
const drawCtx = canvas.getContext("2d");
|
|
554
|
+
if (!drawCtx) {
|
|
555
|
+
throw new Error("MinimoJS: Could not acquire a text rendering context.");
|
|
556
|
+
}
|
|
557
|
+
this.applyTextStyle(drawCtx, sprite);
|
|
558
|
+
if (sprite.backgroundColor !== null) {
|
|
559
|
+
drawCtx.fillStyle = sprite.backgroundColor;
|
|
560
|
+
this.fillRoundedRect(drawCtx, 0, 0, totalWidth, totalHeight, sprite.cornerRadius);
|
|
561
|
+
}
|
|
562
|
+
if (sprite.borderColor !== null && sprite.borderWidth > 0) {
|
|
563
|
+
drawCtx.strokeStyle = sprite.borderColor;
|
|
564
|
+
drawCtx.lineWidth = sprite.borderWidth;
|
|
565
|
+
this.strokeRoundedRect(drawCtx, sprite.borderWidth / 2, sprite.borderWidth / 2, totalWidth - sprite.borderWidth, totalHeight - sprite.borderWidth, sprite.cornerRadius);
|
|
566
|
+
}
|
|
567
|
+
drawCtx.fillStyle = sprite.color;
|
|
568
|
+
drawCtx.textAlign = sprite.textAlign;
|
|
569
|
+
drawCtx.textBaseline = "middle";
|
|
570
|
+
if (sprite.strokeColor !== null && sprite.strokeWidth > 0) {
|
|
571
|
+
drawCtx.strokeStyle = sprite.strokeColor;
|
|
572
|
+
drawCtx.lineWidth = sprite.strokeWidth;
|
|
573
|
+
drawCtx.lineJoin = "round";
|
|
574
|
+
drawCtx.miterLimit = 2;
|
|
575
|
+
}
|
|
576
|
+
const contentLeft = sprite.paddingX + sprite.borderWidth + sprite.strokeWidth;
|
|
577
|
+
const contentRight = totalWidth - sprite.paddingX - sprite.borderWidth - sprite.strokeWidth;
|
|
578
|
+
const contentCenter = totalWidth / 2;
|
|
579
|
+
const availableContentHeight = Math.max(1, totalHeight - sprite.paddingY * 2 - borderPad - strokePad);
|
|
580
|
+
const contentOffsetY = Math.max(0, Math.floor((availableContentHeight - contentHeight) / 2));
|
|
581
|
+
const startY = sprite.paddingY +
|
|
582
|
+
sprite.borderWidth +
|
|
583
|
+
sprite.strokeWidth +
|
|
584
|
+
contentOffsetY +
|
|
585
|
+
lineHeightPx / 2;
|
|
586
|
+
const drawX = sprite.textAlign === "left"
|
|
587
|
+
? contentLeft
|
|
588
|
+
: sprite.textAlign === "right"
|
|
589
|
+
? contentRight
|
|
590
|
+
: contentCenter;
|
|
591
|
+
for (let i = 0; i < lines.length; i++) {
|
|
592
|
+
if (sprite.strokeColor !== null && sprite.strokeWidth > 0) {
|
|
593
|
+
drawCtx.strokeText(lines[i], drawX, startY + i * lineHeightPx);
|
|
594
|
+
}
|
|
595
|
+
drawCtx.fillText(lines[i], drawX, startY + i * lineHeightPx);
|
|
596
|
+
}
|
|
597
|
+
return canvas;
|
|
598
|
+
}
|
|
599
|
+
/** @internal */
|
|
600
|
+
createImageSurface(sprite) {
|
|
601
|
+
const image = sprite.game?.getImage(sprite.imageKey);
|
|
602
|
+
if (!image) {
|
|
603
|
+
throw new Error(`MinimoJS: Image '${sprite.imageKey}' is not loaded.`);
|
|
604
|
+
}
|
|
605
|
+
const canvas = document.createElement("canvas");
|
|
606
|
+
canvas.width = Math.max(1, image instanceof HTMLImageElement ? image.naturalWidth || image.width : image.width);
|
|
607
|
+
canvas.height = Math.max(1, image instanceof HTMLImageElement ? image.naturalHeight || image.height : image.height);
|
|
608
|
+
const ctx = canvas.getContext("2d");
|
|
609
|
+
if (!ctx) {
|
|
610
|
+
throw new Error("MinimoJS: Could not acquire an image rendering context.");
|
|
611
|
+
}
|
|
612
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
613
|
+
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
614
|
+
return canvas;
|
|
615
|
+
}
|
|
616
|
+
/** @internal */
|
|
617
|
+
getDrawSurface(sprite) {
|
|
618
|
+
const width = Math.max(1, Math.round(sprite.width));
|
|
619
|
+
const height = Math.max(1, Math.round(sprite.height));
|
|
620
|
+
let surface = sprite._surface;
|
|
621
|
+
if (!surface) {
|
|
622
|
+
surface = document.createElement("canvas");
|
|
623
|
+
sprite._surface = surface;
|
|
624
|
+
}
|
|
625
|
+
const sizeChanged = surface.width !== width || surface.height !== height;
|
|
626
|
+
if (sprite.frozen && !sizeChanged && surface.width > 0 && surface.height > 0) {
|
|
627
|
+
return surface;
|
|
628
|
+
}
|
|
629
|
+
if (!sprite.frozen &&
|
|
630
|
+
sprite._lastRedrawPassId === this._dynamicSurfacePassId &&
|
|
631
|
+
surface.width === width &&
|
|
632
|
+
surface.height === height) {
|
|
633
|
+
return surface;
|
|
634
|
+
}
|
|
635
|
+
if (sizeChanged) {
|
|
636
|
+
surface.width = width;
|
|
637
|
+
surface.height = height;
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
// Resetting width clears pixels and restores the default 2D context state.
|
|
641
|
+
surface.width = width;
|
|
642
|
+
}
|
|
643
|
+
const ctx = surface.getContext("2d");
|
|
644
|
+
if (!ctx) {
|
|
645
|
+
throw new Error("MinimoJS: Could not acquire a DrawSprite rendering context.");
|
|
646
|
+
}
|
|
647
|
+
sprite._surfaceCtx = ctx;
|
|
648
|
+
sprite.redraw(ctx);
|
|
649
|
+
sprite._lastRedrawPassId = this._dynamicSurfacePassId;
|
|
650
|
+
return surface;
|
|
651
|
+
}
|
|
652
|
+
/** @internal */
|
|
653
|
+
isTextSprite(sprite) {
|
|
654
|
+
return "text" in sprite && "fontFamily" in sprite && "fontSize" in sprite;
|
|
655
|
+
}
|
|
656
|
+
/** @internal */
|
|
657
|
+
isDrawSprite(sprite) {
|
|
658
|
+
return "redraw" in sprite && "_surface" in sprite && "_surfaceCtx" in sprite;
|
|
659
|
+
}
|
|
660
|
+
/** @internal */
|
|
661
|
+
isImageSprite(sprite) {
|
|
662
|
+
return "imageKey" in sprite && "setTexture" in sprite;
|
|
663
|
+
}
|
|
664
|
+
/** @internal */
|
|
665
|
+
getWipeClipRect(drawWidth, drawHeight, direction, progress) {
|
|
666
|
+
const halfWidth = drawWidth / 2;
|
|
667
|
+
const halfHeight = drawHeight / 2;
|
|
668
|
+
switch (direction) {
|
|
669
|
+
case "left-to-right":
|
|
670
|
+
return {
|
|
671
|
+
x: -halfWidth,
|
|
672
|
+
y: -halfHeight,
|
|
673
|
+
width: drawWidth * progress,
|
|
674
|
+
height: drawHeight,
|
|
675
|
+
};
|
|
676
|
+
case "right-to-left": {
|
|
677
|
+
const width = drawWidth * progress;
|
|
678
|
+
return {
|
|
679
|
+
x: halfWidth - width,
|
|
680
|
+
y: -halfHeight,
|
|
681
|
+
width,
|
|
682
|
+
height: drawHeight,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
case "top-to-bottom":
|
|
686
|
+
return {
|
|
687
|
+
x: -halfWidth,
|
|
688
|
+
y: -halfHeight,
|
|
689
|
+
width: drawWidth,
|
|
690
|
+
height: drawHeight * progress,
|
|
691
|
+
};
|
|
692
|
+
case "bottom-to-top": {
|
|
693
|
+
const height = drawHeight * progress;
|
|
694
|
+
return {
|
|
695
|
+
x: -halfWidth,
|
|
696
|
+
y: halfHeight - height,
|
|
697
|
+
width: drawWidth,
|
|
698
|
+
height,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/** @internal */
|
|
704
|
+
getScreenWipeClipRect(width, height, direction, progress) {
|
|
705
|
+
switch (direction) {
|
|
706
|
+
case "left-to-right":
|
|
707
|
+
return {
|
|
708
|
+
x: 0,
|
|
709
|
+
y: 0,
|
|
710
|
+
width: width * progress,
|
|
711
|
+
height,
|
|
712
|
+
};
|
|
713
|
+
case "right-to-left": {
|
|
714
|
+
const clipWidth = width * progress;
|
|
715
|
+
return {
|
|
716
|
+
x: width - clipWidth,
|
|
717
|
+
y: 0,
|
|
718
|
+
width: clipWidth,
|
|
719
|
+
height,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
case "top-to-bottom":
|
|
723
|
+
return {
|
|
724
|
+
x: 0,
|
|
725
|
+
y: 0,
|
|
726
|
+
width,
|
|
727
|
+
height: height * progress,
|
|
728
|
+
};
|
|
729
|
+
case "bottom-to-top": {
|
|
730
|
+
const clipHeight = height * progress;
|
|
731
|
+
return {
|
|
732
|
+
x: 0,
|
|
733
|
+
y: height - clipHeight,
|
|
734
|
+
width,
|
|
735
|
+
height: clipHeight,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** @internal */
|
|
741
|
+
asEmojiSprite(sprite) {
|
|
742
|
+
if ("sprite" in sprite && "size" in sprite) {
|
|
743
|
+
return sprite;
|
|
744
|
+
}
|
|
745
|
+
throw new Error("MinimoJS: Unsupported sprite type for emoji rendering.");
|
|
746
|
+
}
|
|
747
|
+
/** @internal */
|
|
748
|
+
layoutTextLines(ctx, sprite) {
|
|
749
|
+
this.applyTextStyle(ctx, sprite);
|
|
750
|
+
const rawLines = sprite.text.split("\n");
|
|
751
|
+
const availableTextWidth = this.getAvailableTextWidth(sprite);
|
|
752
|
+
if (availableTextWidth <= 0) {
|
|
753
|
+
return rawLines.length > 0 ? rawLines : [""];
|
|
754
|
+
}
|
|
755
|
+
const wrappedLines = [];
|
|
756
|
+
for (const rawLine of rawLines) {
|
|
757
|
+
const words = rawLine.split(/\s+/).filter((word) => word.length > 0);
|
|
758
|
+
if (words.length === 0) {
|
|
759
|
+
wrappedLines.push("");
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
let currentLine = words[0];
|
|
763
|
+
for (let i = 1; i < words.length; i++) {
|
|
764
|
+
const candidate = `${currentLine} ${words[i]}`;
|
|
765
|
+
if (ctx.measureText(candidate).width <= availableTextWidth) {
|
|
766
|
+
currentLine = candidate;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
wrappedLines.push(currentLine);
|
|
770
|
+
currentLine = words[i];
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
wrappedLines.push(currentLine);
|
|
774
|
+
}
|
|
775
|
+
return wrappedLines.length > 0 ? wrappedLines : [""];
|
|
776
|
+
}
|
|
777
|
+
/** @internal */
|
|
778
|
+
getAvailableTextWidth(sprite) {
|
|
779
|
+
const borderPad = Math.max(0, sprite.borderWidth) * 2;
|
|
780
|
+
const strokePad = Math.max(0, sprite.strokeWidth) * 2;
|
|
781
|
+
const limits = [];
|
|
782
|
+
if (sprite.maxWidth > 0) {
|
|
783
|
+
limits.push(sprite.maxWidth - sprite.paddingX * 2 - borderPad - strokePad);
|
|
784
|
+
}
|
|
785
|
+
if (sprite.fixedWidth > 0) {
|
|
786
|
+
limits.push(sprite.fixedWidth - sprite.paddingX * 2 - borderPad - strokePad);
|
|
787
|
+
}
|
|
788
|
+
if (limits.length === 0) {
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
return Math.max(1, Math.floor(Math.min(...limits)));
|
|
792
|
+
}
|
|
793
|
+
/** @internal */
|
|
794
|
+
applyTextStyle(ctx, sprite) {
|
|
795
|
+
ctx.font = `${sprite.fontWeight} ${sprite.fontSize}px ${sprite.fontFamily}`;
|
|
796
|
+
ctx.shadowColor = "transparent";
|
|
797
|
+
ctx.shadowBlur = 0;
|
|
798
|
+
ctx.shadowOffsetX = 0;
|
|
799
|
+
ctx.shadowOffsetY = 0;
|
|
800
|
+
}
|
|
801
|
+
/** @internal */
|
|
802
|
+
fillRoundedRect(ctx, x, y, width, height, radius) {
|
|
803
|
+
this.buildRoundedRectPath(ctx, x, y, width, height, radius);
|
|
804
|
+
ctx.fill();
|
|
805
|
+
}
|
|
806
|
+
/** @internal */
|
|
807
|
+
strokeRoundedRect(ctx, x, y, width, height, radius) {
|
|
808
|
+
this.buildRoundedRectPath(ctx, x, y, width, height, radius);
|
|
809
|
+
ctx.stroke();
|
|
810
|
+
}
|
|
811
|
+
/** @internal */
|
|
812
|
+
buildRoundedRectPath(ctx, x, y, width, height, radius) {
|
|
813
|
+
const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
|
|
814
|
+
ctx.beginPath();
|
|
815
|
+
if (safeRadius <= 0) {
|
|
816
|
+
ctx.rect(x, y, width, height);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
ctx.moveTo(x + safeRadius, y);
|
|
820
|
+
ctx.lineTo(x + width - safeRadius, y);
|
|
821
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
|
|
822
|
+
ctx.lineTo(x + width, y + height - safeRadius);
|
|
823
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
|
|
824
|
+
ctx.lineTo(x + safeRadius, y + height);
|
|
825
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
|
|
826
|
+
ctx.lineTo(x, y + safeRadius);
|
|
827
|
+
ctx.quadraticCurveTo(x, y, x + safeRadius, y);
|
|
828
|
+
ctx.closePath();
|
|
829
|
+
}
|
|
830
|
+
/** @internal */
|
|
831
|
+
getSafeRenderScale(value) {
|
|
832
|
+
if (!Number.isFinite(value))
|
|
833
|
+
return 1;
|
|
834
|
+
const safeValue = value;
|
|
835
|
+
return Math.max(0, safeValue);
|
|
836
|
+
}
|
|
837
|
+
/** @internal */
|
|
838
|
+
getSafePivot(value) {
|
|
839
|
+
if (!Number.isFinite(value))
|
|
840
|
+
return 0.5;
|
|
841
|
+
const safeValue = value;
|
|
842
|
+
return Math.max(0, Math.min(1, safeValue));
|
|
843
|
+
}
|
|
844
|
+
/** @internal */
|
|
845
|
+
getSafeNumber(value, fallback) {
|
|
846
|
+
if (!Number.isFinite(value))
|
|
847
|
+
return fallback;
|
|
848
|
+
return value;
|
|
849
|
+
}
|
|
850
|
+
/** @internal */
|
|
851
|
+
paintCanvasBackground(ctx, width, height, background, backgroundGradient) {
|
|
852
|
+
ctx.clearRect(0, 0, width, height);
|
|
853
|
+
if (backgroundGradient !== null) {
|
|
854
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
855
|
+
gradient.addColorStop(0, backgroundGradient.from);
|
|
856
|
+
gradient.addColorStop(1, backgroundGradient.to);
|
|
857
|
+
ctx.fillStyle = gradient;
|
|
858
|
+
ctx.fillRect(0, 0, width, height);
|
|
859
|
+
}
|
|
860
|
+
else if (background !== null) {
|
|
861
|
+
ctx.fillStyle = background;
|
|
862
|
+
ctx.fillRect(0, 0, width, height);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/** @internal */
|
|
866
|
+
drawBackgroundLayer(ctx, canvas, layer, image) {
|
|
867
|
+
const destX = Math.round(layer.x);
|
|
868
|
+
const destY = Math.round(layer.y);
|
|
869
|
+
const destW = this.getPositiveLength(layer.width, canvas.width);
|
|
870
|
+
const destH = this.getPositiveLength(layer.height, canvas.height);
|
|
871
|
+
const alpha = Math.max(0, Math.min(1, layer.alpha));
|
|
872
|
+
ctx.save();
|
|
873
|
+
ctx.globalAlpha = alpha;
|
|
874
|
+
if (layer.fit === "none") {
|
|
875
|
+
ctx.drawImage(image, destX, destY);
|
|
876
|
+
ctx.restore();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (layer.fit === "stretch") {
|
|
880
|
+
ctx.drawImage(image, destX, destY, destW, destH);
|
|
881
|
+
ctx.restore();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const imageW = Math.max(1, image instanceof HTMLImageElement ? image.naturalWidth || image.width : image.width);
|
|
885
|
+
const imageH = Math.max(1, image instanceof HTMLImageElement ? image.naturalHeight || image.height : image.height);
|
|
886
|
+
const scale = this.getBackgroundScale(layer.fit, destW, destH, imageW, imageH);
|
|
887
|
+
const drawW = imageW * scale;
|
|
888
|
+
const drawH = imageH * scale;
|
|
889
|
+
const drawX = destX + (destW - drawW) / 2;
|
|
890
|
+
const drawY = destY + (destH - drawH) / 2;
|
|
891
|
+
if (layer.fit === "cover") {
|
|
892
|
+
ctx.beginPath();
|
|
893
|
+
ctx.rect(destX, destY, destW, destH);
|
|
894
|
+
ctx.clip();
|
|
895
|
+
}
|
|
896
|
+
ctx.drawImage(image, drawX, drawY, drawW, drawH);
|
|
897
|
+
ctx.restore();
|
|
898
|
+
}
|
|
899
|
+
/** @internal */
|
|
900
|
+
getPositiveLength(value, fallback) {
|
|
901
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
902
|
+
return fallback;
|
|
903
|
+
return Math.round(value);
|
|
904
|
+
}
|
|
905
|
+
/** @internal */
|
|
906
|
+
getBackgroundScale(fit, destW, destH, imageW, imageH) {
|
|
907
|
+
const scaleX = destW / imageW;
|
|
908
|
+
const scaleY = destH / imageH;
|
|
909
|
+
if (fit === "contain") {
|
|
910
|
+
return Math.min(scaleX, scaleY);
|
|
911
|
+
}
|
|
912
|
+
return Math.max(scaleX, scaleY);
|
|
913
|
+
}
|
|
914
|
+
/** @internal */
|
|
915
|
+
easeTransitionProgress(t) {
|
|
916
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
917
|
+
return clamped * clamped * (3 - 2 * clamped);
|
|
918
|
+
}
|
|
919
|
+
/** @internal */
|
|
920
|
+
getSlideOffset(direction, width, height, progress) {
|
|
921
|
+
switch (direction) {
|
|
922
|
+
case "left-to-right":
|
|
923
|
+
return {
|
|
924
|
+
fromX: width * progress,
|
|
925
|
+
fromY: 0,
|
|
926
|
+
toX: width * (progress - 1),
|
|
927
|
+
toY: 0,
|
|
928
|
+
};
|
|
929
|
+
case "right-to-left":
|
|
930
|
+
return {
|
|
931
|
+
fromX: -width * progress,
|
|
932
|
+
fromY: 0,
|
|
933
|
+
toX: width * (1 - progress),
|
|
934
|
+
toY: 0,
|
|
935
|
+
};
|
|
936
|
+
case "top-to-bottom":
|
|
937
|
+
return {
|
|
938
|
+
fromX: 0,
|
|
939
|
+
fromY: height * progress,
|
|
940
|
+
toX: 0,
|
|
941
|
+
toY: height * (progress - 1),
|
|
942
|
+
};
|
|
943
|
+
case "bottom-to-top":
|
|
944
|
+
return {
|
|
945
|
+
fromX: 0,
|
|
946
|
+
fromY: -height * progress,
|
|
947
|
+
toX: 0,
|
|
948
|
+
toY: height * (1 - progress),
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/** @internal */
|
|
953
|
+
drawPixelatedCanvas(ctx, source, width, height, pixelSize) {
|
|
954
|
+
const roundedPixelSize = Math.max(1, Math.round(pixelSize));
|
|
955
|
+
if (roundedPixelSize <= 1) {
|
|
956
|
+
ctx.drawImage(source, 0, 0, width, height);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const scaledWidth = Math.max(1, Math.ceil(width / roundedPixelSize));
|
|
960
|
+
const scaledHeight = Math.max(1, Math.ceil(height / roundedPixelSize));
|
|
961
|
+
const scratch = this.getTransitionScratchSurface(scaledWidth, scaledHeight);
|
|
962
|
+
const scratchCtx = this._transitionScratchContext;
|
|
963
|
+
if (!scratchCtx) {
|
|
964
|
+
ctx.drawImage(source, 0, 0, width, height);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
scratch.width = scaledWidth;
|
|
968
|
+
scratch.height = scaledHeight;
|
|
969
|
+
scratchCtx.clearRect(0, 0, scaledWidth, scaledHeight);
|
|
970
|
+
scratchCtx.imageSmoothingEnabled = false;
|
|
971
|
+
scratchCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight);
|
|
972
|
+
ctx.save();
|
|
973
|
+
ctx.imageSmoothingEnabled = false;
|
|
974
|
+
ctx.drawImage(scratch, 0, 0, scaledWidth, scaledHeight, 0, 0, width, height);
|
|
975
|
+
ctx.restore();
|
|
976
|
+
}
|
|
977
|
+
/** @internal */
|
|
978
|
+
getTransitionScratchSurface(width, height) {
|
|
979
|
+
if (!this._transitionScratchCanvas) {
|
|
980
|
+
this._transitionScratchCanvas = document.createElement("canvas");
|
|
981
|
+
this._transitionScratchContext = this._transitionScratchCanvas.getContext("2d");
|
|
982
|
+
if (!this._transitionScratchContext) {
|
|
983
|
+
throw new Error("MinimoJS: Could not acquire a transition scratch context.");
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (this._transitionScratchCanvas.width !== width ||
|
|
987
|
+
this._transitionScratchCanvas.height !== height) {
|
|
988
|
+
this._transitionScratchCanvas.width = width;
|
|
989
|
+
this._transitionScratchCanvas.height = height;
|
|
990
|
+
this._transitionScratchContext = this._transitionScratchCanvas.getContext("2d");
|
|
991
|
+
if (!this._transitionScratchContext) {
|
|
992
|
+
throw new Error("MinimoJS: Could not acquire a transition scratch context.");
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return this._transitionScratchCanvas;
|
|
996
|
+
}
|
|
997
|
+
/** @internal */
|
|
998
|
+
lerp(from, to, t) {
|
|
999
|
+
return from + (to - from) * t;
|
|
1000
|
+
}
|
|
1001
|
+
/** @internal */
|
|
1002
|
+
applyPageBackground(pageBackground) {
|
|
1003
|
+
if (!document.body)
|
|
1004
|
+
return;
|
|
1005
|
+
if (this._lastAppliedPageBackground === pageBackground)
|
|
1006
|
+
return;
|
|
1007
|
+
if (pageBackground === null) {
|
|
1008
|
+
document.body.style.removeProperty("background");
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
document.body.style.background = pageBackground;
|
|
1012
|
+
}
|
|
1013
|
+
this._lastAppliedPageBackground = pageBackground;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/** @internal */
|
|
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;
|