All files / confetti/src/engine draw.ts

100% Statements 42/42
100% Branches 14/14
100% Functions 3/3
100% Lines 39/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162                                              7x                                             107x                                                         22694x 22694x 22694x 22694x   22694x 22673x 22673x 22673x     21x 21x   21x   17x 17x 17x 13x 13x 5x 5x   9x 9x   9x               9x 9x 9x       4x 4x 4x 3x 22694x 22694x   22694x               22694x 22694x                                             641x 641x 22673x   641x    
/**
 * Frame rendering for `@usefy/confetti`.
 *
 * Draw contract (SPEC §4.3): a single `clearRect` per frame, one pass over
 * the alive particles, manual transform math for the built-in shapes (no
 * `save()`/`restore()` per particle), and a sprite cache so sprite shapes
 * (`textShape`/`imageShape`) rasterize **once** and are `drawImage`d
 * thereafter.
 *
 * ## Custom-shape transforms (why sprites/paths use `setTransform`)
 *
 * `drawImage` and `Path2D` filling have no rotation parameters, so rotated
 * sprite/path particles need a context transform. Instead of a
 * `save()`/`restore()` stack we compose the full matrix (DPR × translate ×
 * rotate × tumble-scale) in plain arithmetic and issue exactly **two**
 * `setTransform` calls per custom particle: one to place it, one to restore
 * the base DPR transform — cheaper than a state stack and consistent with
 * the vector shapes' manual math.
 */
import type { Particle, PathShape, SpriteEntry, SpriteShape } from "../types";
import { drawBuiltin } from "../shapes/builtin";
 
/** Drawn size (px at `scalar: 1`) of a {@link PathShape} unit box when the shape omits `size`. */
export const DEFAULT_PATH_SHAPE_SIZE = 10;
 
/**
 * Cache of rasterized sprites for sprite shapes, keyed by
 * {@link SpriteShape.key}. One entry per key — rasterization happens once,
 * every subsequent frame is a plain `drawImage`.
 *
 * Internal to the engine (each engine owns one) — not part of the public
 * `./headless` surface.
 */
export type SpriteCache = Map<string, SpriteEntry>;
 
/**
 * Create an empty {@link SpriteCache}. Internal — the engine calls this
 * once at construction.
 *
 * @example
 * ```ts
 * // Inside createConfettiEngine:
 * const sprites = createSpriteCache(); // filled lazily on first draw per key
 * ```
 */
export function createSpriteCache(): SpriteCache {
  return new Map();
}
 
/**
 * Draw one particle with the current context state. Applies the wobble
 * offset, opacity, color, tumble (`scaleY = cos(tumblePhase)`), rotation,
 * and shape. `dpr` is the device-pixel-ratio of the base transform — the
 * custom-shape branches compose it into their `setTransform` calls and
 * restore it afterwards.
 *
 * Sprite shapes are looked up in `sprites` by key; a cache miss calls
 * `rasterize(scalar)` — `null` results ("not ready", e.g. an image still
 * decoding) are skipped without caching so they retry on a later frame.
 * Sprite draw size is `entry.width/height × particle.scalar`.
 *
 * Internal — called only from {@link drawFrame}.
 *
 * @example
 * ```ts
 * // Inside drawFrame's pass over the alive particles:
 * renderParticle(ctx, particles[i], sprites, dpr);
 * ```
 */
export function renderParticle(
  ctx: CanvasRenderingContext2D,
  p: Particle,
  sprites: SpriteCache,
  dpr: number = 1,
): void {
  const x = p.x + Math.cos(p.wobblePhase) * p.wobbleAmp;
  const y = p.y;
  ctx.globalAlpha = p.opacity;
  const scaleY = Math.cos(p.tumblePhase);
 
  if (typeof p.shape === "string") {
    ctx.fillStyle = p.color;
    drawBuiltin(ctx, p.shape, x, y, p.rotation, scaleY, p.scalar);
    return;
  }
 
  const cos = Math.cos(p.rotation);
  const sin = Math.sin(p.rotation);
 
  if ("rasterize" in p.shape) {
    // Sprite shape (textShape / imageShape).
    const shape: SpriteShape = p.shape;
    let entry = sprites.get(shape.key);
    if (!entry) {
      const rastered = shape.rasterize(p.scalar);
      if (!rastered) return; // not ready yet — retry next frame, don't cache
      sprites.set(shape.key, rastered);
      entry = rastered;
    }
    const w = entry.width * p.scalar;
    const h = entry.height * p.scalar;
    // M = dpr · translate(x, y) · rotate(θ) · scale(1, scaleY)
    ctx.setTransform(
      dpr * cos,
      dpr * sin,
      -dpr * sin * scaleY,
      dpr * cos * scaleY,
      dpr * x,
      dpr * y,
    );
    ctx.drawImage(entry.source, -w / 2, -h / 2, w, h);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // restore base
    return;
  }
 
  // Path shape: unit-box Path2D, filled with the particle's own color.
  const shape: PathShape = p.shape;
  const path = shape.path();
  if (!path) return; // Path2D unavailable — skip silently
  const size = (shape.size ?? DEFAULT_PATH_SHAPE_SIZE) * p.scalar;
  const sy = size * scaleY;
  ctx.fillStyle = p.color;
  // M = dpr · translate(x, y) · rotate(θ) · scale(size, size·scaleY) · translate(-0.5, -0.5)
  ctx.setTransform(
    dpr * cos * size,
    dpr * sin * size,
    -dpr * sin * sy,
    dpr * cos * sy,
    dpr * (x - 0.5 * cos * size + 0.5 * sin * sy),
    dpr * (y - 0.5 * sin * size - 0.5 * cos * sy),
  );
  ctx.fill(path);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // restore base
}
 
/**
 * Render a full frame: one `clearRect` over the CSS-pixel viewport, then
 * one draw pass over `particles`. Resets `globalAlpha` to `1` afterwards.
 *
 * Internal — the engine calls this once per rAF frame.
 *
 * @example
 * ```ts
 * // Inside the engine's frame():
 * drawFrame(ctx, alive, viewWidth, viewHeight, sprites, dpr);
 * ```
 */
export function drawFrame(
  ctx: CanvasRenderingContext2D,
  particles: readonly Particle[],
  width: number,
  height: number,
  sprites: SpriteCache,
  dpr: number = 1,
): void {
  ctx.clearRect(0, 0, width, height);
  for (let i = 0; i < particles.length; i++) {
    renderParticle(ctx, particles[i], sprites, dpr);
  }
  ctx.globalAlpha = 1;
}