凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Canvas を使ってみんなもお手軽に弾幕作ろうぜ!! - 其の壱 -

はじめに

勉強がてら Canvas を使ってなにか作りたいなーと思いまして、昔作っていた STG のドット素材を利用して、簡単なマルチタッチ対応 STG を作ってみました。

触った指の数だけゆっくりが出てきます。マウスでもプレイ出来ます。死んだらクリックで復活します。

作成の過程で学んだことなどを共有できればと思い、簡単にまとめてみました。
解説は何回かに分けて行おうと思います。今回は、N-Way 弾が画像で出せるところまで書いてみました。

あ、ちなみにタイトルではこんなこと言ってますが、ぶっちゃけ重いので弾とかエフェクトを1000個とか結構辛いです(´・ω:;.:...。

Canvas の用意

を参考にすると Canvas で出来ることが色々とわかります。
まずは Canvas を用意するために下記のような HTML と CSS を書きます。

index.html
<!DOCTYPE HTML>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>だんまくてすと</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
    <canvas id="canvas" width="640" height="480"></canvas>
</body>
</html>
style.css
 * {
	margin: 0px;
	padding: 0px;
}

html, body {
	height: 100%;
	overflow: hidden;
	background-color: #000;
	text-align: center;
}

canvas {
	border: 3px #333 dotted;
}

Canvas の横幅/縦幅は、直接 width / height 属性で指定し、 CSS で指定していません。CSS で指定すると属性で指定した値を単純に縮小/拡大するふるまいになります。img タグと同じような感じです。
これをブラウザで表示してみます。

点線で囲まれた場所が Canvas です。

ただこれだと、ブラウザ画面が大きい時は小さくて迫力がないですし、逆に小さいときははみ出してしまいます。そこでブラウザの大小に合わせて Canvas の拡大縮小をするようにしてみます。
HTML に JavaScript を読み込む記述を追加します。

...
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<script type="text/javascript" src="shooting.js"></script>
...

jQuery もロードしておきます。jQuery を読み込む script タグは、下記のページからコピペしてきて下さい。

JavaScript 側ではドキュメント読み込み時と resize イベントハンドラが呼ばれたときに画面のサイズの変更を行います。そのまま width も height も 100% にしてしまうと、画面が横長の時は Canvas も横長に引き伸ばされる形になってしまうので、短辺が最大になるように拡大をします。

shooting.js
$(function(){

/* ------------------------------------------------------------------------- */
//  Global Parameters
/* ------------------------------------------------------------------------- */
const WIDTH  = 640;
const HEIGHT = 480;

/* ------------------------------------------------------------------------- */
//  Canvas
/* ------------------------------------------------------------------------- */
var Canvas = {
	resize : function() {
		var wRate = $(window).width()  / WIDTH;
		var hRate = $(window).height() / HEIGHT;
		var rate = Math.min(wRate, hRate);

		$('#canvas').css({
			width  : WIDTH  * rate,
			height : HEIGHT * rate,
		});
	},
};

/* ------------------------------------------------------------------------- */
//  OnLoad
/* ------------------------------------------------------------------------- */
$(window).bind({
	resize : Canvas.resize
});
Canvas.resize();
main();

/* ------------------------------------------------------------------------- */
//  Main
/* ------------------------------------------------------------------------- */
function main() {
}

これで、画面に合わせて引き伸ばされるようになります。

何か描いて動かしてみる

Canvas が出来たら取りあえず何か描いてみたいですよねー。Canvas へどうやって図形を描画するかについては、さまざまな図形を描く - Canvas - HTML5.JP をご参照下さい。
ここでは jQuery 風に Canvas を操作したり図形を描いたりすることの出来る jCanvas を利用してみたいと思います。

jCanvas については上記ページ Docs からサンプルや解説を見ることが出来ます。Sandbox も用意されているので、サンプルコードをちょっと試してみる、といったことも簡単なのでとてもオススメです。
では、これらを動かすことも考慮して移動物体クラス Mover を作ってみましょう。

/* ------------------------------------------------------------------------- */
//  Mover
/* ------------------------------------------------------------------------- */
var Mover = function(mover) {
	this.x     = ('x'  in mover) ? mover.x  : 0;
	this.y     = ('y'  in mover) ? mover.y  : 0;
	this.vx    = ('vx' in mover) ? mover.vx : 0;
	this.vy    = ('vy' in mover) ? mover.vy : 3;
	this.w     = ('w'  in mover) ? mover.w  : 10;
	this.h     = ('h'  in mover) ? mover.h  : 10;
	this.cnt   = 0;
	this.flag  = true;
};

Mover.prototype = {
	move : function() {
		this.x += this.vx;
		this.y += this.vy;
		++this.cnt;
	},
	draw : function() {
		$("#canvas").drawEllipse({
			x         : this.x,
			y         : this.y,
			width     : this.w,
			height    : this.h,
			fillStyle : '#fff',
		});
	},
};

これを main 関数で生成します。

function main() {
	var mover = new Mover({
		x  : WIDTH/2,
		y  : HEIGHT/2,
		w  : 10,
		h  : 10,
		vx : 1,
		vy : 3,
	});

	setInterval(function(){
		mover.move();
		mover.draw();
	}, 1000/30);
}


30 FPS で ● が動くかと思いきや、線になってしまいました。これは前描いた ● が消去されていないからです。そこで Canvas オブジェクトに次のような reset メソッドを追加して、これが描画前に必ず呼ばれるようにしてみましょう。

var Canvas = {
	// ...(略)
	clear  : function() {
		$('#canvas').clearCanvas();
	},
};

function main() {
	// ...(略)
	setInterval(function(){
		mover.move();
		Canvas.clear();
		mover.draw();
	}, 1000/30);
}


無事動きました!
ちなみに、クラスの名前を Bullet とせずに Mover としたのは、後々、弾に限らず敵やプレイヤーもこのクラスを継承して作ろうと思っているからです。

ここまでのコード

たくさんの弾を動かしてみる

では次に弾の数を増やしてみましょう。弾を管理してくれる Container クラスを作ってみます。

var Container = function() {
	this.array = [];
}

Container.prototype = {
	add   : function(instance) {
		this.array.push(instance);
	},
	move  : function() {
		var n = 0;
		for (var i in this.array) {
			this.array[i].move();
			if (!this.array[i].flag) {
				this.array.splice(n, 1);
			}
			++n;
		}
	},
	draw : function() {
		for (var i in this.array) {
			this.array[i].draw();
		}
	},
};

Container クラスは内部に array 配列プロパティを持っていて、add で array に要素を追加することが可能です。そして move / draw を呼ぶと、array に格納されている要素を全て走査して move / draw を呼び出す、という簡単な作りになっています。このとき、flag プロパティが false になっている要素については array から削除するようにしています。これを利用すれば、弾が自機と衝突した時や、画面外に出た時などに flag を false にすれば、自動的に削除される寸法です。
では、ついでに画面外に出たら flag を false にする処理を Mover クラスに追加してしまいましょう。

const MARGIN = 50;

Mover.prototype = {
	move : function() {
		this.x += this.vx;
		this.y += this.vy;
		this.chkBoundary();
		++this.cnt;
	},
	// ...(略)
	chkBoundary : function() {
		if (this.x < -this.w - MARGIN || this.x > WIDTH  + this.w + MARGIN ||
			this.y < -this.h - MARGIN || this.y > HEIGHT + this.h + MARGIN) {
			this.flag = false;
		}
	},
};

画面外に MARGIN だけはみ出たら消すようにしています。
では次に弾を適当に大量に生成してみましょう。main 関数を以下のように書き換えます。

function main() {
	var frame = 0;
	var Movers = new Container();

	setInterval(function(){
		if (frame%5 === 0) {
			var angle = 2 * Math.PI * Math.random();
			Movers.add(
				new Mover({
					x  : WIDTH/2,
					y  : HEIGHT/2,
					w  : 10,
					h  : 10,
					vx : Math.sin(angle),
					vy : Math.cos(angle),
				})
			);
			console.log(Movers.array.length);
		}

		Movers.move();
		Canvas.clear();
		Movers.draw();
		++frame;
	}, 1000/FPS);
}

5フレに1回、弾を中心からランダムな方向に発射します。

Chromeデバッグコンソールを使って Movers.array を監視していれば、画面外に出た弾が消去されている様子を見ることも出来ます。

ここまでのコード

だんまくさんぷる2

弾を画像にしてみる

では、次に弾を画像にしてみます。画像を組み込む - Canvas - HTML5.JP に Canvas への
画像描画について詳しい説明が書かれていますが、Canvas に描画するときには、画像をロードしておく必要があります。が、jCanvas ではそのあたりを隠蔽してくれ、更に画像がロードされた際に呼ばれる callback を指定することも出来ます。
しかしながら、描画するたびに毎回読みに行くよりも、ゲームの挙動としては、予め画像をロードしておいた方が良いでしょう。画像を読み込むのが遅くてロードが終わったら突然弾が出現した!なんてそんな Lunatic な難易度は無理です。ということで、画像をロードするクラスを書いてみます。

/* ------------------------------------------------------------------------- */
//  Image Loader
/* ------------------------------------------------------------------------- */
var ImageLoader = function(imgPath) {
	this.loaded = false;
	this.img    = new Image();
	this.path   = imgPath + "?" + new Date().getTime();
};

ImageLoader.prototype = {
	load  : function() {
		this.img.src = this.path;
		this.img.onload = function(_this) {
			return function() {
				this.loaded = true;
			}
		}(this);
	},
};

/* ------------------------------------------------------------------------- */
//  Materials Container
/* ------------------------------------------------------------------------- */
var Materials = {
	map : {},
	add : function(material) {
		var key = material.key;
		this.map[key] = {
			instance : new ImageLoader(material.path),
			path     : material.path,
			width    : material.width,
			height   : material.height,
			cd       : material.cd,
		};
		this.map[key].instance.load();
	},
	loaded : function() {
		for (var key in map) {
			if (!this.map[key].instance.loaded) return false;
		}
		return true;
	},
	img : function(key) {
		return this.map[key].instance.img;
	},
	path : function(key) {
		console.log(this.map[key]);
		return this.map[key].path;
	},
	width : function(key) {
		return this.map[key].width;
	},
	height : function(key) {
		return this.map[key].height;
	},
	cd : function(key) {
		return this.map[key].cd;
	},
}

ImageLoader で画像に画像パスを渡して load を実行するとロードします。ロードが完了すると loaded が false から true に変わります。
これを Materials オブジェクトの map プロパティに add で突っ込んで管理し、キーでリソース情報を取得できるようにします。cd は collisiion detection = 当たり判定です。
読み込ませる画像ですが、以下のようなものを用意します。

これをトリミングして表示するようにします。試しに main 関数を書き換えて画像を表示してみましょう。

// color
const COLOR = {
	RED     : 0,
	GREEN   : 1,
	BLUE    : 2,
	YELLOW  : 3,
	PURPLE  : 4,
	PINK    : 5,
	ORANGE  : 6,
	SKYBLUE : 7,
	BLACK   : 8,
	WHITE   : 9,
};

function main() {
	Materials.add({
		key    : 'bullet.normal',
		path   : 'img/bullet/normal.png',
		width  : 20,
		height : 20,
		cd     : 5,
	});
	var key = 'bullet.normal';
	var loadTimer = setInterval(function(){
		console.log(Materials.loaded());
		if (Materials.loaded()) {
			clearInterval(loadTimer);
			$('#canvas').drawImage({
				source  : Materials.path(key),
				x       : WIDTH/2,
				y       : HEIGHT/2,
				width   : Materials.width(key),
				height  : Materials.height(key),
				sWidth  : Materials.width(key),
				sHeight : Materials.height(key),
				sx      : Materials.width(key) * COLOR.GREEN,
				sy      : 0,
				cropFromCenter: false,
			});
		}
	}, 100);
}


100 ms 毎にロードの完了をチェックして、ロードが完了したらメインの処理に入るようにしています。
また、元画像が 20 px 間隔に色が区切られた弾が描かれているので、それを利用してトリミングすることで弾の色を変えています。

では、先ほどの Ellipse で描いていたものをを画像に変えてみましょう。まず、Mover クラスをいじります。

var Mover = function(mover) {
	this.x     = ('x'     in mover) ? mover.x     : 0;
	this.y     = ('y'     in mover) ? mover.y     : 0;
	this.vx    = ('vx'    in mover) ? mover.vx    : 0;
	this.vy    = ('vy'    in mover) ? mover.vy    : 3;
	this.color = ('color' in mover) ? mover.color : 0;
	this.key   = ('key'   in mover) ? mover.key   : null,
	this.w     = ('key'   in mover) ? Materials.width(mover.key)  : 0;
	this.h     = ('key'   in mover) ? Materials.height(mover.key) : 0;
	this.cd    = ('key'   in mover) ? Materials.cd(mover.key)     : 0;
	this.sx    = this.w * this.color;
	this.sy    = 0;
	this.cnt   = 0;
	this.flag  = true;
};

Mover.prototype = {
	move : function() {
		this.x += this.vx;
		this.y += this.vy;
		this.chkBoundary();
		++this.cnt;
	},
	draw : function() {
		$('#canvas').drawImage({
			source  : Materials.path(this.key),
			x       : this.x,
			y       : this.y,
			width   : this.w,
			height  : this.h,
			sWidth  : this.w,
			sHeight : this.h,
			sx      : this.sx,
			sy      : this.sy,
			cropFromCenter: false,
		});
	},
	chkBoundary : function() {
		if (this.x < -this.w - MARGIN || this.x > WIDTH  + this.w + MARGIN ||
			this.y < -this.h - MARGIN || this.y > HEIGHT + this.h + MARGIN) {
			this.flag = false;
		}
	},
};

リソースの読み込み部分と main 関数は以下のようにします。

/* ------------------------------------------------------------------------- */
//  Resource
/* ------------------------------------------------------------------------- */
Materials.add({
	key    : 'bullet.normal',
	path   : 'img/bullet/normal.png',
	width  : 20,
	height : 20,
	cd     : 5,
});

var loadTimer = setInterval(function(){
	if (Materials.loaded()) {
		clearInterval(loadTimer);
		main();
	}
}, 100);

/* ------------------------------------------------------------------------- */
//  Main
/* ------------------------------------------------------------------------- */
function main() {
	var frame = 0;
	var Movers = new Container();

	setInterval(function(){
		if (frame%5 === 0) {
			var angle = 2 * Math.PI * Math.random();
			Movers.add(
				new Mover({
					x     : WIDTH/2,
					y     : HEIGHT/2,
					w     : 10,
					h     : 10,
					vx    : 3 * Math.sin(angle),
					vy    : 3 * Math.cos(angle),
					key   : 'bullet.normal',
					color : COLOR.BLUE,
				})
			);
		}

		Movers.move();
		Canvas.clear();
		Movers.draw();
		++frame;
	}, 1000/FPS);
}


ここまでのコード

だんまくさんぷる3

弾幕を作ってみる

さてさて、シューティングを作る醍醐味はやはり弾幕を作るところでしょう!
弾を描画するところまでは出来たので、これから弾を秩序立てて動かすところを実装してみましょう。汎用的に使える弾幕の例として N-way 弾を作ってみることにします。
と、その前に弾が Mover として生成しているのはちょっと気持ち悪いので Mover を継承する Bullet というクラスを作ってみましょう。

継承について

で、継承の仕方なのですが、JavaScript はプロトタイプベースのオブジェクト指向言語で、下記のサイトでまとめて下さっているように、やり方はたくさん有ります。

今回は jQuery も用いていることですし、 $.extend を使った継承で実装してみます。

上記エントリを参考にさせて頂きました。

var Bullet = function(bullet) {
	Mover.call(this, bullet);
};
Bullet.prototype = $.extend({}, Mover.prototype);

コンストラクタで Mover のコンストラクタを call で呼んで、prototype は Mover のそれを空オブジェクト {} に上書きします。
N-way 弾は角度指定で弾を飛ばしたいので、速度と角度を指定すればその方向に弾を発射してくれる RadianBullet クラスを練習がてら作ってみましょう。

var RadianBullet = function(bullet) {
	this.v   = ('v'   in bullet) ? bullet.v   : 0;
	this.ang = ('ang' in bullet) ? bullet.ang : 0;
	Mover.call(this, bullet);
};
RadianBullet.prototype = $.extend({}, Bullet.prototype, {
	move : function() {
		this.x += this.v * Math.cos(this.ang);
		this.y += this.v * Math.sin(this.ang);
		this.chkBoundary();
		++this.cnt;
	},
});

これで角度ベースの弾が発射できるようになります。(コンストラクタで this.vx と this.vy に代入してデフォルトの move 使ったほうが実行速度的には速いのですが…、まぁ練習のためということで。。)

では、この RadianBullet を利用して BulletCurtain クラスを作りましょう。

/* ------------------------------------------------------------------------- */
//  Bullet Curtain
/* ------------------------------------------------------------------------- */
var BulletCurtain = function() {
	this.cnt = 0;
	this.flag = true;
};

/* ------------------------------------------------------------------------- */
//  Bullet Curtain Variations
/* ------------------------------------------------------------------------- */
// N-Way Pattern
var BC_NWay = function(bc) {
	BulletCurtain.call(this);
	for (var x in bc) {
		this[x] = bc[x];
	}
};

BC_NWay.prototype = {
	move : function() {
		for (var i = 0; i < this.num; ++i) {
			if (this.cnt === this.period*i) {
				for (var n = 0; n < this.nway; ++n) {
					Bullets.add(new RadianBullet({
						x     : this.x,
						y     : this.y,
						v     : this.v,
						ang   : this.ang0 - this.dang*(this.nway-1)/2 + this.dang*n,
						key   : this.key,
						color : 'color' in this ? this.color : COLOR.RED,
					}));
				}
			}
		}

		++this.cnt;
		if (this.cnt > this.period*this.num) {
			this.flag = false;
		}
	},
	draw : function() {},
};

ネスト深い…。Mover では無いのですが、Container に突っ込んだ時に問題なく動いてくれるように move と draw メソッドを書いておきます。
なんでこの実装で N-Way 弾が飛んでいくかは面白いのでぜひ考えてみて下さい。
では main 関数内でこの BC_NWay を呼んでみましょう。ちなみに BC_NWay.move() 内部で Container である Bullets の add メソッドを呼んでいる形になっていますので、Container の宣言箇所は main() 関数の外に出して、main 関数を書きます。

/* ------------------------------------------------------------------------- */
//  Containers
/* ------------------------------------------------------------------------- */
var Bullets        = new Container();
var BulletCurtains = new Container();

/* ------------------------------------------------------------------------- */
//  Main
/* ------------------------------------------------------------------------- */
function main() {
	var frame = 0;
	setInterval(function(){
		if (frame%60 === 0) {
			BulletCurtains.add(
				new BC_NWay({
					nway   : 5,
					x      : WIDTH/2,
					y      : HEIGHT/4,
					v      : 5,
					ang0   : Math.PI/2,
					dang   : Math.PI/10,
					num    : 8,
					period : 5,
					color  : COLOR.RED,
					key    : 'bullet.normal',
				}, Bullets)
			);
		}

		BulletCurtains.move();
		Bullets.move();
		Canvas.clear();
		Bullets.draw();
		++frame;
	}, 1000/FPS);
}


90度(真下)の方向へ5フレーム置きに18度間隔で8連発赤い弾が5方向に飛んで行きます!

ここまでのコード

次回予告

次回は、敵を出したり自機を操縦したり、マルチタッチ対応させたり…、なところを書こうと思います。

余談

トリミング処理は重い?

弾数が多くなってくると、FPS が落ちてきます。詳しくは調べていないですが、おそらく描画に一番時間がかかっています。個人的にはトリミングコストが結構かかっているのでは…、と疑っているのですが、昔作っていたドット絵を分割するのが面倒で試して見ていません。興味がある方はやってみてください(そして教えて下さい。。)。

Function.bind が iPad でエラー

iPadsafari で ImageLoader.load の

function(_this){return funtion(){...}}(this);

部を、

function(){...}.bind(this);

にすると、undefined となってエラー吐くんですが、何でですかね…?