minimojs 1.0.0-alpha.5 → 1.0.0-alpha.7

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.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/internal/AnimationSystem.js +332 -0
  3. package/dist/internal/AssetSystem.d.ts +1 -0
  4. package/dist/internal/AssetSystem.js +64 -0
  5. package/dist/internal/BackgroundSystem.d.ts +1 -0
  6. package/dist/internal/BackgroundSystem.js +25 -0
  7. package/dist/internal/CanvasSystem.d.ts +1 -0
  8. package/dist/internal/CanvasSystem.js +37 -0
  9. package/dist/internal/ExplosionSystem.d.ts +1 -0
  10. package/dist/internal/ExplosionSystem.js +215 -0
  11. package/dist/internal/InputSystem.d.ts +1 -0
  12. package/dist/internal/InputSystem.js +248 -0
  13. package/dist/internal/LoopSystem.d.ts +1 -0
  14. package/dist/internal/LoopSystem.js +52 -0
  15. package/dist/internal/PhysicsSystem.d.ts +1 -0
  16. package/dist/internal/PhysicsSystem.js +169 -0
  17. package/dist/internal/RenderSystem.d.ts +1 -0
  18. package/dist/internal/RenderSystem.js +398 -0
  19. package/dist/internal/SoundSystem.d.ts +1 -0
  20. package/dist/internal/SoundSystem.js +53 -0
  21. package/dist/internal/SpriteSystem.d.ts +1 -0
  22. package/dist/internal/SpriteSystem.js +27 -0
  23. package/dist/internal/TextSystem.d.ts +1 -0
  24. package/dist/internal/TextSystem.js +15 -0
  25. package/dist/internal/TimerSystem.d.ts +1 -0
  26. package/dist/internal/TimerSystem.js +41 -0
  27. package/dist/internal/TrailSystem.d.ts +1 -0
  28. package/dist/internal/TrailSystem.js +111 -0
  29. package/dist/minimo.d.ts +610 -93
  30. package/dist/minimo.js +932 -805
  31. package/package.json +1 -1
  32. package/dist/animations.js +0 -30
  33. package/dist/audio.js +0 -17
  34. package/dist/game.js +0 -1105
  35. package/dist/input.js +0 -185
  36. package/dist/internal-types.js +0 -4
  37. package/dist/physics.js +0 -10
  38. package/dist/render.js +0 -75
  39. package/dist/sprite.js +0 -149
  40. package/dist/timers.js +0 -23
  41. /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
package/README.md CHANGED
@@ -70,6 +70,8 @@ Note: `drawText()` uses `"Press Start 2P", monospace`. Load the font in your HTM
70
70
  - `examples/space-invader/`
71
71
  - `examples/super-minimo-bros/`
72
72
  - `examples/scale-shift/`
73
+ - `examples/background-desert/`
74
+ - `examples/animations/`
73
75
 
74
76
  Run locally from the `minimojs` directory:
75
77
 
@@ -0,0 +1,332 @@
1
+ /** @internal */
2
+ export class AnimationSystem {
3
+ constructor() {
4
+ this._animations = [];
5
+ this._deformAnimations = [];
6
+ this._motionAnimations = [];
7
+ this._shakeAnimations = [];
8
+ this._blinkAnimations = [];
9
+ this._flickerAnimations = [];
10
+ this._renderDataSprites = new Set();
11
+ }
12
+ update(dtMs) {
13
+ const animations = this._animations;
14
+ const toRemove = [];
15
+ for (let i = 0; i < animations.length; i++) {
16
+ const anim = animations[i];
17
+ anim.elapsed += dtMs;
18
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
19
+ anim.sprite[anim.property] = anim.from + (anim.to - anim.from) * t;
20
+ if (t >= 1) {
21
+ toRemove.push(i);
22
+ anim.onComplete?.();
23
+ }
24
+ }
25
+ for (let i = toRemove.length - 1; i >= 0; i--) {
26
+ animations.splice(toRemove[i], 1);
27
+ }
28
+ const deformAnimations = this._deformAnimations;
29
+ const deformToRemove = [];
30
+ for (let i = 0; i < deformAnimations.length; i++) {
31
+ const anim = deformAnimations[i];
32
+ anim.elapsed += dtMs;
33
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
34
+ if (t >= 1) {
35
+ deformToRemove.push(i);
36
+ anim.onComplete?.();
37
+ }
38
+ }
39
+ for (let i = deformToRemove.length - 1; i >= 0; i--) {
40
+ deformAnimations.splice(deformToRemove[i], 1);
41
+ }
42
+ const motionAnimations = this._motionAnimations;
43
+ const motionToRemove = [];
44
+ for (let i = 0; i < motionAnimations.length; i++) {
45
+ const anim = motionAnimations[i];
46
+ anim.elapsed += dtMs;
47
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
48
+ if (anim.kind === "arc") {
49
+ anim.sprite.x = anim.fromX + (anim.toX - anim.fromX) * t;
50
+ anim.sprite.y =
51
+ anim.fromY +
52
+ (anim.toY - anim.fromY) * t -
53
+ anim.magnitude * 4 * t * (1 - t);
54
+ }
55
+ else if (anim.kind === "bounce") {
56
+ anim.sprite.x = anim.fromX;
57
+ anim.sprite.y = anim.fromY - anim.magnitude * 4 * t * (1 - t);
58
+ }
59
+ else {
60
+ anim.sprite.x = anim.fromX;
61
+ anim.sprite.y = anim.fromY - Math.sin(t * Math.PI) * anim.magnitude;
62
+ }
63
+ if (t >= 1) {
64
+ motionToRemove.push(i);
65
+ anim.onComplete?.();
66
+ }
67
+ }
68
+ for (let i = motionToRemove.length - 1; i >= 0; i--) {
69
+ motionAnimations.splice(motionToRemove[i], 1);
70
+ }
71
+ this.rebuildRenderData(dtMs);
72
+ }
73
+ animateAlpha(sprite, to, durationMs, onComplete) {
74
+ const animations = this._animations.filter((a) => !(a.sprite === sprite && a.property === "alpha"));
75
+ animations.push({
76
+ sprite,
77
+ property: "alpha",
78
+ from: sprite.alpha,
79
+ to,
80
+ durationMs,
81
+ elapsed: 0,
82
+ onComplete,
83
+ });
84
+ this._animations = animations;
85
+ }
86
+ animateRotation(sprite, to, durationMs, onComplete) {
87
+ const animations = this._animations.filter((a) => !(a.sprite === sprite && a.property === "rotation"));
88
+ animations.push({
89
+ sprite,
90
+ property: "rotation",
91
+ from: sprite.rotation,
92
+ to,
93
+ durationMs,
94
+ elapsed: 0,
95
+ onComplete,
96
+ });
97
+ this._animations = animations;
98
+ }
99
+ animateDeform(sprite, toScaleX, toScaleY, pivotX, pivotY, durationMs, onComplete) {
100
+ const current = sprite._renderData;
101
+ const fromScaleX = current?.scaleX ?? 1;
102
+ const fromScaleY = current?.scaleY ?? 1;
103
+ const deformAnimations = this._deformAnimations.filter((a) => a.sprite !== sprite);
104
+ deformAnimations.push({
105
+ sprite,
106
+ fromScaleX,
107
+ fromScaleY,
108
+ toScaleX: this.sanitizeScale(toScaleX),
109
+ toScaleY: this.sanitizeScale(toScaleY),
110
+ pivotX: this.sanitizePivot(pivotX),
111
+ pivotY: this.sanitizePivot(pivotY),
112
+ durationMs,
113
+ elapsed: 0,
114
+ onComplete,
115
+ });
116
+ this._deformAnimations = deformAnimations;
117
+ }
118
+ animateShake(sprite, intensity, durationMs, onComplete) {
119
+ const animations = this._shakeAnimations.filter((a) => a.sprite !== sprite);
120
+ animations.push({
121
+ sprite,
122
+ intensity: this.sanitizeNonNegative(intensity),
123
+ durationMs: this.sanitizeDuration(durationMs),
124
+ elapsed: 0,
125
+ onComplete,
126
+ });
127
+ this._shakeAnimations = animations;
128
+ }
129
+ animateBounce(sprite, height, durationMs, onComplete) {
130
+ this.replaceMotionAnimation({
131
+ sprite,
132
+ kind: "bounce",
133
+ fromX: sprite.x,
134
+ fromY: sprite.y,
135
+ toX: sprite.x,
136
+ toY: sprite.y,
137
+ magnitude: this.sanitizeNonNegative(height),
138
+ durationMs: this.sanitizeDuration(durationMs),
139
+ elapsed: 0,
140
+ onComplete,
141
+ });
142
+ }
143
+ animateFloat(sprite, distance, durationMs, onComplete) {
144
+ this.replaceMotionAnimation({
145
+ sprite,
146
+ kind: "float",
147
+ fromX: sprite.x,
148
+ fromY: sprite.y,
149
+ toX: sprite.x,
150
+ toY: sprite.y,
151
+ magnitude: this.sanitizeNonNegative(distance),
152
+ durationMs: this.sanitizeDuration(durationMs),
153
+ elapsed: 0,
154
+ onComplete,
155
+ });
156
+ }
157
+ animateArc(sprite, toX, toY, arcHeight, durationMs, onComplete) {
158
+ this.replaceMotionAnimation({
159
+ sprite,
160
+ kind: "arc",
161
+ fromX: sprite.x,
162
+ fromY: sprite.y,
163
+ toX,
164
+ toY,
165
+ magnitude: this.sanitizeNonNegative(arcHeight),
166
+ durationMs: this.sanitizeDuration(durationMs),
167
+ elapsed: 0,
168
+ onComplete,
169
+ });
170
+ }
171
+ animateBlink(sprite, times, durationMs, onComplete) {
172
+ const animations = this._blinkAnimations.filter((a) => a.sprite !== sprite);
173
+ animations.push({
174
+ sprite,
175
+ times: Math.max(1, Math.round(Number.isFinite(times) ? times : 1)),
176
+ durationMs: this.sanitizeDuration(durationMs),
177
+ elapsed: 0,
178
+ onComplete,
179
+ });
180
+ this._blinkAnimations = animations;
181
+ }
182
+ animateFlicker(sprite, durationMs, onComplete) {
183
+ const animations = this._flickerAnimations.filter((a) => a.sprite !== sprite);
184
+ animations.push({
185
+ sprite,
186
+ durationMs: this.sanitizeDuration(durationMs),
187
+ elapsed: 0,
188
+ onComplete,
189
+ });
190
+ this._flickerAnimations = animations;
191
+ }
192
+ clearSpriteAnimations(sprite) {
193
+ this._animations = this._animations.filter((a) => a.sprite !== sprite);
194
+ this._deformAnimations = this._deformAnimations.filter((a) => a.sprite !== sprite);
195
+ this._motionAnimations = this._motionAnimations.filter((a) => a.sprite !== sprite);
196
+ this._shakeAnimations = this._shakeAnimations.filter((a) => a.sprite !== sprite);
197
+ this._blinkAnimations = this._blinkAnimations.filter((a) => a.sprite !== sprite);
198
+ this._flickerAnimations = this._flickerAnimations.filter((a) => a.sprite !== sprite);
199
+ this._renderDataSprites.delete(sprite);
200
+ sprite._renderData = null;
201
+ }
202
+ clearAll() {
203
+ for (const sprite of this._renderDataSprites) {
204
+ sprite._renderData = null;
205
+ }
206
+ this._animations = [];
207
+ this._deformAnimations = [];
208
+ this._motionAnimations = [];
209
+ this._shakeAnimations = [];
210
+ this._blinkAnimations = [];
211
+ this._flickerAnimations = [];
212
+ this._renderDataSprites.clear();
213
+ }
214
+ sanitizeScale(value) {
215
+ if (!Number.isFinite(value))
216
+ return 1;
217
+ return Math.max(0, value);
218
+ }
219
+ sanitizeDuration(value) {
220
+ if (!Number.isFinite(value))
221
+ return 1;
222
+ return Math.max(1, value);
223
+ }
224
+ sanitizeNonNegative(value) {
225
+ if (!Number.isFinite(value))
226
+ return 0;
227
+ return Math.max(0, value);
228
+ }
229
+ sanitizePivot(value) {
230
+ if (!Number.isFinite(value))
231
+ return 0.5;
232
+ return Math.max(0, Math.min(1, value));
233
+ }
234
+ replaceMotionAnimation(entry) {
235
+ const animations = this._motionAnimations.filter((a) => a.sprite !== entry.sprite);
236
+ animations.push(entry);
237
+ this._motionAnimations = animations;
238
+ }
239
+ rebuildRenderData(dtMs) {
240
+ const next = new Map();
241
+ const nextSprites = new Set();
242
+ const upsert = (sprite) => {
243
+ let state = next.get(sprite);
244
+ if (!state) {
245
+ state = {
246
+ scaleX: 1,
247
+ scaleY: 1,
248
+ pivotX: 0.5,
249
+ pivotY: 0.5,
250
+ offsetX: 0,
251
+ offsetY: 0,
252
+ alphaMultiplier: 1,
253
+ };
254
+ next.set(sprite, state);
255
+ nextSprites.add(sprite);
256
+ }
257
+ return state;
258
+ };
259
+ for (const anim of this._deformAnimations) {
260
+ const state = upsert(anim.sprite);
261
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
262
+ state.scaleX = anim.fromScaleX + (anim.toScaleX - anim.fromScaleX) * t;
263
+ state.scaleY = anim.fromScaleY + (anim.toScaleY - anim.fromScaleY) * t;
264
+ state.pivotX = anim.pivotX;
265
+ state.pivotY = anim.pivotY;
266
+ }
267
+ const shakeToRemove = [];
268
+ for (let i = 0; i < this._shakeAnimations.length; i++) {
269
+ const anim = this._shakeAnimations[i];
270
+ anim.elapsed += dtMs;
271
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
272
+ const decay = 1 - t;
273
+ const state = upsert(anim.sprite);
274
+ state.offsetX += (Math.random() * 2 - 1) * anim.intensity * decay;
275
+ state.offsetY += (Math.random() * 2 - 1) * anim.intensity * decay;
276
+ if (t >= 1) {
277
+ shakeToRemove.push(i);
278
+ anim.onComplete?.();
279
+ }
280
+ }
281
+ for (let i = shakeToRemove.length - 1; i >= 0; i--) {
282
+ this._shakeAnimations.splice(shakeToRemove[i], 1);
283
+ }
284
+ const blinkToRemove = [];
285
+ for (let i = 0; i < this._blinkAnimations.length; i++) {
286
+ const anim = this._blinkAnimations[i];
287
+ anim.elapsed += dtMs;
288
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
289
+ const state = upsert(anim.sprite);
290
+ const step = Math.floor(t * anim.times * 2);
291
+ state.alphaMultiplier *= step % 2 === 0 ? 1 : 0;
292
+ if (t >= 1) {
293
+ blinkToRemove.push(i);
294
+ anim.onComplete?.();
295
+ }
296
+ }
297
+ for (let i = blinkToRemove.length - 1; i >= 0; i--) {
298
+ this._blinkAnimations.splice(blinkToRemove[i], 1);
299
+ }
300
+ const flickerToRemove = [];
301
+ for (let i = 0; i < this._flickerAnimations.length; i++) {
302
+ const anim = this._flickerAnimations[i];
303
+ anim.elapsed += dtMs;
304
+ const t = Math.min(anim.elapsed / anim.durationMs, 1);
305
+ const state = upsert(anim.sprite);
306
+ state.alphaMultiplier *= 0.25 + Math.random() * 0.75;
307
+ if (t >= 1) {
308
+ flickerToRemove.push(i);
309
+ anim.onComplete?.();
310
+ }
311
+ }
312
+ for (let i = flickerToRemove.length - 1; i >= 0; i--) {
313
+ this._flickerAnimations.splice(flickerToRemove[i], 1);
314
+ }
315
+ for (const sprite of this._renderDataSprites) {
316
+ if (!nextSprites.has(sprite)) {
317
+ sprite._renderData = null;
318
+ }
319
+ }
320
+ for (const [sprite, state] of next.entries()) {
321
+ const isDefault = state.scaleX === 1 &&
322
+ state.scaleY === 1 &&
323
+ state.pivotX === 0.5 &&
324
+ state.pivotY === 0.5 &&
325
+ state.offsetX === 0 &&
326
+ state.offsetY === 0 &&
327
+ state.alphaMultiplier === 1;
328
+ sprite._renderData = isDefault ? null : state;
329
+ }
330
+ this._renderDataSprites = nextSprites;
331
+ }
332
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ /** @internal */
2
+ export class AssetSystem {
3
+ constructor() {
4
+ this._queuedImages = new Map();
5
+ this._loadedImages = new Map();
6
+ this._imageSources = new Map();
7
+ }
8
+ queueImage(key, src) {
9
+ const safeKey = key.trim();
10
+ const safeSrc = src.trim();
11
+ if (safeKey.length === 0) {
12
+ throw new Error("MinimoJS: Image keys must be non-empty strings.");
13
+ }
14
+ if (safeSrc.length === 0) {
15
+ throw new Error("MinimoJS: Image sources must be non-empty strings.");
16
+ }
17
+ const existingSrc = this._imageSources.get(safeKey);
18
+ if (existingSrc !== undefined && existingSrc !== safeSrc) {
19
+ throw new Error(`MinimoJS: Image key "${safeKey}" is already registered with a different source.`);
20
+ }
21
+ this._imageSources.set(safeKey, safeSrc);
22
+ this._queuedImages.set(safeKey, safeSrc);
23
+ }
24
+ getImage(key) {
25
+ return this._loadedImages.get(key);
26
+ }
27
+ hasImage(key) {
28
+ return this._loadedImages.has(key);
29
+ }
30
+ getQueuedImageCount() {
31
+ let total = 0;
32
+ for (const key of this._queuedImages.keys()) {
33
+ if (!this._loadedImages.has(key)) {
34
+ total++;
35
+ }
36
+ }
37
+ return total;
38
+ }
39
+ async loadQueuedImages(onProgress) {
40
+ const entries = [...this._queuedImages.entries()].filter(([key]) => !this._loadedImages.has(key));
41
+ const total = entries.length;
42
+ let loaded = 0;
43
+ onProgress?.(0, total, null);
44
+ if (total === 0)
45
+ return;
46
+ await Promise.all(entries.map(async ([key, src]) => {
47
+ const image = await this.loadImage(src);
48
+ this._loadedImages.set(key, image);
49
+ loaded += 1;
50
+ onProgress?.(loaded, total, key);
51
+ }));
52
+ this._queuedImages.clear();
53
+ }
54
+ loadImage(src) {
55
+ return new Promise((resolve, reject) => {
56
+ const image = new Image();
57
+ image.onload = () => resolve(image);
58
+ image.onerror = () => {
59
+ reject(new Error(`MinimoJS: Failed to load image "${src}".`));
60
+ };
61
+ image.src = src;
62
+ });
63
+ }
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ /** @internal */
2
+ export class BackgroundSystem {
3
+ constructor() {
4
+ this._layers = [];
5
+ }
6
+ add(layer) {
7
+ this._layers.push(layer);
8
+ return layer;
9
+ }
10
+ destroyLayer(layer) {
11
+ const index = this._layers.indexOf(layer);
12
+ if (index !== -1) {
13
+ this._layers.splice(index, 1);
14
+ }
15
+ }
16
+ getLayers() {
17
+ return [...this._layers];
18
+ }
19
+ getMutableLayers() {
20
+ return this._layers;
21
+ }
22
+ clearAll() {
23
+ this._layers = [];
24
+ }
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ /** @internal */
2
+ export class CanvasSystem {
3
+ constructor(canvas) {
4
+ this.canvas = canvas;
5
+ this.onResize = () => this.applyResponsiveCanvasLayout();
6
+ }
7
+ initialize() {
8
+ const mountCanvas = () => {
9
+ if (document.body && !this.canvas.isConnected) {
10
+ document.body.appendChild(this.canvas);
11
+ }
12
+ this.applyResponsiveCanvasLayout();
13
+ };
14
+ if (document.body) {
15
+ mountCanvas();
16
+ }
17
+ else {
18
+ window.addEventListener("DOMContentLoaded", mountCanvas, { once: true });
19
+ }
20
+ window.addEventListener("resize", this.onResize);
21
+ }
22
+ applyResponsiveCanvasLayout() {
23
+ if (!document.body)
24
+ return;
25
+ document.body.style.margin = "0";
26
+ document.body.style.minHeight = "100vh";
27
+ document.body.style.display = "grid";
28
+ document.body.style.placeItems = "center";
29
+ document.body.style.touchAction = "manipulation";
30
+ const viewportW = Math.max(1, window.innerWidth);
31
+ const viewportH = Math.max(1, window.innerHeight);
32
+ const scale = Math.min(viewportW / this.canvas.width, viewportH / this.canvas.height);
33
+ const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
34
+ this.canvas.style.width = `${Math.floor(this.canvas.width * safeScale)}px`;
35
+ this.canvas.style.height = `${Math.floor(this.canvas.height * safeScale)}px`;
36
+ }
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,215 @@
1
+ const DEFAULT_OPTIONS = {
2
+ rows: 3,
3
+ cols: 3,
4
+ durationMs: 500,
5
+ speed: 260,
6
+ gravityY: 900,
7
+ spin: 220,
8
+ fade: true,
9
+ destroySprite: true,
10
+ };
11
+ /** @internal */
12
+ export class ExplosionSystem {
13
+ constructor() {
14
+ this._explosions = [];
15
+ }
16
+ animateExplode(sprite, glyphCanvas, options, onComplete) {
17
+ this.clearSpriteExplosions(sprite);
18
+ const rows = this.sanitizeCount(options?.rows, DEFAULT_OPTIONS.rows);
19
+ const cols = this.sanitizeCount(options?.cols, DEFAULT_OPTIONS.cols);
20
+ const durationMs = this.sanitizeDuration(options?.durationMs, DEFAULT_OPTIONS.durationMs);
21
+ const speed = this.sanitizeNonNegative(options?.speed, DEFAULT_OPTIONS.speed);
22
+ const gravityY = this.sanitizeNumber(options?.gravityY, DEFAULT_OPTIONS.gravityY);
23
+ const spin = this.sanitizeNonNegative(options?.spin, DEFAULT_OPTIONS.spin);
24
+ const fade = options?.fade ?? DEFAULT_OPTIONS.fade;
25
+ const destroySprite = options?.destroySprite ?? DEFAULT_OPTIONS.destroySprite;
26
+ const originalVisible = sprite.visible;
27
+ const renderData = sprite._renderData;
28
+ const deformScaleX = this.getSafeScale(renderData?.scaleX);
29
+ const deformScaleY = this.getSafeScale(renderData?.scaleY);
30
+ const pivotX = this.getSafePivot(renderData?.pivotX);
31
+ const pivotY = this.getSafePivot(renderData?.pivotY);
32
+ const offsetX = this.getSafeNumber(renderData?.offsetX, 0);
33
+ const offsetY = this.getSafeNumber(renderData?.offsetY, 0);
34
+ const alphaMultiplier = this.getSafeNumber(renderData?.alphaMultiplier, 1);
35
+ const baseDisplayWidth = sprite.displayWidth;
36
+ const baseDisplayHeight = sprite.displayHeight;
37
+ const pivotOffsetX = (pivotX - 0.5) * baseDisplayWidth * (1 - deformScaleX);
38
+ const pivotOffsetY = (pivotY - 0.5) * baseDisplayHeight * (1 - deformScaleY);
39
+ const signedScaleX = this.getSafeScale(sprite.scale) * deformScaleX * (sprite.flipX ? -1 : 1);
40
+ const signedScaleY = this.getSafeScale(sprite.scale) * deformScaleY * (sprite.flipY ? -1 : 1);
41
+ const absScaleX = Math.abs(signedScaleX);
42
+ const absScaleY = Math.abs(signedScaleY);
43
+ const rotationRad = (sprite.rotation * Math.PI) / 180;
44
+ const baseX = sprite.renderX + pivotOffsetX + offsetX;
45
+ const baseY = sprite.renderY + pivotOffsetY + offsetY;
46
+ const baseAlpha = Math.max(0, Math.min(1, sprite.alpha * alphaMultiplier));
47
+ const pieces = [];
48
+ for (let row = 0; row < rows; row++) {
49
+ const sy = Math.floor((row * glyphCanvas.height) / rows);
50
+ const nextSy = Math.floor(((row + 1) * glyphCanvas.height) / rows);
51
+ const sh = Math.max(1, nextSy - sy);
52
+ for (let col = 0; col < cols; col++) {
53
+ const sx = Math.floor((col * glyphCanvas.width) / cols);
54
+ const nextSx = Math.floor(((col + 1) * glyphCanvas.width) / cols);
55
+ const sw = Math.max(1, nextSx - sx);
56
+ const localX = (sx + sw / 2 - glyphCanvas.width / 2) * signedScaleX;
57
+ const localY = (sy + sh / 2 - glyphCanvas.height / 2) * signedScaleY;
58
+ const rotated = this.rotate(localX, localY, rotationRad);
59
+ const velocity = this.getPieceVelocity(rotated.x, rotated.y, speed);
60
+ pieces.push({
61
+ x: baseX + rotated.x,
62
+ y: baseY + rotated.y,
63
+ rotation: sprite.rotation,
64
+ alpha: baseAlpha,
65
+ flipX: sprite.flipX,
66
+ flipY: sprite.flipY,
67
+ sx,
68
+ sy,
69
+ sw,
70
+ sh,
71
+ drawWidth: sw * absScaleX,
72
+ drawHeight: sh * absScaleY,
73
+ vx: velocity.x,
74
+ vy: velocity.y,
75
+ angularVelocity: (Math.random() * 2 - 1) * spin,
76
+ });
77
+ }
78
+ }
79
+ if (!destroySprite) {
80
+ sprite.visible = false;
81
+ }
82
+ this._explosions.push({
83
+ sprite,
84
+ canvas: glyphCanvas,
85
+ layer: sprite.layer,
86
+ ignoreScroll: sprite.ignoreScroll,
87
+ pieces,
88
+ elapsedMs: 0,
89
+ durationMs,
90
+ gravityY,
91
+ fade,
92
+ baseAlpha,
93
+ restoreVisibility: !destroySprite,
94
+ originalVisible,
95
+ onComplete,
96
+ });
97
+ }
98
+ update(dt, dtMs) {
99
+ const explosions = this._explosions;
100
+ const toRemove = [];
101
+ for (let i = 0; i < explosions.length; i++) {
102
+ const explosion = explosions[i];
103
+ explosion.elapsedMs += dtMs;
104
+ const t = Math.min(explosion.elapsedMs / explosion.durationMs, 1);
105
+ for (const piece of explosion.pieces) {
106
+ piece.vy += explosion.gravityY * dt;
107
+ piece.x += piece.vx * dt;
108
+ piece.y += piece.vy * dt;
109
+ piece.rotation += piece.angularVelocity * dt;
110
+ piece.alpha = explosion.fade
111
+ ? explosion.baseAlpha * (1 - t)
112
+ : explosion.baseAlpha;
113
+ }
114
+ if (t >= 1) {
115
+ toRemove.push(i);
116
+ if (explosion.restoreVisibility) {
117
+ explosion.sprite.visible = explosion.originalVisible;
118
+ }
119
+ explosion.onComplete?.();
120
+ }
121
+ }
122
+ for (let i = toRemove.length - 1; i >= 0; i--) {
123
+ explosions.splice(toRemove[i], 1);
124
+ }
125
+ }
126
+ getRenderEntries() {
127
+ return this._explosions;
128
+ }
129
+ clearAll() {
130
+ for (const explosion of this._explosions) {
131
+ if (explosion.restoreVisibility) {
132
+ explosion.sprite.visible = explosion.originalVisible;
133
+ }
134
+ }
135
+ this._explosions = [];
136
+ }
137
+ clearSpriteExplosions(sprite) {
138
+ const remaining = [];
139
+ for (const explosion of this._explosions) {
140
+ if (explosion.sprite === sprite) {
141
+ if (explosion.restoreVisibility) {
142
+ explosion.sprite.visible = explosion.originalVisible;
143
+ }
144
+ }
145
+ else {
146
+ remaining.push(explosion);
147
+ }
148
+ }
149
+ this._explosions = remaining;
150
+ }
151
+ getPieceVelocity(x, y, speed) {
152
+ const magnitude = Math.hypot(x, y);
153
+ let dirX = 0;
154
+ let dirY = 0;
155
+ if (magnitude > 0.001) {
156
+ dirX = x / magnitude;
157
+ dirY = y / magnitude;
158
+ }
159
+ else {
160
+ const angle = Math.random() * Math.PI * 2;
161
+ dirX = Math.cos(angle);
162
+ dirY = Math.sin(angle);
163
+ }
164
+ const jitterAngle = (Math.random() - 0.5) * (Math.PI / 2);
165
+ const jittered = this.rotate(dirX, dirY, jitterAngle);
166
+ const pieceSpeed = speed * (0.65 + Math.random() * 0.7);
167
+ return {
168
+ x: jittered.x * pieceSpeed,
169
+ y: jittered.y * pieceSpeed,
170
+ };
171
+ }
172
+ rotate(x, y, radians) {
173
+ const cos = Math.cos(radians);
174
+ const sin = Math.sin(radians);
175
+ return {
176
+ x: x * cos - y * sin,
177
+ y: x * sin + y * cos,
178
+ };
179
+ }
180
+ getSafeScale(value) {
181
+ if (!Number.isFinite(value))
182
+ return 1;
183
+ return Math.max(0, value);
184
+ }
185
+ getSafePivot(value) {
186
+ if (!Number.isFinite(value))
187
+ return 0.5;
188
+ return Math.max(0, Math.min(1, value));
189
+ }
190
+ getSafeNumber(value, fallback) {
191
+ if (!Number.isFinite(value))
192
+ return fallback;
193
+ return value;
194
+ }
195
+ sanitizeCount(value, fallback) {
196
+ if (!Number.isFinite(value))
197
+ return fallback;
198
+ return Math.max(1, Math.round(value));
199
+ }
200
+ sanitizeDuration(value, fallback) {
201
+ if (!Number.isFinite(value))
202
+ return fallback;
203
+ return Math.max(1, value);
204
+ }
205
+ sanitizeNonNegative(value, fallback) {
206
+ if (!Number.isFinite(value))
207
+ return fallback;
208
+ return Math.max(0, value);
209
+ }
210
+ sanitizeNumber(value, fallback) {
211
+ if (!Number.isFinite(value))
212
+ return fallback;
213
+ return value;
214
+ }
215
+ }