JavaScript:パズルゲームのタイトル画面を作る

JavaScript

タイトル画面は、ゲームタイトルの文字列を表示するだけのシンプルなものとする。
タイトル画面表示中にクリックするとゲーム画面に遷移する。
現時点ではゲームオーバーとならないため、タイトル画面に戻ることはない。

キャンバスで文字を中央揃えにする処理

タイトル画面はタイトルの文字列を表示するだけのシンプルなものだが、キャンバスの幅に合わせて中央揃えにしている。

コード上ではdrawTextの描画x座標を

(キャンバスの幅) ÷ 2 ‐ (タイトル文字列のピクセル幅 ÷ 2)

としたい。

これを実現するためには、キャンバスに描画するタイトル文字列のピクセル幅を求める必要がある。
キャンバスcontextのmeasureText()を使えばキャンバスに表示する文字列の幅を取得できる。

例えば、文字列がHelloだったとすれば、以下のようにして文字列Helloのピクセル幅が取得できる。

const width = canvas.getContext("2d").measureText("Hello").width;

今回のソースコードでは、main.js302行以降から始まるdrawTitle関数でmeasureTextを実際に利用している。

ソースコード

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>OTIMONO(10タイトル画面からゲーム画面へ)</title>
	<style>
		*{
			margin: 0;
			padding: 0;
		}
	</style>
	<script src="main.js" type="text/javascript"></script>
</head>
<body>
	<canvas id="canvas" width="512" height="640"></canvas>
</body>
</html>

main.js

// 10タイトル画面からゲーム画面へ
let isDebug = false;			// デバッグモードON/OFF
let canvas = null;
let g = null;
let pos = {x:0, y:0};		// キャンバス上のマウス座標
let dropSpeed = 3;			// 落下速度(10-->1秒, 1-->0.1秒)
let timer = {start: 0, end: 0};	// 落下速度計測用
const TITLE_TEXT = "otimono";	// ゲームタイトル

const BLOCK_SIZE = 64;								// 1ブロックのサイズ(=パズルキャラのサイズ)64x64ピクセル
const ROW_SIZE = 8;									// 行数
const COLUMN_SIZE = 8;								// 列数
const BLOCK_LENGTH = ROW_SIZE * COLUMN_SIZE;		// ステージ全体のブロック長(行数×列数)

// ゲームの状態(game.stateで使用: Symbolの引数文字列はshowInfo()の状態表示に利用)
const TITLE = Symbol('title');		// タイトル画面
const WAITING = Symbol('waiting');	// ブロック配置待ち
const SET_BLOCK = Symbol('set_block');	// ブロック配置
const DROP = Symbol('drop');		// 落下中
const CHECK_BLOCK = Symbol('check_block');	// 消せるブロックをチェック
const DELETE_BLOCK = Symbol('delete_block');	// ブロックを消去

// ステージ背景色(市松模様)
const BACKGROUND_COLOR1 = "rosybrown";	// "pink";
const BACKGROUND_COLOR2 = "azure";		// "skyblue";

// ゲーム全体の設定や情報
let game = {
	state: TITLE,		// ゲーム中の状態
	hiscore: 0,			// 未使用
	offsetY: 128,		// キャンバス左上からのステージ座標オフセット値(Y座標)
};

let currentBlockType = 0;	// 配置するブロックタイプ(1~6)
let nextBlockType = 0;	// 次に配置するブロックタイプ(1~6)

// ゲームに使う画像ファイル
const image_files = ["space.png", "alien.png", "apple.png", "dog.png", "obake.png", "ultra.png", "usi.png", "delete.png"];

// 画像オブジェクト(画像ファイルをゲームで利用するため)
let images = [];

// ステージ情報配列
let stageState = new Array(BLOCK_LENGTH);

/*
 * マウスを動かしている時の処理
 */
const mouseMoveListener = (e) => {
	// オフセット位置:キャンバスがブラウザの左上からどれくらいの位置にあるか?(rect.left, rect.top)
	const rect = e.target.getBoundingClientRect();

	// e.clientXとe.clientYはブラウザ左上からのキャンバスクリック位置なのでオフセット分を引くとキャンバス上の座標が分かる
	pos.x = e.clientX - rect.left;
	pos.y = e.clientY - rect.top;
};

/*
 * マウスボタンを押した時の処理
 */
const mouseDownListener = () => {
	// ブロックが配置できる条件
	//		1)ステージ上にマウスポインタがある かつ
	//		2)全てのブロックが落下して消去ブロックが無い場合
	if(pos.y >= game.offsetY && game.state == WAITING){
		game.state = SET_BLOCK;
	}
	else if(game.state == TITLE){	// タイトル画面の時はゲーム開始
		gameInit();			// 初期化
	}
};

/*
 * 背景を描画
 */
const drawBackground = ()=> {
	let x = 0, y = 0;	// ステージ上のブロック座標

	// 市松模様のステージ色
	let color1 = BACKGROUND_COLOR1;
	let color2 = BACKGROUND_COLOR2;

	// 市松模様を描画
	for(i=0; i<BLOCK_LENGTH; i++){
		// 1段下がると先頭の色を入れ替える
		if(i % COLUMN_SIZE == 0) [color1, color2] = [color2, color1];
		// 格子状にブロックを表示
		x = i % COLUMN_SIZE;			// x方向ブロック座標
		y = Math.floor(i / ROW_SIZE);	// Y方向ブロック座標

		// 色を交互に設定
		bgColor = (i % 2 == 0) ? color1: color2;
		g.fillStyle = bgColor;
		g.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE + game.offsetY, BLOCK_SIZE, BLOCK_SIZE);
	}
}

/*
 * カーソルを描画
 */
const drawCursor = ()=> {
	// ステージ外ではなにもしない
	if(pos.y < game.offsetY) return;

	// マウス座標からブロック位置を求める
	const x = Math.floor(pos.x / BLOCK_SIZE);
	const y = Math.floor(pos.y / BLOCK_SIZE);

	g.fillStyle = "rgba(128, 128, 128, 0.5)";	// グレー色で透過
	g.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
}

/*
 * ステージを表示
 */
const drawStage = ()=> {
	// キャンバスクリア
	//g.clearRect(0, 0, canvas.width, canvas.height);
	g.fillStyle = "snow";
	g.fillRect(0, 0, canvas.width, canvas.height);

	// 背景描画
	drawBackground();
	// カーソル描画
	drawCursor();
	// 次のブロックを表示
	showNextBlock();

	// ステージ配列に基づき、ブロック画像を表示
	for(let i=0; i<BLOCK_LENGTH; i++){
		x = i % COLUMN_SIZE;			// x方向ブロック座標
		y = Math.floor(i / ROW_SIZE);	// Y方向ブロック座標

		// ブロックを表示
		g.drawImage(images[stageState[i]], x*BLOCK_SIZE, y*BLOCK_SIZE + game.offsetY);

		// ゲーム状態を表す配列内容を表示(デバッグ用)
		if(isDebug){
			g.fillStyle = "#fffa";
			g.fillText(stageState[i], x*BLOCK_SIZE+20, y*BLOCK_SIZE+20+game.offsetY);
		}
	}
}

/*
 * 画像ファイル読み込み
 */
const loadImages = async ()=> {
	const images = [];
	const promises = [];

	image_files.forEach((url) =>{
		const promise = new Promise((resolve)=>{
			const img = new Image();
			img.addEventListener("load", ()=>{
				console.log("loaded " + url);
				resolve();	// 読み込み完了通知
			});
			img.src = "./asset/" + url;	// 画像ファイル読み込み開始
			images.push(img);	// 画像オブジェクト配列に格納
		});
		promises.push(promise);
	});
	await Promise.all(promises);	// 全ての画像ファイル読み込みが完了してから
	return images;					// 画像オブジェクトを返す
}

/*
 * ステージ情報を生成
 */
const createStage = (stage)=> {
	stageState = [];
	stageState = [...stage];	// ステージ情報をコピー(stageStage <-- stage)
	console.log(stageState);
}

/*
 * ブロックを落とす
 */
const dropBlock = ()=> {
	// 落下タイミングチェック
	timer.end = new Date().getTime();
	const passedTime = (timer.end - timer.start) / 100;	// 10分の1秒に直す

	if(passedTime <= dropSpeed){
		return;	// 落下しない
	}

	let dropCount = 0;	// 落としたブロックの数
	for(let y=ROW_SIZE-2; y>=0; y--){	// ステージの下方向からチェック
		for(let x=0; x<COLUMN_SIZE; x++){
			const upIndex = y*ROW_SIZE+x;
			const downIndex = (y+1)*ROW_SIZE+x;
			if(stageState[upIndex] != 0 && stageState[downIndex] == 0){
				// 入れ替え
				[ stageState[upIndex], stageState[downIndex] ]= [ stageState[downIndex], stageState[upIndex] ];
				dropCount++;	// 落とすブロックの数をカウント
			}
		}
	}
	timer.start = new Date().getTime();	// 落下タイマー再スタート

	return dropCount;	// 落としたブロック数を返す
}

/*
 * ブロックが消去できるかチェックする
 */
const checkBlock = () =>{
	// ステージの状態をコピー
	const check = [...stageState];
	// 消すブロックの数
	let deleteBlockCount = 0;
	// 横方向チェック
	for(let y=0; y<ROW_SIZE; y++){
		for(let x=1; x<COLUMN_SIZE-1; x++){
			const currentIndex = y*ROW_SIZE + x;	// 中央
			const rightIndex = y*ROW_SIZE + (x + 1);// 右側
			const leftIndex = y*ROW_SIZE + (x - 1);	// 左側
			if(stageState[currentIndex] > 0){
				if((stageState[currentIndex] === stageState[rightIndex] && stageState[currentIndex] === stageState[leftIndex])){
					check[currentIndex] = check[rightIndex] = check[leftIndex] = 7;	// 7はこれから消す予定の×マーク画像
					deleteBlockCount++;
				}
			}
		}
	}
	// 縦方向チェック
	for(let y=1; y<ROW_SIZE-1; y++){
		for(let x=0; x<COLUMN_SIZE; x++){
			const currentIndex = y*ROW_SIZE + x;	// 中央
			const upIndex = (y-1)*ROW_SIZE + x;	// 上側
			const downIndex = (y+1)*ROW_SIZE + x;	// 下側
			if(stageState[currentIndex] > 0){
				if((stageState[currentIndex] === stageState[upIndex] && stageState[currentIndex] === stageState[downIndex])){
					check[currentIndex] = check[upIndex] = check[downIndex] = 7;	// 7はこれから消す予定の×マーク画像
					deleteBlockCount++;
				}
			}
		}
	}

	// チェック後の状態をステージ情報に戻す
	stageState = [...check];
	return deleteBlockCount;
}

/*
 * ブロックを消去する
 */
const deleteBlock = () =>{
	for(let i=0; i<BLOCK_LENGTH; i++){
		if(stageState[i] == 7){	// ×画像だったら
			stageState[i] = 0;	// 何もない画像にする
		}
	}
}

/*
 * 最上段にランダムにブロックを配置する
 */
const setRandomBlock = () =>{
	for(let i=0; i<COLUMN_SIZE; i++){
		const blockType = Math.floor(Math.random() * (images.length-1));	// 0~6
		stageState[i] = blockType;
	}
}

/*
 * 次のブロックをランダムで決定する
 */
const setNextBlock = () =>{
	// 次のブロックタイプをランダムで設定
	nextBlockType = Math.floor(Math.random() * (images.length-2)) + 1;	// 1~6
	console.log("NEXT: " + image_files[nextBlockType]);
}

/*
 * 次のブロックを表示する
 */
const showNextBlock = () =>{
	// NEXTの文字
	g.fillStyle = "rosybrown";
	g.fillText("NEXT", 324, 50);

	// 次のブロック画像を表示
	g.drawImage(images[nextBlockType], 424, 35, 50, 50);
}

/*
 * ゲーム状態を表示(デバッグ表示用)
 */
const showGameState = () =>{
	// Symbolのdescriptionを表示する
	g.fillStyle = "yellow";
	g.fillText(game.state.description, 220, 220 + game.offsetY);
}

/*
 * タイトル画面を表示
 */
const drawTitle = () =>{
	g.fillStyle = "rosybrown";
	g.fillRect(0, 0, canvas.width, canvas.height);

	g.fillStyle = "snow";
	const textObj = g.measureText(TITLE_TEXT);	// キャンバス文字列の情報を得る

	// タイトル文字列を中央にくるように配置
	g.fillText(TITLE_TEXT, canvas.width/2-textObj.width/2, canvas.height/2-15);
}

/*
 * ゲームの初期化
 */
const gameInit = () =>{
	// ステージ情報を生成する
	const stage1 = [
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0
	];
	createStage(stage1);

	// 最上段にランダムにブロックを配置
	setRandomBlock();

	// 最初のブロックを決定
	setNextBlock();

	// 落下モードに移行
	game.state = DROP;

	// 落下タイマースタート
	timer.start = new Date().getTime();
}

/*
 * ゲームのメイン処理(少しだけゲームっぽくなってきている)
 */
const GameMain = () =>{

	// ゲーム状態を表示(デバッグ用)
	if(isDebug){
		showGameState();
	}

	// ゲーム状態(game.state)による分岐処理
	if(game.state == DROP){
		// ステージ表示
		drawStage();
		// ブロックを落とす
		if(dropBlock() == 0){	// 落とすブロックが無くなったら
			game.state = CHECK_BLOCK;
		}
	}
	else if(game.state == CHECK_BLOCK){
		// ステージ表示
		drawStage();
		// 消せるブロックをチェックする
		if(checkBlock() != 0){
			game.state = DELETE_BLOCK;
		}
		else{
			game.state = WAITING;	// 消すブロックがないなら入力待ち
		}
	}
	else if(game.state == DELETE_BLOCK){
		// ステージ表示
		drawStage();
		// ブロックを消去
		setTimeout( function(){
			deleteBlock();
			game.state = DROP;
		}, 200);	// すぐに消去せず、どれが消去ブロックが分かるように200ミリ秒の間×画像を表示している
	}
	else if(game.state == SET_BLOCK){
		// ステージ表示
		drawStage();
		// ステージ最上段にランダムにブロックを配置
		setRandomBlock();
		// ブロックタイプを更新
		currentBlockType = nextBlockType;
		// 次のブロックタイプを設定
		setNextBlock();
		// ステージのクリック位置に画像ブロックを設定
		const blockPos = {
			x: Math.floor(pos.x / BLOCK_SIZE),					//ブロックx座標
			y: Math.floor((pos.y-game.offsetY) / BLOCK_SIZE)	// ブロックのy座標
		};

		stageState[blockPos.x + blockPos.y * COLUMN_SIZE] = currentBlockType;
		console.log(blockPos);

		// ブロックを落とすモードに移行
		game.state = DROP;
	}
	else if(game.state == WAITING){
		// ブロック配置待ち

		// ステージ表示
		drawStage();
	}
	else if(game.state == TITLE){
		// タイトル画面
		drawTitle();
	}
	// フレーム再描画
	requestAnimationFrame(GameMain);
}

/*
 * 起動時の処理
 */
window.addEventListener("load", async ()=>{
	// キャンバス取得
	canvas = document.getElementById("canvas");
	g = canvas.getContext("2d");
	g.font = "normal 30px Impact";	// フォントサイズ・フォント種類 設定
	g.textBaseline = "top";			// テキスト描画時のベースライン

	// マウスイベント設定
	canvas.addEventListener("mousemove", mouseMoveListener, false);
	canvas.addEventListener("mousedown", mouseDownListener, false);

	// 画像ファイルを読み込む
	images = await loadImages(image_files);
	console.log(images);

	// タイトル画面からスタート
	game.state = TITLE;

	// ゲームループ開始
	GameMain();
});

関連記事

コメント

タイトルとURLをコピーしました