ITエンジニア勉強ブログ

自分が学んだことを共有していきます。

JavaScriptでライフゲームを実装

何番煎じかわかりませんが、JavaScriptライフゲームを実装してみました。


See the Pen
Game of Life
by Imai (@imai1)
on CodePen.

筆者のJavaScriptの文法・機能の把握のための習作ですが、既に世にある実装例よりも設計にもこだわってみました。

ライフゲームとは、二次元空間上に分布する生物集団の反映を(極めて単純化して)模したシミュレーションの一種です。詳しく知りたい方は以下のリンクなどを参考にしてください。
ja.wikipedia.org

コード解説

GameOfLifeクラスと全体構成

コメントの通り、二次元盤面、状態更新ルール、盤面の描画、をそれぞれ3つのクラスに分け、それらをGameOfLifeクラスで合成して管理します。

/**
 * Game of Life 全体の管理クラス。
 * Board, Rule, ViewをStrategyパターン風に合成する。
 */
class GameOfLife {
  constructor(width, height, rule, view) {
    this.board = new Board(width, height);
    this.rule = rule;
    this.view = view;
  }
  initialize() { ... }
  update() { ... }
  draw() { ... }
}

このようにクラスを設計したのは、部品の追加や入れ替えを簡単にするためです。例えば、オリジナルのルール以外の更新ルールを使用したい場合は、新しいルールクラスを実装してruleパラメータに与えるだけで変更できます。他にも盤面の状態をcanvasタグではなくtableタグで描画したり、2次元盤面を端と端が繋がっているトーラス状に変更したりする場合も同様です。

各種クラスのインスタンスを作成して、一定時間ごとに描画を実行するための設定部分はこちらです。

// 実行部分

const CANVAS_ID = "canvas";
const BOARD_WIDTH = 100;
const BOARD_HEIGHT = 100;
const PIXEL_SIZE = 3;
const INTERVAL_MILLISECONDS = 100;

const rule = new OriginalRule();
const view = new CanvasView(CANVAS_ID, PIXEL_SIZE);
const gameOfLife = new GameOfLife(BOARD_WIDTH, BOARD_HEIGHT, rule, view);
gameOfLife.initialize();
gameOfLife.draw();

function update() {
  gameOfLife.update();
  gameOfLife.draw();
}

setInterval(update, INTERVAL_MILLISECONDS);

CodePen以外でこのコードを使用する場合は、この処理も関数で包んだ方が良いかもしれません。

CanvasViewクラス

/**
 * Boardの状態をcanvasタグに反映する描画クラス。
 */
class CanvasView {
  constructor(id, pixelSize=1) {
    this.id = id;
    this.pixelSize = pixelSize;
  }
  draw(board) {
    const canvas = document.getElementById(this.id);
    const ctx = canvas.getContext("2d");

    ctx.clearRect(0, 0, canvas.width, canvas.height);
  
    for (let y = 0; y < board.height; ++y) {
      for (let x = 0; x < board.width; ++x) {
        if (board.getValue(x, y) == 1) {
          const px = x * this.pixelSize;
          const py = y * this.pixelSize;
          ctx.fillRect(px, py, this.pixelSize, this.pixelSize);
        }
      }
    }
  }
}

CanvasViewは盤面の状態を指定したIDのcanvasタグに描画するクラスです。1つのセルの一辺の長さをpixelSizeピクセルとして、左上を原点とした向きに盤面を描画します。

前述の通り、同じインターフェースの別のクラスを作成することで、canvas以外の描画方法を実装できます。tabledivを利用した実装例がWeb上にありました。盤面の表現をDOMに移す利点の一つは、CSSで見た目を制御できるようになることです。例えばセルに色をつけたり模様をつけたりなど。

OriginalRuleクラス

/**
 * Conwayのオリジナルルールによってセルの次状態を計算するクラス。
 */
class OriginalRule {
  nextValue(currentValue, sumNeighbors) {
    if (currentValue == 1) {
      if (sumNeighbors == 2 || sumNeighbors == 3) {
        return 1;
      } else {
        return 0;
      }
    } else {
      if (sumNeighbors == 3) {
        return 1;
      } else {
        return 0;
      }
    }
  }
}

Conwayのオリジナルルールを愚直に実装したものです。

  • 現在の状態が生存 (1) のとき、
    • 隣接セルの生存数が2か3であれば、次状態も生存のままになる。
    • それ以外では次状態は死滅になる。
  • 現在の状態が死滅 (0) のとき、
    • 隣接セルの生存数がちょうど3であれば、次状態は生存になる。
    • それ以外では次状態は死滅のままになる。

こちらも前述の通り、別のルールクラスを作成することで、様々なバリエーションを実装することができます。

細かいことを言うと、non-totalisticなルール(隣接セルの生存数ではなく、隣接セルの形によって次状態が決まるルール)まで含めて抽象化するにはこの設計ではマズイのですが、そこまで拡張できるようにするとRuleがBoardに依存する形になってしまうため、シンプルさ・疎結合を優先しました。

また、totalisticな範囲でのルール変更については、別のクラスを作成するのではなく、コンストラクタの引数でルールを制御できるようにしても良いかもしれません。現状態と隣接セルの生存数を添字にとって、次状態の生き死にを表す2x9のブーリアン二次元配列でルールを表現できます。

Boardクラス

/**
 * 2次元盤面クラス。
 */
class Board {
  constructor(width, height) {
    this.cell = new Array(width * height);
    this.width = width;
    this.height = height;
  }
  getValue(x, y) {
    return this.cell[y * this.width + x];
  }
  setValue(x, y, value) {
    this.cell[y * this.width + x] = value;
  }
  sumNeighbors(x, y) {
    const xMin = Math.max(0, x - 1);
    const xMax = Math.min(this.width - 1, x + 1);
    const yMin = Math.max(0, y - 1);
    const yMax = Math.min(this.height - 1, y + 1);

    let sum = 0;
    for (let y2 = yMin; y2 <= yMax; ++y2) {
      for (let x2 = xMin; x2 <= xMax; ++x2) {
        sum += this.getValue(x2, y2);
      }
    }
    sum -= this.getValue(x, y);

    return sum;
  }
}

二次元座標によってセルの盤面を管理するクラスです。sumNeighborsのように、更新ルールに依存しない盤面の便利機能はここに追加していくのが良いと思います。端がトーラスなのかどうかや、他にも非ユークリッド的?な変わった盤面構造を実現したい場合も、別種のBoardクラスを作成すれば他の部分は修正せずに実現できるのではないでしょうか。

終わりに

将来JavaScriptに関する理解が深まったら、またこのコードを見直して、リファクタリングしてみようと思います。