Katashin .info

State パターンでアニメーションの挙動を制御する

あるオブジェクトが絶えずアニメーションをするようなコードを書く時、様々な状態に応じて挙動を変えたいというのはよくあることだと思います。単純に実装すると、状態に対応するフラグを記録しておき、それに応じて条件分岐するという書き方になると思います。しかし、状態の数が増えると、フラグ管理が大変になり、コードがどんどん汚くなっていきます。そこで、State パターンのように、状態ごとに別々の挙動を行う関数を定義し、オブジェクトの状態が遷移した時に実行する関数を切り替えるようにすると、コードが状態ごとに整理され、見やすくなります。

アニメーションにおける条件分岐のつらさ #

例えば、下記のように、矢印キーで動かすことができ、壁にぶつかると跳ね返るような Ball オブジェクトについて考えます。Ball は update() メソッドを持ち、requestAnimationFrame() で毎回このメソッドが呼ばれます。cx, cy が Ball の中心の座標で、vx, vyupdate() ごとに cx, cy に加算される値 (速度) です。矢印キーを押すと ax, ay (加速度) が更新され、押した方向へとボールが動きます。簡単のために、DOM への要素の追加などのコードは省いています。

const viewportHeight = 300
const viewportWidth = 400

class Ball {
  constructor() {
    // 中心点の座標
    this.cx = 200
    this.cy = 150

    // 速度
    this.vx = this.vy = 0

    // 加速度
    this.ax = this.ay = 0

    // ボールの半径
    this.r = 10
  }

  update() {
    // 壁にぶつかったら跳ね返る
    if (this.cx - this.r < 0 || this.cy + this.r > viewportWidth) {
      this.vx *= -1
    }
    if (this.cy - this.r < 0 || this.cy + this.r > viewportHeight) {
      this.vy *= -1
    }

    this.vx += this.ax
    this.vy += this.ay
    this.cx += this.vx
    this.cy += this.vy
  }

  accelerate(code) {
    this.ax = this.ay = 0
    switch (code) {
      case 37: // left
        this.ax = -0.1
        break
      case 38: // up
        this.ay = -0.1
        break
      case 39: // right
        this.ax = 0.1
        break
      case 40: // down
        this.ay = 0.1
        break
      default: // do nothing
    }
  }
}

let b = new Ball()
$(window)
  .on('keydown', (e) => b.accelerate(e.which))
  .on('keyup', () => b.accelerate())

animate()

function animate() {
  b.update()
  requestAnimationFrame(animate)
}

この Ball オブジェクトを拡張して、一定の速度以上の時は壁にぶつからなくするようにしたり、マウスポインターを乗せたときに動きを止め何らかのエフェクトをかけたりすると、条件分岐が増えてつらくなってきます。書いた直後は良いのですが、時間が立つにつれ、各条件分岐について完全に把握することは難しくなり、コードの変更の際、バグを混入させる確率が上がります。

class Ball {
  ...
  update() {
    if (/* マウスホバーしていたら */) {
      /* 何かのエフェクト */
      return;
    }

    if (/* 速度が一定以下なら */) {
      // 壁にぶつかったら跳ね返る
      if (this.cx - this.r < 0 || this.cy + this.r > viewportWidth) {
        this.vx *= -1;
      }
      if (this.cy - this.r < 0 || this.cy + this.r > viewportHeight) {
        this.vy *= -1;
      }
    }

    this.vx += this.ax;
    this.vy += this.ay;
    this.cx += this.vx;
    this.cy += this.vy;
  }
  ...
}

条件分岐の代わりに State パターンを使う #

State パターンを使うと、上記の Ball オブジェクトの挙動をよりすっきりとしたコードで書くことができます。State パターンでは、各状態ごとにクラスを定義し、ある状態の時のオブジェクトの挙動は、対応する状態のクラスのみに記述されます。つまり、オブジェクトの挙動をそのオブジェクトのクラスに書くのではなく、状態のクラスに書くという点が特徴です。

上記の Ball オブジェクトの挙動を State パターンで書くと以下のようになります。まず、各状態ごとの挙動を定義した関数を作ります。次に Ball オブジェクトに新たに behavior というプロパティを追加します。behavior は関数が入るプロパティであり、update() メソッドの中で毎回呼ばれます。そして、状態が切り替わるごとに、behavior に状態に対応した関数を代入するようにします。今回の例では、update の内部以外に状態によって挙動が変わる部分がないため、関数自体を切り替えています。完全なコードは CodePen に置いておきました。Simple Ball Animation with State Pattern

class Ball {
  constructor() {
    this.behavior = moveBehavior;
  }

  update() {
    this.behavior(this);
  }
}

function moveBehavior(ball) {
  /* 矢印キーで動かす処理 */

  if (/* 速度が一定以上 */) {
    ball.behavior = leaveBehavior; // 状態を変える
  }
}

function effectBehavior(ball) {
  /* マウスホバーでエフェクトをかける処理 */
}

function leaveBehavior(ball) {
  /* 壁にぶつからずに動く処理 */
}

まとめ #

オブジェクトの update 時に実行される関数を状態ごとに変えることによって、アニメーションの挙動の記述の条件分岐を減らし、コードをすっきりとさせました。単純なアニメーションなら同一のオブジェクトに直接挙動を書けば良いですが、ユーザーの入力が増えたりして、取りうる状態が増えそうであれば、State パターンで書き直したほうが良いように思います。