🤔

ライフゲームを3Dで作ってみたらどんな感じか見てみたかった

2023/12/24に公開

経緯

最近プログラミングのアルゴリズム問題を解くことに楽しさを見出したこのごろ。

何かやりたいなと思って、まず個人的に有名なライフゲームを作ってみようかなということで作ってみました。

ただHTMLのDiv要素などを並べるようなのはちょっとめんどくさそうだし、何番煎じなのかわからんということで、3Dで実装してみたら色んな角度から見れて新しい発見できるかなと思いました。

ということで、PlayCanvasを使ってライフゲームを実装してみました。

できたもの

↓ここでコンテンツを体験できます
https://playcanv.as/p/n6bOimLD/

制作内容

制作した環境はPlayCanvas

3Dコンテンツを作るなら持ってこいのツールです。

https://playcanvas.jp/

プロジェクト

制作したプロジェクトは公開しています。

https://playcanvas.com/project/1176158/

閲覧するにはPlayCanvasアカウントが必要なのでご留意ください

ライフゲームの処理

各メソッドごとに簡単に解説していきます。

スクリプト属性

エディターから変更することもできる、処理を実行するための前提となるスクリプト属性。
ここでは以下を指定することができます。

  • time : セルを更新する時間の設定
  • model : セルとなる3Dモデルの設定
  • cell : セルを幾つ並べるのか幅と高さを指定
GameOflife.attributes.add('time', {type: 'number', default: 1, description: '更新する秒間指定', title: 'time(s)'});
GameOflife.attributes.add('model', {type: 'entity', description: 'セルとなる複製するエンティティを選択'})
GameOflife.attributes.add('cell', {type: 'json', description: 'セルを並べる幅と高さを指定',
    schema: [{
        name: 'width',
        type: 'number',
        default: 10
    }, {
        name: 'height',
        type: 'number',
        default: 10
    }]
});

↓スクリプト属性についてはこちらから
https://developer.playcanvas.com/ja/user-manual/scripting/script-attributes/

initialize

スクリプトが最初に一度実行するメソッドです。

this.updateTimerはこのあと紹介する update という毎フレーム処理を実行するメソッドでマイフレームから指定した秒数で処理するために使用します。
this.secondsTimerも同様の処理のために update で使用されます。こちらは何秒経過したのか総数をカウントします。

this.initCell()は初回にセルを並べる処理を実行します。

他処理は this.app.on('eventname') で他スクリプト間で処理を実行するためにイベントを作成しています。
これらはコンテンツの左上のボタンなどで使用されてます。

GameOflife.prototype.initialize = function() {
    this.playFlag = false;

    this.updateTimer = 0;
    this.secondsTimer = 0;

    this.initCell();

    this.app.on('trigger:play', function(targetEntity) {
        this.playFlag = !this.playFlag;
        targetEntity.element.text = (this.playFlag) ? 'Stop' : 'Play';
    }, this)
    this.app.on('trigger:reset', function() {
        for(let i = 0; i < this.cell.height; ++i){
            for(let j = 0; j < this.cell.width; ++j) {
                this.state[i][j].entity.enabled = false;
                this.state[i][j].flag = 0;
            }
        }
    }, this)
    this.app.on('trigger:random', this.randomCell, this)
    this.app.on('trigger:gliderGun', this.gliderGun, this)
};

↓PlayCanvasのスクリプト構造についてはこちらから
https://developer.playcanvas.com/ja/user-manual/scripting/anatomy/

initCell

initialize でも軽く触れましたが、初回にセルを並べる処理を実行します。
スクリプト属性で設定した複製する3Dモデルを clone() で複製し、ポジションも各々設定し並べています。

this.stateに並べたセルの情報をまとめて、この後も処理するために設定しています。
配列としていますが、 entity と flag を設定します。
entity には3Dモデルの情報、 flag には 1 か 0 の数値を入れるように設定します。
生きているセルなら 1 、死んでいるセルなら 0 を設定するようにしています。
この数値は周囲で生きているセルを参照するために使用されます。

細かいですが、セルのポジションを 1/2 にすることで必ずカメラが中央になるように設定をしています。

GameOflife.prototype.initCell = function() {
    this.state = new Array();
    for(let i = 0; i < this.cell.height; ++i){
        this.state[i] = new Array();
        for(let j = 0; j < this.cell.width; ++j) {
            let cloneModel = this.model.clone();
            this.state[i][j] = {
                entity: cloneModel,
                flag: 0
            };
            cloneModel.setPosition(j-this.cell.width/2, i-this.cell.height/2, 0);
            this.entity.addChild(cloneModel);
        }
    }
};

copyState と nextCell

nextCellでは、セルの情報を持つthis.stateを更新する処理を実行します。
しかし、この情報のまま更新しようとすると隣接したセルが変わってしまい、正しくライフゲームの条件を実行できません。
このために、 this.state のコピーを作成する必要があるのでcopyState()で愚直ですが同じ情報を持った配列を作ります。
ここでは flag の情報だけ参照するので flag のみ設定しています。

nextCellにはライフゲームのルールを書くセルに処理します。
ライフゲームのルールは以下の通りです。

wikipedia引用

ライフゲームでは初期状態のみでその後の状態が決定される。碁盤のような格子があり、一つの格子はセル(細胞)と呼ばれる。各セルには8つの近傍のセルがある (ムーア近傍) 。各セルには「生」と「死」の2つの状態があり、あるセルの次のステップ(世代)の状態は周囲の8つのセルの今の世代における状態により決定される。

セルの生死は次のルールに従う。

  • 誕生 : 死んでいるセルに隣接する生きたセルがちょうど3つあれば、次の世代が誕生する。
  • 生存 : 生きているセルに隣接する生きたセルが2つか3つならば、次の世代でも生存する。
  • 過疎 : 生きているセルに隣接する生きたセルが1つ以下ならば、過疎により死滅する。
  • 過密 : 生きているセルに隣接する生きたセルが4つ以上ならば、過密により死滅する。

とてもわかりやすいルールなので、このまま条件分岐でルールを当てこんでいます。

セルの生死判定は flag の 1 と 0 で取得しています。

セルの周囲の生死判定の総数をactiveCellsに総計して最終的にルールに当てこみます。

flag から最終的に entity を enabled を切り替えています。
これをすることで3Dモデルが表示非表示されます。

GameOflife.prototype.copyState = function() {
    let copy = new Array(this.cell.height);
    for (let i = 0; i < this.cell.height; ++i) {
        copy[i] = new Array(this.cell.width);
        for (let j = 0; j < this.cell.width; ++j) {
            copy[i][j] = {
                flag: this.state[i][j].flag
            };
        }
    }
    return copy;
};

GameOflife.prototype.nextCell = function() {
    let prevState = this.copyState();
    const maxH = this.cell.height - 1;
    const maxW = this.cell.width - 1;
    for(let i = 0; i <= maxH; ++i){
        for(let j = 0; j <= maxW; ++j) {
            let getCell = prevState[i][j].flag;
            let activeCells = 0;

                if(i > 0 && j >0) { // 左下
                    activeCells += prevState[i-1][j-1].flag;
                }
                if(i > 0) { // 下
                    activeCells += prevState[i-1][j].flag;
                }
                if(i > 0 && j < maxW) { // 右下
                    activeCells += prevState[i-1][j+1].flag;
                }
                if(j > 0) { // 左
                    activeCells += prevState[i][j-1].flag;
                }
                if(j < maxW) { // 右
                    activeCells += prevState[i][j+1].flag;
                }
                if(i < maxH && j > 0) { // 左上
                    activeCells += prevState[i+1][j-1].flag;
                }
                if(i < maxH) { // 上
                    activeCells += prevState[i+1][j].flag;
                }
                if(i < maxH && j < maxW) { // 右上
                    activeCells += prevState[i+1][j+1].flag;
                }

            if(getCell > 0) { // 生きているセル
                 // activeCellsが2または3ならば生存
                if(activeCells === 2 || activeCells === 3) { // 生存
                    this.state[i][j].flag = 1;
                } else if(activeCells <= 1) { // 過疎
                    this.state[i][j].flag = 0;
                } else if(activeCells >= 4) { // 過密
                    this.state[i][j].flag = 0;
                }
            } else { // 死んでいるセル
                if(activeCells === 3) { // 誕生
                    this.state[i][j].flag = 1;
                }
            }
            this.state[i][j].entity.enabled = (this.state[i][j].flag > 0) ? true : false;
        }
    }
}

update

updateは毎フレーム処理を実行します。
先の説明でも軽く紹介していましたが、スクリプト属性で設定したthis.timeの秒数で処理するように対応しています。
その秒数で、nextCell()を実行しています。

this.playFlagはPlayとStopを制御するためのものでした。

GameOflife.prototype.update = function(dt) {
    if(this.playFlag) {
        this.updateTimer += dt;
        if (this.updateTimer >= this.time) {
            this.secondsTimer++;
            this.updateTimer = 0;

            this.nextCell();
        }
    }else{
        this.updateTimer = 0;
    }
};

randomCell と gliderGun

初回時では死んでいるセルしか配置されていないのでライフゲームが始まらないので、ランダムに生死を決定するように設定するためにrandomCellを用意しました。

あと、グライダー銃というものがありまして、これはライフゲームの繁殖パターンの一つで永久に打ち出し続けるものです。

GameOflife.prototype.randomCell = function() {
    for(let i = 0; i < this.cell.height; ++i){
        for(let j = 0; j < this.cell.width; ++j) {
            if(Math.floor(Math.random()*2) >= 1 ) {
                this.state[i][j].entity.enabled = true;
                this.state[i][j].flag = 1;
            } else {
                this.state[i][j].entity.enabled = false;
                this.state[i][j].flag = 0;
            }
        }
    }
};

GameOflife.prototype.gliderGun = function() {
    this.app.fire('trigger:reset', this);
    if(this.cell.width - 36 < 0) { alert('セルをwidth37以上,height10以上で設定してね'); return false }
    this.state[this.cell.height-1][25].entity.enabled = true; this.state[this.cell.height-1][25].flag = 1;
    this.state[this.cell.height-2][23].entity.enabled = true; this.state[this.cell.height-2][23].flag = 1;
    this.state[this.cell.height-2][25].entity.enabled = true; this.state[this.cell.height-2][25].flag = 1;
    this.state[this.cell.height-3][13].entity.enabled = true; this.state[this.cell.height-3][13].flag = 1;
    this.state[this.cell.height-3][14].entity.enabled = true; this.state[this.cell.height-3][14].flag = 1;
    this.state[this.cell.height-3][21].entity.enabled = true; this.state[this.cell.height-3][21].flag = 1;
    this.state[this.cell.height-3][22].entity.enabled = true; this.state[this.cell.height-3][22].flag = 1;
    this.state[this.cell.height-3][35].entity.enabled = true; this.state[this.cell.height-3][35].flag = 1;
    this.state[this.cell.height-3][36].entity.enabled = true; this.state[this.cell.height-3][36].flag = 1;
    this.state[this.cell.height-4][12].entity.enabled = true; this.state[this.cell.height-4][12].flag = 1;
    this.state[this.cell.height-4][16].entity.enabled = true; this.state[this.cell.height-4][16].flag = 1;
    this.state[this.cell.height-4][21].entity.enabled = true; this.state[this.cell.height-4][21].flag = 1;
    this.state[this.cell.height-4][22].entity.enabled = true; this.state[this.cell.height-4][22].flag = 1;
    this.state[this.cell.height-4][35].entity.enabled = true; this.state[this.cell.height-4][35].flag = 1;
    this.state[this.cell.height-4][36].entity.enabled = true; this.state[this.cell.height-4][36].flag = 1;
    this.state[this.cell.height-5][1].entity.enabled = true; this.state[this.cell.height-5][1].flag = 1;
    this.state[this.cell.height-5][2].entity.enabled = true; this.state[this.cell.height-5][2].flag = 1;
    this.state[this.cell.height-5][11].entity.enabled = true; this.state[this.cell.height-5][11].flag = 1;
    this.state[this.cell.height-5][17].entity.enabled = true; this.state[this.cell.height-5][17].flag = 1;
    this.state[this.cell.height-5][21].entity.enabled = true; this.state[this.cell.height-5][21].flag = 1;
    this.state[this.cell.height-5][22].entity.enabled = true; this.state[this.cell.height-5][22].flag = 1;
    this.state[this.cell.height-6][1].entity.enabled = true; this.state[this.cell.height-6][1].flag = 1;
    this.state[this.cell.height-6][2].entity.enabled = true; this.state[this.cell.height-6][2].flag = 1;
    this.state[this.cell.height-6][11].entity.enabled = true; this.state[this.cell.height-6][11].flag = 1;
    this.state[this.cell.height-6][15].entity.enabled = true; this.state[this.cell.height-6][15].flag = 1;
    this.state[this.cell.height-6][17].entity.enabled = true; this.state[this.cell.height-6][17].flag = 1;
    this.state[this.cell.height-6][18].entity.enabled = true; this.state[this.cell.height-6][18].flag = 1;
    this.state[this.cell.height-6][23].entity.enabled = true; this.state[this.cell.height-6][23].flag = 1;
    this.state[this.cell.height-6][25].entity.enabled = true; this.state[this.cell.height-6][25].flag = 1;
    this.state[this.cell.height-7][11].entity.enabled = true; this.state[this.cell.height-7][11].flag = 1;
    this.state[this.cell.height-7][17].entity.enabled = true; this.state[this.cell.height-7][17].flag = 1;
    this.state[this.cell.height-7][25].entity.enabled = true; this.state[this.cell.height-7][25].flag = 1;
    this.state[this.cell.height-8][12].entity.enabled = true; this.state[this.cell.height-8][12].flag = 1;
    this.state[this.cell.height-8][16].entity.enabled = true; this.state[this.cell.height-8][16].flag = 1;
    this.state[this.cell.height-9][13].entity.enabled = true; this.state[this.cell.height-9][13].flag = 1;
    this.state[this.cell.height-9][14].entity.enabled = true; this.state[this.cell.height-9][14].flag = 1;
    };

グライダー銃について
https://ja.wikipedia.org/wiki/グライダー銃

総評

以上がライフゲームの実装でした。

アルゴリズムとしては少しもの足りなかったですが、3Dは新鮮味があって面白いかったです。

PlayCanvasはこういうプロトを作るのはとても簡単で助かります…
あと、ビジュアルをすぐに出せるのはやはりいいですね。

ライフゲームも実装方法について、軽く紹介もできてよかったです。
実装してみて分かりますが、意外と簡単に作れたなという感想でした。

Webとかの実装以外にもこういうアルゴリズム的な処理も面白いので、ぜひ皆さんも試してみてください。

Discussion