1. HOME»
  2. プログラミング・Web»
  3. JavaScript»
  4. JavaScriptでRPGを作ろう!スマホにも対応したゲームの作り方

JavaScriptでRPGを作ろう!スマホにも対応したゲームの作り方

山田

ふんふーんだべ♪

りこ

あ、山田先生がヘッドフォンしてる。なにいてるんだろう

アル

せんせー、なに聴い……

山田

がぶっ……はっ、しまったべ。またかじってしまったべ!

アル

線、切れちゃったよ……

山田

あ、アルくん……りこちゃんも! 気づかなかったべ
いやいや、つい長いものを見ると、かじりたくなってしまうんだべ……

りこ

マウスもかじっちゃうんだよね!

山田

そうなんだべ……

アル

長いもので思い出したけど、今日、ぼくのクラスの先生の靴紐くつひもだれかに切られてたんだって
すごく大切たいせつな靴だったみたいで、ぼくのクラスの子、すっごくおこられてた

山田

えっ……うそだべ?

アル

……ホントだけど、なんでそんなにびっくりするの?

りこ

……

アル

……

山田

……うそだべ?《》

アル

……ホントだって

山田

……《》

りこ

……

山田

……きっとその子にも、いつかしあわせがやってくるべ

目次
  1. プリロードできるようにしてみよう!
  2. 音楽を再生してみよう!

プリロードできるようにしてみよう!

つづいて、素材そざいをプリロードできるようにしてみましょう!

アル

ねぇ、プリロードってなぁに?

山田

素材をさきに読み込んでおくことだべ

りこ

えっ、先に読み込んでおかなくちゃいけないの?

山田

うむ……場合ばあいによっては、思わぬバグが出てきてしまったりするべ

アル

バグ?

山田

プログラミングでの間違まちがいのことだべ

りこ

でも、いまのところちゃんと動いてるよ

山田

そうだべな……たとえば、こんなふうに作ったとするべ

scene.onenterframe = () => {
	const img = new Image();
	img.src = 'img/rico.png';
	console.log( img.width );
}
山田

りこちゃんの画像がぞうを読み込んで、その横幅よこはばをコンソールに表示ひょうじするプログラムだべ

りこ

えっと、シーンのonenterframeの中にあるから、ずっとコンソールに表示されつづけるのね!

山田

さて、これをブラウザ確認かくにんするとどうなると思うべ?

りこ

えっ、私の画像の横幅は96pxだから、ずっと96って表示され続けるんじゃないの?

山田

ふっふっふ……それはどうかなだべ

最初に0が何回か入ってしまう
りこ

あれ? どうして0が入ってるの!?

アル

0pxの画像なんて、わけが分からないよ!

山田

これは、画像がまだ読み込めていないのに、横幅を表示させようとしてしまったからだべ

りこ

あぁ、なるほどね……

山田

こういったことが、バグの原因げんいんとなったりするべ
これまでのプログラムは、プリロードしていなくてもうまくいくように作ったべけど、今後こんごはそうもいかないときがくるべ

アル

もし画像のサイズをもとに計算するプログラムなんかだったら、絶対にバグがでてきちゃうよね

りこ

でも、プリロードの大切さが分かったよ!

アル

ぼくも!

山田

では、素材をプリロードできる機能きのうを作っていくべ
でもまず、プリロードは非同期ひどうきで動いてしまう、ということを考えて欲しいんだべ

アル

非同期? 同時どうじに動かないってこと?

山田

むむ……同時に動かないんじゃなくて、逆に同時に動いてるように見えたりするべ

プログラミング同期どうきと非同期は、言葉的にとても分かりにくいべな……
そうだべな……非同期の前に、同期ってなんだと思うべ?

アル

同じにする……みたいな? ファイル内容ないようだったり、タイミングだったり……

山田

うむ。プログラムでは、たいてい上から順番じゅんばん実行じっこうされていくべな……でもパソコンが気まぐれで、ときにはなかから実行してしまったりしたら、思いどおりに動かないべ

だから、ちゃんと上からひとつのおねがいを実行し、それが終わったタイミングで、次のお願いを実行する……これがプログラミングでいう同期だべ

アル

なるほど。同時に動かすわけじゃないんだ!

りこ

一番上を実行、それが終わったら次を実行……確実かくじつにお願いごとを実行していってくれるのが同期なのね!

山田

そして、もしちゃんと上から順に実行していても、それが終わる前に次のお願いを実行してしまったら、実行結果じっこうけっかは思わぬものになってしまうべ
なぜなら、先に書いたお願いごとの前に、あとに書いたお願いごとの結果が出てしまったりするんだべ。これではどの結果が出るか分からないべ。自動販売機じどうはんばいきで全部のボタンを同時に押すようなものだべ

……さて、プログラミングをしていると、こんなふうに順番がはっきりしておらず、まるで同時に実行されているように感じられることがあるべ。同時に実行されてるように感じるから、まるで同期してるっぽいべけど、でもこれでは同期とは言えないべ

……なるほど。これが、非同期だべ

アル

えっ、じゃあ非同期ってダメなんじゃん!

山田

ところがどっこい、時間のかかるお願いごとは、完了かんりょうするまえに次のお願いごとを実行してくれるべから、プレイヤーはち時間が少なくてすむという、いい部分もあるんだべ

アル

あ、そうか……

りこ

プログラムって、上から順番に実行されるのよね? ふつうは同期なんじゃないの?

山田

でも、さっきは画像が読み込まれる前に横幅を取得しゅとくしようとして、0と表示されてたべな

りこ

あ……

山田

そう……JavaScriptでは、必ずしも上から実行結果が返されるというわけではないんだべ
基本的に上からは実行されるべけど、結果が出るまでに時間がかかれば、その前に次を実行してしまうべ
それで実行結果が予想外よそうがいなことになったりするんだべ

りこ

つまりさっきのれいでは……えっと、画像の読み込みには時間がかかるから、その前に画像の横幅を表示するお願いを実行した。でも画像がまだ読み込めてなかったから、横幅は0って表示された。こういうことね!

山田

そういうことだべ!

アル

うーん。むずかしいな……それでどうすればいいの?

山田

うむ。ではプリロードの機能きのうを作っていくべ!

js/engine/game.js

'use strict'

/**
 * ゲームづくりの基本となるクラス
 */
class Game {

	/**
	 * 引数
	 * width : ゲームの横幅
	 * height : ゲームの縦幅
	 */
	constructor( width, height ) {
		//canvas要素を作成
		this.canvas = document.createElement( 'canvas' );
		//作成したcanvas要素をbodyタグに追加
		document.body.appendChild( this.canvas );
		//canvasの横幅(ゲームの横幅)を設定。もし横幅が指定されていなければ320を代入
		this.canvas.width = width || 320;
		//canvasの縦幅(ゲームの縦幅)を設定。もし縦幅が指定されていなければ320を代入
		this.canvas.height = height || 320;

		//シーンを入れておくための配列
		this.scenes = [];
		//現在のシーンをいれておくためのもの
		this.currentScene;

		//プリロードは、ゲームのメイン部分が始まる前に動かしたいので、それを入れておくための配列
		this._preloadPromises = [];

		//現在のシーンを一時的に入れておくためのもの。シーンが切り替わったかどうかを判断するのに使う
		this._temporaryCurrentScene;

		//ゲームに使用するキーと、そのキーが押されているかどうかを入れるための連想配列
		//例 { up: false, down: false }
		this.input = {};
		//登録されたキーに割り当てられたプロパティ名と、キー名を、関連づけるための連想配列
		//例 { up: "ArrowUp", down: "ArrowDown" }
		this._keys = {};
	} //constructor() 終了

	/**
	 * プリロードのためのメソッド
	 *
	 * 引数には、使いたい素材を制限なく入れることができる
	 */
	preload() {
		//引数の素材を_assetsに追加
		const _assets = arguments;
		//素材の数だけ繰り返す
		for ( let i=0; i<_assets.length; i++ ) {
			//_preloadPromises[i]に、あなたはプリロードのプロミス(非同期処理をやりやすくする)だよ、と教える
			this._preloadPromises[i] = new Promise( ( resolve, reject ) => {
				//もしそのファイル拡張子が、jpg、jpeg、png、gifのどれかのとき
				if ( _assets[i].match( /\.(jpg|jpeg|png|gif)$/i ) ) {
					//_imgに、あなたは画像ですよ、と教える
					let _img = new Image();
					//img.srcに、引数で指定した画像ファイルを代入
					_img.src = _assets[i];

					//画像が読み込み終わったら、成功ということで、resolve()を呼び出す
					_img.addEventListener( 'load', () => {
						resolve();
					}, { passive: true, once: true } );

					//画像が読み込めなければ、エラーということで、reject()を呼び出す
					_img.addEventListener( 'error', () => {
						reject( `「${_assets[i]}」は読み込めないよ!` );
					}, { passive: true, once: true } );
				}
				//ファイル拡張子がどれでもないとき
				else {
					//エラーということで、reject()を呼び出す
					reject( `「${_assets[i]}」の形式は、プリロードに対応していないよ!` );
				}
			} );
		}
	} //preload() 終了

	/**
	 * プリロードなどの設定が終わったあとに実行する
	 *
	 * 引数
	 * callback : プリロードなどの設定が終わったあとに実行したいプログラム。今回はゲームのメイン部分
	 */
	main( callback ) {
		//ゲームが始まる前に実行しておきたいもの(今回はプリロード)が、すべて成功したあとに、実行したかったゲームのメイン部分「callback()」を実行
		//失敗したときはコンソールにエラーを表示
		Promise.all( this._preloadPromises ).then( result => {
			callback();
		} ).catch( reject => {
			console.error( reject );
		} );
	} //main() 終了

	/**
	 * startメソッドを呼び出すことで、メインループが開始される
	 */
	start() {
		//デフォルトのキーバインドを登録する(使いたいキーを登録する)
		this.keybind( 'up', 'ArrowUp' );
		this.keybind( 'down', 'ArrowDown' );
		this.keybind( 'right', 'ArrowRight' );
		this.keybind( 'left', 'ArrowLeft' );

		//現在のシーン(currentScene)になにも入っていないときは、scenes[0]を代入
		this.currentScene = this.currentScene || this.scenes[0];

		//ゲームがはじまったときと、ブラウザのサイズが変わったときに呼ばれる。縦横の比を変えずに、canvasを拡大縮小できる
		const _resizeEvent = () => {
			//ブラウザとcanvasの比率の、縦と横を計算し、小さいほうを_ratioに代入する
			const _ratio = Math.min( innerWidth / this.canvas.width, innerHeight / this.canvas.height );
			//canvasのサイズを、ブラウザに合わせて変更する
			this.canvas.style.width = this.canvas.width*_ratio + 'px';
			this.canvas.style.height = this.canvas.height*_ratio + 'px';
		} //_resizeEvent() 終了

		//ブラウザのサイズが変更されたとき、_resizeを呼び出す
		addEventListener( 'resize', _resizeEvent, { passive: true } );
		//_resizeを呼び出す
		_resizeEvent();

		//メインループを呼び出す
		this._mainLoop();

		//イベントリスナーをセットする
		this._setEventListener();
	} //start() 終了

	/**
	 * イベントリスナーをセットするためのメソッド
	 */
	_setEventListener() {
		//なにかキーが押されたときと、はなされたときに呼ばれる
		const _keyEvent = e => {
			//デフォルトのイベントを発生させない
			e.preventDefault();
			//_keysに登録された数だけ繰り返す
			for ( let key in this._keys ) {
				//イベントのタイプによって呼び出すメソッドを変える
				switch ( e.type ) {
					case 'keydown' :
						//押されたキーが、登録されたキーの中に存在するとき、inputのそのキーをtrueにする
						if ( e.key === this._keys[key] ) this.input[key] = true;
						break;
					case 'keyup' :
						//押されたキーが、登録されたキーの中に存在するとき、inputのそのキーをfalseにする
						if ( e.key === this._keys[key] ) this.input[key] = false;
						break;
				}
			}
		}
		//なにかキーが押されたとき
		addEventListener( 'keydown', _keyEvent, { passive: false } );
		//キーがはなされたとき
		addEventListener( 'keyup', _keyEvent, { passive: false } );

		//画面がタッチされたり、指が動いたりしたときなどに呼ばれる
		//シーンや、スプライトなどのオブジェクトの左上端から見た、それぞれの指の位置を取得できるようになる
		const _touchEvent = e => {
			//デフォルトのイベントを発生させない
			e.preventDefault();
			//タッチされた場所などの情報を取得
			const _touches = e.changedTouches[0];
			//ターゲット(今回はcanvas)のサイズ、ブラウザで表示されている部分の左上から見てどこにあるか、などの情報を取得
			const _rect = _touches.target.getBoundingClientRect();
			//タッチされた場所を計算
			const _fingerPosition = {
				x: ( _touches.clientX - _rect.left ) / _rect.width * this.canvas.width,
				y: ( _touches.clientY - _rect.top ) / _rect.height * this.canvas.height
			};
			//イベントのタイプを_eventTypeに代入
			const _eventType = e.type;
			//タッチイベントを割り当てるためのメソッドを呼び出す
			this.currentScene.assignTouchevent( _eventType, _fingerPosition );
		} //_touchEvent() 終了

		//タッチされたとき
		this.canvas.addEventListener( 'touchstart', _touchEvent, { passive: false } );
		//指が動かされたとき
		this.canvas.addEventListener( 'touchmove', _touchEvent, { passive: false } );
		//指がはなされたとき
		this.canvas.addEventListener( 'touchend', _touchEvent, { passive: false } );
	} //_setEventListener() 終了

	/**
	 * メインループ
	 */
	_mainLoop() {
		//画家さん(コンテキスト)を呼ぶ
		const ctx = this.canvas.getContext( '2d' );
		//塗りつぶしの色に、黒を指定する
		ctx.fillStyle = '#000000';
		//左上から、画面のサイズまでを、塗りつぶす
		ctx.fillRect( 0, 0, this.canvas.width, this.canvas.height );

		//現在のシーンのupdateメソッドを呼び出す
		this.currentScene.update();

		//一時的に入れておいたシーンが現在のシーンではないとき(シーンが切り替わったとき)、現在のシーンのonchangesceneメソッドを呼び出す
		if ( this._temporaryCurrentScene !== this.currentScene ) this.currentScene.onchangescene();

		//現在のシーンの、ゲームに登場する全てのもの(オブジェクト)の数だけ繰り返す
		for ( let i=0; i<this.currentScene.objs.length; i++ ) {
			//現在のシーンの、すべてのオブジェクトのupdateメソッドを呼び出す
			this.currentScene.objs[i].update( this.canvas );
		}

		//現在のシーンを覚えておいてもらう
		this._temporaryCurrentScene = this.currentScene;

		//自分自身(_mainLoop)を呼び出して、ループさせる
		requestAnimationFrame( this._mainLoop.bind( this ) );
	} //_mainLoop() 終了

	/**
	 * ゲームにシーンに追加できるようになる、addメソッドを作成
	 *
	 * 引数
	 * scene : 追加したいシーン
	 */
	add( scene ) {
		//引数がSceneのとき、this.scenesの末尾にsceneを追加
		if ( scene instanceof Scene ) this.scenes.push( scene );
		//引数がSceneでなければ、コンソールにエラーを表示
		else console.error( 'Gameに追加できるのはSceneだけだよ!' );
	} //add()終了

	/**
	 * 使いたいキーを登録できるようになる、keybindメソッドを作成
	 *
	 * 引数
	 * name : キーにつける名前
	 * key : キーコード
	 */
	keybind( name, key ) {
		//キーの名前と、キーコードを関連づける
		this._keys[name] = key;
		//キーが押されているかどうかを入れておく変数に、まずはfalseを代入しておく
		this.input[name] = false;
	} //keybind() 終了

}
山田

29行目で、プリロードでつかうPromiseプロミスオブジェクトを入れておくためのプロパティを、配列はいれつとして初期化しょきかしているべ

アル

ぷろみす?

りこ

なんだかどんどんむずかしくなってるよぉ……

山田

大丈夫だいじょうぶだべ。ひとつずつ、おぼえていくべよ
プロミスは、非同期処理ひどうきしょりをやりやすくするためのものだべ

アル

もう、なにがなんだか……

山田

まず……さきほど、画像の横幅を表示しようとしたのに、0と表示されてしまったべな。これは、画像の読み込みには少し時間がかかるべから、次のお願いごとへとうつってしまったんだべ。これが非同期だべ

しかし、プリロードはゲームがはじまる前に素材を読み込んでおくことだべ
最初さいしょに素材を読み込んでおこうとしたのだべから、読み込み終わる前にゲームがはじまってはいけないんだべよ

アル

たしかにそうだよね

りこ

とにかく、素材を読み込み終わってから、ゲームをスタートさせたいのよね

山田

そんなときに使えるのが、プロミスだべ!
プロミスを使えば、非同期のお願いごとをすべて実行し、それが終わったあとに、つぎのお願いごとを実行する、といったことができるんだべ!

りこ

画像の読み込みみたいな時間がかかるお願いでも、順序よく実行できるってこと?

山田

そういうことだべ!
そして47〜78行目で、プリロードできるようにするためのメソッドを作っているべ!

アル

なるほど。引数は使いたい素材をすべてだね!

山田

また、画像が読み込まれたかどうかは62〜64行目のaddEventListenerで判断しているんだべ

りこ

addEventListenerって、キャラクターの移動やブラウザのサイズ変更のときも使ったよね!

山田

うむ。addEventListenerは、なにかが《》こったとき、なにかを起こす、というものだべ
キーがされたとき、キャラクターを動かす。ブラウザのサイズが変更へんこうされたとき、ゲーム画面のサイズも変更する……
そして今回は、画像が読み込み終わったとき、resolveメソッドを呼び出すんだべ!

アル

resolveメソッド?

山田

成功せいこうしたってことだべ
今回は、プロミスで画像の読み込みが成功したときに、resolveメソッドを呼び出しているべ

りこ

ちょっとむずかしいけど、とにかく、画像の読み込みが成功したから、resolveメソッドを呼び出しているのね

アル

失敗しっぱいしたら?

山田

67〜69行目で、画像が読み込めなかったとき、rejectメソッドを呼び出しているべ。これが失敗したときのものだべ

りこ

なるほど。resolveが成功で、rejectが失敗ね!

山田

さらに、55行目では、ファイル拡張子かくちょうしを取得して、読み込もうとしたものが、プリロードできる画像ファイルかどうかを判断はんだんしているべ
使える拡張子はjpg、jpeg、png、gifだべ
もしどれでもなければ、72〜75行目で、rejectメソッドを呼び出しているべ

りこ

プリロードできないファイルだったときも、失敗ってことになるのね! ちょっと分かってきたよ

山田

さて、この画像を読み込むためのお願い……つまり54〜75行目は、すべてプロミスとして、this._preloadPromises[i]に入れているべ。これが53〜76行目だべ

そしてそれを51行目のforでかえして、すべての画像を読み込んでいるんだべ

アル

なるほど……ひとつひとつの画像の読み込みが、成功したか、失敗したか、ってのを判断してくれるお願いを繰り返して、this._preloadPromises[i]に入れたんだね!

りこ

でも、成功したか失敗したかを判断して、それをどうすればいいの?

山田

ふっふっふ。86〜94行目を見て欲しいんだべ

アル

mainメソッド……これはなに?

山田

このmainメソッドの引数ひきすうに、ゲーム部分ぶぶんのプログラムを指定していすれば、プリロードが成功したあとに実行されるんだべ

アル

なるほど! 本当は非同期で動くはずの画像の読み込みでも、それがちゃんと終わったあとに、ゲームを実行できるんだ!

山田

そういうことだべ!
さて、画像が読み込まれたかどうかは、すべてthis._preloadPromisesに入っているべ。そしてゲーム部分のプログラムはcallbackに入っているべ
つまり、this._preloadPromisesに入っているものが、すべて成功したあとに、callbackを実行したいんだべ

そんなときに使えるのが、Promise.allメソッドだべ

りこ

Promise.all?

山田

Promise.allは、引数に指定したプロミスをすべて実行してくれるものだべ。こんかいならば、画像が読み込まれたかどうかを、ずべて実行してくれているんだべ

さて、このプロミスが成功したかどうかを受け取るのが、89行目のthenメソッドだべ。もし成功していれば90行目でcallbackを実行し、失敗していれば92行目でエラーを表示しているんだべ

アル

そうか、これで画像の読み込みがすべて成功したあとに、ゲームを実行できるのか!

りこ

Promise、ちょっとむずかしいけど、すごく便利べんりなのね!

山田

さて、では実際にプリロードを使ってみるべ!

js/main.js

'use strict'

//ブラウザがページを完全に読みこむまで待つ
addEventListener( 'load', () => {

	//変数gameに、あなたはゲームですよ、と教える
	const game = new Game();
	//ゲームに使う画像などの素材を先に読み込んでおく(プリロード)
	game.preload( 'img/yamada.png', 'img/rico.png', 'img/aru.png', 'img/start.png', 'img/goal.png', 'img/tile.png', 'img/dpad.png' );
	//使いたいキーとして、スペースキーを登録する
	game.keybind( 'space', ' ' );
	//ゲームを開始する準備ができたあとに実行する
	game.main( () => {

		//タイトルシーン
		const titleScene = () => {

			//変数sceneに、あなたはシーンですよ、と教える
			const scene = new Scene();

			//変数titleTextに、あなたは「くろねこラビリンス」というテキストだよ、と教える
			const titleText = new Text( 'くろねこラビリンス' );
			//テキストを上下左右中央の位置にする
			titleText.center().middle();
			//シーンにテキストを追加
			scene.add( titleText );

			//シーンがタッチされたとき
			scene.ontouchstart = () => {
				//メインシーンに切り替える
				game.currentScene = mainScene();
			} //scene.ontouchstart() 終了

			//ループから常に呼び出される
			scene.onenterframe = () => {
				//スペースキーが押されたとき、メインシーンに切り替える
				if ( game.input.space ) game.currentScene = mainScene();
			} //scene.onenterframe() 終了

			//作ったシーンを返す
			return scene;

		} //titleScene() 終了

		//メインシーン
		const mainScene = () => {

			//マップの作成
			const map = [
				[11,11,11,11,11,11,11,11,11,11],
				[11,10,10,10,10,10,10,10,10,11],
				[11, 4, 4, 4, 4, 4, 4, 4, 4,11],
				[11, 4,11, 4, 4,11,11,11, 4,11],
				[11, 4,11,11,11,11,10,10, 4,11],
				[11, 4,11,10,10,11, 4, 4, 4,11],
				[11, 4,11, 4, 4,11,11,11, 4,11],
				[11, 4, 9, 4, 4, 9,10,11, 4,11],
				[11, 4, 4, 4, 4, 4, 4,11, 4,11],
				[11,11,11,11,11,11,11,11,11,11]
			];
			//タイルのサイズ
			const TILE_SIZE = 32;
			//歩く速さ
			const WALKING_SPEED = 4;

			//変数sceneに、あなたはシーンですよ、と教える
			const scene = new Scene();

			//変数tilemapに、あなたはタイルマップですよ、と教える
			const tilemap = new Tilemap( 'img/tile.png' );
			//tilemap.dataに、どんなマップなのか教える
			tilemap.data = map;
			//マップ全体の位置をずらす
			tilemap.x = TILE_SIZE*4 - TILE_SIZE/2;
			tilemap.y = TILE_SIZE*3 - TILE_SIZE/2;
			//移動できないタイルを指定する
			tilemap.obstacles = [0, 3, 6, 7, 8, 9, 10, 11];
			//マップを登録する
			scene.add( tilemap );

			//変数startに、あなたはスタートのタイルですよ、と教える
			const start = new Tile( 'img/start.png' );
			//マップ左上からの座標を指定する
			start.x = TILE_SIZE;
			start.y = TILE_SIZE*2;
			//スタートのタイルを、tilemapに追加して、とお願いする
			tilemap.add( start );

			//変数goalに、あなたはゴールのタイルですよ、と教える
			const goal = new Tile( 'img/goal.png' );
			//マップ左上からの座標を指定する
			goal.x = TILE_SIZE*8;
			goal.y = TILE_SIZE*8;
			//ゴールのタイルを、tilemapに追加して、とお願いする
			tilemap.add( goal );

			//変数yamadaに、あなたは山田先生のキャラクタータイルですよ、と教える
			const yamada = new CharacterTile( 'img/yamada.png' );
			//山田先生を画面の中央に配置
			yamada.x = yamada.y = TILE_SIZE*5 - TILE_SIZE/2;
			//タイルマップの動きと同期させない
			yamada.isSynchronize = false;
			//tilemapに、山田先生のタイルを追加して、とお願いする
			tilemap.add( yamada );

			//変数ricoに、あなたはりこちゃんのキャラクタータイルですよ、と教える
			const rico = new CharacterTile( 'img/rico.png' );
			//りこちゃんの位置を決める
			rico.x = TILE_SIZE*7 - TILE_SIZE/2;
			rico.y = TILE_SIZE*5 - TILE_SIZE/2;
			//タイルマップの動きと同期させない
			rico.isSynchronize = false;
			//tilemapに、りこちゃんのキャラクタータイルを追加して、とお願いする
			tilemap.add( rico );

			//変数aruに、あなたはアルくんのキャラクタータイルですよ、と教える
			const aru = new CharacterTile( 'img/aru.png' );
			//アルくんの位置を決める
			aru.x = TILE_SIZE*7 - TILE_SIZE/2;
			aru.y = TILE_SIZE*6 - TILE_SIZE/2;
			//タイルマップの動きと同期させない
			aru.isSynchronize = false;
			//tilemapに、アルくんのキャラクタータイルを追加して、とお願いする
			tilemap.add( aru );

			//変数partyに、あなたは山田先生とりこちゃんとアルくんのパーティですよ、と教える
			const party = new Party( yamada, rico, aru );
			//パーティの歩く速さに、WALKING_SPEEDの値を代入する
			party.speed = WALKING_SPEED;

			//変数dpadに、あなたはD-Padですよ、と教える
			const dpad = new DPad( 'img/dpad.png', 80 );
			//D-Padの位置を指定する
			dpad.x = 10;
			dpad.y = 230;
			//sceneに、D-Padを追加して、とお願いする
			scene.add( dpad );

			//キャラクターのアニメーションのための変数
			let toggleForAnimation = 0;
			//ゴールのテキストが表示されているかどうかの変数
			let hasDisplayedGoalText = false;
			//移動可能かどうかの変数
			let isMovable = true;

			//ループから常に呼び出される
			scene.onenterframe = () => {
				//タイルマップの位置がタイルのサイズで割り切れるとき
				if ( ( tilemap.x - TILE_SIZE/2 ) % TILE_SIZE === 0 && ( tilemap.y - TILE_SIZE/2 ) % TILE_SIZE === 0 ) {
					//タイルマップの移動速度に0を代入する
					tilemap.vx = tilemap.vy = 0;
					//パーティ全員の数だけ繰り返す
					for ( let i in party.member ) {
						//パーティ全員の移動速度を0にする
						party.member[i].vx = party.member[i].vy = 0;
						//パーティ全員の画像を切り替える
						party.member[i].animation = 1;
					}

					//山田先生のタイルがゴールのタイルと重なっているとき、イベントを発生させる
					if ( yamada.isOverlapped( goal ) ) {
						//ゴールのテキストが表示されていないとき
						if ( !hasDisplayedGoalText ) {
							//変数goalTextに、あなたは「ゴールだべ!」というテキストだよ、と教える
							const goalText = new Text( 'ゴールだべ!' );
							//テキストサイズを変更
							goalText.size = 50;
							//テキストを上下左右中央の位置にする
							goalText.center().middle();
							//シーンにテキストを追加
							scene.add( goalText );
							//ゴールのテキストが表示されているかどうかの変数にtrueを代入
							hasDisplayedGoalText = true;
							//移動ができないようにする
							isMovable = false;
							//6秒たったら、タイトルシーンに切り替える
							setTimeout( () => {
								game.currentScene = titleScene();
							}, 6000 );
						}
					}

					//移動可能なとき
					if ( isMovable ) {
						//方向キー、もしくはD-Padが押されているときは、setMemberVelocityメソッドを呼び出し、タイルマップの移動速度と、山田先生の向きに、それぞれの値を代入する
						if ( game.input.left || dpad.arrow.left ) {
							party.setMemberVelocity( 'left' );
							tilemap.vx = WALKING_SPEED;
							yamada.direction = 1;
						}
						else if ( game.input.right || dpad.arrow.right ) {
							party.setMemberVelocity( 'right' );
							tilemap.vx = -1 * WALKING_SPEED;
							yamada.direction = 2;
						}
						else if ( game.input.up || dpad.arrow.up ) {
							party.setMemberVelocity( 'up' );
							tilemap.vy = WALKING_SPEED;
							yamada.direction = 3;
						}
						else if ( game.input.down || dpad.arrow.down ) {
							party.setMemberVelocity( 'down' );
							tilemap.vy = -1 * WALKING_SPEED;
							yamada.direction = 0;
						}

						//移動後のマップ座標を求める
						const yamadaCoordinateAfterMoveX = yamada.mapX - tilemap.vx/WALKING_SPEED;
						const yamadaCoordinateAfterMoveY = yamada.mapY - tilemap.vy/WALKING_SPEED;
						//もし移動後のマップ座標に障害物があるとき
						if ( tilemap.hasObstacle( yamadaCoordinateAfterMoveX, yamadaCoordinateAfterMoveY ) ) {
							//移動量に0を代入する
							tilemap.vx = tilemap.vy = 0;
							//パーティ全員の移動速度に0を代入する
							for ( let i in party.member ) {
								party.member[i].vx = party.member[i].vy = 0;
							}					
						}

						//タイルマップが動いているとき、パーティメンバーの向きを変える
						if ( tilemap.vx !== 0 || tilemap.vy !== 0 ) party.setMemberDirection();
					}
				}
				//タイルマップのXとY座標両方がタイルのサイズで割り切れるとき以外で、タイルの半分のサイズで割り切れるとき
				else if ( ( tilemap.x + TILE_SIZE/2 ) % ( TILE_SIZE/2 ) === 0 && ( tilemap.y + TILE_SIZE/2 ) % ( TILE_SIZE/2 ) === 0 ) {
					//0と1を交互に取得できる
					toggleForAnimation ^= 1;
					//パーティメンバーの数だけ繰り返す
					for ( let i in party.member ) {
						//toggleForAnimationの数値によって、パーティ全員の画像を切り替える
						if ( toggleForAnimation === 0 ) party.member[i].animation = 2;
						else party.member[i].animation = 0;
					}
				}
			} //scene.onenterframe 終了

			//作ったシーンを返す
			return scene;

		} //mainScene() 終了

		//gameに、シーンを追加して、とお願いする
		game.add( titleScene() );
		game.add( mainScene() );

		//gameに、ゲームをスタートして、とお願いする
		game.start();

	} ); //main() 終了

} );
山田

まず、9行目で使いたい画像をすべてプリロードしているべ

アル

あれ、ここで画像を読み込むだけでいいの? だってスプライトとかを使うとき、もう一回読み込むんでしょ?

山田

ふっふっふ。じつは、同じファイルは2度も読み込まないんだべ
だから最初に読み込んでしまっておけば、次からはその読み込まれたものが使われるんだべよ

アル

なるほどー

山田

そして13行目と249行目……これはゲーム部分のプログラムを、mainメソッドの引数にしているんだべ

りこ

これでプリロードとゲームが順番に実行されるのね!

山田

では、さきほどと同じように、りこちゃんの画像の横幅を表示して、ためしてみるべ!

scene.onenterframe = () => {
	const img = new Image();
	img.src = 'img/rico.png';
	console.log( img.width );
}
最初に0が入らなくなった!
りこ

最初に0が入ってないってことは、ちゃんと画像が読み込まれてからゲームがスタートしてるってことね!

音楽を再生してみよう!

つづいて、ゲーム内で音楽を再生さいせいしてみましょう!

山田

さて、ゲームエンジンも、これで完成かんせいが近いべ
これから、ゲームにおいてとっても重要じゅうようで、しかも一番むずかしい機能を作っていくべよ!

アル

むむ。そういわれると、なんだかえてくるね……

りこ

わくわく

山田

それは、音楽を再生する機能だべ……

アル

あ、それは重要だ!

りこ

BGMも流せるんだ……すごい!

山田

しかし、音楽をながすのには、とってもむずかしい問題もんだいがあるんだべ……

りこ

なになに?

山田

ブラウザが音をブロックしてしまうだべよ……

アル

な……どうして?

山田

たとえば、サイトを開いた瞬間しゅんかん突然とつぜん音が流れたら、びっくりしてしまうべ。偶然ぐうぜんにも音量おんりょう最大さいだいにしてたりしたら、もう、とび上がってしまうべよ

アル

あぁ、なるほど!

りこ

たしかにそうだよね

山田

そういったことをふせぐために、ブラウザが音を再生しないようにしてくれるんだべ

この機能、サイトを見るときはありがたいんだべけど、ゲームを作って公開こうかいしたいときには、ちょっと大変になるんだべ

りこ

どうすればいいの?

山田

ブラウザで音を再生するには、サイトを見る人の操作そうさが必要になるんだべ
いきなり音が出てしまったらびっくりするべけど、音がでることをちゃんと警告けいこくして、その上でタップやキー入力などの操作をしてもらってから、音を再生するんだべ

アル

そっか。一回操作してから再生されるんだったら、びっくりしなくてすむね!

山田

では、音声を再生する機能を作っていくべよ!
まず、engineフォルダにsound.jsファイルを作って欲しいべ!

rpg/
|-- img (省略)/
|-- index.html
|-- js/
|   |-- engine/
|   |   |-- game.js
|   |   |-- dpad.js
|   |   |-- charactertile.js
|   |   |-- party.js
|   |   |-- scene.js
|   |   |-- sound.js (追加)
|   |   |-- sprite.js
|   |   |-- text.js
|   |   |-- tile.js
|   |   `-- tilemap.js
|   `-- main.js
`-- sound (省略)/
山田

もちろん、index.htmlからも読み込むべよ

index.html

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
	<title>くろねこラビリンス</title>
	<style>
		* {
			padding: 0;
			margin: 0;
		}
		canvas {
			display: block;
		}
	</style>
</head>
<body>
	<script src="js/engine/scene.js"></script>
	<script src="js/engine/sound.js"></script>
	<script src="js/engine/sprite.js"></script>
	<script src="js/engine/dpad.js"></script>
	<script src="js/engine/party.js"></script>
	<script src="js/engine/text.js"></script>
	<script src="js/engine/tile.js"></script>
	<script src="js/engine/charactertile.js"></script>
	<script src="js/engine/tilemap.js"></script>
	<script src="js/engine/game.js"></script>
	<script src="js/main.js"></script>
</body>
</html>
山田

そして、sound.jsはこのようにして欲しいんだべ!

js/engine/sound.js

'use strict'

/**
 * Audioを使いやすくしたSoundクラス
 * Gameクラスから呼び出して使うので、普段は使わない
 */
class Sound extends Audio {

	/**
	 * 引数
	 * src : 音声ファイルまでのパス
	 */
	constructor( src ) {
		//親クラスのコンストラクタを呼び出す
		super( src );
		//autoplayを無効にする
		this.autoplay = false;
	} //constructor() 終了

	/**
	 * 音声を再生するためのメソッド
	 */
	start() {
		//ミュートを無効にする
		this.muted = false;
		//音声を再生
		this.play();
	} //start() 終了

	/**
	 * 音声をループで再生するためのメソッド
	 */
	loop() {
		//ループ再生されるようにする(superを使っているのは、Soundクラスにloopメソッドがあるため)
		super.loop = true;
		//音声を再生するメソッドを呼び出す
		this.start();
	} //loop() 終了

	/**
	 * 音声をストップするためのメソッド
	 */
	stop() {
		//音声を一時停止する
		this.pause();
		//再生場所を最初に戻す
		this.currentTime = 0;
	} //stop() 終了

}
山田

さて、このSoundクラス、Audioクラスを使いやすくしたものだべ
再生、ループ再生、停止ていしが、かんたんにできるようにしたんだべよ

ちなみにAudioクラスっていうのは、JavaScriptで音声を再生できる機能がそろってるものだべ

アル

これを使って音楽を再生するんだね!

山田

しかしだべ……このSoundクラス、Gameクラスから呼び出して使うために作ったんだべ

りこ

直接ちょくせつは使わないってこと? どうやって使うんだろう……

山田

では、game.jsを、こんなふうにして欲しいんだべ!

js/engine/game.js

'use strict'

/**
 * ゲームづくりの基本となるクラス
 */
class Game {

	/**
	 * 引数
	 * width : ゲームの横幅
	 * height : ゲームの縦幅
	 */
	constructor( width, height ) {
		//canvas要素を作成
		this.canvas = document.createElement( 'canvas' );
		//作成したcanvas要素をbodyタグに追加
		document.body.appendChild( this.canvas );
		//canvasの横幅(ゲームの横幅)を設定。もし横幅が指定されていなければ320を代入
		this.canvas.width = width || 320;
		//canvasの縦幅(ゲームの縦幅)を設定。もし縦幅が指定されていなければ320を代入
		this.canvas.height = height || 320;

		//シーンを入れておくための配列
		this.scenes = [];
		//現在のシーンをいれておくためのもの
		this.currentScene;

		//音声を入れておくためのもの
		this.sounds = [];
		//画面がすでにタッチされたかどうか
		this._isAlreadyTouched = false;
		//設定が終わったかどうか
		this._hasFinishedSetting = false;

		//プリロードは、ゲームのメイン部分が始まる前に動かしたいので、それを入れておくための配列
		this._preloadPromises = [];

		//現在のシーンを一時的に入れておくためのもの。シーンが切り替わったかどうかを判断するのに使う
		this._temporaryCurrentScene;

		//ゲームに使用するキーと、そのキーが押されているかどうかを入れるための連想配列
		//例 { up: false, down: false }
		this.input = {};
		//登録されたキーに割り当てられたプロパティ名と、キー名を、関連づけるための連想配列
		//例 { up: "ArrowUp", down: "ArrowDown" }
		this._keys = {};
	} //constructor() 終了

	/**
	 * プリロードのためのメソッド
	 *
	 * 引数には、使いたい素材を制限なく入れることができる
	 */
	preload() {
		//引数の素材を_assetsに追加
		const _assets = arguments;
		//素材の数だけ繰り返す
		for ( let i=0; i<_assets.length; i++ ) {
			//_preloadPromises[i]に、あなたはプリロードのプロミス(非同期処理をやりやすくする)だよ、と教える
			this._preloadPromises[i] = new Promise( ( resolve, reject ) => {
				//もしそのファイル拡張子が、jpg、jpeg、png、gifのどれかのとき
				if ( _assets[i].match( /\.(jpg|jpeg|png|gif)$/i ) ) {
					//_imgに、あなたは画像ですよ、と教える
					let _img = new Image();
					//img.srcに、引数で指定した画像ファイルを代入
					_img.src = _assets[i];

					//画像が読み込み終わったら、成功ということで、resolve()を呼び出す
					_img.addEventListener( 'load', () => {
						resolve();
					}, { passive: true, once: true } );

					//画像が読み込めなければ、エラーということで、reject()を呼び出す
					_img.addEventListener( 'error', () => {
						reject( `「${_assets[i]}」は読み込めないよ!` );
					}, { passive: true, once: true } );
				}
				//もしそのファイル拡張子が、wav、wave、mp3、oggのどれかのとき
				else if ( _assets[i].match( /\.(wav|wave|mp3|ogg)$/i ) ) {
					//_soundに、あなたはサウンドですよ、と教える
					let _sound = new Sound();
					//_sound.srcに、引数で指定した音声ファイルを代入
					_sound.src = _assets[i];
					//this.soundsに、読み込んだ音声を入れておく
					this.sounds[ _assets[i] ] = _sound;
					//音声を再生する準備をする
					this.sounds[ _assets[i] ].load();

					//サウンドが読み込み終わったら、成功ということで、resolve()を呼び出す
					_sound.addEventListener( 'canplaythrough', () => {
						resolve();
					}, { passive: true, once: true } );

					//サウンドが読み込めなければ、エラーということで、reject()を呼び出す
					_sound.addEventListener( 'error', () => {
						reject( `「${_assets[i]}」は読み込めないよ!` );
					}, { passive: true, once: true } );
				}
				//ファイル拡張子がどれでもないとき
				else {
					//エラーということで、reject()を呼び出す
					reject( `「${_assets[i]}」の形式は、プリロードに対応していないよ!` );
				}
			} );
		}
	} //preload() 終了

	/**
	 * プリロードなどの設定が終わったあとに実行する
	 *
	 * 引数
	 * callback : プリロードなどの設定が終わったあとに実行したいプログラム。今回はゲームのメイン部分
	 */
	main( callback ) {
		//ゲームが始まる前に実行しておきたいもの(今回はプリロード)が、すべて成功したあとに、実行したかったゲームのメイン部分「callback()」を実行
		//失敗したときはコンソールにエラーを表示
		Promise.all( this._preloadPromises ).then( result => {
			callback();
		} ).catch( reject => {
			console.error( reject );
		} );
	} //main() 終了

	/**
	 * startメソッドを呼び出すことで、メインループが開始される
	 */
	start() {
		//デフォルトのキーバインドを登録する(使いたいキーを登録する)
		this.keybind( 'up', 'ArrowUp' );
		this.keybind( 'down', 'ArrowDown' );
		this.keybind( 'right', 'ArrowRight' );
		this.keybind( 'left', 'ArrowLeft' );

		//現在のシーン(currentScene)になにも入っていないときは、scenes[0]を代入
		this.currentScene = this.currentScene || this.scenes[0];

		//ゲームがはじまったときと、ブラウザのサイズが変わったときに呼ばれる。縦横の比を変えずに、canvasを拡大縮小できる
		const _resizeEvent = () => {
			//ブラウザとcanvasの比率の、縦と横を計算し、小さいほうを_ratioに代入する
			const _ratio = Math.min( innerWidth / this.canvas.width, innerHeight / this.canvas.height );
			//canvasのサイズを、ブラウザに合わせて変更する
			this.canvas.style.width = this.canvas.width*_ratio + 'px';
			this.canvas.style.height = this.canvas.height*_ratio + 'px';
		} //_resizeEvent() 終了

		//ブラウザのサイズが変更されたとき、_resizeを呼び出す
		addEventListener( 'resize', _resizeEvent, { passive: true } );
		//_resizeを呼び出す
		_resizeEvent();

		//メインループを呼び出す
		this._mainLoop();

		//ユーザーの操作を待つためのメソッドを呼び出す
		this._waitUserManipulation();
		//イベントリスナーをセットする(削除)
		//this._setEventListener();(削除)
	} //start() 終了

	/**
	 * イベントリスナーをセットするためのメソッド
	 */
	_setEventListener() {
		//なにかキーが押されたときと、はなされたときに呼ばれる
		const _keyEvent = e => {
			//デフォルトのイベントを発生させない
			e.preventDefault();
			//_keysに登録された数だけ繰り返す
			for ( let key in this._keys ) {
				//イベントのタイプによって呼び出すメソッドを変える
				switch ( e.type ) {
					case 'keydown' :
						//押されたキーが、登録されたキーの中に存在するとき、inputのそのキーをtrueにする
						if ( e.key === this._keys[key] ) this.input[key] = true;
						break;
					case 'keyup' :
						//押されたキーが、登録されたキーの中に存在するとき、inputのそのキーをfalseにする
						if ( e.key === this._keys[key] ) this.input[key] = false;
						break;
				}
			}
		}
		//なにかキーが押されたとき
		addEventListener( 'keydown', _keyEvent, { passive: false } );
		//キーがはなされたとき
		addEventListener( 'keyup', _keyEvent, { passive: false } );

		//画面がタッチされたり、指が動いたりしたときなどに呼ばれる
		//シーンや、スプライトなどのオブジェクトの左上端から見た、それぞれの指の位置を取得できるようになる
		const _touchEvent = e => {
			//デフォルトのイベントを発生させない
			e.preventDefault();
			//タッチされた場所などの情報を取得
			const _touches = e.changedTouches[0];
			//ターゲット(今回はcanvas)のサイズ、ブラウザで表示されている部分の左上から見てどこにあるか、などの情報を取得
			const _rect = _touches.target.getBoundingClientRect();
			//タッチされた場所を計算
			const _fingerPosition = {
				x: ( _touches.clientX - _rect.left ) / _rect.width * this.canvas.width,
				y: ( _touches.clientY - _rect.top ) / _rect.height * this.canvas.height
			};
			//イベントのタイプを_eventTypeに代入
			const _eventType = e.type;
			//タッチイベントを割り当てるためのメソッドを呼び出す
			this.currentScene.assignTouchevent( _eventType, _fingerPosition );
		} //_touchEvent() 終了

		//タッチされたとき
		this.canvas.addEventListener( 'touchstart', _touchEvent, { passive: false } );
		//指が動かされたとき
		this.canvas.addEventListener( 'touchmove', _touchEvent, { passive: false } );
		//指がはなされたとき
		this.canvas.addEventListener( 'touchend', _touchEvent, { passive: false } );
	} //_setEventListener() 終了

	/**
	 * ユーザーからの操作を待つためのメソッド
	 */
	_waitUserManipulation() {
		//すべての音声を再生する
		const _playAllSounds = e => {
			//デフォルトのイベントを発生させない
			e.preventDefault();
			//画面にタッチされたかどうかの変数をtrueにする
			this._isAlreadyTouched = true;

			//音声を再生するためのプロミスを入れておく配列
			const _playPromises = [];

			//this.soundsの数だけ繰り返す
			//この繰り返しは、読み込まれた音声を、最初に全て同時に再生してしまおうというもの
			//こうすることで、スマホのブラウザなどの、音声を自動で流せないという制限を解決できる
			for ( let sound in this.sounds ) {
				//音声を再生する準備をする
				this.sounds[ sound ].load();
				//音声をミュートにする
				this.sounds[ sound ].muted = true;
				//音声を再生するメソッドはPromiseを返してくれるので、soundPromiseに追加
				_playPromises.push( this.sounds[ sound ].play() );
			}

			//Promiseが成功か失敗かのチェーン
			Promise.all( _playPromises ).then( () => {
				//成功した場合は全ての音をストップする
				for ( let sound in this.sounds ) {
					this.sounds[ sound ].stop();
				}
			} ).catch( err => {
				//失敗した場合はエラーを表示
				console.log( err );
			} );

			//音声を再生するときのエラーを防ぐために、すこしだけ待つ
			setTimeout( () => {
				//イベントリスナーをセットする
				this._setEventListener();
				this._hasFinishedSetting = true;
			}, 2000 );
		} //_playAllSounds() 終了

		//タッチされたときや、なにかキーが押されたとき、_playAllSoundsを呼び出す
		this.canvas.addEventListener( 'touchstart', _playAllSounds, { passive: false, once: true } );
		addEventListener( 'keydown', _playAllSounds, { passive: false, once: true } );
	} //_waitUserManipulation() 終了

	/**
	 * メインループ
	 */
	_mainLoop() {
		//画家さん(コンテキスト)を呼ぶ
		const ctx = this.canvas.getContext( '2d' );
		//塗りつぶしの色に、黒を指定する
		ctx.fillStyle = '#000000';
		//左上から、画面のサイズまでを、塗りつぶす
		ctx.fillRect( 0, 0, this.canvas.width, this.canvas.height );

		//もし、ユーザーがまだ画面をタッチしていない(画面を操作していない)とき、スタートパネルを表示
		if ( !this._isAlreadyTouched ) this.startPanel();
		//設定がすでに終了しているとき
		else if ( this._hasFinishedSetting ) {
			//現在のシーンのupdateメソッドを呼び出す
			this.currentScene.update();

			//一時的に入れておいたシーンが現在のシーンではないとき(シーンが切り替わったとき)、現在のシーンのonchangesceneメソッドを呼び出す
			if ( this._temporaryCurrentScene !== this.currentScene ) this.currentScene.onchangescene();

			//現在のシーンの、ゲームに登場する全てのもの(オブジェクト)の数だけ繰り返す
			for ( let i=0; i<this.currentScene.objs.length; i++ ) {
				//現在のシーンの、すべてのオブジェクトのupdateメソッドを呼び出す
				this.currentScene.objs[i].update( this.canvas );
			}

			//現在のシーンを覚えておいてもらう
			this._temporaryCurrentScene = this.currentScene;
		}

		//自分自身(_mainLoop)を呼び出して、ループさせる
		requestAnimationFrame( this._mainLoop.bind( this ) );
	} //_mainLoop() 終了

	/**
	 * ゲームを開始して一番最初に表示される画面をつくるメソッド。ここでユーザーに操作してもらい、音声を出せるようにする
	 */
	startPanel() {
		//表示したいテキストを_textに代入
		const _text = 'タップ、またはなにかキーを押してね!'
		//表示したいテキストのフォントを_fontに代入
		const _font = "游ゴシック体, 'Yu Gothic', YuGothic, sans-serif";
		//フォントサイズは、ゲーム画面の横幅を20で割ったもの。(今回は表示したい文字が18文字なので、左右の余白も考え、20で割る)
		const _fontSize = this.canvas.width/20;
		//画家さん(コンテキスト)を呼ぶ
		const _ctx = this.canvas.getContext( '2d' );
		//テキストの横幅を取得
		const _textWidth = _ctx.measureText( _text ).width;
		//フォントの設定
		_ctx.font = `normal ${_fontSize}px ${_font}`;
		//ベースラインを文字の中央にする
		_ctx.textBaseline = 'middle';
		//テキストの色をグレーに設定
		_ctx.fillStyle = '#aaaaaa';
		//テキストを上下左右中央の位置に表示
		_ctx.fillText( _text, ( this.canvas.width - _textWidth )/2, this.canvas.height/2 );
	} //startPanel() 終了

	/**
	 * ゲームにシーンに追加できるようになる、addメソッドを作成
	 *
	 * 引数
	 * scene : 追加したいシーン
	 */
	add( scene ) {
		//引数がSceneのとき、this.scenesの末尾にsceneを追加
		if ( scene instanceof Scene ) this.scenes.push( scene );
		//引数がSceneでなければ、コンソールにエラーを表示
		else console.error( 'Gameに追加できるのはSceneだけだよ!' );
	} //add()終了

	/**
	 * 使いたいキーを登録できるようになる、keybindメソッドを作成
	 *
	 * 引数
	 * name : キーにつける名前
	 * key : キーコード
	 */
	keybind( name, key ) {
		//キーの名前と、キーコードを関連づける
		this._keys[name] = key;
		//キーが押されているかどうかを入れておく変数に、まずはfalseを代入しておく
		this.input[name] = false;
	} //keybind() 終了

}
山田

まず、28〜33行目で、音声おんせいの読み込みに必要なプロパティを初期化しょきかしているべ!

りこ

音声を入れておくためものもと、画面がタッチされたかどうか……設定せっていが終わったかどうか、なんてのもあるのね!

山田

そうなんだべ……音声の再生は大変なんだべ……
そして78〜98行目では、音声ファイルのプリロードできるようにしているべ
まぁ、これは画像のときとほとんど同じだべな

アル

ほんとだ。あ、でも85行目でthis.soundsに読み込んだ音声を入れているんだね

山田

そうなんだべよ。このthis.soundsに入っているものを指定して、音楽を再生できるようにしていくべ
そして次に155行目で、ユーザーの操作を待つためのメソッドを呼び出しているべ

りこ

ユーザーの操作っていうのは、タッチとか、キー入力とかのことね!

山田

だべ。そして219〜264行目が、そのユーザーの操作を待つためのメソッドだべ

りこ

えっと、あ、262行目と263行目に、タッチ操作やキー入力のaddEventListenerがある!

山田

うむ。そしてタッチされたときや、キーが押されたときは、_playAllSoundsを呼び出しているべ
これは、使いたい音声をすべて同時に再生してしまおう、という関数だべ

アル

ええっ! 同時に全部再生するの!?

りこ

どういうこと!?

山田

さきほど、ブラウザで音楽を再生するには、ユーザーからの操作が必要だというはなしをしたべな
そしていったん再生された音声ファイルであれば、あとはいつでも再生できるべ
ならば、ゲームが始まる前に、ゲームをプレイしてくれる人に操作をお願いして、そのときに使いたい音をすべて再生してしまえばいいべ

アル

でもそれって、急にすべての音が流れてびっくりさせちゃわないの?

山田

もちろん、ミュートで再生して、一瞬で停止するべよ

アル

あ、なるほどー

山田

……というより、再生されないこともけっこう多いべ

りこ

え、そんなんでいいの?

山田

うむ。ここで重要じゅうようなのが、すべての音声ファイルを、とにかく再生しようとしてみることだべ
どちらにしろ、ミュートで再生してすぐに止めてしまうのだから、再生されたかされなかったかは、重要じゃないんだべよ

もちろん、もしここで再生されなくても、ゲーム中は再生されるようになるべよ

アル

うーん……なんだかなぁ

山田

とにかく、プログラムを見ていくべ!

アル

うん

山田

228行目では、音を再生するためのプロミスを入れておく配列を宣言しているべ

りこ

プロミス、さっきも使ったね!

山田

そして233〜240行目で、すべての音声を再生しているべ

りこ

235行目で再生の準備をして、237行目でミュートにして……

アル

あれ、239行目はどういうこと?

山田

音声を再生するplayメソッドの戻り値は、じつはPromiseなんだべ
だからこれを_playPromisesに追加しているんだべ

アル

じゃあこれで、さっきやったみたいにできるんだ

山田

うむ。プリロードのときと同じようにPromise.allとthenを使って、すべての音声の再生が成功したか失敗したかをチェックできるんだべ
それをやっているのが243〜251行目だべ

りこ

なるほど。成功したときは音を止めて、失敗したときはエラーを表示しているのね!

山田

そうだべ! JavaScriptでの音声の再生は、どういうわけだか、これが重要になるんだべ

アル

そっかー。だからさっき、再生されたかされなかったかは重要じゃないって言ってたのか

山田

その通りだべ。しかし、このあとすぐに音声を再生しようとしても、うまく再生できないことがあるべ……

アル

そんなぁ……

山田

安心するべ。同時にすべての音声を再生したあと、ちょっとだけ待つんだべ。そうすることで、うまく再生できるようになるべ!

アル

へぇ、そうなんだ!

山田

そしてさらに、278行目は、ユーザーの操作がない場合は、操作をお願いする画面を表示するものだべ

りこ

あ、ここでタッチやキー入力をお願いするのね!

山田

そして280行目、295行目で、シーンやスプライトなどを表示するお願いを、設定が終了したあとに呼び出すようにしているべ

りこ

なるほど! あ、操作をお願いする画面っていうのは?

山田

304〜323行目だべ。これはキャンバスに文字を表示しているだけだべな

アル

へぇ、こうやって作るのかぁ

山田

では、実際に音声を再生してみるべ!

js/main.js

'use strict'

//ブラウザがページを完全に読みこむまで待つ
addEventListener( 'load', () => {

	//変数gameに、あなたはゲームですよ、と教える
	const game = new Game();
	//ゲームに使う画像などの素材を先に読み込んでおく(プリロード)
	game.preload( 'img/yamada.png', 'img/rico.png', 'img/aru.png', 'img/start.png', 'img/goal.png', 'img/tile.png', 'img/dpad.png', 'sound/bgm.mp3', 'sound/start.mp3', 'sound/clear.mp3' );
	//使いたいキーとして、スペースキーを登録する
	game.keybind( 'space', ' ' );
	//ゲームを開始する準備ができたあとに実行する
	game.main( () => {

		//タイトルシーン
		const titleScene = () => {

			//変数sceneに、あなたはシーンですよ、と教える
			const scene = new Scene();

			//変数titleTextに、あなたは「くろねこラビリンス」というテキストだよ、と教える
			const titleText = new Text( 'くろねこラビリンス' );
			//テキストを上下左右中央の位置にする
			titleText.center().middle();
			//シーンにテキストを追加
			scene.add( titleText );

			//シーンが切り替わったときに、一度だけ呼ばれる
			scene.onchangescene = () => {
				//clear.mp3をストップ
				game.sounds[ 'sound/clear.mp3' ].stop();
				//start.mp3を再生
				game.sounds[ 'sound/start.mp3' ].start();
			}

			//シーンがタッチされたとき
			scene.ontouchstart = () => {
				//メインシーンに切り替える
				game.currentScene = mainScene();
			} //scene.ontouchstart() 終了

			//ループから常に呼び出される
			scene.onenterframe = () => {
				//スペースキーが押されたとき、メインシーンに切り替える
				if ( game.input.space ) game.currentScene = mainScene();
			} //scene.onenterframe() 終了

			//作ったシーンを返す
			return scene;

		} //titleScene() 終了

		//メインシーン
		const mainScene = () => {

			//マップの作成
			const map = [
				[11,11,11,11,11,11,11,11,11,11],
				[11,10,10,10,10,10,10,10,10,11],
				[11, 4, 4, 4, 4, 4, 4, 4, 4,11],
				[11, 4,11, 4, 4,11,11,11, 4,11],
				[11, 4,11,11,11,11,10,10, 4,11],
				[11, 4,11,10,10,11, 4, 4, 4,11],
				[11, 4,11, 4, 4,11,11,11, 4,11],
				[11, 4, 9, 4, 4, 9,10,11, 4,11],
				[11, 4, 4, 4, 4, 4, 4,11, 4,11],
				[11,11,11,11,11,11,11,11,11,11]
			];
			//タイルのサイズ
			const TILE_SIZE = 32;
			//歩く速さ
			const WALKING_SPEED = 4;

			//変数sceneに、あなたはシーンですよ、と教える
			const scene = new Scene();

			//変数tilemapに、あなたはタイルマップですよ、と教える
			const tilemap = new Tilemap( 'img/tile.png' );
			//tilemap.dataに、どんなマップなのか教える
			tilemap.data = map;
			//マップ全体の位置をずらす
			tilemap.x = TILE_SIZE*4 - TILE_SIZE/2;
			tilemap.y = TILE_SIZE*3 - TILE_SIZE/2;
			//移動できないタイルを指定する
			tilemap.obstacles = [0, 3, 6, 7, 8, 9, 10, 11];
			//マップを登録する
			scene.add( tilemap );

			//変数startに、あなたはスタートのタイルですよ、と教える
			const start = new Tile( 'img/start.png' );
			//マップ左上からの座標を指定する
			start.x = TILE_SIZE;
			start.y = TILE_SIZE*2;
			//スタートのタイルを、tilemapに追加して、とお願いする
			tilemap.add( start );

			//変数goalに、あなたはゴールのタイルですよ、と教える
			const goal = new Tile( 'img/goal.png' );
			//マップ左上からの座標を指定する
			goal.x = TILE_SIZE*8;
			goal.y = TILE_SIZE*8;
			//ゴールのタイルを、tilemapに追加して、とお願いする
			tilemap.add( goal );

			//変数yamadaに、あなたは山田先生のキャラクタータイルですよ、と教える
			const yamada = new CharacterTile( 'img/yamada.png' );
			//山田先生を画面の中央に配置
			yamada.x = yamada.y = TILE_SIZE*5 - TILE_SIZE/2;
			//タイルマップの動きと同期させない
			yamada.isSynchronize = false;
			//tilemapに、山田先生のタイルを追加して、とお願いする
			tilemap.add( yamada );

			//変数ricoに、あなたはりこちゃんのキャラクタータイルですよ、と教える
			const rico = new CharacterTile( 'img/rico.png' );
			//りこちゃんの位置を決める
			rico.x = TILE_SIZE*7 - TILE_SIZE/2;
			rico.y = TILE_SIZE*5 - TILE_SIZE/2;
			//タイルマップの動きと同期させない
			rico.isSynchronize = false;
			//tilemapに、りこちゃんのキャラクタータイルを追加して、とお願いする
			tilemap.add( rico );

			//変数aruに、あなたはアルくんのキャラクタータイルですよ、と教える
			const aru = new CharacterTile( 'img/aru.png' );
			//アルくんの位置を決める
			aru.x = TILE_SIZE*7 - TILE_SIZE/2;
			aru.y = TILE_SIZE*6 - TILE_SIZE/2;
			//タイルマップの動きと同期させない
			aru.isSynchronize = false;
			//tilemapに、アルくんのキャラクタータイルを追加して、とお願いする
			tilemap.add( aru );

			//変数partyに、あなたは山田先生とりこちゃんとアルくんのパーティですよ、と教える
			const party = new Party( yamada, rico, aru );
			//パーティの歩く速さに、WALKING_SPEEDの値を代入する
			party.speed = WALKING_SPEED;

			//変数dpadに、あなたはD-Padですよ、と教える
			const dpad = new DPad( 'img/dpad.png', 80 );
			//D-Padの位置を指定する
			dpad.x = 10;
			dpad.y = 230;
			//sceneに、D-Padを追加して、とお願いする
			scene.add( dpad );

			//シーンが切り替わったときに、一度だけ呼ばれる
			scene.onchangescene = () => {
				//start.mp3をストップ
				game.sounds[ 'sound/start.mp3' ].stop();
				//bgm.mp3をループ再生
				game.sounds[ 'sound/bgm.mp3' ].loop();
			}

			//キャラクターのアニメーションのための変数
			let toggleForAnimation = 0;
			//ゴールのテキストが表示されているかどうかの変数
			let hasDisplayedGoalText = false;
			//移動可能かどうかの変数
			let isMovable = true;

			//ループから常に呼び出される
			scene.onenterframe = () => {
				//タイルマップの位置がタイルのサイズで割り切れるとき
				if ( ( tilemap.x - TILE_SIZE/2 ) % TILE_SIZE === 0 && ( tilemap.y - TILE_SIZE/2 ) % TILE_SIZE === 0 ) {
					//タイルマップの移動速度に0を代入する
					tilemap.vx = tilemap.vy = 0;
					//パーティ全員の数だけ繰り返す
					for ( let i in party.member ) {
						//パーティ全員の移動速度を0にする
						party.member[i].vx = party.member[i].vy = 0;
						//パーティ全員の画像を切り替える
						party.member[i].animation = 1;
					}

					//山田先生のタイルがゴールのタイルと重なっているとき、イベントを発生させる
					if ( yamada.isOverlapped( goal ) ) {
						//ゴールのテキストが表示されていないとき
						if ( !hasDisplayedGoalText ) {
							//変数goalTextに、あなたは「ゴールだべ!」というテキストだよ、と教える
							const goalText = new Text( 'ゴールだべ!' );
							//テキストサイズを変更
							goalText.size = 50;
							//テキストを上下左右中央の位置にする
							goalText.center().middle();
							//シーンにテキストを追加
							scene.add( goalText );
							//ゴールのテキストが表示されているかどうかの変数にtrueを代入
							hasDisplayedGoalText = true;
							//移動ができないようにする
							isMovable = false;
							//bgm.mp3をストップ
							game.sounds[ 'sound/bgm.mp3' ].stop();
							//clear.mp3を再生
							game.sounds[ 'sound/clear.mp3' ].start();
							//6秒たったら、タイトルシーンに切り替える
							setTimeout( () => {
								game.currentScene = titleScene();
							}, 6000 );
						}
					}

					//移動可能なとき
					if ( isMovable ) {
						//方向キー、もしくはD-Padが押されているときは、setMemberVelocityメソッドを呼び出し、タイルマップの移動速度と、山田先生の向きに、それぞれの値を代入する
						if ( game.input.left || dpad.arrow.left ) {
							party.setMemberVelocity( 'left' );
							tilemap.vx = WALKING_SPEED;
							yamada.direction = 1;
						}
						else if ( game.input.right || dpad.arrow.right ) {
							party.setMemberVelocity( 'right' );
							tilemap.vx = -1 * WALKING_SPEED;
							yamada.direction = 2;
						}
						else if ( game.input.up || dpad.arrow.up ) {
							party.setMemberVelocity( 'up' );
							tilemap.vy = WALKING_SPEED;
							yamada.direction = 3;
						}
						else if ( game.input.down || dpad.arrow.down ) {
							party.setMemberVelocity( 'down' );
							tilemap.vy = -1 * WALKING_SPEED;
							yamada.direction = 0;
						}

						//移動後のマップ座標を求める
						const yamadaCoordinateAfterMoveX = yamada.mapX - tilemap.vx/WALKING_SPEED;
						const yamadaCoordinateAfterMoveY = yamada.mapY - tilemap.vy/WALKING_SPEED;
						//もし移動後のマップ座標に障害物があるとき
						if ( tilemap.hasObstacle( yamadaCoordinateAfterMoveX, yamadaCoordinateAfterMoveY ) ) {
							//移動量に0を代入する
							tilemap.vx = tilemap.vy = 0;
							//パーティ全員の移動速度に0を代入する
							for ( let i in party.member ) {
								party.member[i].vx = party.member[i].vy = 0;
							}					
						}

						//タイルマップが動いているとき、パーティメンバーの向きを変える
						if ( tilemap.vx !== 0 || tilemap.vy !== 0 ) party.setMemberDirection();
					}
				}
				//タイルマップのXとY座標両方がタイルのサイズで割り切れるとき以外で、タイルの半分のサイズで割り切れるとき
				else if ( ( tilemap.x + TILE_SIZE/2 ) % ( TILE_SIZE/2 ) === 0 && ( tilemap.y + TILE_SIZE/2 ) % ( TILE_SIZE/2 ) === 0 ) {
					//0と1を交互に取得できる
					toggleForAnimation ^= 1;
					//パーティメンバーの数だけ繰り返す
					for ( let i in party.member ) {
						//toggleForAnimationの数値によって、パーティ全員の画像を切り替える
						if ( toggleForAnimation === 0 ) party.member[i].animation = 2;
						else party.member[i].animation = 0;
					}
				}
			} //scene.onenterframe 終了

			//作ったシーンを返す
			return scene;

		} //mainScene() 終了

		//gameに、シーンを追加して、とお願いする
		game.add( titleScene() );
		game.add( mainScene() );

		//gameに、ゲームをスタートして、とお願いする
		game.start();

	} ); //main() 終了

} );
山田

追加したのは、9行目、28〜34行目、147〜153行目、192〜195行目だべ
まず9行目では、音声ファイルもプリロードするファイルに加えてるんだべ

りこ

ふむふむ

山田

28〜34行目、147〜153行目では、シーンが変更されたときに一度だけ呼ばれるメソッド、onchangesceneを使って、音をきりかえているんだべな

アル

stopで停止、startで再生、loopでループ再生だね!

山田

そして192〜195行目では、プレイヤーがゴールしたときに、音をきりかえているべ

アル

はやく、ブラウザで見てみようよ!

りこ

わたしもはやく見たい!

山田

では、ブラウザで確認してみるべ!

ゲームを開始すると、操作をお願いする画面が表示される
山田

ゲームを開始すると、まずこのように操作をお願いする画面が表示されるべ

りこ

ここでなにかキーを押せばいいのかな……スペースキーえいっ!

画像では分かりにくいけれど音楽が再生される!
りこ

きゃー、音楽が流れたよ!

アル

すごい! この音楽、まるでドラクエのレベ……

山田

ぶぁっくしょーいてやんでぇべらぼうめ! あぁ、くしゃみが出てしまったべ……

アル

むむ……

次のページでは、マップを大きくして、もっとゲームらしくなるようにしていきます。

このシリーズの一覧はこちら

  1. 小学生からのプログラミング入門。プログラミングってなぁに?
  2. Scratchの使い方と、ゲーム作りの基礎知識を学ぼう! 小学生からのプログラミング入門
  3. Scratchでじゃんけんゲームを作ろう! 小学生からのプログラミング入門
  4. Scratchでシューティングゲームを作ろう! 小学生からのプログラミング入門
  5. Scratchでピアノ鍵盤を作って音を鳴らそう! 小学生からのプログラミング入門
  6. テキストエディタ(Visual Studio Code)をインストールしてみよう! 小学生からのプログラミング入門
  7. Visual Studio Codeを日本語化してみよう! 小学生からのプログラミング入門
  8. JavaScriptでおみくじを作ろう! 小学生からのプログラミング入門
  9. JavaScriptで今月の残り日数を計算してみよう! 小学生からのプログラミング入門
  10. JavaScriptで画像を表示してみよう! 小学生からのプログラミング入門
  11. JavaScriptで画像を移動してみよう! 小学生からのプログラミング入門
  12. 【JavaScript】キー入力でキャラを動かしてみよう! 小学生からのプログラミング入門
  13. 【JavaScript】ファイルを分けて管理してみよう! 小学生からのプログラミング入門
  14. 【JavaScript】オブジェクトを使ってみよう! 小学生からのプログラミング入門
  15. 【JavaScript】ゲームのメインループを作ってみよう! 小学生からのプログラミング入門
  16. 【JavaScript】キャラを決まった間隔ずつ動かす! 小学生からのプログラミング入門
  17. HTML5とCanvasを使ってみよう! 小学生からのプログラミング入門
  18. 【JavaScript】迷路やRPGで使えるマップを作ってみよう! 小学生からのプログラミング入門
  19. 【JavaScript】マップでキャラを動かせるようにしよう! 小学生からのプログラミング入門
  20. 【JavaScript】クラスの概念をしっかりと理解しよう! 小学生からのプログラミング入門
  21. 【JavaScript】プログラム全体をクラスを使って作ってみよう! 小学生からのプログラミング入門
  22. 【JavaScript】文字を表示するクラスを作ってみよう! 小学生からのプログラミング入門
  23. 【JavaScript】改行と一文字ずつ画面に表示する方法! 小学生からのプログラミング入門
  24. 【JavaScript】ノベルゲーム風にキー入力で文字を切り替える方法! 小学生からのプログラミング入門
  25. JavaScriptでRPGを作ろう!スマホにも対応したゲームの作り方
  26. webpackを使ってゲームエンジンを作ろう!(JSライブラリの作り方)
  27. WindowsにPythonをインストールしてみよう!小学生からのPython入門
  28. MacにPythonをインストールしてみよう!小学生からのPython入門
  29. Pythonでじゃんけんゲームを作ってみよう!小学生からのPython入門
  30. Pythonのtkinterを使って、ウィンドウを表示してみよう!
  31. Pythonのtkinterで、画像つきのおみくじゲームを作ろう!
オリジナルゲーム.com