minimojs 1.0.0-alpha.15 → 1.0.0-alpha.17

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
@@ -40,17 +40,35 @@ npm install minimojs
40
40
  ## Quick Start
41
41
 
42
42
  ```ts
43
- import { Game, Sprite, type IScene } from "minimojs";
43
+ import { DrawSprite, Game, Sprite, type IScene } from "minimojs";
44
44
 
45
45
  const game = new Game(720, 1280);
46
46
  game.gravityY = 980;
47
47
 
48
+ class MeterSprite extends DrawSprite {
49
+ public value = 0.5;
50
+
51
+ constructor(x: number, y: number) {
52
+ super(160, 28, x, y);
53
+ }
54
+
55
+ redraw(ctx: CanvasRenderingContext2D) {
56
+ ctx.fillStyle = "#1a2236";
57
+ ctx.fillRect(0, 0, this.width, this.height);
58
+ ctx.fillStyle = "#7ce7ff";
59
+ ctx.fillRect(0, 0, this.width * this.value, this.height);
60
+ }
61
+ }
62
+
48
63
  class DemoScene implements IScene {
49
64
  private player: Sprite | null = null;
65
+ private meter: MeterSprite | null = null;
50
66
 
51
67
  onCreate() {
52
68
  this.player = game.add(new Sprite("🐢", 360, 640, 48));
53
69
  this.player.gravityScale = 1;
70
+ this.meter = game.add(new MeterSprite(360, 80));
71
+ this.meter.ignoreScroll = true;
54
72
  }
55
73
 
56
74
  onUpdate(dt: number) {
@@ -61,6 +79,7 @@ class DemoScene implements IScene {
61
79
  else this.player.vx = 0;
62
80
 
63
81
  if (game.isKeyPressed(" ")) this.player.vy = -600;
82
+ if (this.meter) this.meter.value = Math.abs(this.player.vx) / 200;
64
83
  game.drawText("MinimoJS", 10, 10, 16);
65
84
  }
66
85
  }
@@ -80,6 +99,8 @@ Note: `drawText()` uses `"Press Start 2P", monospace`. Load the font in your HTM
80
99
  - `examples/background-desert/`
81
100
  - `examples/scene-lab/`
82
101
  - `examples/image-sprite-sad-plush/`
102
+ - `examples/draw-sprite/`
103
+ - `examples/pseudo-3d-racer/`
83
104
  - `examples/animations/`
84
105
 
85
106
  Run locally from the `minimojs` directory:
@@ -37,12 +37,12 @@ export class PhysicsSystem {
37
37
  }
38
38
  }
39
39
  overlap(a, b) {
40
- const halfWidthA = a.displayWidth / 2;
41
- const halfHeightA = a.displayHeight / 2;
42
- const halfWidthB = b.displayWidth / 2;
43
- const halfHeightB = b.displayHeight / 2;
44
- return (Math.abs(a.renderX - b.renderX) < halfWidthA + halfWidthB &&
45
- Math.abs(a.renderY - b.renderY) < halfHeightA + halfHeightB);
40
+ const halfWidthA = a.bodyDisplayWidth / 2;
41
+ const halfHeightA = a.bodyDisplayHeight / 2;
42
+ const halfWidthB = b.bodyDisplayWidth / 2;
43
+ const halfHeightB = b.bodyDisplayHeight / 2;
44
+ return (Math.abs(a.bodyCenterX - b.bodyCenterX) < halfWidthA + halfWidthB &&
45
+ Math.abs(a.bodyCenterY - b.bodyCenterY) < halfHeightA + halfHeightB);
46
46
  }
47
47
  overlapAny(listA, listB) {
48
48
  for (const a of listA) {
@@ -100,12 +100,12 @@ export class PhysicsSystem {
100
100
  }
101
101
  }
102
102
  getCollisionResolution(a, b) {
103
- const halfWidthA = a.displayWidth / 2;
104
- const halfHeightA = a.displayHeight / 2;
105
- const halfWidthB = b.displayWidth / 2;
106
- const halfHeightB = b.displayHeight / 2;
107
- const dx = b.renderX - a.renderX;
108
- const dy = b.renderY - a.renderY;
103
+ const halfWidthA = a.bodyDisplayWidth / 2;
104
+ const halfHeightA = a.bodyDisplayHeight / 2;
105
+ const halfWidthB = b.bodyDisplayWidth / 2;
106
+ const halfHeightB = b.bodyDisplayHeight / 2;
107
+ const dx = b.bodyCenterX - a.bodyCenterX;
108
+ const dy = b.bodyCenterY - a.bodyCenterY;
109
109
  const overlapX = halfWidthA + halfWidthB - Math.abs(dx);
110
110
  const overlapY = halfHeightA + halfHeightB - Math.abs(dy);
111
111
  if (overlapX <= 0 || overlapY <= 0) {
@@ -5,10 +5,14 @@ export class RenderSystem {
5
5
  this._lastAppliedPageBackground = undefined;
6
6
  this._transitionScratchCanvas = null;
7
7
  this._transitionScratchContext = null;
8
+ this._dynamicSurfacePassId = 0;
8
9
  }
9
10
  clearSpriteCache() {
10
11
  this._surfaceCache.clear();
11
12
  }
13
+ beginDynamicSurfacePass() {
14
+ this._dynamicSurfacePassId += 1;
15
+ }
12
16
  getSpriteGlyphCanvasForEffects(sprite) {
13
17
  return this.getRenderSurface(sprite);
14
18
  }
@@ -196,6 +200,60 @@ export class RenderSystem {
196
200
  ctx.fillText(entry.text, entry.x, entry.y);
197
201
  ctx.restore();
198
202
  }
203
+ if (options.debugBodies) {
204
+ this.drawBodyDebugOverlay(ctx, sorted, options.scrollX, options.scrollY);
205
+ }
206
+ if (options.debugInputAreas) {
207
+ this.drawInputDebugOverlay(ctx, sorted, options.scrollX, options.scrollY);
208
+ }
209
+ }
210
+ drawBodyDebugOverlay(ctx, sprites, scrollX, scrollY) {
211
+ ctx.save();
212
+ ctx.setLineDash([6, 4]);
213
+ ctx.lineWidth = 2;
214
+ for (const sprite of sprites) {
215
+ const halfWidth = sprite.bodyDisplayWidth / 2;
216
+ const halfHeight = sprite.bodyDisplayHeight / 2;
217
+ const centerX = sprite.ignoreScroll ? sprite.bodyCenterX : sprite.bodyCenterX - scrollX;
218
+ const centerY = sprite.ignoreScroll ? sprite.bodyCenterY : sprite.bodyCenterY - scrollY;
219
+ const left = Math.round(centerX - halfWidth) + 0.5;
220
+ const top = Math.round(centerY - halfHeight) + 0.5;
221
+ const width = Math.max(1, Math.round(sprite.bodyDisplayWidth) - 1);
222
+ const height = Math.max(1, Math.round(sprite.bodyDisplayHeight) - 1);
223
+ const stroke = sprite.isStatic
224
+ ? 'rgba(0, 217, 255, 0.95)'
225
+ : 'rgba(255, 77, 109, 0.95)';
226
+ const fill = sprite.isStatic
227
+ ? 'rgba(0, 217, 255, 0.12)'
228
+ : 'rgba(255, 77, 109, 0.12)';
229
+ ctx.strokeStyle = stroke;
230
+ ctx.fillStyle = fill;
231
+ ctx.fillRect(left, top, width, height);
232
+ ctx.strokeRect(left, top, width, height);
233
+ }
234
+ ctx.restore();
235
+ }
236
+ drawInputDebugOverlay(ctx, sprites, scrollX, scrollY) {
237
+ ctx.save();
238
+ ctx.setLineDash([3, 5]);
239
+ ctx.lineWidth = 2;
240
+ for (const sprite of sprites) {
241
+ const halfWidth = sprite.displayWidth / 2;
242
+ const halfHeight = sprite.displayHeight / 2;
243
+ const centerX = sprite.ignoreScroll ? sprite.renderX : sprite.renderX - scrollX;
244
+ const centerY = sprite.ignoreScroll ? sprite.renderY : sprite.renderY - scrollY;
245
+ const left = Math.round(centerX - halfWidth) + 0.5;
246
+ const top = Math.round(centerY - halfHeight) + 0.5;
247
+ const width = Math.max(1, Math.round(sprite.displayWidth) - 1);
248
+ const height = Math.max(1, Math.round(sprite.displayHeight) - 1);
249
+ const stroke = "rgba(255, 196, 0, 0.98)";
250
+ const fill = "rgba(255, 196, 0, 0.10)";
251
+ ctx.strokeStyle = stroke;
252
+ ctx.fillStyle = fill;
253
+ ctx.fillRect(left, top, width, height);
254
+ ctx.strokeRect(left, top, width, height);
255
+ }
256
+ ctx.restore();
199
257
  }
200
258
  renderLoadingScreen(options) {
201
259
  const ctx = options.context;
@@ -294,6 +352,9 @@ export class RenderSystem {
294
352
  ctx.restore();
295
353
  }
296
354
  getRenderSurface(sprite) {
355
+ if (this.isDrawSprite(sprite)) {
356
+ return this.getDrawSurface(sprite);
357
+ }
297
358
  const cacheKey = sprite.getRenderCacheKey();
298
359
  const cached = this._surfaceCache.get(cacheKey);
299
360
  if (cached)
@@ -413,9 +474,47 @@ export class RenderSystem {
413
474
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
414
475
  return canvas;
415
476
  }
477
+ getDrawSurface(sprite) {
478
+ const width = Math.max(1, Math.round(sprite.width));
479
+ const height = Math.max(1, Math.round(sprite.height));
480
+ let surface = sprite._surface;
481
+ if (!surface) {
482
+ surface = document.createElement("canvas");
483
+ sprite._surface = surface;
484
+ }
485
+ const sizeChanged = surface.width !== width || surface.height !== height;
486
+ if (sprite.frozen && !sizeChanged && surface.width > 0 && surface.height > 0) {
487
+ return surface;
488
+ }
489
+ if (!sprite.frozen &&
490
+ sprite._lastRedrawPassId === this._dynamicSurfacePassId &&
491
+ surface.width === width &&
492
+ surface.height === height) {
493
+ return surface;
494
+ }
495
+ if (sizeChanged) {
496
+ surface.width = width;
497
+ surface.height = height;
498
+ }
499
+ else {
500
+ // Resetting width clears pixels and restores the default 2D context state.
501
+ surface.width = width;
502
+ }
503
+ const ctx = surface.getContext("2d");
504
+ if (!ctx) {
505
+ throw new Error("MinimoJS: Could not acquire a DrawSprite rendering context.");
506
+ }
507
+ sprite._surfaceCtx = ctx;
508
+ sprite.redraw(ctx);
509
+ sprite._lastRedrawPassId = this._dynamicSurfacePassId;
510
+ return surface;
511
+ }
416
512
  isTextSprite(sprite) {
417
513
  return "text" in sprite && "fontFamily" in sprite && "fontSize" in sprite;
418
514
  }
515
+ isDrawSprite(sprite) {
516
+ return "redraw" in sprite && "_surface" in sprite && "_surfaceCtx" in sprite;
517
+ }
419
518
  isImageSprite(sprite) {
420
519
  return "imageKey" in sprite && "setTexture" in sprite;
421
520
  }
package/dist/minimo.d.ts CHANGED
@@ -252,6 +252,52 @@ export declare abstract class BaseSprite {
252
252
  * Effective rendered/collision height in pixels.
253
253
  */
254
254
  get displayHeight(): number;
255
+ /**
256
+ * Optional logical body width used by physics helpers and collision checks.
257
+ *
258
+ * When `null` (default), MinimoJS uses the sprite's visual {@link BaseSprite.width}.
259
+ * When set, this value is scaled by {@link BaseSprite.scale} the same way as the
260
+ * visual sprite size.
261
+ */
262
+ bodyWidth: number | null;
263
+ /**
264
+ * Optional logical body height used by physics helpers and collision checks.
265
+ *
266
+ * When `null` (default), MinimoJS uses the sprite's visual {@link BaseSprite.height}.
267
+ * When set, this value is scaled by {@link BaseSprite.scale} the same way as the
268
+ * visual sprite size.
269
+ */
270
+ bodyHeight: number | null;
271
+ /**
272
+ * Horizontal body offset, in local sprite pixels before scale is applied.
273
+ *
274
+ * Positive values move the collision body to the right of the sprite's rendered center.
275
+ * Negative values move it to the left.
276
+ */
277
+ bodyOffsetX: number;
278
+ /**
279
+ * Vertical body offset, in local sprite pixels before scale is applied.
280
+ *
281
+ * Positive values move the collision body downward relative to the sprite's
282
+ * rendered center. Negative values move it upward.
283
+ */
284
+ bodyOffsetY: number;
285
+ /**
286
+ * Effective collision-body width in pixels after applying {@link BaseSprite.scale}.
287
+ */
288
+ get bodyDisplayWidth(): number;
289
+ /**
290
+ * Effective collision-body height in pixels after applying {@link BaseSprite.scale}.
291
+ */
292
+ get bodyDisplayHeight(): number;
293
+ /**
294
+ * Resolved body center X used internally by physics helpers and collision checks.
295
+ */
296
+ get bodyCenterX(): number;
297
+ /**
298
+ * Resolved body center Y used internally by physics helpers and collision checks.
299
+ */
300
+ get bodyCenterY(): number;
255
301
  /**
256
302
  * CSS text color used when rendering this sprite.
257
303
  *
@@ -425,6 +471,96 @@ export declare class ImageSprite extends BaseSprite {
425
471
  private getResolvedImage;
426
472
  private assertTextureAvailable;
427
473
  }
474
+ /**
475
+ * A renderable sprite backed by a per-instance canvas that is repainted on demand.
476
+ *
477
+ * `DrawSprite` is useful for procedural shapes, gauges, charts, minimaps, and
478
+ * HUD widgets whose appearance changes often and is easier to express with
479
+ * Canvas 2D drawing commands than with emoji, text, or image swaps.
480
+ *
481
+ * Treat `DrawSprite` as a specialized tool, not the default sprite type.
482
+ * Because it may execute custom Canvas 2D drawing code repeatedly, overusing it
483
+ * can affect game performance much more than regular {@link Sprite},
484
+ * {@link ImageSprite}, or {@link TextSprite} instances.
485
+ *
486
+ * Prefer the other sprite types whenever they can express the same result more
487
+ * simply. Reach for `DrawSprite` only when you truly need procedural drawing or
488
+ * custom per-sprite canvas rendering that the built-in sprite types cannot
489
+ * provide cleanly.
490
+ *
491
+ * MinimoJS creates and owns an internal canvas for each `DrawSprite` instance.
492
+ * Before every engine render that needs this sprite's surface, the engine:
493
+ *
494
+ * 1. Resolves the sprite's internal canvas size
495
+ * 2. Optionally clears and repaints that internal canvas
496
+ * 3. Draws the resulting canvas like any other sprite surface
497
+ *
498
+ * By default, `DrawSprite` is live-rendered: the engine clears the internal
499
+ * canvas and calls {@link DrawSprite.redraw} every frame.
500
+ *
501
+ * Set {@link DrawSprite.frozen} to `true` if you want to keep and reuse the
502
+ * last rendered canvas contents without repainting on each frame. This is
503
+ * useful for shapes or procedural art that you only want to draw once.
504
+ *
505
+ * While `frozen` is `true`, MinimoJS reuses the existing surface exactly as-is:
506
+ * it does not clear the canvas and does not call {@link DrawSprite.redraw}
507
+ * again, unless the surface does not exist yet or its size had to be rebuilt.
508
+ *
509
+ * This means freezing is a rendering optimization and content-preservation
510
+ * flag, not a separate caching system. You can switch `frozen` on or off at
511
+ * runtime whenever it makes sense for your sprite.
512
+ *
513
+ * The local drawing coordinate system uses the sprite surface itself:
514
+ * - `(0, 0)` is the top-left corner of the internal canvas
515
+ * - `width` / `height` match the sprite's logical size before `scale`
516
+ * - draw centered content yourself if you want the visual origin in the middle
517
+ *
518
+ * Override {@link DrawSprite.redraw} in a subclass, or assign your own method
519
+ * on an instance if you prefer an inline style in JavaScript.
520
+ */
521
+ export declare class DrawSprite extends BaseSprite {
522
+ private static _nextSurfaceId;
523
+ /**
524
+ * When `false` (default), MinimoJS clears this sprite's internal canvas and
525
+ * calls {@link DrawSprite.redraw} on every render pass.
526
+ *
527
+ * When `true`, MinimoJS keeps and reuses the existing canvas contents without
528
+ * clearing or repainting them again, unless the internal surface does not yet
529
+ * exist or had to be resized.
530
+ *
531
+ * Use this when your procedural drawing becomes static after its first paint,
532
+ * or when you want explicit manual control over when the sprite is redrawn by
533
+ * toggling `frozen` at runtime.
534
+ */
535
+ frozen: boolean;
536
+ private readonly _surfaceId;
537
+ private readonly _width;
538
+ private readonly _height;
539
+ get width(): number;
540
+ get height(): number;
541
+ /**
542
+ * Creates a new dynamic canvas-backed sprite.
543
+ *
544
+ * @param width - Internal canvas width in pixels. Minimum `1`.
545
+ * @param height - Internal canvas height in pixels. Minimum `1`.
546
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
547
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
548
+ */
549
+ constructor(width: number, height: number, x?: number, y?: number);
550
+ /**
551
+ * Called by the engine whenever the sprite's internal surface must be repainted.
552
+ *
553
+ * The provided context is already cleared and reset to the default 2D canvas
554
+ * state for the current surface size. MinimoJS repaints each `DrawSprite` at
555
+ * most once per render pass while it is not {@link DrawSprite.frozen}, so
556
+ * repeated snapshot reads during the same frame reuse the already-redrawn
557
+ * surface.
558
+ *
559
+ * @param ctx - The sprite's internal 2D drawing context.
560
+ */
561
+ redraw(_ctx: CanvasRenderingContext2D): void;
562
+ getRenderCacheKey(): string;
563
+ }
428
564
  /**
429
565
  * A renderable text actor that participates in the same animation/effects
430
566
  * pipeline as regular sprites.
@@ -982,6 +1118,20 @@ export declare class Game {
982
1118
  from: string;
983
1119
  to: string;
984
1120
  } | null;
1121
+ /**
1122
+ * When `true`, MinimoJS draws every sprite's collision body as an overlay.
1123
+ *
1124
+ * This is a runtime debugging aid only. It does not change collisions,
1125
+ * input, rendering order, or physics behavior.
1126
+ */
1127
+ debugBodies: boolean;
1128
+ /**
1129
+ * When `true`, MinimoJS draws every sprite's input area as an overlay.
1130
+ *
1131
+ * This is a runtime debugging aid only. It does not change input behavior,
1132
+ * physics, or rendering.
1133
+ */
1134
+ debugInputAreas: boolean;
985
1135
  /**
986
1136
  * Background color for the full web page (`document.body`).
987
1137
  * Set to any valid CSS color string. Default: `null` (engine leaves page background unchanged).
package/dist/minimo.js CHANGED
@@ -53,6 +53,36 @@ export class BaseSprite {
53
53
  this.scale = 1;
54
54
  /** @internal */
55
55
  this._renderData = null;
56
+ /**
57
+ * Optional logical body width used by physics helpers and collision checks.
58
+ *
59
+ * When `null` (default), MinimoJS uses the sprite's visual {@link BaseSprite.width}.
60
+ * When set, this value is scaled by {@link BaseSprite.scale} the same way as the
61
+ * visual sprite size.
62
+ */
63
+ this.bodyWidth = null;
64
+ /**
65
+ * Optional logical body height used by physics helpers and collision checks.
66
+ *
67
+ * When `null` (default), MinimoJS uses the sprite's visual {@link BaseSprite.height}.
68
+ * When set, this value is scaled by {@link BaseSprite.scale} the same way as the
69
+ * visual sprite size.
70
+ */
71
+ this.bodyHeight = null;
72
+ /**
73
+ * Horizontal body offset, in local sprite pixels before scale is applied.
74
+ *
75
+ * Positive values move the collision body to the right of the sprite's rendered center.
76
+ * Negative values move it to the left.
77
+ */
78
+ this.bodyOffsetX = 0;
79
+ /**
80
+ * Vertical body offset, in local sprite pixels before scale is applied.
81
+ *
82
+ * Positive values move the collision body downward relative to the sprite's
83
+ * rendered center. Negative values move it upward.
84
+ */
85
+ this.bodyOffsetY = 0;
56
86
  /**
57
87
  * CSS text color used when rendering this sprite.
58
88
  *
@@ -179,6 +209,44 @@ export class BaseSprite {
179
209
  const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
180
210
  return this.height * Math.max(0, safeScale);
181
211
  }
212
+ /**
213
+ * Effective collision-body width in pixels after applying {@link BaseSprite.scale}.
214
+ */
215
+ get bodyDisplayWidth() {
216
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
217
+ const baseWidth = typeof this.bodyWidth === "number" &&
218
+ Number.isFinite(this.bodyWidth) &&
219
+ this.bodyWidth > 0
220
+ ? this.bodyWidth
221
+ : this.width;
222
+ return baseWidth * Math.max(0, safeScale);
223
+ }
224
+ /**
225
+ * Effective collision-body height in pixels after applying {@link BaseSprite.scale}.
226
+ */
227
+ get bodyDisplayHeight() {
228
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
229
+ const baseHeight = typeof this.bodyHeight === "number" &&
230
+ Number.isFinite(this.bodyHeight) &&
231
+ this.bodyHeight > 0
232
+ ? this.bodyHeight
233
+ : this.height;
234
+ return baseHeight * Math.max(0, safeScale);
235
+ }
236
+ /**
237
+ * Resolved body center X used internally by physics helpers and collision checks.
238
+ */
239
+ get bodyCenterX() {
240
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
241
+ return this.renderX + this.bodyOffsetX * Math.max(0, safeScale);
242
+ }
243
+ /**
244
+ * Resolved body center Y used internally by physics helpers and collision checks.
245
+ */
246
+ get bodyCenterY() {
247
+ const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
248
+ return this.renderY + this.bodyOffsetY * Math.max(0, safeScale);
249
+ }
182
250
  getAnchorOffsetX() {
183
251
  return 0;
184
252
  }
@@ -315,6 +383,112 @@ export class ImageSprite extends BaseSprite {
315
383
  }
316
384
  }
317
385
  }
386
+ /**
387
+ * A renderable sprite backed by a per-instance canvas that is repainted on demand.
388
+ *
389
+ * `DrawSprite` is useful for procedural shapes, gauges, charts, minimaps, and
390
+ * HUD widgets whose appearance changes often and is easier to express with
391
+ * Canvas 2D drawing commands than with emoji, text, or image swaps.
392
+ *
393
+ * Treat `DrawSprite` as a specialized tool, not the default sprite type.
394
+ * Because it may execute custom Canvas 2D drawing code repeatedly, overusing it
395
+ * can affect game performance much more than regular {@link Sprite},
396
+ * {@link ImageSprite}, or {@link TextSprite} instances.
397
+ *
398
+ * Prefer the other sprite types whenever they can express the same result more
399
+ * simply. Reach for `DrawSprite` only when you truly need procedural drawing or
400
+ * custom per-sprite canvas rendering that the built-in sprite types cannot
401
+ * provide cleanly.
402
+ *
403
+ * MinimoJS creates and owns an internal canvas for each `DrawSprite` instance.
404
+ * Before every engine render that needs this sprite's surface, the engine:
405
+ *
406
+ * 1. Resolves the sprite's internal canvas size
407
+ * 2. Optionally clears and repaints that internal canvas
408
+ * 3. Draws the resulting canvas like any other sprite surface
409
+ *
410
+ * By default, `DrawSprite` is live-rendered: the engine clears the internal
411
+ * canvas and calls {@link DrawSprite.redraw} every frame.
412
+ *
413
+ * Set {@link DrawSprite.frozen} to `true` if you want to keep and reuse the
414
+ * last rendered canvas contents without repainting on each frame. This is
415
+ * useful for shapes or procedural art that you only want to draw once.
416
+ *
417
+ * While `frozen` is `true`, MinimoJS reuses the existing surface exactly as-is:
418
+ * it does not clear the canvas and does not call {@link DrawSprite.redraw}
419
+ * again, unless the surface does not exist yet or its size had to be rebuilt.
420
+ *
421
+ * This means freezing is a rendering optimization and content-preservation
422
+ * flag, not a separate caching system. You can switch `frozen` on or off at
423
+ * runtime whenever it makes sense for your sprite.
424
+ *
425
+ * The local drawing coordinate system uses the sprite surface itself:
426
+ * - `(0, 0)` is the top-left corner of the internal canvas
427
+ * - `width` / `height` match the sprite's logical size before `scale`
428
+ * - draw centered content yourself if you want the visual origin in the middle
429
+ *
430
+ * Override {@link DrawSprite.redraw} in a subclass, or assign your own method
431
+ * on an instance if you prefer an inline style in JavaScript.
432
+ */
433
+ export class DrawSprite extends BaseSprite {
434
+ get width() {
435
+ return this._width;
436
+ }
437
+ get height() {
438
+ return this._height;
439
+ }
440
+ /**
441
+ * Creates a new dynamic canvas-backed sprite.
442
+ *
443
+ * @param width - Internal canvas width in pixels. Minimum `1`.
444
+ * @param height - Internal canvas height in pixels. Minimum `1`.
445
+ * @param x - Initial X position in world space (center), in pixels. Default: `0`.
446
+ * @param y - Initial Y position in world space (center), in pixels. Default: `0`.
447
+ */
448
+ constructor(width, height, x = 0, y = 0) {
449
+ super();
450
+ /** @internal */
451
+ this._surface = null;
452
+ /** @internal */
453
+ this._surfaceCtx = null;
454
+ /** @internal */
455
+ this._lastRedrawPassId = -1;
456
+ /**
457
+ * When `false` (default), MinimoJS clears this sprite's internal canvas and
458
+ * calls {@link DrawSprite.redraw} on every render pass.
459
+ *
460
+ * When `true`, MinimoJS keeps and reuses the existing canvas contents without
461
+ * clearing or repainting them again, unless the internal surface does not yet
462
+ * exist or had to be resized.
463
+ *
464
+ * Use this when your procedural drawing becomes static after its first paint,
465
+ * or when you want explicit manual control over when the sprite is redrawn by
466
+ * toggling `frozen` at runtime.
467
+ */
468
+ this.frozen = false;
469
+ this._surfaceId = DrawSprite._nextSurfaceId++;
470
+ this._width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
471
+ this._height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
472
+ this.x = x;
473
+ this.y = y;
474
+ }
475
+ /**
476
+ * Called by the engine whenever the sprite's internal surface must be repainted.
477
+ *
478
+ * The provided context is already cleared and reset to the default 2D canvas
479
+ * state for the current surface size. MinimoJS repaints each `DrawSprite` at
480
+ * most once per render pass while it is not {@link DrawSprite.frozen}, so
481
+ * repeated snapshot reads during the same frame reuse the already-redrawn
482
+ * surface.
483
+ *
484
+ * @param ctx - The sprite's internal 2D drawing context.
485
+ */
486
+ redraw(_ctx) { }
487
+ getRenderCacheKey() {
488
+ return `draw:${this._surfaceId}`;
489
+ }
490
+ }
491
+ DrawSprite._nextSurfaceId = 1;
318
492
  /**
319
493
  * A renderable text actor that participates in the same animation/effects
320
494
  * pipeline as regular sprites.
@@ -1053,6 +1227,20 @@ export class Game {
1053
1227
  * ```
1054
1228
  */
1055
1229
  this.backgroundGradient = null;
1230
+ /**
1231
+ * When `true`, MinimoJS draws every sprite's collision body as an overlay.
1232
+ *
1233
+ * This is a runtime debugging aid only. It does not change collisions,
1234
+ * input, rendering order, or physics behavior.
1235
+ */
1236
+ this.debugBodies = false;
1237
+ /**
1238
+ * When `true`, MinimoJS draws every sprite's input area as an overlay.
1239
+ *
1240
+ * This is a runtime debugging aid only. It does not change input behavior,
1241
+ * physics, or rendering.
1242
+ */
1243
+ this.debugInputAreas = false;
1056
1244
  /**
1057
1245
  * Background color for the full web page (`document.body`).
1058
1246
  * Set to any valid CSS color string. Default: `null` (engine leaves page background unchanged).
@@ -2458,6 +2646,7 @@ export class Game {
2458
2646
  // Private — rendering
2459
2647
  // -------------------------------------------------------------------------
2460
2648
  /** @internal */ _onLoopFrameCallback(dt, dtMs) {
2649
+ this._renderSystem.beginDynamicSurfacePass();
2461
2650
  if (this._transitionSystem.isActive) {
2462
2651
  this._transitionSystem.update(dtMs);
2463
2652
  if (this._transitionSystem.isActive) {
@@ -2584,6 +2773,8 @@ export class Game {
2584
2773
  background: this.background,
2585
2774
  backgroundGradient: this.backgroundGradient,
2586
2775
  pageBackground: this.pageBackground,
2776
+ debugBodies: this.debugBodies,
2777
+ debugInputAreas: this.debugInputAreas,
2587
2778
  });
2588
2779
  }
2589
2780
  /** @internal */ _renderTransition() {
@@ -2600,6 +2791,7 @@ export class Game {
2600
2791
  });
2601
2792
  }
2602
2793
  /** @internal */ _captureFrameSnapshot() {
2794
+ this._renderSystem.beginDynamicSurfacePass();
2603
2795
  return this._renderSystem.captureFrame({
2604
2796
  canvas: this._canvas,
2605
2797
  context: this._ctx,
@@ -2615,6 +2807,8 @@ export class Game {
2615
2807
  background: this.background,
2616
2808
  backgroundGradient: this.backgroundGradient,
2617
2809
  pageBackground: this.pageBackground,
2810
+ debugBodies: this.debugBodies,
2811
+ debugInputAreas: this.debugInputAreas,
2618
2812
  });
2619
2813
  }
2620
2814
  /** @internal */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.15",
3
+ "version": "1.0.0-alpha.17",
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",