minimojs 1.0.0-alpha.11 → 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.
package/README.md CHANGED
@@ -21,6 +21,7 @@ This README is intentionally high-level. It explains what the project is and how
21
21
  - Runtime update delta (`dt`) is seconds
22
22
  - Coordinate system is center-based world space
23
23
  - Positive Y goes downward
24
+ - For new mobile-first games, prefer a portrait canvas of `720x1280`
24
25
 
25
26
  ## What It Intentionally Avoids
26
27
 
@@ -41,14 +42,14 @@ npm install minimojs
41
42
  ```ts
42
43
  import { Game, Sprite, type IScene } from "minimojs";
43
44
 
44
- const game = new Game(800, 600);
45
+ const game = new Game(720, 1280);
45
46
  game.gravityY = 980;
46
47
 
47
48
  class DemoScene implements IScene {
48
49
  private player: Sprite | null = null;
49
50
 
50
51
  onCreate() {
51
- this.player = game.add(new Sprite("🐢", 400, 300, 48));
52
+ this.player = game.add(new Sprite("🐢", 360, 640, 48));
52
53
  this.player.gravityScale = 1;
53
54
  }
54
55
 
@@ -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
  *
@@ -576,7 +672,7 @@ export interface CollisionInfo {
576
672
  * Example custom font registration:
577
673
  *
578
674
  * ```ts
579
- * const game = new Game(800, 600);
675
+ * const game = new Game(720, 1280);
580
676
  * game.requireFont('"Bangers"', { weight: "400" });
581
677
  * game.start();
582
678
  * ```
@@ -588,7 +684,7 @@ export interface CollisionInfo {
588
684
  * ```ts
589
685
  * import { Game, Sprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
590
686
  *
591
- * const game = new Game(800, 600);
687
+ * const game = new Game(720, 1280);
592
688
  *
593
689
  * const player = new Sprite("🐢", 400, 500, 48);
594
690
  * game.add(player);
@@ -935,15 +1031,16 @@ export declare class Game {
935
1031
  * The engine creates its own `<canvas>`, sets its dimensions, and appends it
936
1032
  * to `document.body`. The canvas is automatically centered and responsively
937
1033
  * scaled to use the maximum available viewport space while preserving aspect ratio.
1034
+ * For new mobile-first Minimo Games, prefer a portrait canvas such as `720x1280`.
938
1035
  *
939
- * @param width - Canvas width in pixels. Default: `800`.
940
- * @param height - Canvas height in pixels. Default: `600`.
1036
+ * @param width - Canvas width in pixels. Default: `720`.
1037
+ * @param height - Canvas height in pixels. Default: `1280`.
941
1038
  *
942
1039
  * @throws Error if a 2D context cannot be obtained.
943
1040
  *
944
1041
  * @example
945
1042
  * ```ts
946
- * const game = new Game(800, 600);
1043
+ * const game = new Game(720, 1280);
947
1044
  * game.physics = true;
948
1045
  * ```
949
1046
  */
@@ -1681,6 +1778,69 @@ export declare class Game {
1681
1778
  * ```
1682
1779
  */
1683
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;
1684
1844
  /**
1685
1845
  * Schedules a callback to fire after `delayMs` milliseconds, driven by the
1686
1846
  * rAF loop (not `setTimeout`). Timers accumulate elapsed time each frame and
@@ -1882,7 +2042,7 @@ export declare class Game {
1882
2042
  * overlays. Order per frame:
1883
2043
  * 1. Accumulate timer elapsed time; fire ready callbacks.
1884
2044
  * 2. Advance animations (linear interpolation).
1885
- * 3. Advance active explosion effects.
2045
+ * 3. Advance active piece and wipe effects.
1886
2046
  * 4. Apply gravity to sprite velocities.
1887
2047
  * 5. Apply velocities to sprite positions.
1888
2048
  * 6. Call `onUpdate(dt)`.
package/dist/minimo.js CHANGED
@@ -693,7 +693,7 @@ export class BackgroundLayer {
693
693
  * Example custom font registration:
694
694
  *
695
695
  * ```ts
696
- * const game = new Game(800, 600);
696
+ * const game = new Game(720, 1280);
697
697
  * game.requireFont('"Bangers"', { weight: "400" });
698
698
  * game.start();
699
699
  * ```
@@ -705,7 +705,7 @@ export class BackgroundLayer {
705
705
  * ```ts
706
706
  * import { Game, Sprite } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo.js";
707
707
  *
708
- * const game = new Game(800, 600);
708
+ * const game = new Game(720, 1280);
709
709
  *
710
710
  * const player = new Sprite("🐢", 400, 500, 48);
711
711
  * game.add(player);
@@ -981,19 +981,20 @@ export class Game {
981
981
  * The engine creates its own `<canvas>`, sets its dimensions, and appends it
982
982
  * to `document.body`. The canvas is automatically centered and responsively
983
983
  * scaled to use the maximum available viewport space while preserving aspect ratio.
984
+ * For new mobile-first Minimo Games, prefer a portrait canvas such as `720x1280`.
984
985
  *
985
- * @param width - Canvas width in pixels. Default: `800`.
986
- * @param height - Canvas height in pixels. Default: `600`.
986
+ * @param width - Canvas width in pixels. Default: `720`.
987
+ * @param height - Canvas height in pixels. Default: `1280`.
987
988
  *
988
989
  * @throws Error if a 2D context cannot be obtained.
989
990
  *
990
991
  * @example
991
992
  * ```ts
992
- * const game = new Game(800, 600);
993
+ * const game = new Game(720, 1280);
993
994
  * game.physics = true;
994
995
  * ```
995
996
  */
996
- constructor(width = 800, height = 600) {
997
+ constructor(width = 720, height = 1280) {
997
998
  /** @internal */ this._requiredFonts = new Map();
998
999
  /** @internal */ this._hasAppliedGlobalFontRequirements = false;
999
1000
  /** @internal */ this._isRegisteringPreloadAssets = false;
@@ -1981,6 +1982,87 @@ export class Game {
1981
1982
  this.destroySprite(sprite);
1982
1983
  }
1983
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
+ }
1984
2066
  // -------------------------------------------------------------------------
1985
2067
  // Timers
1986
2068
  // -------------------------------------------------------------------------
@@ -2239,7 +2321,7 @@ export class Game {
2239
2321
  * overlays. Order per frame:
2240
2322
  * 1. Accumulate timer elapsed time; fire ready callbacks.
2241
2323
  * 2. Advance animations (linear interpolation).
2242
- * 3. Advance active explosion effects.
2324
+ * 3. Advance active piece and wipe effects.
2243
2325
  * 4. Apply gravity to sprite velocities.
2244
2326
  * 5. Apply velocities to sprite positions.
2245
2327
  * 6. Call `onUpdate(dt)`.
@@ -2424,6 +2506,7 @@ export class Game {
2424
2506
  sprites: this._spriteSystem.getMutableSprites(),
2425
2507
  trails: this._trailSystem.getRenderEntries(),
2426
2508
  explosions: this._explosionSystem.getRenderEntries(),
2509
+ wipes: this._explosionSystem.getWipeEntries(),
2427
2510
  textEntries: this._textSystem.getEntries(),
2428
2511
  scrollX: this.scrollX,
2429
2512
  scrollY: this.scrollY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.11",
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",