minimojs 1.0.0-alpha.12 → 1.0.0-alpha.13

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.
@@ -1,4 +1,4 @@
1
- const DEFAULT_OPTIONS = {
1
+ const DEFAULT_EXPLODE_OPTIONS = {
2
2
  rows: 3,
3
3
  cols: 3,
4
4
  durationMs: 500,
@@ -8,43 +8,299 @@ const DEFAULT_OPTIONS = {
8
8
  fade: true,
9
9
  destroySprite: true,
10
10
  };
11
+ const DEFAULT_ASSEMBLE_OPTIONS = {
12
+ rows: 3,
13
+ cols: 3,
14
+ durationMs: 500,
15
+ speed: 260,
16
+ gravityY: 900,
17
+ spin: 220,
18
+ fade: true,
19
+ };
20
+ const DEFAULT_DISINTEGRATE_OPTIONS = {
21
+ rows: 4,
22
+ cols: 4,
23
+ durationMs: 420,
24
+ direction: "left-to-right",
25
+ distance: 42,
26
+ spin: 120,
27
+ fade: true,
28
+ destroySprite: true,
29
+ };
30
+ const DEFAULT_INTEGRATE_OPTIONS = {
31
+ rows: 4,
32
+ cols: 4,
33
+ durationMs: 420,
34
+ direction: "left-to-right",
35
+ distance: 42,
36
+ spin: 120,
37
+ fade: true,
38
+ };
39
+ const DEFAULT_WIPE_OPTIONS = {
40
+ mode: "reveal",
41
+ direction: "left-to-right",
42
+ durationMs: 360,
43
+ destroySprite: true,
44
+ };
11
45
  /** @internal */
12
46
  export class ExplosionSystem {
13
47
  constructor() {
14
- this._explosions = [];
48
+ this._pieceEffects = [];
49
+ this._wipes = [];
15
50
  }
16
51
  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));
52
+ const context = this.buildPieceSpriteContext(sprite, glyphCanvas);
53
+ this.spawnRadialEffect("explode", sprite, glyphCanvas, context, {
54
+ rows: this.sanitizeCount(options?.rows, DEFAULT_EXPLODE_OPTIONS.rows),
55
+ cols: this.sanitizeCount(options?.cols, DEFAULT_EXPLODE_OPTIONS.cols),
56
+ durationMs: this.sanitizeDuration(options?.durationMs, DEFAULT_EXPLODE_OPTIONS.durationMs),
57
+ speed: this.sanitizeNonNegative(options?.speed, DEFAULT_EXPLODE_OPTIONS.speed),
58
+ gravityY: this.sanitizeNumber(options?.gravityY, DEFAULT_EXPLODE_OPTIONS.gravityY),
59
+ spin: this.sanitizeNonNegative(options?.spin, DEFAULT_EXPLODE_OPTIONS.spin),
60
+ fade: options?.fade ?? DEFAULT_EXPLODE_OPTIONS.fade,
61
+ }, false, options?.destroySprite === false, options?.destroySprite === false ? sprite.visible : false, onComplete);
62
+ }
63
+ animateAssemble(sprite, glyphCanvas, options, onComplete) {
64
+ const context = this.buildPieceSpriteContext(sprite, glyphCanvas);
65
+ this.spawnRadialEffect("assemble", sprite, glyphCanvas, context, {
66
+ rows: this.sanitizeCount(options?.rows, DEFAULT_ASSEMBLE_OPTIONS.rows),
67
+ cols: this.sanitizeCount(options?.cols, DEFAULT_ASSEMBLE_OPTIONS.cols),
68
+ durationMs: this.sanitizeDuration(options?.durationMs, DEFAULT_ASSEMBLE_OPTIONS.durationMs),
69
+ speed: this.sanitizeNonNegative(options?.speed, DEFAULT_ASSEMBLE_OPTIONS.speed),
70
+ gravityY: this.sanitizeNumber(options?.gravityY, DEFAULT_ASSEMBLE_OPTIONS.gravityY),
71
+ spin: this.sanitizeNonNegative(options?.spin, DEFAULT_ASSEMBLE_OPTIONS.spin),
72
+ fade: options?.fade ?? DEFAULT_ASSEMBLE_OPTIONS.fade,
73
+ }, true, true, true, onComplete);
74
+ }
75
+ animateDisintegrate(sprite, glyphCanvas, options, onComplete) {
76
+ const context = this.buildPieceSpriteContext(sprite, glyphCanvas);
77
+ this.spawnDirectionalEffect("disintegrate", sprite, glyphCanvas, context, {
78
+ rows: this.sanitizeCount(options?.rows, DEFAULT_DISINTEGRATE_OPTIONS.rows),
79
+ cols: this.sanitizeCount(options?.cols, DEFAULT_DISINTEGRATE_OPTIONS.cols),
80
+ durationMs: this.sanitizeDuration(options?.durationMs, DEFAULT_DISINTEGRATE_OPTIONS.durationMs),
81
+ direction: options?.direction ?? DEFAULT_DISINTEGRATE_OPTIONS.direction,
82
+ distance: this.sanitizeNonNegative(options?.distance, DEFAULT_DISINTEGRATE_OPTIONS.distance),
83
+ spin: this.sanitizeNonNegative(options?.spin, DEFAULT_DISINTEGRATE_OPTIONS.spin),
84
+ fade: options?.fade ?? DEFAULT_DISINTEGRATE_OPTIONS.fade,
85
+ }, false, options?.destroySprite === false, false, onComplete);
86
+ }
87
+ animateIntegrate(sprite, glyphCanvas, options, onComplete) {
88
+ const context = this.buildPieceSpriteContext(sprite, glyphCanvas);
89
+ this.spawnDirectionalEffect("integrate", sprite, glyphCanvas, context, {
90
+ rows: this.sanitizeCount(options?.rows, DEFAULT_INTEGRATE_OPTIONS.rows),
91
+ cols: this.sanitizeCount(options?.cols, DEFAULT_INTEGRATE_OPTIONS.cols),
92
+ durationMs: this.sanitizeDuration(options?.durationMs, DEFAULT_INTEGRATE_OPTIONS.durationMs),
93
+ direction: options?.direction ?? DEFAULT_INTEGRATE_OPTIONS.direction,
94
+ distance: this.sanitizeNonNegative(options?.distance, DEFAULT_INTEGRATE_OPTIONS.distance),
95
+ spin: this.sanitizeNonNegative(options?.spin, DEFAULT_INTEGRATE_OPTIONS.spin),
96
+ fade: options?.fade ?? DEFAULT_INTEGRATE_OPTIONS.fade,
97
+ }, true, true, true, onComplete);
98
+ }
99
+ animateWipe(sprite, snapshot, options, onComplete) {
100
+ this.clearSpriteEffects(sprite);
101
+ const mode = options?.mode ?? DEFAULT_WIPE_OPTIONS.mode;
102
+ const durationMs = this.sanitizeDuration(options?.durationMs, DEFAULT_WIPE_OPTIONS.durationMs);
103
+ const direction = options?.direction ?? DEFAULT_WIPE_OPTIONS.direction;
104
+ const destroySprite = options?.destroySprite ?? DEFAULT_WIPE_OPTIONS.destroySprite;
105
+ const restoreVisibility = mode === "reveal";
106
+ sprite.visible = false;
107
+ this._wipes.push({
108
+ sprite,
109
+ canvas: snapshot.canvas,
110
+ x: snapshot.x,
111
+ y: snapshot.y,
112
+ rotation: snapshot.rotation,
113
+ alpha: snapshot.alpha,
114
+ flipX: snapshot.flipX,
115
+ flipY: snapshot.flipY,
116
+ drawWidth: snapshot.drawWidth,
117
+ drawHeight: snapshot.drawHeight,
118
+ layer: snapshot.layer,
119
+ ignoreScroll: snapshot.ignoreScroll,
120
+ mode,
121
+ direction,
122
+ progress: mode === "reveal" ? 0 : 1,
123
+ elapsedMs: 0,
124
+ durationMs,
125
+ restoreVisibility,
126
+ finalVisible: mode === "reveal",
127
+ onComplete,
128
+ });
129
+ }
130
+ update(_dt, dtMs) {
131
+ const pieceToRemove = [];
132
+ for (let i = 0; i < this._pieceEffects.length; i++) {
133
+ const effect = this._pieceEffects[i];
134
+ effect.elapsedMs += dtMs;
135
+ const t = Math.min(effect.elapsedMs / effect.durationMs, 1);
136
+ for (const piece of effect.pieces) {
137
+ const localT = t <= piece.delayT ? 0 : Math.min(1, (t - piece.delayT) / (1 - piece.delayT));
138
+ const eased = this.easePieceProgress(effect.mode, localT);
139
+ piece.x = this.lerp(piece.startX, piece.endX, eased);
140
+ piece.y = this.lerp(piece.startY, piece.endY, eased);
141
+ piece.rotation = this.lerp(piece.startRotation, piece.endRotation, eased);
142
+ piece.alpha = this.lerp(piece.startAlpha, piece.endAlpha, eased);
143
+ }
144
+ if (t >= 1) {
145
+ pieceToRemove.push(i);
146
+ if (effect.restoreVisibility) {
147
+ effect.sprite.visible = effect.finalVisible;
148
+ }
149
+ effect.onComplete?.();
150
+ }
151
+ }
152
+ for (let i = pieceToRemove.length - 1; i >= 0; i--) {
153
+ this._pieceEffects.splice(pieceToRemove[i], 1);
154
+ }
155
+ const wipeToRemove = [];
156
+ for (let i = 0; i < this._wipes.length; i++) {
157
+ const wipe = this._wipes[i];
158
+ wipe.elapsedMs += dtMs;
159
+ const t = Math.min(wipe.elapsedMs / wipe.durationMs, 1);
160
+ wipe.progress = wipe.mode === "reveal" ? t : 1 - t;
161
+ if (t >= 1) {
162
+ wipeToRemove.push(i);
163
+ if (wipe.restoreVisibility) {
164
+ wipe.sprite.visible = wipe.finalVisible;
165
+ }
166
+ wipe.onComplete?.();
167
+ }
168
+ }
169
+ for (let i = wipeToRemove.length - 1; i >= 0; i--) {
170
+ this._wipes.splice(wipeToRemove[i], 1);
171
+ }
172
+ }
173
+ getRenderEntries() {
174
+ return this._pieceEffects;
175
+ }
176
+ getWipeEntries() {
177
+ return this._wipes;
178
+ }
179
+ clearAll() {
180
+ for (const effect of this._pieceEffects) {
181
+ if (effect.restoreVisibility) {
182
+ effect.sprite.visible = effect.finalVisible;
183
+ }
184
+ }
185
+ for (const wipe of this._wipes) {
186
+ if (wipe.restoreVisibility) {
187
+ wipe.sprite.visible = wipe.finalVisible;
188
+ }
189
+ }
190
+ this._pieceEffects = [];
191
+ this._wipes = [];
192
+ }
193
+ spawnRadialEffect(mode, sprite, glyphCanvas, context, options, forceHideSprite, restoreVisibility, finalVisible, onComplete) {
194
+ this.clearSpriteEffects(sprite);
195
+ if (forceHideSprite || restoreVisibility) {
196
+ sprite.visible = false;
197
+ }
198
+ const durationSeconds = options.durationMs / 1000;
199
+ const pieces = this.buildPieceGrid(glyphCanvas, context, options.rows, options.cols, (_row, _col, sx, sy, sw, sh, centerX, centerY) => {
200
+ const velocity = this.getRadialVelocity(centerX, centerY);
201
+ const distance = options.speed * durationSeconds * (0.65 + Math.random() * 0.7);
202
+ const targetX = context.baseX + centerX + velocity.x * distance;
203
+ const targetY = context.baseY +
204
+ centerY +
205
+ velocity.y * distance +
206
+ 0.5 * options.gravityY * durationSeconds * durationSeconds;
207
+ const assembledX = context.baseX + centerX;
208
+ const assembledY = context.baseY + centerY;
209
+ const startRotation = mode === "explode"
210
+ ? context.rotation
211
+ : context.rotation + (Math.random() * 2 - 1) * options.spin * durationSeconds;
212
+ const endRotation = mode === "explode"
213
+ ? context.rotation + (Math.random() * 2 - 1) * options.spin * durationSeconds
214
+ : context.rotation;
215
+ return {
216
+ sx,
217
+ sy,
218
+ sw,
219
+ sh,
220
+ assembledX,
221
+ assembledY,
222
+ offsetX: targetX - assembledX,
223
+ offsetY: targetY - assembledY,
224
+ startRotation,
225
+ endRotation,
226
+ delayT: 0,
227
+ fade: options.fade,
228
+ };
229
+ }, mode);
230
+ this._pieceEffects.push({
231
+ sprite,
232
+ canvas: glyphCanvas,
233
+ layer: context.layer,
234
+ ignoreScroll: context.ignoreScroll,
235
+ pieces,
236
+ elapsedMs: 0,
237
+ durationMs: options.durationMs,
238
+ mode,
239
+ restoreVisibility,
240
+ finalVisible,
241
+ onComplete,
242
+ });
243
+ }
244
+ spawnDirectionalEffect(mode, sprite, glyphCanvas, context, options, forceHideSprite, restoreVisibility, finalVisible, onComplete) {
245
+ this.clearSpriteEffects(sprite);
246
+ if (forceHideSprite || restoreVisibility) {
247
+ sprite.visible = false;
248
+ }
249
+ const durationSeconds = options.durationMs / 1000;
250
+ const maxDelayT = 0.58;
251
+ const inPlace = options.direction === "in-place";
252
+ const directionalFlow = inPlace
253
+ ? "left-to-right"
254
+ : options.direction;
255
+ const directionVector = inPlace ? { x: 0, y: 0 } : this.getDirectionVector(directionalFlow);
256
+ const perpendicular = inPlace ? { x: 0, y: 0 } : { x: -directionVector.y, y: directionVector.x };
257
+ const pieces = this.buildPieceGrid(glyphCanvas, context, options.rows, options.cols, (row, col, sx, sy, sw, sh, centerX, centerY) => {
258
+ const baseDistance = inPlace ? 0 : options.distance * (0.45 + Math.random() * 0.75);
259
+ const sidewaysDistance = inPlace ? 0 : options.distance * 0.22 * (Math.random() * 2 - 1);
260
+ const offsetX = directionVector.x * baseDistance + perpendicular.x * sidewaysDistance;
261
+ const offsetY = directionVector.y * baseDistance + perpendicular.y * sidewaysDistance;
262
+ const assembledX = context.baseX + centerX;
263
+ const assembledY = context.baseY + centerY;
264
+ const delayT = inPlace
265
+ ? Math.random() * (maxDelayT + 0.05)
266
+ : this.getDirectionalProgress(row, col, options.rows, options.cols, directionalFlow) *
267
+ maxDelayT +
268
+ Math.random() * 0.05;
269
+ const rotationOffset = inPlace
270
+ ? 0
271
+ : (Math.random() * 2 - 1) * options.spin * durationSeconds;
272
+ return {
273
+ sx,
274
+ sy,
275
+ sw,
276
+ sh,
277
+ assembledX,
278
+ assembledY,
279
+ offsetX,
280
+ offsetY,
281
+ startRotation: mode === "disintegrate" ? context.rotation : context.rotation + rotationOffset,
282
+ endRotation: mode === "disintegrate" ? context.rotation + rotationOffset : context.rotation,
283
+ delayT: Math.min(maxDelayT + 0.05, delayT),
284
+ fade: options.fade,
285
+ };
286
+ }, mode);
287
+ this._pieceEffects.push({
288
+ sprite,
289
+ canvas: glyphCanvas,
290
+ layer: context.layer,
291
+ ignoreScroll: context.ignoreScroll,
292
+ pieces,
293
+ elapsedMs: 0,
294
+ durationMs: options.durationMs,
295
+ mode,
296
+ restoreVisibility,
297
+ finalVisible,
298
+ onComplete,
299
+ });
300
+ }
301
+ buildPieceGrid(glyphCanvas, context, rows, cols, builder, mode) {
47
302
  const pieces = [];
303
+ const rotationRad = (context.rotation * Math.PI) / 180;
48
304
  for (let row = 0; row < rows; row++) {
49
305
  const sy = Math.floor((row * glyphCanvas.height) / rows);
50
306
  const nextSy = Math.floor(((row + 1) * glyphCanvas.height) / rows);
@@ -53,102 +309,142 @@ export class ExplosionSystem {
53
309
  const sx = Math.floor((col * glyphCanvas.width) / cols);
54
310
  const nextSx = Math.floor(((col + 1) * glyphCanvas.width) / cols);
55
311
  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;
312
+ const localX = (sx + sw / 2 - glyphCanvas.width / 2) * context.signedScaleX;
313
+ const localY = (sy + sh / 2 - glyphCanvas.height / 2) * context.signedScaleY;
58
314
  const rotated = this.rotate(localX, localY, rotationRad);
59
- const velocity = this.getPieceVelocity(rotated.x, rotated.y, speed);
315
+ const generated = builder(row, col, sx, sy, sw, sh, rotated.x, rotated.y);
316
+ const startX = mode === "explode" || mode === "disintegrate"
317
+ ? generated.assembledX
318
+ : generated.assembledX + generated.offsetX;
319
+ const startY = mode === "explode" || mode === "disintegrate"
320
+ ? generated.assembledY
321
+ : generated.assembledY + generated.offsetY;
322
+ const endX = mode === "explode" || mode === "disintegrate"
323
+ ? generated.assembledX + generated.offsetX
324
+ : generated.assembledX;
325
+ const endY = mode === "explode" || mode === "disintegrate"
326
+ ? generated.assembledY + generated.offsetY
327
+ : generated.assembledY;
328
+ const startAlpha = mode === "explode" || mode === "disintegrate"
329
+ ? context.baseAlpha
330
+ : generated.fade
331
+ ? 0
332
+ : context.baseAlpha;
333
+ const endAlpha = mode === "explode" || mode === "disintegrate"
334
+ ? generated.fade
335
+ ? 0
336
+ : context.baseAlpha
337
+ : context.baseAlpha;
60
338
  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,
339
+ x: startX,
340
+ y: startY,
341
+ rotation: generated.startRotation,
342
+ alpha: startAlpha,
343
+ flipX: context.flipX,
344
+ flipY: context.flipY,
345
+ sx: generated.sx,
346
+ sy: generated.sy,
347
+ sw: generated.sw,
348
+ sh: generated.sh,
349
+ drawWidth: generated.sw * context.absScaleX,
350
+ drawHeight: generated.sh * context.absScaleY,
351
+ startX,
352
+ startY,
353
+ endX,
354
+ endY,
355
+ startRotation: generated.startRotation,
356
+ endRotation: generated.endRotation,
357
+ startAlpha,
358
+ endAlpha,
359
+ delayT: generated.delayT,
76
360
  });
77
361
  }
78
362
  }
79
- if (!destroySprite) {
80
- sprite.visible = false;
81
- }
82
- this._explosions.push({
83
- sprite,
84
- canvas: glyphCanvas,
363
+ return pieces;
364
+ }
365
+ buildPieceSpriteContext(sprite, glyphCanvas) {
366
+ const renderData = sprite._renderData;
367
+ const deformScaleX = this.getSafeScale(renderData?.scaleX);
368
+ const deformScaleY = this.getSafeScale(renderData?.scaleY);
369
+ const pivotX = this.getSafePivot(renderData?.pivotX);
370
+ const pivotY = this.getSafePivot(renderData?.pivotY);
371
+ const offsetX = this.getSafeNumber(renderData?.offsetX, 0);
372
+ const offsetY = this.getSafeNumber(renderData?.offsetY, 0);
373
+ const alphaMultiplier = this.getSafeNumber(renderData?.alphaMultiplier, 1);
374
+ const baseDisplayWidth = sprite.displayWidth;
375
+ const baseDisplayHeight = sprite.displayHeight;
376
+ const pivotOffsetX = (pivotX - 0.5) * baseDisplayWidth * (1 - deformScaleX);
377
+ const pivotOffsetY = (pivotY - 0.5) * baseDisplayHeight * (1 - deformScaleY);
378
+ const signedScaleX = this.getSafeScale(sprite.scale) * deformScaleX * (sprite.flipX ? -1 : 1);
379
+ const signedScaleY = this.getSafeScale(sprite.scale) * deformScaleY * (sprite.flipY ? -1 : 1);
380
+ return {
85
381
  layer: sprite.layer,
86
382
  ignoreScroll: sprite.ignoreScroll,
87
- pieces,
88
- elapsedMs: 0,
89
- durationMs,
90
- gravityY,
91
- fade,
92
- baseAlpha,
93
- restoreVisibility: !destroySprite,
94
- originalVisible,
95
- onComplete,
96
- });
383
+ baseX: sprite.renderX + pivotOffsetX + offsetX,
384
+ baseY: sprite.renderY + pivotOffsetY + offsetY,
385
+ rotation: sprite.rotation,
386
+ baseAlpha: Math.max(0, Math.min(1, sprite.alpha * alphaMultiplier)),
387
+ flipX: sprite.flipX,
388
+ flipY: sprite.flipY,
389
+ signedScaleX,
390
+ signedScaleY,
391
+ absScaleX: Math.abs(signedScaleX),
392
+ absScaleY: Math.abs(signedScaleY),
393
+ };
97
394
  }
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;
395
+ clearSpriteEffects(sprite) {
396
+ const remainingPieces = [];
397
+ for (const effect of this._pieceEffects) {
398
+ if (effect.sprite === sprite) {
399
+ if (effect.restoreVisibility) {
400
+ effect.sprite.visible = effect.finalVisible;
118
401
  }
119
- explosion.onComplete?.();
120
402
  }
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;
403
+ else {
404
+ remainingPieces.push(effect);
133
405
  }
134
406
  }
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;
407
+ this._pieceEffects = remainingPieces;
408
+ const remainingWipes = [];
409
+ for (const wipe of this._wipes) {
410
+ if (wipe.sprite === sprite) {
411
+ if (wipe.restoreVisibility) {
412
+ wipe.sprite.visible = wipe.finalVisible;
143
413
  }
144
414
  }
145
415
  else {
146
- remaining.push(explosion);
416
+ remainingWipes.push(wipe);
147
417
  }
148
418
  }
149
- this._explosions = remaining;
419
+ this._wipes = remainingWipes;
420
+ }
421
+ getDirectionalProgress(row, col, rows, cols, direction) {
422
+ const rowProgress = rows <= 1 ? 0 : row / (rows - 1);
423
+ const colProgress = cols <= 1 ? 0 : col / (cols - 1);
424
+ switch (direction) {
425
+ case "left-to-right":
426
+ return colProgress;
427
+ case "right-to-left":
428
+ return 1 - colProgress;
429
+ case "top-to-bottom":
430
+ return rowProgress;
431
+ case "bottom-to-top":
432
+ return 1 - rowProgress;
433
+ }
150
434
  }
151
- getPieceVelocity(x, y, speed) {
435
+ getDirectionVector(direction) {
436
+ switch (direction) {
437
+ case "left-to-right":
438
+ return { x: 1, y: 0 };
439
+ case "right-to-left":
440
+ return { x: -1, y: 0 };
441
+ case "top-to-bottom":
442
+ return { x: 0, y: 1 };
443
+ case "bottom-to-top":
444
+ return { x: 0, y: -1 };
445
+ }
446
+ }
447
+ getRadialVelocity(x, y) {
152
448
  const magnitude = Math.hypot(x, y);
153
449
  let dirX = 0;
154
450
  let dirY = 0;
@@ -162,12 +458,18 @@ export class ExplosionSystem {
162
458
  dirY = Math.sin(angle);
163
459
  }
164
460
  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
- };
461
+ return this.rotate(dirX, dirY, jitterAngle);
462
+ }
463
+ easePieceProgress(mode, t) {
464
+ const clamped = Math.max(0, Math.min(1, t));
465
+ switch (mode) {
466
+ case "explode":
467
+ case "disintegrate":
468
+ return 1 - (1 - clamped) * (1 - clamped);
469
+ case "assemble":
470
+ case "integrate":
471
+ return clamped * clamped * (3 - 2 * clamped);
472
+ }
171
473
  }
172
474
  rotate(x, y, radians) {
173
475
  const cos = Math.cos(radians);
@@ -177,6 +479,9 @@ export class ExplosionSystem {
177
479
  y: x * sin + y * cos,
178
480
  };
179
481
  }
482
+ lerp(from, to, t) {
483
+ return from + (to - from) * t;
484
+ }
180
485
  getSafeScale(value) {
181
486
  if (!Number.isFinite(value))
182
487
  return 1;
@@ -95,6 +95,31 @@ export class RenderSystem {
95
95
  ctx.drawImage(snapshot.canvas, -snapshot.drawWidth / 2, -snapshot.drawHeight / 2, snapshot.drawWidth, snapshot.drawHeight);
96
96
  ctx.restore();
97
97
  }
98
+ const wipeEntries = [...options.wipes].sort((a, b) => a.layer - b.layer);
99
+ for (const entry of wipeEntries) {
100
+ const visibleProgress = Math.max(0, Math.min(1, entry.progress));
101
+ if (visibleProgress <= 0)
102
+ continue;
103
+ ctx.save();
104
+ ctx.globalAlpha = entry.alpha;
105
+ const drawX = Math.round(entry.ignoreScroll ? entry.x : entry.x - options.scrollX);
106
+ const drawY = Math.round(entry.ignoreScroll ? entry.y : entry.y - options.scrollY);
107
+ ctx.translate(drawX, drawY);
108
+ if (entry.rotation !== 0) {
109
+ ctx.rotate((entry.rotation * Math.PI) / 180);
110
+ }
111
+ if (entry.flipX || entry.flipY) {
112
+ ctx.scale(entry.flipX ? -1 : 1, entry.flipY ? -1 : 1);
113
+ }
114
+ const clip = this.getWipeClipRect(entry.drawWidth, entry.drawHeight, entry.direction, visibleProgress);
115
+ if (clip.width > 0 && clip.height > 0) {
116
+ ctx.beginPath();
117
+ ctx.rect(clip.x, clip.y, clip.width, clip.height);
118
+ ctx.clip();
119
+ ctx.drawImage(entry.canvas, -entry.drawWidth / 2, -entry.drawHeight / 2, entry.drawWidth, entry.drawHeight);
120
+ }
121
+ ctx.restore();
122
+ }
98
123
  const explosionEntries = [...options.explosions].sort((a, b) => a.layer - b.layer);
99
124
  for (const entry of explosionEntries) {
100
125
  for (const piece of entry.pieces) {
@@ -278,6 +303,44 @@ export class RenderSystem {
278
303
  isImageSprite(sprite) {
279
304
  return "imageKey" in sprite && "setTexture" in sprite;
280
305
  }
306
+ getWipeClipRect(drawWidth, drawHeight, direction, progress) {
307
+ const halfWidth = drawWidth / 2;
308
+ const halfHeight = drawHeight / 2;
309
+ switch (direction) {
310
+ case "left-to-right":
311
+ return {
312
+ x: -halfWidth,
313
+ y: -halfHeight,
314
+ width: drawWidth * progress,
315
+ height: drawHeight,
316
+ };
317
+ case "right-to-left": {
318
+ const width = drawWidth * progress;
319
+ return {
320
+ x: halfWidth - width,
321
+ y: -halfHeight,
322
+ width,
323
+ height: drawHeight,
324
+ };
325
+ }
326
+ case "top-to-bottom":
327
+ return {
328
+ x: -halfWidth,
329
+ y: -halfHeight,
330
+ width: drawWidth,
331
+ height: drawHeight * progress,
332
+ };
333
+ case "bottom-to-top": {
334
+ const height = drawHeight * progress;
335
+ return {
336
+ x: -halfWidth,
337
+ y: halfHeight - height,
338
+ width: drawWidth,
339
+ height,
340
+ };
341
+ }
342
+ }
343
+ }
281
344
  asEmojiSprite(sprite) {
282
345
  if ("sprite" in sprite && "size" in sprite) {
283
346
  return sprite;
package/dist/minimo.d.ts CHANGED
@@ -30,6 +30,102 @@ export interface ExplodeOptions {
30
30
  /** Whether the original sprite is destroyed instead of restored after the effect. */
31
31
  destroySprite?: boolean;
32
32
  }
33
+ /**
34
+ * Cardinal flow direction used by directional visual effects.
35
+ */
36
+ export type FlowDirection = "left-to-right" | "right-to-left" | "top-to-bottom" | "bottom-to-top";
37
+ /**
38
+ * Direction used by piece-based local integration and disintegration effects.
39
+ *
40
+ * - Cardinal values apply a directional sweep across the sprite.
41
+ * - `"in-place"` keeps pieces in their final positions and only randomizes
42
+ * when each piece appears or disappears.
43
+ */
44
+ export type PieceFlowDirection = FlowDirection | "in-place";
45
+ /**
46
+ * Optional tuning values for {@link Game.animateAssemble}.
47
+ *
48
+ * All fields are optional. Omitted fields use the engine defaults.
49
+ */
50
+ export interface AssembleOptions {
51
+ /** Number of vertical slices used to reconstruct the sprite. */
52
+ rows?: number;
53
+ /** Number of horizontal slices used to reconstruct the sprite. */
54
+ cols?: number;
55
+ /** Total effect duration in **milliseconds**. */
56
+ durationMs?: number;
57
+ /** Initial outward distance magnitude in pixels per second equivalent. */
58
+ speed?: number;
59
+ /** Downward pull applied while pieces converge, in pixels per second squared. */
60
+ gravityY?: number;
61
+ /** Maximum angular speed applied to pieces, in degrees per second. */
62
+ spin?: number;
63
+ /** Whether pieces fade in while assembling. */
64
+ fade?: boolean;
65
+ }
66
+ /**
67
+ * Optional tuning values for {@link Game.animateDisintegrate}.
68
+ *
69
+ * All fields are optional. Omitted fields use the engine defaults.
70
+ */
71
+ export interface DisintegrateOptions {
72
+ /** Number of vertical slices used to dissolve the sprite. */
73
+ rows?: number;
74
+ /** Number of horizontal slices used to dissolve the sprite. */
75
+ cols?: number;
76
+ /** Total effect duration in **milliseconds**. */
77
+ durationMs?: number;
78
+ /** Primary sweep direction across the sprite. */
79
+ direction?: PieceFlowDirection;
80
+ /** Maximum local travel distance in pixels for each piece. */
81
+ distance?: number;
82
+ /** Maximum angular speed applied to pieces, in degrees per second. */
83
+ spin?: number;
84
+ /** Whether pieces fade out as they disintegrate. */
85
+ fade?: boolean;
86
+ /** Whether the original sprite is destroyed instead of left hidden after the effect. */
87
+ destroySprite?: boolean;
88
+ }
89
+ /**
90
+ * Optional tuning values for {@link Game.animateIntegrate}.
91
+ *
92
+ * All fields are optional. Omitted fields use the engine defaults.
93
+ */
94
+ export interface IntegrateOptions {
95
+ /** Number of vertical slices used to reconstruct the sprite. */
96
+ rows?: number;
97
+ /** Number of horizontal slices used to reconstruct the sprite. */
98
+ cols?: number;
99
+ /** Total effect duration in **milliseconds**. */
100
+ durationMs?: number;
101
+ /** Primary sweep direction across the sprite. */
102
+ direction?: PieceFlowDirection;
103
+ /** Maximum local travel distance in pixels for each piece. */
104
+ distance?: number;
105
+ /** Maximum angular speed applied to pieces, in degrees per second. */
106
+ spin?: number;
107
+ /** Whether pieces fade in while integrating. */
108
+ fade?: boolean;
109
+ }
110
+ /**
111
+ * Wipe mode used by {@link Game.animateWipe}.
112
+ */
113
+ export type WipeMode = "reveal" | "cover";
114
+ /**
115
+ * Optional tuning values for {@link Game.animateWipe}.
116
+ *
117
+ * All fields are optional. Omitted fields use the engine defaults.
118
+ */
119
+ export interface WipeOptions {
120
+ /** Whether the wipe reveals the sprite or covers it away. */
121
+ mode?: WipeMode;
122
+ /** Primary wipe direction. */
123
+ direction?: FlowDirection;
124
+ /** Total effect duration in **milliseconds**. */
125
+ durationMs?: number;
126
+ /** Whether the original sprite is destroyed for `mode: "cover"`. */
127
+ destroySprite?: boolean;
128
+ }
33
129
  /**
34
130
  * Optional tuning values for {@link Game.animateTrail}.
35
131
  *
@@ -1682,6 +1778,69 @@ export declare class Game {
1682
1778
  * ```
1683
1779
  */
1684
1780
  animateExplode(sprite: BaseSprite, options?: ExplodeOptions, onComplete?: () => void): void;
1781
+ /**
1782
+ * Spawns visual pieces around a sprite and converges them back into its
1783
+ * current rendered appearance.
1784
+ *
1785
+ * This is a render-only effect. The spawned pieces are not real sprites and
1786
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
1787
+ *
1788
+ * The original sprite is hidden while the assembly plays and is restored when
1789
+ * the effect finishes.
1790
+ *
1791
+ * @param sprite - The sprite to assemble.
1792
+ * @param options - Optional assembly tuning values.
1793
+ * @param onComplete - Optional callback invoked when the effect finishes.
1794
+ */
1795
+ animateAssemble(sprite: BaseSprite, options?: AssembleOptions, onComplete?: () => void): void;
1796
+ /**
1797
+ * Breaks a sprite apart locally inside its own bounds using a directional
1798
+ * dissolve sweep.
1799
+ *
1800
+ * This is a render-only effect. The spawned pieces are not real sprites and
1801
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
1802
+ *
1803
+ * By default, the original sprite is destroyed immediately after the effect
1804
+ * is spawned. Set `destroySprite: false` to leave the sprite hidden instead.
1805
+ *
1806
+ * @param sprite - The sprite to disintegrate.
1807
+ * @param options - Optional disintegration tuning values.
1808
+ * @param onComplete - Optional callback invoked when the effect finishes.
1809
+ */
1810
+ animateDisintegrate(sprite: BaseSprite, options?: DisintegrateOptions, onComplete?: () => void): void;
1811
+ /**
1812
+ * Reconstructs a sprite locally inside its own bounds using a directional
1813
+ * integration sweep.
1814
+ *
1815
+ * This is a render-only effect. The spawned pieces are not real sprites and
1816
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
1817
+ *
1818
+ * The original sprite is hidden while the integration plays and is restored
1819
+ * when the effect finishes.
1820
+ *
1821
+ * @param sprite - The sprite to integrate.
1822
+ * @param options - Optional integration tuning values.
1823
+ * @param onComplete - Optional callback invoked when the effect finishes.
1824
+ */
1825
+ animateIntegrate(sprite: BaseSprite, options?: IntegrateOptions, onComplete?: () => void): void;
1826
+ /**
1827
+ * Reveals or covers a sprite with a directional wipe mask.
1828
+ *
1829
+ * This is a render-only effect that captures the sprite's current appearance
1830
+ * and animates a clipping region over that snapshot.
1831
+ *
1832
+ * For `mode: "reveal"`, the original sprite is hidden during the wipe and is
1833
+ * restored when the effect finishes.
1834
+ *
1835
+ * For `mode: "cover"`, the effect hides the sprite over time. If
1836
+ * `destroySprite` is omitted or `true`, the sprite is destroyed immediately
1837
+ * after the wipe starts and only the wipe snapshot remains visible.
1838
+ *
1839
+ * @param sprite - The sprite to wipe.
1840
+ * @param options - Optional wipe tuning values.
1841
+ * @param onComplete - Optional callback invoked when the effect finishes.
1842
+ */
1843
+ animateWipe(sprite: BaseSprite, options?: WipeOptions, onComplete?: () => void): void;
1685
1844
  /**
1686
1845
  * Schedules a callback to fire after `delayMs` milliseconds, driven by the
1687
1846
  * rAF loop (not `setTimeout`). Timers accumulate elapsed time each frame and
@@ -1883,7 +2042,7 @@ export declare class Game {
1883
2042
  * overlays. Order per frame:
1884
2043
  * 1. Accumulate timer elapsed time; fire ready callbacks.
1885
2044
  * 2. Advance animations (linear interpolation).
1886
- * 3. Advance active explosion effects.
2045
+ * 3. Advance active piece and wipe effects.
1887
2046
  * 4. Apply gravity to sprite velocities.
1888
2047
  * 5. Apply velocities to sprite positions.
1889
2048
  * 6. Call `onUpdate(dt)`.
package/dist/minimo.js CHANGED
@@ -1982,6 +1982,87 @@ export class Game {
1982
1982
  this.destroySprite(sprite);
1983
1983
  }
1984
1984
  }
1985
+ /**
1986
+ * Spawns visual pieces around a sprite and converges them back into its
1987
+ * current rendered appearance.
1988
+ *
1989
+ * This is a render-only effect. The spawned pieces are not real sprites and
1990
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
1991
+ *
1992
+ * The original sprite is hidden while the assembly plays and is restored when
1993
+ * the effect finishes.
1994
+ *
1995
+ * @param sprite - The sprite to assemble.
1996
+ * @param options - Optional assembly tuning values.
1997
+ * @param onComplete - Optional callback invoked when the effect finishes.
1998
+ */
1999
+ animateAssemble(sprite, options, onComplete) {
2000
+ const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
2001
+ this._explosionSystem.animateAssemble(sprite, glyphCanvas, options, onComplete);
2002
+ }
2003
+ /**
2004
+ * Breaks a sprite apart locally inside its own bounds using a directional
2005
+ * dissolve sweep.
2006
+ *
2007
+ * This is a render-only effect. The spawned pieces are not real sprites and
2008
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
2009
+ *
2010
+ * By default, the original sprite is destroyed immediately after the effect
2011
+ * is spawned. Set `destroySprite: false` to leave the sprite hidden instead.
2012
+ *
2013
+ * @param sprite - The sprite to disintegrate.
2014
+ * @param options - Optional disintegration tuning values.
2015
+ * @param onComplete - Optional callback invoked when the effect finishes.
2016
+ */
2017
+ animateDisintegrate(sprite, options, onComplete) {
2018
+ const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
2019
+ this._explosionSystem.animateDisintegrate(sprite, glyphCanvas, options, onComplete);
2020
+ if (options?.destroySprite !== false) {
2021
+ this.destroySprite(sprite);
2022
+ }
2023
+ }
2024
+ /**
2025
+ * Reconstructs a sprite locally inside its own bounds using a directional
2026
+ * integration sweep.
2027
+ *
2028
+ * This is a render-only effect. The spawned pieces are not real sprites and
2029
+ * do not appear in {@link Game.getSprites}, receive physics, or collide.
2030
+ *
2031
+ * The original sprite is hidden while the integration plays and is restored
2032
+ * when the effect finishes.
2033
+ *
2034
+ * @param sprite - The sprite to integrate.
2035
+ * @param options - Optional integration tuning values.
2036
+ * @param onComplete - Optional callback invoked when the effect finishes.
2037
+ */
2038
+ animateIntegrate(sprite, options, onComplete) {
2039
+ const glyphCanvas = this._renderSystem.getSpriteGlyphCanvasForEffects(sprite);
2040
+ this._explosionSystem.animateIntegrate(sprite, glyphCanvas, options, onComplete);
2041
+ }
2042
+ /**
2043
+ * Reveals or covers a sprite with a directional wipe mask.
2044
+ *
2045
+ * This is a render-only effect that captures the sprite's current appearance
2046
+ * and animates a clipping region over that snapshot.
2047
+ *
2048
+ * For `mode: "reveal"`, the original sprite is hidden during the wipe and is
2049
+ * restored when the effect finishes.
2050
+ *
2051
+ * For `mode: "cover"`, the effect hides the sprite over time. If
2052
+ * `destroySprite` is omitted or `true`, the sprite is destroyed immediately
2053
+ * after the wipe starts and only the wipe snapshot remains visible.
2054
+ *
2055
+ * @param sprite - The sprite to wipe.
2056
+ * @param options - Optional wipe tuning values.
2057
+ * @param onComplete - Optional callback invoked when the effect finishes.
2058
+ */
2059
+ animateWipe(sprite, options, onComplete) {
2060
+ const snapshot = this._renderSystem.getSpriteRenderSnapshot(sprite);
2061
+ this._explosionSystem.animateWipe(sprite, snapshot, options, onComplete);
2062
+ if ((options?.mode ?? "reveal") === "cover" && options?.destroySprite !== false) {
2063
+ this.destroySprite(sprite);
2064
+ }
2065
+ }
1985
2066
  // -------------------------------------------------------------------------
1986
2067
  // Timers
1987
2068
  // -------------------------------------------------------------------------
@@ -2240,7 +2321,7 @@ export class Game {
2240
2321
  * overlays. Order per frame:
2241
2322
  * 1. Accumulate timer elapsed time; fire ready callbacks.
2242
2323
  * 2. Advance animations (linear interpolation).
2243
- * 3. Advance active explosion effects.
2324
+ * 3. Advance active piece and wipe effects.
2244
2325
  * 4. Apply gravity to sprite velocities.
2245
2326
  * 5. Apply velocities to sprite positions.
2246
2327
  * 6. Call `onUpdate(dt)`.
@@ -2425,6 +2506,7 @@ export class Game {
2425
2506
  sprites: this._spriteSystem.getMutableSprites(),
2426
2507
  trails: this._trailSystem.getRenderEntries(),
2427
2508
  explosions: this._explosionSystem.getRenderEntries(),
2509
+ wipes: this._explosionSystem.getWipeEntries(),
2428
2510
  textEntries: this._textSystem.getEntries(),
2429
2511
  scrollX: this.scrollX,
2430
2512
  scrollY: this.scrollY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.12",
3
+ "version": "1.0.0-alpha.13",
4
4
  "description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Emoji-only sprites, rAF loop, TypeScript-first, LLM-friendly.",
5
5
  "type": "module",
6
6
  "main": "dist/minimo.js",