みかづきブログ・カスタム

基本的にはちょちょいのほいです。

TypeScriptでCanvasに紙吹雪を舞い散らせる 🎉

5年前ぐらい前につくった紙吹雪をJavaScriptからTypeScriptに移植してみました。
Canvasに紙吹雪をレンダリングする基本方針はそのままに、ちょっとだけ挙動を変更しています。
具体的には、TypeScriptバージョンは紙吹雪が舞い散り終わった後、ループさせずにCanvasをremoveChildするようにしました。

JavaScriptバージョン

See the Pen Confetti by kimmy (@kimmy) on CodePen.

TypeScriptバージョン

See the Pen Confetti by kimmy (@kimmy) on CodePen.

※ ループしないので、舞い散り終わっている可能性が高いです。
※ 舞い散る様子は https://codepen.io/kimmy/pen/abKMvxR/ をご確認ください


ソースコード

class Progress {
  timestamp: number | null;
  duration: number;
  progress: number;
  delta: number;
  isLoop: boolean;

  constructor({
    duration,
    isLoop,
  }: {
    duration?: number;
    isLoop?: boolean;
  } = {}) {
    this.timestamp = null;
    this.duration = duration || Progress.CONST.DURATION;
    this.progress = 0;
    this.delta = 0;
    this.isLoop = !!isLoop;

    this.reset();
  }

  static get CONST() {
    return {
      DURATION: 8000,
    };
  }

  reset() {
    this.timestamp = null;
  }

  start(now: number) {
    this.timestamp = now;
  }

  tick(now: number) {
    if (this.timestamp) {
      this.delta = now - this.timestamp;
      this.progress = Math.min(this.delta / this.duration, 1);

      if (this.progress >= 1 && this.isLoop) {
        this.start(now);
      }

      return this.progress;
    } else {
      return 0;
    }
  }
}

type Position = {
  initX: number;
  initY: number;
};

class Sprite {
  canvas: HTMLCanvasElement;
  position: Position;
  rotation: number;
  speed: number;

  constructor({
    canvas,
    position,
    rotation,
    speed,
  }: {
    canvas: HTMLCanvasElement;
    position: Position;
    rotation: number;
    speed: number;
  }) {
    this.canvas = canvas;
    this.position = position;
    this.rotation = rotation;
    this.speed = speed;
  }
}

export class Confetti {
  parent: HTMLElement | HTMLBodyElement;
  canvas: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;
  width: number;
  height: number;
  length: number;
  yRange: number;
  progress: Progress;
  rotationRange: number;
  speedRange: number;
  sprites: Sprite[];

  constructor({
    elm,
    width,
    height,
    length,
    yRange,
    duration,
    isLoop = false,
    rotationRange,
    speedRange,
  }: {
    elm?: HTMLElement | HTMLBodyElement;
    width?: number;
    height?: number;
    length?: number;
    yRange?: number;
    duration?: number;
    isLoop?: boolean;
    rotationRange?: number;
    speedRange?: number;
  } = {}) {
    this.parent = elm || document.body;
    this.canvas = document.createElement('canvas');
    this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
    this.width = width || this.parent.offsetWidth;
    this.height = height || this.parent.offsetHeight;
    this.length = length || Confetti.CONST.PAPER_LENGTH;
    this.yRange = yRange || this.height * 2;
    this.progress = new Progress({
      duration,
      isLoop,
    });
    this.rotationRange =
      typeof rotationRange === 'number'
        ? rotationRange
        : Confetti.CONST.ROTATION_RANGE;
    this.speedRange =
      typeof speedRange === 'number' ? speedRange : Confetti.CONST.SPEED_RANGE;
    this.sprites = [];

    this.canvas.style.cssText = [
      'display: block',
      'position: absolute',
      'top: 0',
      'left: 0',
      'pointer-events: none',
    ].join(';');

    this.render = this.render.bind(this);

    this.build();

    this.parent.appendChild(this.canvas);
    this.progress.start(performance.now());

    requestAnimationFrame(this.render);
  }

  static get CONST() {
    return {
      SPRITE_WIDTH: 9,
      SPRITE_HEIGHT: 16,
      PAPER_LENGTH: 100,
      DURATION: 8000,
      ROTATION_RATE: 50,
      ROTATION_RANGE: 10,
      SPEED_RANGE: 10,
      COLORS: [
        '#EF5350',
        '#EC407A',
        '#AB47BC',
        '#7E57C2',
        '#5C6BC0',
        '#42A5F5',
        '#29B6F6',
        '#26C6DA',
        '#26A69A',
        '#66BB6A',
        '#9CCC65',
        '#D4E157',
        '#FFEE58',
        '#FFCA28',
        '#FFA726',
        '#FF7043',
        '#8D6E63',
        '#BDBDBD',
        '#78909C',
      ],
    };
  }

  build() {
    for (let i = 0; i < this.length; ++i) {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;

      canvas.width = Confetti.CONST.SPRITE_WIDTH;
      canvas.height = Confetti.CONST.SPRITE_HEIGHT;

      ctx.save();
      ctx.fillStyle =
        Confetti.CONST.COLORS[
          (Math.random() * Confetti.CONST.COLORS.length) | 0
        ];
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.restore();

      this.sprites.push(
        new Sprite({
          canvas,
          position: {
            initX: Math.random() * this.width,
            initY: -canvas.height - Math.random() * this.yRange,
          },
          rotation: this.rotationRange / 2 - Math.random() * this.rotationRange,
          speed: this.speedRange / 2 + Math.random() * (this.speedRange / 2),
        }),
      );
    }
  }

  render(now: number) {
    const progress = this.progress.tick(now);

    this.canvas.width = this.width;
    this.canvas.height = this.height;

    for (let i = 0; i < this.length; ++i) {
      this.ctx.save();
      this.ctx.translate(
        this.sprites[i].position.initX +
          this.sprites[i].rotation * Confetti.CONST.ROTATION_RATE * progress,
        this.sprites[i].position.initY + progress * (this.height + this.yRange),
      );
      this.ctx.rotate(this.sprites[i].rotation);
      this.ctx.drawImage(
        this.sprites[i].canvas,
        (-Confetti.CONST.SPRITE_WIDTH *
          Math.abs(Math.sin(progress * Math.PI * 2 * this.sprites[i].speed))) /
          2,
        -Confetti.CONST.SPRITE_HEIGHT / 2,
        Confetti.CONST.SPRITE_WIDTH *
          Math.abs(Math.sin(progress * Math.PI * 2 * this.sprites[i].speed)),
        Confetti.CONST.SPRITE_HEIGHT,
      );
      this.ctx.restore();
    }

    if (progress < 1) {
      requestAnimationFrame(this.render);
    } else {
      this.parent.removeChild(this.canvas);
    }
  }
}

使い方

new Confetti();

色々パラメータを渡せるようにはなっていますが、とりあえず、newするだけでも、

  1. bodyにcanvasを生成
  2. 8秒かけて100枚紙吹雪を舞い散らせる
  3. 舞い散らせた後にcanvasを削除

という動作をするようにしています。