13日目:Canvasに描画する

13日目です。今回は数字の画像をCanvasに描画します。なお、背景画像はcanvas要素のスタイルシートのbackground-imageに割り当てています。このようにすると手軽にcanvas要素の背景画像を表示することができます。この手法だと数字を消した場合、背景画像を再描画する手間がなくなります。同じCanvas内に背景画像を描画した場合は数字を消したら、その部分に背景画像を描画する処理が必要になります。ちなみに、他にもcanvas要素を2枚重ねて奥に背景画像、手前に数字を描画する方法があります。

まず、canvas要素をdiv要素の代わりにHTMLファイル内に記述します。この時にwidthとheight属性を指定しておかないとSafariでは正しく画像が描画されないことがあります。

<canvas id="stage" width="200" height="320"></canvas>

div要素やinput要素を使った場合と違いCanvasに描画した場合、数字を示す要素が存在しないことになります。これまではdiv要素などに1つずつ割り当てていたためクリックイベントも、その要素ごとに処理が行われていました。Canvasの上に透明なdiv要素を重ねるという方法もありますが、今回はcanvas要素がクリックされた座標から表示されている数字を割り出すという方法を採用してみます。なお、この方法はベストな方法ではなく、どちらかというと問題が発生しやすいものです。ただ、このような方法でもできる、ということで進めていきます。

まず、canvas要素をクリックした場合の処理です。以下のようにクリックされたらaddNumber関数を呼び出します。

canvas.onclick = addNumber; // ●canvas要素にクリックイベントを割り当てる

addNumber関数内では以下のように引数(パラメーター)を取るように変更します。

function addNumber(evt){

addNumber関数内でクリックした座標はevt.offsetXevt.offsetYで読み出すことができます。
あとはクリックされた座標値から計算するわけですが、幸いこのゲームは横4つ、縦6つの数字が規則正しく並んでいるため以下のように簡単に求めることができます。なお、48という数字が出てきますが、これは数字の画像の横幅と縦幅(どちらも48ピクセル)を示しています。定数にしておくのが筋ですが、とりあえず今回は直接記述しています。

// ●クリック座標を求める
var clickX = parseInt(evt.offsetX / 48);
var clickY = parseInt(evt.offsetY / 48);
var ptr = clickX + clickY*numXCount; // 数字の配列内の位置を計算する
var clickNum = num[ptr]; // 配列から数字を読み出す
if (!clickNum){ return; } // 数字以外の範囲をクリックした場合は以後の処理をしない

なお、クリックした数字の合計が10になると配列内にある数字はなくなるわけですが、なくなった場合はnullになるようにしています。クリック時にfalse(偽)になる値だったら(null、undefinedなど)以後の処理をしないようにします。これを忘れるとエラーになります。

Canvasを利用した場合、これまでのdiv要素を使った場合と異なり数字は自前で描画したり消去する必要があります。今回は数字を表示する関数としてdrawNumber、消去する関数としてeraseNumberを作成しました。
関数drawNumberには引数(パラメーター)として描画する数字、X座標、Y座標が渡されます。関数内ではCanvasに描画するための処理を行います。ただ、最適化などは一切考えないことにします。そもそも、リアルタイムに数字を描画したりするわけではないので速度も求められないというのもあります。
Canvasに数字を描画するには一定の手順が必要です。まず、document.getElementById()でcanvas要素を取得します。次にcanvas要素のgetContext()メソッドを使って2D描画に関するコンテキストを取得します。このコンテキストというのは、Canvasに描画する際に必要な呼び出し方法(API)などを示しています。JavaScriptだとgetContext("2d")で返されたオブジェクトに各種メソッドやプロパティが格納されることになります。
Canvasに任意の画像を描画するにはdrawImage()メソッドを使います。パラメーターは数パターンありますが、画像を指定した位置に描画するだけであれば最初のパラメーターに描画する画像オブジェクト、2番目にX座標、3番目にY座標を指定するだけです。
ただし、Canvasの場合、単純にdrawImage()だけでは数字の画像が表示されないことがあります。手間のかかる無難どころな方法としては以下のように画像オブジェクトを生成した後にsrcプロパティに描画する数字のURLを入れます。その後、画像が完全に読み込まれたら(つまりloadイベントが発生したら)drawImage()メソッドを呼び出します。ちなみに、このような手間のかかる方法は、ほとんど使われませんが、基本的にはこのような流れです。

// ●数字を指定座標に描画する
function drawNumber(n, x, y){
var canvas = document.getElementById("stage"); // canvas要素を取得する
var ctx = canvas.getContext("2d"); // 2dコンテキストを取得する
var url = "images/"+n+".png"; // 表示する数値のURLを求める
var imgObj = new Image(); // 新規に画像オブジェクトを生成
imgObj.src = url; // URLを画像オブジェクトに設定する
imgObj.onload = function(){ // 画像データの読み込みが完了したら処理する
ctx.drawImage(imgObj, x, y); // 指定した座標に数字の画像を描画する
}
}

次に数字を消す処理を行うeraseNumber関数です。指定した範囲を消すにはclearRect()メソッドを使います。clearRect()メソッドを使う場合もコンテキストを取得しておく必要があります。clearRect()メソッドは4つのパラメーターが必要です。最初のパラメーターがX座標、2番目がY座標、3番目が横幅、4番目が縦幅になります。eraseNumber関数内は以下のようになっています。いちいちコンテキストを取得するのは効率が悪いのですが、今回は高速化などは考えずにこのままにしておきます。

// ●指定座標の範囲を消す
function eraseNumber(x, y){
var canvas = document.getElementById("stage"); // canvas要素を取得する
var ctx = canvas.getContext("2d"); // 2dコンテキストを取得する
ctx.clearRect(x, y, 48, 48); // 指定された範囲を消す
}

描画処理以外でも修正が必要です。クリックされた際に以前は、1つ前にクリックされた要素を変数に保存していましたが、今回はクリックした座標と並列内での数字の位置も保存するようにしています。これがプログラムの先頭で宣言している以下の行になります。

var prevNumberX; // ●直前にクリックした数字のX座標を入れる変数 var prevNumberY; // ●直前にクリックした数字のY座標を入れる変数 var prevPtr; // ●直前にクリックした数字の配列内での位置を入れる変数
とりあえず、これで数字をCanvasに描画できるようになりました。このままゲームとしてもプレイできます。が、これまでは直前にクリックした数字の背景色を赤色にしクリックされた事を示していました。オリジナルのTEN -10-でもタッチされた数値は、やや暗くなるようになっています。
ということで、次回はクリックされたら数字を暗くしたり、一部最適化してみます。

[サンプルを実行する]

[サンプルをダウンロード]

HTMLファイルの内容

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>TEN</title>
<style>
#stage { width : 200px; height : 320px; background-image: url(images/bg.png); }
#clearMsg { position : absolute; left: 10px; top: 200px; font-size: 18pt; color: red; visibility: hidden; }
</style>
</head>
<body>
<h1>TEN</h1>
<div>SCORE : <span id="score">0</span> TIME : <span id="timeString">0</span></div>
<canvas id="stage" width="200" height="320"></canvas>
<div id="clearMsg">ステージクリア!</div>
<script src="main.js"></script>
</body>
</html>

JavaScriptファイル (main.js) の内容

// TEN HTML5 version
// (c) 2014 Cybermuse
// Arranger 古籏一浩
var score = 0;			// スコア(得点)を入れる変数
var count = 0;		// ボタンの合計を入れる変数
var prevNumber = 0;	// 直前にクリックした数字を入れる変数
var prevNumberX;	// ●直前にクリックした数字のX座標を入れる変数
var prevNumberY;	// ●直前にクリックした数字のY座標を入れる変数
var prevPtr;	// ●直前にクリックした数字の配列内での位置を入れる変数
var time = 0;	// ゲームオーバーまでの時間
var timerID = null;	// カウントダウンタイマーのIDを入れる変数
var firstClickFlag = false;	// 最初にクリックしたかどうかのフラグ
var gameFlag = true;	// ゲーム中かどうかのフラグ。trueならゲーム中。
var num = [ ];	// ●数字を入れる配列変数を用意
var numXCount = 4;	// ●横の数字の個数
var numYCount = 6;	// ●縦の数字の個数
// 初期化処理
function init(){
	var canvas = document.getElementById("stage");	// canvas要素を取得する
	canvas.onclick = addNumber;	// ●canvas要素にクリックイベントを割り当てる
	count = setRandomValue(numXCount, numYCount);	// 戻り値は要素の総数
	time = 30;	// 30秒に設定する
	document.getElementById("timeString").innerHTML = time + "秒";	// 時間を表示する
}
// ランダムに値を設定する関数
function setRandomValue(w, h){
	num = [ ];	// ●配列内容を消去する
	var len = w * h;	// ●数字の総数
	// ランダムな値を設定する
	for(var i=0; i<len; i+=2){	// 数字の数だけ繰り返す
		var n = Math.floor(Math.random() * 9) + 1;	// 1〜9までの数値をランダムに生成する
		num.push(n);
		num.push(10 - n);
	}
	// 配列要素をシャッフルする
	for(var i=0; i<len; i++){
		var p1 = Math.floor(Math.random() * len);	// 要素の数の範囲で乱数を発生させる
		var p2 = Math.floor(Math.random() * len);	// 要素の数の範囲で乱数を発生させる
		var n = num[p1];	// 2つの要素の内容を入れ替える
		num[p1] = num[p2];
		num[p2] = n;
	}
	// ●表示処理
	document.getElementById("clearMsg").style.visibility = "hidden";	// ステージクリアメッセージを消す
	for(var y=0; y<h; y++){	// 数字の数だけ繰り返す
		for(var x=0; x<w; x++){
			drawNumber(num[x+y*w], x*48, y*48);
		}
	}
	return len;	// ●数字の総数を返す
}
// ●数字を指定座標に描画する
function drawNumber(n, x, y){
	var canvas = document.getElementById("stage");	// canvas要素を取得する
	var ctx = canvas.getContext("2d");	// 2dコンテキストを取得する
	var url = "images/"+n+".png";	// 表示する数値のURLを求める
	var imgObj = new Image();	// 新規に画像オブジェクトを生成
	imgObj.src = url;	// URLを画像オブジェクトに設定する
	imgObj.onload = function(){	// 画像データの読み込みが完了したら処理する
		ctx.drawImage(imgObj, x, y);	// 指定した座標に数字の画像を描画する
	}
}
// ●指定座標の範囲を消す
function eraseNumber(x, y){
	var canvas = document.getElementById("stage");	// canvas要素を取得する
	var ctx = canvas.getContext("2d");	// 2dコンテキストを取得する
	ctx.clearRect(x, y, 48, 48);	// 指定された範囲を消す
}
// 2つの数字の合計が10かどうか調べる
function addNumber(evt){
	// ゲーム中かどうかを調べる
	if (gameFlag === false){ return; }	// ゲーム中でない場合は以後の処理をしない
	// 初めてのクリックかどうか調べる
	if (firstClickFlag === false){
		firstClickFlag = true;	// クリックされたらtrueにする
		timerID = setInterval("displayTimer()", 1000);	// 1秒ごと定期的にタイマー表示関数を呼び出す
	}
	// ●クリック座標を求める
	var clickX = parseInt(evt.offsetX / 48);
	var clickY = parseInt(evt.offsetY / 48);
	var ptr = clickX + clickY*numXCount;	// 数字の配列内の位置を計算する
	var clickNum = num[ptr];	// 配列から数字を読み出す
	if (!clickNum){ return; } 	// 数字以外の範囲をクリックした場合は以後の処理をしない
	// 1個目のクリックの場合
	if (prevNumber === 0){
		prevNumber = clickNum;	// ●クリックされた数字を変数に入れる
		prevNumberX = clickX;	// ●クリックされた数字のX座標を変数に入れる
		prevNumberY = clickY;	// ●クリックされた数字のY座標を変数に入れる
		prevPtr = ptr;	// ●クリックされた数字の配列内での位置を変数に入れる
		return;	// 関数から抜ける
	}
	// 2個目のクリックの場合
	var total = prevNumber + parseInt(clickNum);	// 直前の数値とクリックされた数値を加算する
	if (total === 10){	// 合計が10の場合の処理
		eraseNumber(clickX*48, clickY*48)	// ●現在の数字を非表示にする
		eraseNumber(prevNumberX*48, prevNumberY*48)	// ●前にクリックした数字を非表示にする
		num[prevPtr] = null;	// ●直前の数字を消す。忘れると何度もクリックできてしまう
		num[ptr] = null;	// ●数字を消す。忘れると何度もクリックできてしまう
		prevNumber = 0;	// 数値を0にする
		score = score + 1;	// スコア(得点)を追加する
		document.getElementById("score").innerHTML = score;	// スコア(得点)を表示する
		count = count - 2;	// 合計から2を引く
		if (count === 0){	// 全部消したかどうか調べる
			document.getElementById("clearMsg").style.visibility = "visible";	// ステージクリアメッセージを表示する
			clearInterval(timerID);	// タイマーを停止する
			setTimeout(function(){	// 2秒後に表示されているメッセージを消すためのタイマー
				count = setRandomValue(numXCount, numYCount);	// ●乱数値を設定する
				timerID = setInterval("displayTimer()", 1000);	// 1秒ごと定期的にタイマー表示関数を呼び出す
			}, 2000);
		}
		return;	// 関数から抜ける
	}
	// 合計が10でなかった場合
	prevNumber = 0;	// 数値を0にする
}
// タイマーの表示処理
function displayTimer(){
	time = time - 1;
	document.getElementById("timeString").innerHTML = time + "秒";
	if (time < 1){	// 時間が1より少なくなったらゲームオーバー
		gameFlag = false;	// ゲーム停止をフラグで示す。falseならゲーム中ではない
		clearInterval(timerID);	// タイマーを停止する
		alert("ゲームオーバー");
	}
}
init();	// 初期化処理を呼び出す