p5.jsでコンウェイのライフゲーム


2017年10月29日
With
p5.jsでコンウェイのライフゲーム はコメントを受け付けていません

えらく間が開きましたが、今回は久しぶりに「ジェネラティブ・アート」のProcessing向けスケッチをp5.jsに移植したものを公開します。


■ [普及版]ジェネラティブ・アート―Processingによる実践ガイド

むかーしむかし。まだパソコンが「一家に一台」には、ほど遠いぜいたく品だったころ。パソコンが欲しいのだけれど、バイトもできない地方のリアル小坊や中坊は、週末になると市内に数件もない「パソコンショップ」に出かけて一日中店頭デモを眺めたり、書店に数冊しか入荷がない「パソコン雑誌」を買って、次の号が出るまで何度も何度も読み返したりすることでしか、欲望を昇華させることができませんでした。

そんな当時の思い出の中で、僕がゲーム以外に「コンピュータぽいカッコ良さ」を強烈に感じたのが「マンデルブロ集合(フラクタルイメージ)」や「ライフゲーム(セル・オートマトン)」でした。

そのビジュアルがコンピュータ関連の本や雑誌の表紙に使われるようなこともありましたし、サンプルプログラムが出ていたこともありました。ちょっとパソコンに詳しい店員がいるショップでは、デモとしてエンドレスで走らせているところもありましたね。

もちろん、当時はその背景にある数式や理論について、まったく知識がなかったのですが(今でも似たようなものですけど)、ディスプレイに表示される美しくて不思議なパターンの数々に、意味はよく分からないながらも心揺さぶられつつ、「いつか、あんなグラフィックを描くプログラムを、自分のパソコンで動かしてみたい!」と目を輝かせながら、強く思っていたのであります。

結局、自分のパソコンを手に入れることができたのは二十歳を過ぎてからなのですが、その頃には、幼少期のそんな真摯な思いもどこへやら。プログラムで幾何学的な画像を描くことよりも、パソコン通信で扇情的な画像(主に性的な意味で)を集めることに血道をあげる汚れきったオトナになっていましたとさ。

さて「ジェネラティブ・アート」では、第7章の「自律性」で、コンウェイのライフゲームとその派生形を中心とした「セル・オートマトン(CA)」。第8章では「フラクタル」の基本的なアルゴリズムと、Processingによる実装例が紹介されています。

今回はまず「コンウェイのライフゲーム」のp5.js移植版について。ライフゲームやCAについての詳しい説明はWikipediaにありますので、興味と度胸がある方は参照してみてください。Wikipediaの説明は結構難しいですが、アルゴリズムそのものはとてもシンプルです。

ライフゲーム(Wikipedia)
セル・オートマトン(Wikipedia)

●[DEMO] Conway’s Game of Life for p5.js(クリックすると別タブが開きます)

ライフゲームの基本的なアルゴリズムは、格子状に並んだセル(画面では黒丸もしくは白丸のいずれかとして表示)の1個ずつについて、

1.生きているセル(黒丸)の周囲に隣接する8つのセルのうち、2つもしくは3つが生きていれば、次世代(次のフレーム)でも生き続ける。もし、1つ以下(過疎)もしくは4つ以上(過密)の場合は死亡する(白丸になる)。

2.死んでいるセル(白丸)の周囲に、ちょうど3つの生きているセル(黒丸)があれば、次世代(次のフレーム)に生命が生まれる(黒丸になる)。

というルールに従って次フレームの生死状態を決定し、画面を描き換えていくというものになります。ランダム要素はスタート時の各セルの生死状態のみで、2フレーム目以降は上記のルールに従って変化します。ちなみに、このスケッチではcanvasの上下、左右がつながっている、いわゆる「トーラス型」のループフィールドを設定しています。

初期状態から、あっという間に固定された状態に収束してしまう場合もあれば、いったん収束すると見せかけておきながら、隅っこのほうで変化していたセル群の影響が画面全体にぶわっと拡大したりと、いろんなパターンを観察できます。長いこと眺めていても、不思議と飽きません。状況が収束しきってしまったら、ブラウザ上でマウスクリックしてやると、初期状態をセットし直して再スタートします。

2次元配列の扱いにちょっとだけ注意

Processing(Java)からp5.js(JavaScript)への移植にあたって、ちょっとだけつまずいたのは「2次元(多次元)配列」の扱いでした。Processing向けのコードでは、1行目に

Cell[][] _cellArray;

と書いてあり、_cellArrayを「オブジェクト(Cell)型の2次元配列」として一発で定義してしまっていたのですが、JavaScriptではこのやり方が使えません。

そこで、最初に_cellArrayをグローバルの変数として宣言だけしておき、setup()内で、まず「横に並ぶセル数の要素を持つ1次元配列」にしてから、さらに「各要素に縦に並ぶセル数分の要素を持つ配列を代入」することで2次元配列化するという、ちょっと面倒くさい初期化処理をしています。

プログラムでは、この後「restart()」という関数で、各要素に「Cell」オブジェクトのインスタンスを収めてから「draw()」のループに移ります。今回の場合、オブジェクトは2重のfor文を使って順に入れているので特に問題は起きないのですが、JavaScriptで多次元配列を使う場合、配列を用意する時(もしくは用意した直後)、何らかの方法でそのすべてに仮でもいいので値を入れおかないと、例えば「_cellArray[4][5]」のような形で特定の要素を参照したい場合に「undefined」となってしまい、うまく動いてくれません。このあたり、Processingからp5.jsへの移植にあたっては注意しておいたほうがいいポイントかと思います。

汎用性に優れたCAフレームワーク

このブログでは、ずいぶん前に、CreateJSで作った「ライフゲーム」を紹介したことがありました。

CreateJSでConway’s Game of Life(2015/5/17)

世代更新のルールは一緒なのですが、プログラムとしての実装方法は、前回のものと今回のものとで、かなり違っています。以前のものは、セルの状態を、画面に表示されているセル数分の要素を持つ、ながーい1次元配列として管理しており、canvasサイズと1セルのサイズから折り返し位置を決め、周辺セルの要素を算出するという仕組みになっていました。あと、1次元配列全体をコピーすることで前世代の状態を保持していたり、上下左右がつながっていない有限フィールドであったりしたといった部分も違いますね。

「ジェネラティブ・アート」のプログラムは、canvasのサイズと1セルのサイズから縦と横に詰めるセルの数を出し、それに対応した2次元配列を用意して、それぞれに、自分の「位置」や「今世代および前世代の生死状態」「周辺セルの状況」といったプロパティと、「ステータスを更新」「描画」するメソッドを持ったCellオブジェクトを収めるという設計になっています。

この設計、CAフレームワークとしての汎用性にとても優れていて、例えば「ステータスを更新」するメソッド(calcNextState)の中身を書き換えてやることで、世代更新のルールを独自のものに変更したり、追加したりすることが簡単にできます。

実際、本の中では、基本となる「コンウェイのライフゲーム」のオブジェクト定義部分を少しずつ変えながら「ブライアンの脳」「ヴィシュニアク・ヴォート」「波(平均化)」といった、まったく見栄えの違うビジュアルを生みだす3つのスケッチを派生させています。これらについても非常に面白いので、近々ご紹介しようと思います。

var _cellSize = 10;
var _numX, _numY;
var _cellArray;

function setup() {
    createCanvas(600, 600);
    frameRate(15);
    _numX = floor(width / _cellSize);
    _numY = floor(height / _cellSize);
    _cellArray = [_numY];
    for (var i = 0; i < _numX; i++) {
        _cellArray[i] = [_numX];
    }
    restart();
}

function restart() {
    for (var x = 0; x < _numX; x++) {
        for (var y = 0; y < _numY; y++) {
            var newCell = new Cell(x, y);
            _cellArray[x][y] = newCell;
        }
    }
    for (var x = 0; x < _numX; x++) {
        for (var y = 0; y < _numY; y++) {
            var above = y - 1;
            var below = y + 1;
            var left = x - 1;
            var right = x + 1;
            if (above < 0) { above = _numY - 1; }
            if (below == _numY) { below = 0; }
            if (left < 0) { left = _numX - 1; }
            if (right == _numX) { right = 0; }
            _cellArray[x][y].addNeighbour(_cellArray[left][above]);
            _cellArray[x][y].addNeighbour(_cellArray[left][y]);
            _cellArray[x][y].addNeighbour(_cellArray[left][below]);
            _cellArray[x][y].addNeighbour(_cellArray[x][below]);
            _cellArray[x][y].addNeighbour(_cellArray[right][below]);
            _cellArray[x][y].addNeighbour(_cellArray[right][y]);
            _cellArray[x][y].addNeighbour(_cellArray[right][above]);
            _cellArray[x][y].addNeighbour(_cellArray[x][above]);
        }
    }
}

function draw() {
    background(200);
    for (var x = 0; x < _numX; x++) {
        for (var y = 0; y < _numY; y++) {
            _cellArray[x][y].calcNextState();
        }
    }
    translate(_cellSize / 2, _cellSize / 2);
    for (var x = 0; x < _numX; x++) {
        for (var y = 0; y < _numY; y++) {
            _cellArray[x][y].drawMe();
        }
    }
}

function mousePressed() {
    restart();
}

function Cell(ex, why) {
    this.x = ex * _cellSize;
    this.y = why * _cellSize;
    if (random(2) > 1) {
        this.nextState = true;
    } else {
        this.nextState = false;
    }
    this.state = this.nextState;
    this.neighbours = [];
}

Cell.prototype.addNeighbour = function (cell) {
    this.neighbours.push(cell);
}

Cell.prototype.calcNextState = function () {
    var liveCount = 0;
    for (var i = 0; i < this.neighbours.length; i++) {
        if (this.neighbours[i].state == true) {
            liveCount++;
        }
    }
    if (this.state == true) {
        if ((liveCount == 2) || (liveCount == 3)) {
            this.nextState = true;
        } else {
            this.nextState = false;
        }
    } else {
        if (liveCount == 3) {
            this.nextState = true;
        } else {
            this.nextState = false;
        }
    }
}

Cell.prototype.drawMe = function () {
    this.state = this.nextState;
    stroke(0);
    strokeWeight(1);
    if (this.state == true) {
        fill(0);
    } else {
        fill(255);
    }
    ellipse(this.x, this.y, _cellSize, _cellSize);
}