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