Canvasでライフゲームを作ってみた

Canvasでライフゲームを作ってみたライフゲームが何かは知っていたし、いくつかの有名なパターンの動きも見たことがあったはずだけど、自分で適宜セルの生死をトグルしたり、パターンをロードしたりできると、思ったよりも面白かった。そして、iPadAndroidでも、全く同じコードがちゃんと動くというのが改めてすごいなと思ったりした。

JavaScriptにはクラスはないし、オブジェクトはある意味シングルトン。今の場合、クラスもプロトタイプも不要だろうと思って、ゲームのグリッドはWorldオブジェクト、CanvasのグリッドはGridオブジェクトに突っ込んだ。GridはWorldからしか呼ばなくて、Canvas周りの座標変換やらは全部Gridに任せているのだけど、可視性のコントロールはどうやってやればいいんだろう。

動的にサイズが決まる2次元配列を作るのが、どうも良くやり方が分からない。

	setup: function(size) {
	    this.size = size;
	    this.land = (function(size) {
		var a = [];
		for (var i = 0; i < size; i++){
		    a.push(new Array(size));
		}
		return a;
	    })(size);

と、forで1つ1つ配列をプッシュ。なんか、、、つらい。2次元配列を使ったのがそもそもの間違いなのか、jQueryの$.mapの挙動にもびっくりした。2次元配列をmapで操作するとき、Rubyなら、

[[1, 2], [3, 4]].map {|e| [e[0] * 2, e[1] * 2]}
 => [[2, 4], [6, 8]]

と書くだろうと思って、jQueryで以下のように書くとflattenされる。あれ?

var arr = [[1, 2], [3, 4]];
$.map(arr, function (e) { return new Array(e[0]*2, e[1]*2)}));
// => [2, 4, 6, 8]

for i in itemsで、関数の引数にインデックスが渡るのも不便な気がするのだけど、そんなもんなのか。ていうか、Arrayのときにfor in は禁句らしい。for inはプロパティを全部走査するので、Array.prototypeにプロパティが追加されていたら、

> Array.prototype.hoge = function() {};
[Function]
> for (var i in "abc".split("")) {console.log(i)}
0
1
2
hoge
undefined
>

のようになってしまう。うがが。

ひと通り書き終わってから、ネット上にあるCanvasによるライフゲームJavaScript実装をあれこれ眺めていて思ったけど、多くの人はもっとCライクに書いているように思った。ぼくはどうも名前を付けたがる。例えばWorld.landに2次元配列でセルの生死の状態を入れているけど、そのセルの操作は、

	liveCell: function(cell) {
	    this.land[cell[0]][cell[1]] = true;
	},

	// take 2 dimentional array as an argument
	liveCells: function(cells) {
	    for (var i in cells)
		this.liveCell(cells[i]);
	},
:
:
    for (var i in offsprings) this.liveCell(offsprings[i]);

と名前を付けている。これは、

    for (var i in offsprings) this.land[offsprings[i][0][offsprings[i][1]] = true;

と書けばいいことでもある。

パターンデータの定義を除いたコードは以下。パターン定義は、パターンファイルをRubyで処理してJavaScriptの二次元配列リテラルなんかを生成した。結局、Rubyかよ、みたいな……。

$(function() {
    var canvas = document.getElementById('board'),
	ctx = canvas.getContext('2d');

    var World = {
	setup: function(size) {
	    this.size = size;
	    this.countupGeneration(0);
	    // create a 2 dimentional array
	    this.land = (function(size) {
		var a = [];
		for (var i = 0; i < size; i++){
		    a.push(new Array(size));
		}
		return a;
	    })(size);
	    Grid.init(this.size);
	    this.draw();
	},

	clearCells: function() {
	    this.setup(this.size);
	},

	killCell: function(cell) {
	    this.land[cell[0]][cell[1]] = false;
	},

	liveCell: function(cell) {
	    this.land[cell[0]][cell[1]] = true;
	},

	// take 2 dimentional array as an argument
	liveCells: function(cells) {
	    for (var i in cells)
		this.liveCell(cells[i]);
	},

	toggleCellCoord: function(xpos, ypos) {
	    var x, y;
	    x = Math.floor(xpos / (canvas.width  / this.size));
	    y = Math.floor(ypos / (canvas.height / this.size));
	    if (this.land[x][y])
		this.killCell([x, y]);
	    else
		this.liveCell([x, y]);
	    this.draw();
	},

	draw: function() {Grid.refresh();},

	countupGeneration: (function() {
	    var gen = 0;
	    return (function(n) {
		gen = (n == 0) ? 0 : gen + 1;
		$("#generation").html(gen);
	    })
	})(),

	tick: function () {
	    var cells = [],
	        offsprings = [],
	        deadpool = [],
	        n;
	    for (var x = 0; x < this.size; x++) {
		for (var y = 0; y < this.size; y++) {
		    n = 0;
		    cells = this.neighbors([x, y]);
		    for (var i in cells) {
			if (this.land[cells[i][0]][cells[i][1]]) n++;
		    }
		    if (this.land[x][y]) {
			if ((n <= 1) || (n >= 4)) deadpool.push([x, y]);
		    } else {
			if (n === 3) offsprings.push([x, y]);
		    }
		}
	    }
	    for (var i in offsprings) this.liveCell(offsprings[i]);
	    for (var i in deadpool)   this.killCell(deadpool[i]);
	    this.offsprings = [];
	    this.deadpool = [];
	    this.countupGeneration();
	    Grid.refresh();
	},

	// stitch the edges so that this world has no ends.
	wrapEdges: function (pos) {
	    var x, y;
	    x = pos[0] % this.size;
	    y = pos[1] % this.size;
	    if (x === -1) x = this.size - 1;
	    if (y === -1) y = this.size - 1;
	    return [x, y];
	},

	neighbors: function (cell) {
	    var d = [[0, -1], [0, 1], [-1, 0], [1, 0],
		    [-1, -1], [1, -1], [1, 1], [-1, 1]];
	    var n = [];
	    var x, y;
	    for (var i in d) {
		x = cell[0] + d[i][0];
		y = cell[1] + d[i][1];
		n.push(this.wrapEdges([x, y]));
	    }
	    return n;
	}
    }

    var Grid = {
	init: function(numberOfCells) {
	    this.x = this.y = numberOfCells;
	    this.w = canvas.width;
	    this.h = canvas.height;
	    this.size = this.w / this.x;
	},
	gridDraw: function() {
	    ctx.strokeStyle = "rgb(230, 230, 230)";
	    for(var x = 0; x < this.w; x += this.size) {
		ctx.beginPath();
		ctx.moveTo(x, 0);
		ctx.lineTo(x, this.h);
		ctx.stroke();
	    }
	    for(var y = 0; y < this.w; y += this.size) {
		ctx.beginPath();
		ctx.moveTo(0, y);
		ctx.lineTo(this.w, y);
		ctx.stroke();
	    }
	},
	refresh: function() {
	    canvas.width = canvas.width;
//	    this.gridDraw();
	    var x, y;
	    ctx.fillStyle = "rgb(0, 0, 0)";
	    for (var x = 0; x < World.size; x++) {
		for (var y = 0; y < World.size; y++) {
		    if (World.land[x][y]) {
			ctx.fillRect(x * this.size, y * this.size, this.size, this.size);
		    }
		}
	    }
	}
    };

    // client code
    var Shape = {
	glider: [[0, 0], [1, 0], [2, 0], [0, 1], [1, 2]],
	blinker: [[5, 5], [6, 5], [7, 5]]
    };
    var timerId;
    var size = 80, latency;
    var setParams = function() {
	latency = document.getElementById('latency').value;
    };

    // set the initial state

    setParams();
    World.setup(30);
    World.liveCells(Shape.blinker);
    World.liveCells(Shape.glider);
    World.draw();

    // setup buttons

    $("#start").click(function() {
	if (!timerId) {
	    timerId = (function() {
		setParams();
		return setInterval(function(){ World.tick();}, latency);
	    })();
	}
    });
    $("#stop").click(function() {
	clearInterval(timerId);
	timerId = undefined;
    });
    $("#clear").click(function() {
	World.clearCells();
    });
    $("#board").click(function(e) {
	World.toggleCellCoord(e.clientX - canvas.offsetLeft,
			      e.clientY - canvas.offsetTop);
    });

    // setup pattern buttons

    $.each(Patterns, function(idx, val) {
	$("#patterns").prepend("<input type=\"button\" value=\""
			      + val["name"] + "\" "
			      + "id=\"shape" + idx + "\""
			      + ">");
	$("#shape" + idx).each(function() {
	    $(this).click(function() {
		var data = [];
		var w = h = max = 0;
		for (i in val["data"]) {
		    w = val["data"][i][0] > w ? val["data"][i][0] : w;
		    h = val["data"][i][1] > h ? val["data"][i][1] : h;
		}
		World.setup(size);
		$.each(val["data"], function(i) {
		    data.push([val["data"][i][0] + Math.floor((size - w) / 2),
			       val["data"][i][1] + Math.floor((size - h) / 2)]);
		});
 		World.liveCells(data);
		World.draw();
	    });
	});
    });
});