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

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

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,
    rotationRange,
    speedRange
  }: {
    elm?: HTMLElement | HTMLBodyElement;
    width?: number;
    height?: number;
    length?: number;
    yRange?: number;
    duration?: number;
    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: false
    });
    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を削除

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